diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7542a33 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,15 @@ +# Copyright (c) 2025 ADBC Drivers Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +* text=auto eol=lf diff --git a/.gitignore b/.gitignore index a69b167..01d12e2 100644 --- a/.gitignore +++ b/.gitignore @@ -259,3 +259,7 @@ validation-report.xml # Git worktrees .worktrees/ + +# Go shared library build artifacts +go/libadbc_driver_snowflake.so +go/libadbc_driver_snowflake.h diff --git a/csharp/.editorconfig b/csharp/.editorconfig new file mode 100644 index 0000000..3d2f9b2 --- /dev/null +++ b/csharp/.editorconfig @@ -0,0 +1,43 @@ +# Copyright (c) 2025 ADBC Drivers Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# EditorConfig for the C# workspace: the native Snowflake driver and its tests. +# +# Layout rules (whitespace, line endings, brace placement) are enforced so `dotnet format` +# is deterministic. Semantic style preferences (var, expression bodies) are documented at +# `silent` severity: they guide the IDE but are intentionally NOT auto-rewritten by +# `dotnet format`, so the tool never churns hand-written code. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{json,yml,yaml}] +indent_size = 2 + +[*.cs] +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false + +# Preferences the codebase follows, documented but not machine-enforced (silent = no format churn). +csharp_style_namespace_declarations = file_scoped:silent +csharp_style_var_for_built_in_types = true:silent +csharp_style_var_when_type_is_apparent = true:silent +csharp_style_var_elsewhere = true:silent +dotnet_style_require_accessibility_modifiers = never:silent diff --git a/csharp/.gitignore b/csharp/.gitignore index 2f66299..07d0bd7 100644 --- a/csharp/.gitignore +++ b/csharp/.gitignore @@ -14,6 +14,7 @@ .vs .vscode +.idea/ bin obj x64 @@ -22,6 +23,7 @@ x64 *.obj *.exe *.csproj.user +AdbcDrivers.Snowflake.sln.DotSettings.user *.pass artifacts/ diff --git a/csharp/AdbcDrivers.Snowflake.sln b/csharp/AdbcDrivers.Snowflake.sln index 38a0933..24dd535 100644 --- a/csharp/AdbcDrivers.Snowflake.sln +++ b/csharp/AdbcDrivers.Snowflake.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.4.33403.182 @@ -16,36 +17,124 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdbcDrivers.Snowflake.Inter EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdbcDrivers.Snowflake.Interop.Tests", "test\Interop\AdbcDrivers.Snowflake.Interop.Tests.csproj", "{7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Native", "Native", "{986E768A-9E42-6229-8E82-349DB5D13BDD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdbcDrivers.Snowflake.Native", "src\Native\AdbcDrivers.Snowflake.Native.csproj", "{7554C6D5-B570-401D-859E-ECCA3D6BD25A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Native", "Native", "{7BEB73B5-2377-DFD3-0252-3311B8DAB980}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AdbcDrivers.Snowflake.Native.Tests", "test\Native\AdbcDrivers.Snowflake.Native.Tests.csproj", "{3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {8BFC2CBA-D9B9-C719-0617-59A21A7D7DF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8BFC2CBA-D9B9-C719-0617-59A21A7D7DF9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8BFC2CBA-D9B9-C719-0617-59A21A7D7DF9}.Debug|x64.ActiveCfg = Debug|Any CPU + {8BFC2CBA-D9B9-C719-0617-59A21A7D7DF9}.Debug|x64.Build.0 = Debug|Any CPU + {8BFC2CBA-D9B9-C719-0617-59A21A7D7DF9}.Debug|x86.ActiveCfg = Debug|Any CPU + {8BFC2CBA-D9B9-C719-0617-59A21A7D7DF9}.Debug|x86.Build.0 = Debug|Any CPU {8BFC2CBA-D9B9-C719-0617-59A21A7D7DF9}.Release|Any CPU.ActiveCfg = Release|Any CPU {8BFC2CBA-D9B9-C719-0617-59A21A7D7DF9}.Release|Any CPU.Build.0 = Release|Any CPU + {8BFC2CBA-D9B9-C719-0617-59A21A7D7DF9}.Release|x64.ActiveCfg = Release|Any CPU + {8BFC2CBA-D9B9-C719-0617-59A21A7D7DF9}.Release|x64.Build.0 = Release|Any CPU + {8BFC2CBA-D9B9-C719-0617-59A21A7D7DF9}.Release|x86.ActiveCfg = Release|Any CPU + {8BFC2CBA-D9B9-C719-0617-59A21A7D7DF9}.Release|x86.Build.0 = Release|Any CPU {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00}.Debug|x64.Build.0 = Debug|Any CPU + {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00}.Debug|x86.Build.0 = Debug|Any CPU {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00}.Release|Any CPU.ActiveCfg = Release|Any CPU {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00}.Release|Any CPU.Build.0 = Release|Any CPU + {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00}.Release|x64.ActiveCfg = Release|Any CPU + {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00}.Release|x64.Build.0 = Release|Any CPU + {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00}.Release|x86.ActiveCfg = Release|Any CPU + {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00}.Release|x86.Build.0 = Release|Any CPU {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51}.Debug|x64.ActiveCfg = Debug|Any CPU + {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51}.Debug|x64.Build.0 = Debug|Any CPU + {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51}.Debug|x86.ActiveCfg = Debug|Any CPU + {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51}.Debug|x86.Build.0 = Debug|Any CPU {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51}.Release|Any CPU.ActiveCfg = Release|Any CPU {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51}.Release|Any CPU.Build.0 = Release|Any CPU + {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51}.Release|x64.ActiveCfg = Release|Any CPU + {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51}.Release|x64.Build.0 = Release|Any CPU + {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51}.Release|x86.ActiveCfg = Release|Any CPU + {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51}.Release|x86.Build.0 = Release|Any CPU {80163E19-0794-37AE-1FA0-FFFA6A2DEC62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {80163E19-0794-37AE-1FA0-FFFA6A2DEC62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80163E19-0794-37AE-1FA0-FFFA6A2DEC62}.Debug|x64.ActiveCfg = Debug|Any CPU + {80163E19-0794-37AE-1FA0-FFFA6A2DEC62}.Debug|x64.Build.0 = Debug|Any CPU + {80163E19-0794-37AE-1FA0-FFFA6A2DEC62}.Debug|x86.ActiveCfg = Debug|Any CPU + {80163E19-0794-37AE-1FA0-FFFA6A2DEC62}.Debug|x86.Build.0 = Debug|Any CPU {80163E19-0794-37AE-1FA0-FFFA6A2DEC62}.Release|Any CPU.ActiveCfg = Release|Any CPU {80163E19-0794-37AE-1FA0-FFFA6A2DEC62}.Release|Any CPU.Build.0 = Release|Any CPU + {80163E19-0794-37AE-1FA0-FFFA6A2DEC62}.Release|x64.ActiveCfg = Release|Any CPU + {80163E19-0794-37AE-1FA0-FFFA6A2DEC62}.Release|x64.Build.0 = Release|Any CPU + {80163E19-0794-37AE-1FA0-FFFA6A2DEC62}.Release|x86.ActiveCfg = Release|Any CPU + {80163E19-0794-37AE-1FA0-FFFA6A2DEC62}.Release|x86.Build.0 = Release|Any CPU {3750A5D6-4C21-3854-8449-E81F94EA61DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3750A5D6-4C21-3854-8449-E81F94EA61DA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3750A5D6-4C21-3854-8449-E81F94EA61DA}.Debug|x64.ActiveCfg = Debug|Any CPU + {3750A5D6-4C21-3854-8449-E81F94EA61DA}.Debug|x64.Build.0 = Debug|Any CPU + {3750A5D6-4C21-3854-8449-E81F94EA61DA}.Debug|x86.ActiveCfg = Debug|Any CPU + {3750A5D6-4C21-3854-8449-E81F94EA61DA}.Debug|x86.Build.0 = Debug|Any CPU {3750A5D6-4C21-3854-8449-E81F94EA61DA}.Release|Any CPU.ActiveCfg = Release|Any CPU {3750A5D6-4C21-3854-8449-E81F94EA61DA}.Release|Any CPU.Build.0 = Release|Any CPU + {3750A5D6-4C21-3854-8449-E81F94EA61DA}.Release|x64.ActiveCfg = Release|Any CPU + {3750A5D6-4C21-3854-8449-E81F94EA61DA}.Release|x64.Build.0 = Release|Any CPU + {3750A5D6-4C21-3854-8449-E81F94EA61DA}.Release|x86.ActiveCfg = Release|Any CPU + {3750A5D6-4C21-3854-8449-E81F94EA61DA}.Release|x86.Build.0 = Release|Any CPU {7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}.Debug|x64.ActiveCfg = Debug|Any CPU + {7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}.Debug|x64.Build.0 = Debug|Any CPU + {7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}.Debug|x86.ActiveCfg = Debug|Any CPU + {7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}.Debug|x86.Build.0 = Debug|Any CPU {7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}.Release|Any CPU.ActiveCfg = Release|Any CPU {7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}.Release|Any CPU.Build.0 = Release|Any CPU + {7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}.Release|x64.ActiveCfg = Release|Any CPU + {7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}.Release|x64.Build.0 = Release|Any CPU + {7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}.Release|x86.ActiveCfg = Release|Any CPU + {7E63DF1C-ED91-B9F3-064F-D6779FE1AC4F}.Release|x86.Build.0 = Release|Any CPU + {7554C6D5-B570-401D-859E-ECCA3D6BD25A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7554C6D5-B570-401D-859E-ECCA3D6BD25A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7554C6D5-B570-401D-859E-ECCA3D6BD25A}.Debug|x64.ActiveCfg = Debug|Any CPU + {7554C6D5-B570-401D-859E-ECCA3D6BD25A}.Debug|x64.Build.0 = Debug|Any CPU + {7554C6D5-B570-401D-859E-ECCA3D6BD25A}.Debug|x86.ActiveCfg = Debug|Any CPU + {7554C6D5-B570-401D-859E-ECCA3D6BD25A}.Debug|x86.Build.0 = Debug|Any CPU + {7554C6D5-B570-401D-859E-ECCA3D6BD25A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7554C6D5-B570-401D-859E-ECCA3D6BD25A}.Release|Any CPU.Build.0 = Release|Any CPU + {7554C6D5-B570-401D-859E-ECCA3D6BD25A}.Release|x64.ActiveCfg = Release|Any CPU + {7554C6D5-B570-401D-859E-ECCA3D6BD25A}.Release|x64.Build.0 = Release|Any CPU + {7554C6D5-B570-401D-859E-ECCA3D6BD25A}.Release|x86.ActiveCfg = Release|Any CPU + {7554C6D5-B570-401D-859E-ECCA3D6BD25A}.Release|x86.Build.0 = Release|Any CPU + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}.Debug|x64.ActiveCfg = Debug|Any CPU + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}.Debug|x64.Build.0 = Debug|Any CPU + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}.Debug|x86.ActiveCfg = Debug|Any CPU + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}.Debug|x86.Build.0 = Debug|Any CPU + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}.Release|Any CPU.Build.0 = Release|Any CPU + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}.Release|x64.ActiveCfg = Release|Any CPU + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}.Release|x64.Build.0 = Release|Any CPU + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}.Release|x86.ActiveCfg = Release|Any CPU + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -55,6 +144,10 @@ Global {CE59B9B8-E2D9-68B5-D25D-6CC418E5BE00} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {9C9EDBEB-BAE2-E3C2-BD74-7E34C6AFEE51} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} {80163E19-0794-37AE-1FA0-FFFA6A2DEC62} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {986E768A-9E42-6229-8E82-349DB5D13BDD} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {7554C6D5-B570-401D-859E-ECCA3D6BD25A} = {986E768A-9E42-6229-8E82-349DB5D13BDD} + {7BEB73B5-2377-DFD3-0252-3311B8DAB980} = {0C88DD14-F956-CE84-757C-A364CCF449FC} + {3141B3F2-EF05-47FB-BBCA-5FA22AAC6515} = {7BEB73B5-2377-DFD3-0252-3311B8DAB980} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4795CF16-0FDB-4BE0-9768-5CF31564DC03} diff --git a/csharp/run_benchmark.ps1 b/csharp/run_benchmark.ps1 new file mode 100644 index 0000000..7177571 --- /dev/null +++ b/csharp/run_benchmark.ps1 @@ -0,0 +1,135 @@ +# Copyright (c) 2025 ADBC Drivers Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Benchmark runner: native C# driver vs Go/Interop driver. Both projects expose an +# identically-shaped BenchmarkTests.BaselineQueryPerformance; this runs each suite 5 +# times and parses the per-limit timings (the [NATIVE]/[INTEROP] log lines). + +$ErrorActionPreference = "Stop" + +# Determine the repository root from the location of this script. +# Assumes this script lives in the repository root. If you move it into +# a subdirectory (e.g. scripts\), change this to: +# $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$repoRoot = $PSScriptRoot + +# Resolve project paths +$nativeProject = Join-Path $repoRoot "test\Native\AdbcDrivers.Snowflake.Native.Tests.csproj" +$interopProject = Join-Path $repoRoot "test\Interop\AdbcDrivers.Snowflake.Interop.Tests.csproj" + +# Use an existing SNOWFLAKE_TEST_CONFIG_FILE environment variable if present. +# Otherwise prompt the user for the config file. +if (-not $env:SNOWFLAKE_TEST_CONFIG_FILE) { + $env:SNOWFLAKE_TEST_CONFIG_FILE = Read-Host "Enter the full path to snowflakeconfig.local.json" +} + +if (-not (Test-Path $env:SNOWFLAKE_TEST_CONFIG_FILE)) { + throw "Snowflake config file not found: '$($env:SNOWFLAKE_TEST_CONFIG_FILE)'" +} + +Write-Host "Using Snowflake config: $env:SNOWFLAKE_TEST_CONFIG_FILE" + +$outDir = Join-Path $env:TEMP "adbc_bench" +New-Item -ItemType Directory -Force -Path $outDir | Out-Null +Get-ChildItem $outDir -Filter *.txt -ErrorAction SilentlyContinue | Remove-Item -Force + +$runs = 5 + +Write-Output "########## NATIVE (C#) driver — BenchmarkTests ##########" +for ($i = 1; $i -le $runs; $i++) { + Write-Output "---- native run $i/$runs ----" + dotnet test $nativeProject -c Release --no-build ` + --filter "FullyQualifiedName~BenchmarkTests.BaselineQueryPerformance" ` + --logger "console;verbosity=detailed" 2>&1 | + Tee-Object -FilePath "$outDir\native_$i.txt" | Out-Null +} + +Write-Output "########## INTEROP (Go) driver — BenchmarkTests ##########" +for ($i = 1; $i -le $runs; $i++) { + Write-Output "---- interop run $i/$runs ----" + dotnet test $interopProject -c Release --no-build ` + --filter "FullyQualifiedName~BenchmarkTests.BaselineQueryPerformance" ` + --logger "console;verbosity=detailed" 2>&1 | + Tee-Object -FilePath "$outDir\interop_$i.txt" | Out-Null +} + +# ---- parse ---- +function Get-Timings($glob) { + $map = @{} + foreach ($f in Get-ChildItem $outDir -Filter $glob) { + foreach ($line in Get-Content $f.FullName) { + if ($line -match 'for limit (\d+) in (\d+) ms') { + $limit = [int]$Matches[1] + $ms = [int]$Matches[2] + if (-not $map.ContainsKey($limit)) { + $map[$limit] = @() + } + $map[$limit] += $ms + } + } + } + return $map +} + +function Show-Stats($name, $map) { + Write-Output "" + Write-Output "===== $name =====" + foreach ($limit in ($map.Keys | Sort-Object)) { + $vals = $map[$limit] + $mean = [math]::Round(($vals | Measure-Object -Average).Average, 0) + $min = ($vals | Measure-Object -Minimum).Minimum + $max = ($vals | Measure-Object -Maximum).Maximum + + if ($vals.Count -gt 1) { + $sd = [math]::Round( + [math]::Sqrt( + (($vals | + ForEach-Object { [math]::Pow($_ - $mean, 2) } | + Measure-Object -Sum).Sum) / ($vals.Count - 1) + ), + 0 + ) + } + else { + $sd = 0 + } + + $joined = ($vals -join ', ') + Write-Output ("limit {0,8}: n={1} mean={2,7} ms min={3,7} max={4,7} sd={5,6} [{6}]" -f $limit, $vals.Count, $mean, $min, $max, $sd, $joined) + } +} + +Write-Output "" +Write-Output "==================== RESULTS ====================" + +$nat = Get-Timings "native_*.txt" +$intr = Get-Timings "interop_*.txt" + +Show-Stats "NATIVE (C#)" $nat +Show-Stats "INTEROP (Go)" $intr + +Write-Output "" +Write-Output "===== mean comparison (native / interop) =====" + +foreach ($limit in ($nat.Keys | Sort-Object)) { + if ($intr.ContainsKey($limit)) { + $nm = ($nat[$limit] | Measure-Object -Average).Average + $im = ($intr[$limit] | Measure-Object -Average).Average + $ratio = [math]::Round($nm / $im, 2) + + Write-Output ("limit {0,8}: native {1,7} ms vs interop {2,7} ms ratio={3}x" -f $limit, [math]::Round($nm, 0), [math]::Round($im, 0), $ratio) + } +} + +Write-Output "DONE" diff --git a/csharp/src/Native/AdbcDrivers.Snowflake.Native.csproj b/csharp/src/Native/AdbcDrivers.Snowflake.Native.csproj new file mode 100644 index 0000000..0167971 --- /dev/null +++ b/csharp/src/Native/AdbcDrivers.Snowflake.Native.csproj @@ -0,0 +1,30 @@ + + + net8.0 + readme.md + Native C# Snowflake driver for Apache Arrow ADBC + + + + + + + + + + + + + + + + + + + true + \ + PreserveNewest + + + diff --git a/csharp/src/Native/Configuration/AuthenticationConfig.cs b/csharp/src/Native/Configuration/AuthenticationConfig.cs new file mode 100644 index 0000000..e9dae9d --- /dev/null +++ b/csharp/src/Native/Configuration/AuthenticationConfig.cs @@ -0,0 +1,64 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Generic; + +namespace AdbcDrivers.Snowflake.Native.Configuration; + +/// +/// Represents authentication configuration for Snowflake connections. +/// +internal class AuthenticationConfig +{ + /// + /// Gets or sets the authentication type. + /// + public AuthenticationType Type { get; set; } = AuthenticationType.UsernamePassword; + + /// + /// Gets or sets the password for basic authentication. + /// + public string? Password { get; set; } + + /// + /// Gets or sets the path to the RSA private key file. + /// + public string? PrivateKeyPath { get; set; } + + /// + /// Gets or sets the RSA private key value in PKCS8 format (inline, not from file). + /// + public string? PrivateKey { get; set; } + + /// + /// Gets or sets the passphrase for encrypted private keys. + /// + public string? PrivateKeyPassphrase { get; set; } + + /// + /// Gets or sets the access token for token-based authentication: an OAuth access token + /// () or a programmatic access token + /// (). Both arrive via the same ADBC option + /// (adbc.snowflake.sql.client_option.auth_token); the auth type decides how it is + /// presented to Snowflake. + /// + public string? Token { get; set; } + + /// + /// Gets or sets additional SSO properties. + /// + public Dictionary SsoProperties { get; set; } = new(); +} diff --git a/csharp/src/Native/Configuration/AuthenticationType.cs b/csharp/src/Native/Configuration/AuthenticationType.cs new file mode 100644 index 0000000..0cf563a --- /dev/null +++ b/csharp/src/Native/Configuration/AuthenticationType.cs @@ -0,0 +1,54 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +namespace AdbcDrivers.Snowflake.Native.Configuration; + +/// +/// Represents the available authentication types for Snowflake. +/// +internal enum AuthenticationType +{ + /// + /// Username and password authentication. + /// + UsernamePassword, + + /// + /// RSA key pair authentication. + /// + KeyPair, + + /// + /// OAuth 2.0 token authentication. + /// + OAuth, + + /// + /// Programmatic access token (PAT) authentication — Snowflake's replacement for + /// password-style programmatic access. The user must be subject to a network policy. + /// + Pat, + + /// + /// Single Sign-On authentication. + /// + Sso, + + /// + /// External browser authentication. + /// + ExternalBrowser +} diff --git a/csharp/src/Native/Configuration/ConnectionConfig.cs b/csharp/src/Native/Configuration/ConnectionConfig.cs new file mode 100644 index 0000000..0636596 --- /dev/null +++ b/csharp/src/Native/Configuration/ConnectionConfig.cs @@ -0,0 +1,118 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.ComponentModel.DataAnnotations; + +namespace AdbcDrivers.Snowflake.Native.Configuration; + +/// +/// Represents connection configuration parameters for Snowflake ADBC driver. +/// +internal class ConnectionConfig +{ + /// + /// Gets or sets the Snowflake account identifier. + /// + [Required] + public string Account { get; set; } = string.Empty; + + /// + /// Gets or sets the username for authentication. + /// Optional when using OAuth (user is derived from the token). + /// + public string User { get; set; } = string.Empty; + + /// + /// Gets or sets the default database name. + /// + public string? Database { get; set; } + + /// + /// Gets or sets the default schema name. + /// + public string? Schema { get; set; } + + /// + /// Gets or sets the warehouse to use for query execution. + /// + public string? Warehouse { get; set; } + + /// + /// Gets or sets the role to assume after connection. + /// + public string? Role { get; set; } + + /// + /// Gets or sets the default query tag applied to every statement on the connection, surfaced in + /// the Snowsight query history. A statement can override it via + /// adbc.snowflake.statement.query_tag. + /// + public string? QueryTag { get; set; } + + /// + /// Gets or sets the authentication configuration. + /// + [Required] + public AuthenticationConfig Authentication { get; set; } = new(); + + /// + /// Gets or sets the connection pool configuration. + /// + public ConnectionPoolConfig PoolConfig { get; set; } = new(); + + /// + /// Gets or sets the per-statement (query) timeout — Snowflake's STATEMENT_TIMEOUT_IN_SECONDS. + /// Set from adbc.snowflake.sql.client_option.request_timeout (gosnowflake requestTimeout). + /// + public TimeSpan QueryTimeout { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets how long to wait for login/authentication to complete before failing, bounding the + /// connection-establishment round trip (gosnowflake loginTimeout). Defaults to 60 seconds. + /// + public TimeSpan LoginTimeout { get; set; } = TimeSpan.FromSeconds(60); + + /// + /// Gets or sets how many result-set chunks are downloaded in parallel while streaming a large + /// result (adbc.snowflake.rpc.prefetch_concurrency). Defaults to 10. + /// + public int PrefetchConcurrency { get; set; } = 10; + + /// + /// Gets or sets whether to enable compression for requests. + /// + public bool EnableCompression { get; set; } = true; + + /// + /// Gets or sets the network transport configuration (host, proxy). + /// + public NetworkConfig Network { get; set; } = new(); + + /// + /// Gets or sets whether the driver keeps idle pooled sessions alive with a periodic heartbeat + /// (Snowflake's CLIENT_SESSION_KEEP_ALIVE). Off by default; when on, an idle connection is + /// pinged every so it does not lapse to master-token expiry. + /// + public bool ClientSessionKeepAlive { get; set; } + + /// + /// Gets or sets how often an idle session is heartbeated when + /// is enabled. Defaults to one hour (well under the ~4h master-token window); the parser clamps it + /// to [15 minutes, 1 hour]. + /// + public TimeSpan HeartbeatFrequency { get; set; } = TimeSpan.FromHours(1); +} diff --git a/csharp/src/Native/Configuration/ConnectionPoolConfig.cs b/csharp/src/Native/Configuration/ConnectionPoolConfig.cs new file mode 100644 index 0000000..ff8d628 --- /dev/null +++ b/csharp/src/Native/Configuration/ConnectionPoolConfig.cs @@ -0,0 +1,49 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.ComponentModel.DataAnnotations; + +namespace AdbcDrivers.Snowflake.Native.Configuration; + +/// +/// Represents connection pool configuration parameters. +/// +internal class ConnectionPoolConfig +{ + /// + /// Gets or sets the maximum number of connections in the pool. + /// + [Range(1, 100)] + public int MaxPoolSize { get; set; } = 10; + + /// + /// Gets or sets the maximum idle time before a connection is removed from the pool. + /// + public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(10); + + /// + /// Gets or sets the maximum lifetime of a connection in the pool. + /// + public TimeSpan MaxConnectionLifetime { get; set; } = TimeSpan.FromHours(1); + + /// + /// Gets or sets how long to wait for an available connection when the pool is at + /// before failing, rather than blocking indefinitely. Defaults to + /// 120 seconds (matching the Snowflake .NET connector's waitingForIdleSessionTimeout). + /// + public TimeSpan AcquireTimeout { get; set; } = TimeSpan.FromSeconds(120); +} diff --git a/csharp/src/Native/Configuration/ConnectionStringParser.cs b/csharp/src/Native/Configuration/ConnectionStringParser.cs new file mode 100644 index 0000000..09f7d26 --- /dev/null +++ b/csharp/src/Native/Configuration/ConnectionStringParser.cs @@ -0,0 +1,297 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; + +using Apache.Arrow.Adbc; + +namespace AdbcDrivers.Snowflake.Native.Configuration; + +/// +/// Parses ADBC parameters into ConnectionConfig objects. +/// +internal static class ConnectionStringParser +{ + /// Lower bound for the keep-alive heartbeat frequency. + private static readonly TimeSpan MinHeartbeatFrequency = TimeSpan.FromMinutes(15); + + /// Upper bound for the keep-alive heartbeat frequency. + private static readonly TimeSpan MaxHeartbeatFrequency = TimeSpan.FromHours(1); + + /// + /// Parses ADBC parameters with connection-specific overrides into a ConnectionConfig object. + /// Connection parameters take precedence over database defaults. + /// + /// Connection-specific parameters (take precedence). + /// Database default parameters. + /// A configured ConnectionConfig object with merged parameters. + /// Thrown when the parameters are invalid. + public static ConnectionConfig ParseParameters( + IReadOnlyDictionary? connectionParameters = null, + IReadOnlyDictionary? databaseDefaults = null) + { + // If both are null, create empty dictionary (will fail validation) + if (connectionParameters == null && databaseDefaults == null) + { + return BuildConfig(new Dictionary(StringComparer.OrdinalIgnoreCase)); + } + + // If only one is provided, use it directly + if (databaseDefaults == null || databaseDefaults.Count == 0) + { + return BuildConfig(new Dictionary(connectionParameters!, StringComparer.OrdinalIgnoreCase)); + } + + if (connectionParameters == null || connectionParameters.Count == 0) + { + return BuildConfig(new Dictionary(databaseDefaults, StringComparer.OrdinalIgnoreCase)); + } + + // Both provided - merge with connection parameters taking precedence + var merged = new Dictionary(connectionParameters, StringComparer.OrdinalIgnoreCase); + + foreach (var kvp in databaseDefaults) + { + if (!merged.ContainsKey(kvp.Key)) + { + merged[kvp.Key] = kvp.Value; + } + } + + return BuildConfig(merged); + } + + private static ConnectionConfig BuildConfig(IReadOnlyDictionary parameters) + { + var config = new ConnectionConfig + { + Account = GetRequiredParameter(parameters, "adbc.snowflake.sql.account"), + User = GetOptionalParameter(parameters, "username") ?? string.Empty, + Database = GetOptionalParameter(parameters, AdbcOptions.Connection.CurrentCatalog) + ?? GetOptionalParameter(parameters, "adbc.snowflake.sql.db"), + Schema = GetOptionalParameter(parameters, AdbcOptions.Connection.CurrentDbSchema) + ?? GetOptionalParameter(parameters, "adbc.snowflake.sql.schema"), + Warehouse = GetOptionalParameter(parameters, "adbc.snowflake.sql.warehouse"), + Role = GetOptionalParameter(parameters, "adbc.snowflake.sql.role"), + QueryTag = GetOptionalParameter(parameters, SnowflakeStatement.QueryTagOption), + Authentication = ParseAuthenticationConfig(parameters) + }; + + if (GetOptionalInt(parameters, "adbc.snowflake.sql.client_option.request_timeout") is { } requestTimeoutSeconds) + config.QueryTimeout = TimeSpan.FromSeconds(requestTimeoutSeconds); + + if (GetOptionalInt(parameters, "adbc.snowflake.sql.client_option.login_timeout") is { } loginTimeoutSeconds) + config.LoginTimeout = TimeSpan.FromSeconds(loginTimeoutSeconds); + + if (GetOptionalInt(parameters, "adbc.snowflake.rpc.prefetch_concurrency") is { } prefetch) + config.PrefetchConcurrency = Math.Max(1, prefetch); + + if (GetOptionalBool(parameters, "adbc.snowflake.sql.client_option.enable_compression") is { } enableCompression) + config.EnableCompression = enableCompression; + + if (GetOptionalBool(parameters, "adbc.snowflake.sql.client_option.keep_session_alive") is { } keepAlive) + config.ClientSessionKeepAlive = keepAlive; + + if (GetOptionalInt(parameters, "adbc.snowflake.sql.client_option.keep_session_alive_heartbeat_frequency") is { } freqSeconds) + { + // Clamp to a safe band: frequent enough to stay under the ~4h master window, but not + // so frequent it hammers the server. Mirrors gosnowflake's heartbeat-frequency bounds. + var clamped = Math.Clamp(freqSeconds, (int)MinHeartbeatFrequency.TotalSeconds, (int)MaxHeartbeatFrequency.TotalSeconds); + config.HeartbeatFrequency = TimeSpan.FromSeconds(clamped); + } + + config.PoolConfig = ParseConnectionPoolConfig(parameters); + config.Network = ParseNetworkConfig(parameters); + + ValidateConfiguration(config); + + return config; + } + + private static AuthenticationConfig ParseAuthenticationConfig(IReadOnlyDictionary parameters) + { + var authConfig = new AuthenticationConfig(); + + // ADBC standard: adbc.snowflake.sql.auth_type + string? authTypeStr = GetOptionalParameter(parameters, "adbc.snowflake.sql.auth_type"); + + if (authTypeStr != null) + { + // Canonical values follow the ADBC Snowflake driver reference (auth_snowflake, + // auth_jwt, ...); the connector-net-style spellings are kept as aliases. + authConfig.Type = authTypeStr.ToLowerInvariant() switch + { + "auth_snowflake" or "snowflake" => AuthenticationType.UsernamePassword, + "auth_jwt" or "snowflake_jwt" or "jwt" => AuthenticationType.KeyPair, + "auth_oauth" or "oauth" => AuthenticationType.OAuth, + "auth_pat" or "programmatic_access_token" or "pat" => AuthenticationType.Pat, + "auth_ext_browser" or "externalbrowser" => AuthenticationType.ExternalBrowser, + "auth_okta" or "auth_mfa" or "auth_wif" => throw new ArgumentException( + $"auth_type '{authTypeStr}' is a recognized ADBC Snowflake auth method but is not supported by this driver yet."), + _ => throw new ArgumentException($"Unsupported auth_type: {authTypeStr}") + }; + } + + // Password - ADBC standard doesn't prefix this + authConfig.Password = GetOptionalParameter(parameters, "password"); + + // Private key file path - ADBC standard: adbc.snowflake.sql.client_option.jwt_private_key + authConfig.PrivateKeyPath = GetOptionalParameter(parameters, "adbc.snowflake.sql.client_option.jwt_private_key"); + + // Private key value (inline PEM) - ADBC standard: adbc.snowflake.sql.client_option.jwt_private_key_pkcs8_value + authConfig.PrivateKey = GetOptionalParameter(parameters, "adbc.snowflake.sql.client_option.jwt_private_key_pkcs8_value"); + + // Private key passphrase - ADBC standard: adbc.snowflake.sql.client_option.jwt_private_key_pkcs8_password + authConfig.PrivateKeyPassphrase = GetOptionalParameter(parameters, "adbc.snowflake.sql.client_option.jwt_private_key_pkcs8_password"); + + // Access token (OAuth or PAT, per auth_type) - ADBC standard: adbc.snowflake.sql.client_option.auth_token + authConfig.Token = GetOptionalParameter(parameters, "adbc.snowflake.sql.client_option.auth_token"); + + return authConfig; + } + + private static ConnectionPoolConfig ParseConnectionPoolConfig(IReadOnlyDictionary parameters) + { + var poolConfig = new ConnectionPoolConfig(); + + // Client-side pooling is our own feature (the ADBC Snowflake/gosnowflake driver has none), so + // these keys live under our own adbc.snowflake.pool.* namespace for consistency with the rest. + if (GetOptionalInt(parameters, "adbc.snowflake.pool.max_size") is { } maxPoolSize) + poolConfig.MaxPoolSize = maxPoolSize; + + if (GetOptionalParameter(parameters, "adbc.snowflake.pool.idle_timeout") is { } idleTimeoutStr) + poolConfig.IdleTimeout = ParseTimeSpan(idleTimeoutStr); + + if (GetOptionalParameter(parameters, "adbc.snowflake.pool.acquire_timeout") is { } acquireTimeoutStr) + poolConfig.AcquireTimeout = ParseTimeSpan(acquireTimeoutStr); + + if (GetOptionalParameter(parameters, "adbc.snowflake.pool.max_lifetime") is { } maxLifetimeStr) + poolConfig.MaxConnectionLifetime = ParseTimeSpan(maxLifetimeStr); + + return poolConfig; + } + + private static TimeSpan ParseTimeSpan(string value) + { + // Support Snowflake format (e.g., "30s", "60m") and plain seconds + if (int.TryParse(value, out int seconds)) + return TimeSpan.FromSeconds(seconds); + + if (value.EndsWith("s", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(value[..^1], out int s)) + { + return TimeSpan.FromSeconds(s); + } + } + else if (value.EndsWith("m", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(value[..^1], out int m)) + { + return TimeSpan.FromMinutes(m); + } + } + else if (value.EndsWith("h", StringComparison.OrdinalIgnoreCase)) + { + if (int.TryParse(value[..^1], out int h)) + { + return TimeSpan.FromHours(h); + } + } + + throw new ArgumentException($"Invalid timespan format: {value}. Expected format: number with optional suffix (s, m, h) or plain seconds."); + } + + internal static NetworkConfig ParseNetworkConfig(IReadOnlyDictionary? parameters) + { + var network = new NetworkConfig(); + if (parameters is null) + return network; + + network.Host = GetOptionalParameter(parameters, "adbc.snowflake.sql.uri.host"); + + if (GetOptionalInt(parameters, "adbc.snowflake.sql.uri.port") is { } port) + network.Port = port; + + if (GetOptionalParameter(parameters, "adbc.snowflake.sql.uri.protocol") is { } protocol) + network.Protocol = protocol; + + if (GetOptionalBool(parameters, "adbc.snowflake.sql.client_option.no_proxy") is { } noProxy) + network.NoProxy = noProxy; + + if (GetOptionalBool(parameters, "adbc.snowflake.sql.client_option.tls_skip_verify") is { } tlsSkipVerify) + network.TlsSkipVerify = tlsSkipVerify; + + return network; + } + + private static string GetRequiredParameter(IReadOnlyDictionary parameters, string key) + { + if (!parameters.TryGetValue(key, out string? value) || string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException($"Required parameter '{key}' is missing or empty."); + } + return value; + } + + private static string? GetOptionalParameter(IReadOnlyDictionary parameters, string key) + { + parameters.TryGetValue(key, out string? value); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + private static int? GetOptionalInt(IReadOnlyDictionary parameters, string key) + { + var value = GetOptionalParameter(parameters, key); + if (value is null) + return null; + + return !int.TryParse(value, out int parsed) + ? throw new ArgumentException($"Parameter '{key}' must be a whole number but was '{value}'.") + : parsed; + } + + private static bool? GetOptionalBool(IReadOnlyDictionary parameters, string key) + { + var value = GetOptionalParameter(parameters, key); + if (value is null) + return null; + + return !bool.TryParse(value, out bool parsed) + ? throw new ArgumentException($"Parameter '{key}' must be 'true' or 'false' but was '{value}'.") + : parsed; + } + + private static void ValidateConfiguration(ConnectionConfig config) + { + var validationResults = new List(); + var validationContext = new ValidationContext(config); + + Validator.TryValidateObject(config, validationContext, validationResults, true); + + var poolValidationContext = new ValidationContext(config.PoolConfig); + Validator.TryValidateObject(config.PoolConfig, poolValidationContext, validationResults, true); + + if (!validationResults.Any()) + return; + + var errorMessages = validationResults.Select(vr => vr.ErrorMessage).ToArray(); + throw new ArgumentException($"Configuration validation failed: {string.Join("; ", errorMessages)}"); + } +} diff --git a/csharp/src/Native/Configuration/NetworkConfig.cs b/csharp/src/Native/Configuration/NetworkConfig.cs new file mode 100644 index 0000000..3a10f08 --- /dev/null +++ b/csharp/src/Native/Configuration/NetworkConfig.cs @@ -0,0 +1,38 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +namespace AdbcDrivers.Snowflake.Native.Configuration; + +/// +/// Network configuration for HTTP transport (host override, proxy settings). +/// +internal class NetworkConfig +{ + /// Explicit host override. When set, used directly instead of deriving from account. + public string? Host { get; set; } + + /// Port override. Default is 443. + public int Port { get; set; } = 443; + + /// Protocol (https or http). Default is https. + public string Protocol { get; set; } = "https"; + + /// When true, explicitly disables all proxy usage (ignores system proxy settings). + public bool NoProxy { get; set; } + + /// Whether to skip TLS certificate verification. + public bool TlsSkipVerify { get; set; } +} diff --git a/csharp/src/Native/InMemoryArrowStream.cs b/csharp/src/Native/InMemoryArrowStream.cs new file mode 100644 index 0000000..c96d5dc --- /dev/null +++ b/csharp/src/Native/InMemoryArrowStream.cs @@ -0,0 +1,49 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Apache.Arrow.Ipc; + +using Apache.Arrow; + +namespace AdbcDrivers.Snowflake.Native; + +/// +/// A lightweight that yields a single, pre-built +/// record batch held in memory. Used for metadata results (e.g. GetTableTypes, +/// GetInfo, GetObjects) where the entire result is constructed up front. +/// +internal sealed class InMemoryArrowStream(Schema schema, IReadOnlyList data) : IArrowArrayStream +{ + private RecordBatch? _batch = new(schema, data, data[0].Length); + + public Schema Schema => schema; + + public ValueTask ReadNextRecordBatchAsync(CancellationToken cancellationToken = default) + { + RecordBatch? batch = _batch; + _batch = null; + return new ValueTask(batch); + } + + public void Dispose() + { + _batch?.Dispose(); + _batch = null; + } +} diff --git a/csharp/src/Native/Services/Authentication/AuthenticationService.cs b/csharp/src/Native/Services/Authentication/AuthenticationService.cs new file mode 100644 index 0000000..acf997d --- /dev/null +++ b/csharp/src/Native/Services/Authentication/AuthenticationService.cs @@ -0,0 +1,74 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Provides authentication services for Snowflake connections. +/// +internal class AuthenticationService : IAuthenticationService +{ + private readonly IBasicAuthenticator _basicAuth; + private readonly IKeyPairAuthenticator _keyPairAuth; + private readonly IOAuthAuthenticator _oauthAuth; + private readonly IPatAuthenticator _patAuth; + private readonly ISsoAuthenticator _ssoAuth; + + /// + /// Initializes a new instance of the class. + /// + /// The basic authenticator. + /// The key pair authenticator. + /// The OAuth authenticator. + /// The programmatic-access-token authenticator. + /// The SSO authenticator. + public AuthenticationService( + IBasicAuthenticator basicAuth, + IKeyPairAuthenticator keyPairAuth, + IOAuthAuthenticator oauthAuth, + IPatAuthenticator patAuth, + ISsoAuthenticator ssoAuth) + { + _basicAuth = basicAuth ?? throw new ArgumentNullException(nameof(basicAuth)); + _keyPairAuth = keyPairAuth ?? throw new ArgumentNullException(nameof(keyPairAuth)); + _oauthAuth = oauthAuth ?? throw new ArgumentNullException(nameof(oauthAuth)); + _patAuth = patAuth ?? throw new ArgumentNullException(nameof(patAuth)); + _ssoAuth = ssoAuth ?? throw new ArgumentNullException(nameof(ssoAuth)); + } + + /// + public async Task AuthenticateAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(config); + + return config.Authentication.Type switch + { + AuthenticationType.UsernamePassword => await _basicAuth.AuthenticateAsync(config, cancellationToken).ConfigureAwait(false), + AuthenticationType.KeyPair => await _keyPairAuth.AuthenticateAsync(config, cancellationToken).ConfigureAwait(false), + AuthenticationType.OAuth => await _oauthAuth.AuthenticateAsync(config, cancellationToken).ConfigureAwait(false), + AuthenticationType.Pat => await _patAuth.AuthenticateAsync(config, cancellationToken).ConfigureAwait(false), + AuthenticationType.Sso or AuthenticationType.ExternalBrowser => await _ssoAuth.AuthenticateAsync(config, cancellationToken).ConfigureAwait(false), + _ => throw new NotSupportedException($"Authentication type {config.Authentication.Type} is not supported.") + }; + } +} diff --git a/csharp/src/Native/Services/Authentication/AuthenticationToken.cs b/csharp/src/Native/Services/Authentication/AuthenticationToken.cs new file mode 100644 index 0000000..9682b65 --- /dev/null +++ b/csharp/src/Native/Services/Authentication/AuthenticationToken.cs @@ -0,0 +1,54 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Represents an authentication token for Snowflake connections. +/// +internal class AuthenticationToken +{ + /// + /// Gets or sets when the session token expires (~1h). Once past, a query gets GS + /// 390112 and the session is renewed from the master token; so this is informational, + /// not a hard wall. + /// + public DateTimeOffset ExpiresAt { get; set; } + + /// + /// Gets or sets when the master token expires (~4h) — the point past which the connection + /// is beyond recovery (renewal itself fails, GS 390114). This is what pool eviction keys on: + /// a session-expired-but-master-alive connection is still usable via renewal. + /// + public DateTimeOffset MasterExpiresAt { get; set; } + + /// + /// Gets or sets the session token (if available). + /// + public string? SessionToken { get; set; } + + /// + /// Gets or sets the master token (if available). + /// + public string? MasterToken { get; set; } + + /// + /// Gets or sets the session ID. + /// + public string? SessionId { get; set; } +} diff --git a/csharp/src/Native/Services/Authentication/BasicAuthenticator.cs b/csharp/src/Native/Services/Authentication/BasicAuthenticator.cs new file mode 100644 index 0000000..eabd04d --- /dev/null +++ b/csharp/src/Native/Services/Authentication/BasicAuthenticator.cs @@ -0,0 +1,73 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Implements basic username/password authentication for Snowflake. +/// +internal class BasicAuthenticator : IBasicAuthenticator +{ + private readonly SnowflakeLoginClient _loginClient; + + /// + /// Initializes a new instance of the class. + /// + /// The shared login client. + public BasicAuthenticator(SnowflakeLoginClient loginClient) + { + _loginClient = loginClient ?? throw new ArgumentNullException(nameof(loginClient)); + } + + /// + public async Task AuthenticateAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(config); + ValidateRequirements(config); + + var authData = new LoginRequestData + { + AUTHENTICATOR = "snowflake", + LOGIN_NAME = config.User, + PASSWORD = config.Authentication.Password + }; + + return await _loginClient.LoginAsync(config.Account, authData, config, cancellationToken).ConfigureAwait(false); + } + + /// Reports everything missing for username/password auth in a single error. + internal static void ValidateRequirements(ConnectionConfig config) + { + var missing = new List(); + if (string.IsNullOrEmpty(config.Account)) + missing.Add("account"); + if (string.IsNullOrEmpty(config.User)) + missing.Add("user"); + if (string.IsNullOrEmpty(config.Authentication.Password)) + missing.Add("password"); + + if (missing.Count > 0) + throw new ArgumentException($"Username/password authentication requires: {string.Join(", ", missing)}.", nameof(config)); + } +} diff --git a/csharp/src/Native/Services/Authentication/ClientEnvironment.cs b/csharp/src/Native/Services/Authentication/ClientEnvironment.cs new file mode 100644 index 0000000..7b92f33 --- /dev/null +++ b/csharp/src/Native/Services/Authentication/ClientEnvironment.cs @@ -0,0 +1,66 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Runtime.InteropServices; +using System.Text.Json.Serialization; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Represents client environment information for Snowflake authentication. +/// +internal class ClientEnvironment +{ + /// + /// Gets or sets the application name. + /// + [JsonPropertyName("APPLICATION")] + public string APPLICATION { get; set; } = string.Empty; + + /// + /// Gets or sets the operating system version. + /// + [JsonPropertyName("OS_VERSION")] + public string OS_VERSION { get; set; } = string.Empty; + + /// + /// Gets or sets the .NET runtime identifier. + /// + [JsonPropertyName("NET_RUNTIME")] + public string NET_RUNTIME { get; set; } = string.Empty; + + /// + /// Gets or sets the .NET version. + /// + [JsonPropertyName("NET_VERSION")] + public string NET_VERSION { get; set; } = string.Empty; + + /// + /// Creates a ClientEnvironment instance with system information. + /// + /// A populated ClientEnvironment instance. + public static ClientEnvironment Create() + { + return new ClientEnvironment + { + APPLICATION = "ADBC", + OS_VERSION = RuntimeInformation.OSDescription, + NET_RUNTIME = RuntimeInformation.FrameworkDescription, + NET_VERSION = Environment.Version.ToString() + }; + } +} diff --git a/csharp/src/Native/Services/Authentication/IAuthenticationService.cs b/csharp/src/Native/Services/Authentication/IAuthenticationService.cs new file mode 100644 index 0000000..f64764c --- /dev/null +++ b/csharp/src/Native/Services/Authentication/IAuthenticationService.cs @@ -0,0 +1,37 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Provides authentication services for Snowflake connections. +/// +internal interface IAuthenticationService +{ + /// + /// Authenticates using the connection configuration — the credentials + /// () plus the session context + /// (warehouse, database, schema, role) that some authenticators send with the login. + /// + /// The connection configuration. + /// The cancellation token. + /// An authentication token. + Task AuthenticateAsync(ConnectionConfig config, CancellationToken cancellationToken = default); +} diff --git a/csharp/src/Native/Services/Authentication/IBasicAuthenticator.cs b/csharp/src/Native/Services/Authentication/IBasicAuthenticator.cs new file mode 100644 index 0000000..ab766b6 --- /dev/null +++ b/csharp/src/Native/Services/Authentication/IBasicAuthenticator.cs @@ -0,0 +1,39 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Provides basic username/password authentication for Snowflake. +/// +internal interface IBasicAuthenticator +{ + /// + /// Authenticates using the username/password credentials in the connection configuration. + /// Validates its own requirements (account, user, password) and reports everything + /// missing in a single error. + /// + /// The connection configuration. + /// The cancellation token. + /// An authentication token. + Task AuthenticateAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default); +} diff --git a/csharp/src/Native/Services/Authentication/IKeyPairAuthenticator.cs b/csharp/src/Native/Services/Authentication/IKeyPairAuthenticator.cs new file mode 100644 index 0000000..abe314c --- /dev/null +++ b/csharp/src/Native/Services/Authentication/IKeyPairAuthenticator.cs @@ -0,0 +1,40 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Provides RSA key pair authentication for Snowflake. +/// +internal interface IKeyPairAuthenticator +{ + /// + /// Authenticates using the RSA key pair configured in the connection configuration + /// (a private-key file path or inline PKCS#8 PEM, plus an optional passphrase). + /// Validates its own requirements (account, user, key material) and reports everything + /// missing in a single error. + /// + /// The connection configuration. + /// The cancellation token. + /// An authentication token. + Task AuthenticateAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default); +} diff --git a/csharp/src/Native/Services/Authentication/IOAuthAuthenticator.cs b/csharp/src/Native/Services/Authentication/IOAuthAuthenticator.cs new file mode 100644 index 0000000..9d8d06e --- /dev/null +++ b/csharp/src/Native/Services/Authentication/IOAuthAuthenticator.cs @@ -0,0 +1,40 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Provides OAuth 2.0 authentication for Snowflake. +/// +internal interface IOAuthAuthenticator +{ + /// + /// Authenticates using the OAuth 2.0 access token in the connection configuration. + /// User identity is derived from the token by Snowflake, so no user is required. + /// Validates its own requirements (account, token) and reports everything missing in + /// a single error. + /// + /// The connection configuration. + /// The cancellation token. + /// An authentication token. + Task AuthenticateAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default); +} diff --git a/csharp/src/Native/Services/Authentication/IPatAuthenticator.cs b/csharp/src/Native/Services/Authentication/IPatAuthenticator.cs new file mode 100644 index 0000000..823725a --- /dev/null +++ b/csharp/src/Native/Services/Authentication/IPatAuthenticator.cs @@ -0,0 +1,39 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Provides programmatic access token (PAT) authentication for Snowflake. +/// +internal interface IPatAuthenticator +{ + /// + /// Authenticates using the programmatic access token in the connection configuration. + /// Validates its own requirements (account, user, token) and reports everything missing + /// in a single error. + /// + /// The connection configuration. + /// The cancellation token. + /// An authentication token. + Task AuthenticateAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default); +} diff --git a/csharp/src/Native/Services/Authentication/ISsoAuthenticator.cs b/csharp/src/Native/Services/Authentication/ISsoAuthenticator.cs new file mode 100644 index 0000000..7377f4b --- /dev/null +++ b/csharp/src/Native/Services/Authentication/ISsoAuthenticator.cs @@ -0,0 +1,38 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Provides Single Sign-On (SSO) authentication for Snowflake. +/// +internal interface ISsoAuthenticator +{ + /// + /// Authenticates using SSO with external browser. Validates its own requirements + /// (account, user) and reports everything missing in a single error. + /// + /// The connection configuration. + /// The cancellation token. + /// An authentication token. + Task AuthenticateAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default); +} diff --git a/csharp/src/Native/Services/Authentication/KeyPairAuthenticator.cs b/csharp/src/Native/Services/Authentication/KeyPairAuthenticator.cs new file mode 100644 index 0000000..721f483 --- /dev/null +++ b/csharp/src/Native/Services/Authentication/KeyPairAuthenticator.cs @@ -0,0 +1,169 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +using Apache.Arrow.Adbc; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Implements RSA key pair authentication for Snowflake. +/// +internal class KeyPairAuthenticator : IKeyPairAuthenticator +{ + private readonly SnowflakeLoginClient _loginClient; + + /// + /// Initializes a new instance of the class. + /// + /// The shared login client. + public KeyPairAuthenticator(SnowflakeLoginClient loginClient) + { + _loginClient = loginClient ?? throw new ArgumentNullException(nameof(loginClient)); + } + + /// + public async Task AuthenticateAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(config); + ValidateRequirements(config); + + string privateKeyPem = await ResolvePrivateKeyPemAsync(config.Authentication, cancellationToken).ConfigureAwait(false); + var jwtToken = GenerateJwtToken(config.Account, config.User, privateKeyPem, config.Authentication.PrivateKeyPassphrase); + + var authData = new LoginRequestData + { + AUTHENTICATOR = "SNOWFLAKE_JWT", + LOGIN_NAME = config.User, + TOKEN = jwtToken + }; + + return await _loginClient.LoginAsync(config.Account, authData, config, cancellationToken).ConfigureAwait(false); + } + + internal static void ValidateRequirements(ConnectionConfig config) + { + var missing = new List(); + if (string.IsNullOrEmpty(config.Account)) + missing.Add("account"); + if (string.IsNullOrEmpty(config.User)) + missing.Add("user"); + if (string.IsNullOrEmpty(config.Authentication.PrivateKeyPath) && string.IsNullOrEmpty(config.Authentication.PrivateKey)) + missing.Add("a private key (file path or inline PKCS#8 value)"); + + if (missing.Count > 0) + throw new ArgumentException($"Key-pair authentication requires: {string.Join(", ", missing)}.", nameof(config)); + } + + /// + /// Resolves the key material: a configured file path is read from disk; otherwise the + /// inline PEM (jwt_private_key_pkcs8_value) is used as-is — never treated as a path. + /// + internal static async Task ResolvePrivateKeyPemAsync(AuthenticationConfig authConfig, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(authConfig.PrivateKeyPath)) + return authConfig.PrivateKey!; + + if (!File.Exists(authConfig.PrivateKeyPath)) + throw new AdbcException($"Private key file not found: {authConfig.PrivateKeyPath}"); + + return await File.ReadAllTextAsync(authConfig.PrivateKeyPath, cancellationToken).ConfigureAwait(false); + } + + /// + /// Builds the RS256-signed login JWT. The issuer and subject use the bare account locator — + /// a region/cloud suffix (anything after the first '.') is dropped — and the issuer carries + /// the public-key fingerprint as SHA256: + base64(SHA-256(SubjectPublicKeyInfo)), + /// matching gosnowflake and connector-net; Snowflake rejects the token without the prefix. + /// + internal static string GenerateJwtToken(string account, string user, string privateKeyPem, string? passphrase) + { + try + { + using var rsa = RSA.Create(); + + if (!string.IsNullOrEmpty(passphrase)) + { + rsa.ImportFromEncryptedPem(privateKeyPem, passphrase); + } + else + { + rsa.ImportFromPem(privateKeyPem); + } + + var publicKey = rsa.ExportSubjectPublicKeyInfo(); + var publicKeyFingerprint = "SHA256:" + Convert.ToBase64String(SHA256.HashData(publicKey)); + + int regionSeparator = account.IndexOf('.'); + string accountName = (regionSeparator > 0 ? account[..regionSeparator] : account).ToUpperInvariant(); + string userName = user.ToUpperInvariant(); + + var header = new + { + alg = "RS256", + typ = "JWT" + }; + + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var payload = new + { + iss = $"{accountName}.{userName}.{publicKeyFingerprint}", + sub = $"{accountName}.{userName}", + iat = now, + exp = now + 3600 + }; + + var headerBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(header))); + var payloadBase64 = Base64UrlEncode(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload))); + + var signatureInput = $"{headerBase64}.{payloadBase64}"; + var signatureBytes = rsa.SignData( + Encoding.UTF8.GetBytes(signatureInput), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + + return $"{signatureInput}.{Base64UrlEncode(signatureBytes)}"; + } + catch (CryptographicException ex) + { + throw new AdbcException($"Failed to process private key: {ex.Message}", ex); + } + catch (ArgumentException ex) + { + // ImportFromPem reports text with no recognizable PEM block as ArgumentException. + throw new AdbcException($"Failed to process private key: {ex.Message}", ex); + } + } + + private static string Base64UrlEncode(byte[] input) + { + return Convert.ToBase64String(input) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/csharp/src/Native/Services/Authentication/LoginRequestModels.cs b/csharp/src/Native/Services/Authentication/LoginRequestModels.cs new file mode 100644 index 0000000..8df2789 --- /dev/null +++ b/csharp/src/Native/Services/Authentication/LoginRequestModels.cs @@ -0,0 +1,116 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Represents the login request body sent to Snowflake. +/// +internal class LoginRequestBody +{ + /// + /// Gets or sets the login request data. + /// + [JsonPropertyName("data")] + public LoginRequestData Data { get; set; } = new(); +} + +/// +/// Represents the data portion of the login request. +/// +internal class LoginRequestData +{ + /// + /// Gets or sets the client application ID. + /// + [JsonPropertyName("CLIENT_APP_ID")] + public string CLIENT_APP_ID { get; set; } = string.Empty; + + /// + /// Gets or sets the client application version. + /// + [JsonPropertyName("CLIENT_APP_VERSION")] + public string CLIENT_APP_VERSION { get; set; } = string.Empty; + + /// + /// Gets or sets the Snowflake account name. + /// + [JsonPropertyName("ACCOUNT_NAME")] + public string ACCOUNT_NAME { get; set; } = string.Empty; + + /// + /// Gets or sets the login name (username). + /// + [JsonPropertyName("LOGIN_NAME")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? LOGIN_NAME { get; set; } + + /// + /// Gets or sets the password. + /// + [JsonPropertyName("PASSWORD")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PASSWORD { get; set; } + + /// + /// Gets or sets the OAuth token. + /// + [JsonPropertyName("TOKEN")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? TOKEN { get; set; } + + /// + /// Gets or sets the authenticator type. + /// + [JsonPropertyName("AUTHENTICATOR")] + public string AUTHENTICATOR { get; set; } = "snowflake"; + + /// + /// Gets or sets the client environment information. + /// + [JsonPropertyName("CLIENT_ENVIRONMENT")] + public ClientEnvironment CLIENT_ENVIRONMENT { get; set; } = new(); + + /// + /// Gets or sets the raw SAML response for SSO authentication. + /// + [JsonPropertyName("RAW_SAML_RESPONSE")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? RAW_SAML_RESPONSE { get; set; } + + /// + /// Gets or sets the proof key for external browser authentication. + /// + [JsonPropertyName("PROOF_KEY")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? PROOF_KEY { get; set; } + + /// + /// Gets or sets the browser mode redirect port for external browser authentication. + /// + [JsonPropertyName("BROWSER_MODE_REDIRECT_PORT")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? BROWSER_MODE_REDIRECT_PORT { get; set; } + + /// + /// Gets or sets the session parameters. + /// + [JsonPropertyName("SESSION_PARAMETERS")] + public Dictionary SESSION_PARAMETERS { get; set; } = new(); +} diff --git a/csharp/src/Native/Services/Authentication/LoginResponseModels.cs b/csharp/src/Native/Services/Authentication/LoginResponseModels.cs new file mode 100644 index 0000000..4a79e07 --- /dev/null +++ b/csharp/src/Native/Services/Authentication/LoginResponseModels.cs @@ -0,0 +1,61 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Represents the login response from Snowflake. +/// +internal class LoginResponse +{ + public bool Success { get; set; } + public string? Message { get; set; } + public LoginData? Data { get; set; } +} + +/// +/// Represents the data portion of the login response. +/// +internal class LoginData +{ + public string? Token { get; set; } + public long? SessionId { get; set; } + public string? MasterToken { get; set; } + + /// Session-token validity in seconds (Snowflake validityInSeconds, ~1h). + public int ValidityInSeconds { get; set; } = 3600; + + /// Master-token validity in seconds (Snowflake masterValidityInSeconds, ~4h). + public int MasterValidityInSeconds { get; set; } = 14400; +} + +/// +/// Represents the authenticator-request response from Snowflake (for SSO). +/// +internal class AuthenticatorResponse +{ + public bool Success { get; set; } + public AuthenticatorResponseData? Data { get; set; } +} + +/// +/// Represents the data portion of the authenticator-request response. +/// +internal class AuthenticatorResponseData +{ + public string? SsoUrl { get; set; } + public string? ProofKey { get; set; } +} diff --git a/csharp/src/Native/Services/Authentication/OAuthAuthenticator.cs b/csharp/src/Native/Services/Authentication/OAuthAuthenticator.cs new file mode 100644 index 0000000..ab9da53 --- /dev/null +++ b/csharp/src/Native/Services/Authentication/OAuthAuthenticator.cs @@ -0,0 +1,70 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Implements OAuth 2.0 authentication for Snowflake: exchanges a caller-supplied OAuth token for a +/// Snowflake session by logging in with authenticator=OAUTH. +/// +internal class OAuthAuthenticator : IOAuthAuthenticator +{ + private readonly SnowflakeLoginClient _loginClient; + + /// + /// Initializes a new instance of the class. + /// + /// The shared login client. + public OAuthAuthenticator(SnowflakeLoginClient loginClient) + { + _loginClient = loginClient ?? throw new ArgumentNullException(nameof(loginClient)); + } + + /// + public async Task AuthenticateAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(config); + ValidateRequirements(config); + + var authData = new LoginRequestData + { + AUTHENTICATOR = "OAUTH", + TOKEN = config.Authentication.Token + }; + + return await _loginClient.LoginAsync(config.Account, authData, config, cancellationToken).ConfigureAwait(false); + } + + internal static void ValidateRequirements(ConnectionConfig config) + { + var missing = new List(); + if (string.IsNullOrEmpty(config.Account)) + missing.Add("account"); + if (string.IsNullOrEmpty(config.Authentication.Token)) + missing.Add("an OAuth token"); + + if (missing.Count > 0) + throw new ArgumentException($"OAuth authentication requires: {string.Join(", ", missing)}.", nameof(config)); + } +} diff --git a/csharp/src/Native/Services/Authentication/PatAuthenticator.cs b/csharp/src/Native/Services/Authentication/PatAuthenticator.cs new file mode 100644 index 0000000..87df355 --- /dev/null +++ b/csharp/src/Native/Services/Authentication/PatAuthenticator.cs @@ -0,0 +1,76 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Implements programmatic access token (PAT) authentication: presents the caller-supplied PAT +/// to the login endpoint with authenticator=PROGRAMMATIC_ACCESS_TOKEN. Unlike OAuth, a +/// PAT is bound to a specific user, so the login carries the user name; Snowflake additionally +/// requires that user to be subject to a network policy. +/// +internal class PatAuthenticator : IPatAuthenticator +{ + private readonly SnowflakeLoginClient _loginClient; + + /// + /// Initializes a new instance of the class. + /// + /// The shared login client. + public PatAuthenticator(SnowflakeLoginClient loginClient) + { + _loginClient = loginClient ?? throw new ArgumentNullException(nameof(loginClient)); + } + + /// + public async Task AuthenticateAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(config); + ValidateRequirements(config); + + var authData = new LoginRequestData + { + AUTHENTICATOR = "PROGRAMMATIC_ACCESS_TOKEN", + LOGIN_NAME = config.User, + TOKEN = config.Authentication.Token + }; + + return await _loginClient.LoginAsync(config.Account, authData, config, cancellationToken).ConfigureAwait(false); + } + + /// Reports everything missing for PAT auth in a single error. + internal static void ValidateRequirements(ConnectionConfig config) + { + var missing = new List(); + if (string.IsNullOrEmpty(config.Account)) + missing.Add("account"); + if (string.IsNullOrEmpty(config.User)) + missing.Add("user"); + if (string.IsNullOrEmpty(config.Authentication.Token)) + missing.Add("a programmatic access token"); + + if (missing.Count > 0) + throw new ArgumentException($"Programmatic access token authentication requires: {string.Join(", ", missing)}.", nameof(config)); + } +} diff --git a/csharp/src/Native/Services/Authentication/SnowflakeLoginClient.cs b/csharp/src/Native/Services/Authentication/SnowflakeLoginClient.cs new file mode 100644 index 0000000..d3974a4 --- /dev/null +++ b/csharp/src/Native/Services/Authentication/SnowflakeLoginClient.cs @@ -0,0 +1,174 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using AdbcDrivers.Snowflake.Native.Configuration; +using Apache.Arrow.Adbc; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Shared client for the Snowflake login protocol. +/// +internal class SnowflakeLoginClient +{ + readonly HttpClient _httpClient; + + const string LoginEndpoint = "/session/v1/login-request"; + internal const string AuthenticatorEndpoint = "/session/authenticator-request"; + const string SessionEndpoint = "/session"; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client for making requests. + public SnowflakeLoginClient(HttpClient httpClient) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + /// Performs a login request to Snowflake. + /// + /// The Snowflake account identifier. + /// Auth-specific fields set by the caller. + /// Optional connection configuration. + /// The cancellation token. + /// An authentication token. + public async Task LoginAsync( + string account, + LoginRequestData authData, + ConnectionConfig? config = null, + CancellationToken cancellationToken = default) + { + // Fill in common fields. CLIENT_APP_ID/CLIENT_APP_VERSION are NOT free-form identity: + // Snowflake gates server-side capabilities on them — a ".NET" client below the version + // that introduced Arrow support gets JSON results regardless of the requested + // DOTNET_QUERY_RESULT_FORMAT. So this must claim an Arrow-capable connector-net version, + // not this driver's own assembly version. + authData.CLIENT_APP_ID = ".NET"; + authData.CLIENT_APP_VERSION = "3.1.0"; + authData.ACCOUNT_NAME = account; + authData.CLIENT_ENVIRONMENT = ClientEnvironment.Create(); + authData.SESSION_PARAMETERS = new Dictionary + { + { "DOTNET_QUERY_RESULT_FORMAT", "ARROW" } + }; + + var loginUrl = BuildUrl(account, LoginEndpoint, config); + var loginRequest = new LoginRequestBody { Data = authData }; + + try + { + var response = await _httpClient.PostAsJsonAsync(loginUrl, loginRequest, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var responseContent = await response.Content.ReadFromJsonAsync(cancellationToken).ConfigureAwait(false); + + if (responseContent?.Data == null) + throw new AdbcException("Invalid response from Snowflake authentication service."); + + if (!responseContent.Success) + { + var errorMessage = responseContent.Message ?? "Authentication failed."; + throw new AdbcException($"Snowflake authentication failed: {errorMessage}"); + } + + return new AuthenticationToken + { + SessionToken = responseContent.Data.Token ?? throw new AdbcException("No token received from Snowflake."), + SessionId = responseContent.Data.SessionId?.ToString(), + MasterToken = responseContent.Data.MasterToken, + ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(responseContent.Data.ValidityInSeconds), + MasterExpiresAt = DateTimeOffset.UtcNow.AddSeconds(responseContent.Data.MasterValidityInSeconds) + }; + } + catch (HttpRequestException ex) + { + throw new AdbcException($"Failed to authenticate with Snowflake: {ex.Message}", ex); + } + catch (JsonException ex) + { + throw new AdbcException($"Failed to parse Snowflake authentication response: {ex.Message}", ex); + } + } + + /// + /// Best-effort close of a Snowflake session (POST /session?delete=true) so it is not + /// left orphaned on the server until it times out. Failures are ignored — an un-closed session + /// simply expires naturally. + /// + internal async Task CloseSessionAsync(AuthenticationToken token, ConnectionConfig config, CancellationToken cancellationToken = default) + { + // Nothing to close without a session token (it can legitimately be null/empty). + if (string.IsNullOrEmpty(token.SessionToken)) + return; + + var accountUrl = SnowflakeAccountUrl.Build(config.Account, config.Network); + var requestId = Guid.NewGuid().ToString(); + var requestGuid = Guid.NewGuid().ToString(); + var url = $"{accountUrl}{SessionEndpoint}?delete=true&requestId={requestId}&request_guid={requestGuid}"; + + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Headers.TryAddWithoutValidation("Authorization", $"Snowflake Token=\"{token.SessionToken}\""); + request.Headers.TryAddWithoutValidation("Accept", "application/snowflake"); + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + /// + /// Builds a Snowflake URL for the given account and endpoint. + /// + /// The Snowflake account identifier. + /// The API endpoint path. + /// Optional connection configuration for query parameters. + /// The fully-qualified URL. + internal string BuildUrl(string account, string endpoint, ConnectionConfig? config = null) + { + var accountUrl = SnowflakeAccountUrl.Build(account, config?.Network); + + var uriBuilder = new UriBuilder($"{accountUrl}{endpoint}"); + var query = HttpUtility.ParseQueryString(string.Empty); + + if (config != null) + { + if (!string.IsNullOrEmpty(config.Warehouse)) + query["warehouse"] = config.Warehouse; + + if (!string.IsNullOrEmpty(config.Database)) + query["databaseName"] = config.Database; + + if (!string.IsNullOrEmpty(config.Schema)) + query["schemaName"] = config.Schema; + + if (!string.IsNullOrEmpty(config.Role)) + query["roleName"] = config.Role; + } + + query["requestId"] = Guid.NewGuid().ToString(); + query["request_guid"] = Guid.NewGuid().ToString(); + + uriBuilder.Query = query.ToString(); + return uriBuilder.ToString(); + } +} diff --git a/csharp/src/Native/Services/Authentication/SsoAuthenticator.cs b/csharp/src/Native/Services/Authentication/SsoAuthenticator.cs new file mode 100644 index 0000000..9be8087 --- /dev/null +++ b/csharp/src/Native/Services/Authentication/SsoAuthenticator.cs @@ -0,0 +1,284 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Net.Sockets; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +using Apache.Arrow.Adbc; + +namespace AdbcDrivers.Snowflake.Native.Services.Authentication; + +/// +/// Implements Single Sign-On (SSO) authentication for Snowflake using external browser. +/// Follows the same flow as the official snowflake-connector-net: +/// 1. Start local HTTP listener on a random port +/// 2. POST to /session/authenticator-request with the port → get ssoUrl + proofKey +/// 3. Open browser to ssoUrl +/// 4. Snowflake redirects back to localhost with ?token=... +/// 5. Send login request with Token + ProofKey +/// +internal class SsoAuthenticator : ISsoAuthenticator +{ + internal static readonly TimeSpan DefaultBrowserTimeout = TimeSpan.FromSeconds(120); + + private static readonly string SuccessHtml = + "" + + "Snowflake Authentication" + + "

Authentication Successful

" + + "

Your identity was confirmed. You can close this window and return to your application.

" + + ""; + + private static readonly string ErrorHtml = + "" + + "Snowflake Authentication" + + "

Authentication Failed

" + + "

Unable to extract authentication token. Please try again.

" + + ""; + + private const string TOKEN_QUERY_PREFIX = "?token="; + + private readonly SnowflakeLoginClient _loginClient; + private readonly HttpClient _httpClient; + + /// + /// Initializes a new instance of the class. + /// + /// The shared login client. + /// The HTTP client for the authenticator-request endpoint. + public SsoAuthenticator(SnowflakeLoginClient loginClient, HttpClient httpClient) + { + _loginClient = loginClient ?? throw new ArgumentNullException(nameof(loginClient)); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + public async Task AuthenticateAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(config); + ValidateRequirements(config); + + string account = config.Account; + string user = config.User; + + // Step 1: Find a free port and start the local HTTP listener + int localPort = GetRandomUnusedPort(); + using var listener = CreateHttpListener(localPort); + listener.Start(); + + try + { + // Step 2: Get SSO URL and proof key from Snowflake + var (ssoUrl, proofKey) = await GetSsoUrlAndProofKeyAsync( + account, user, localPort, cancellationToken).ConfigureAwait(false); + + // Step 3: Open browser for user authentication + OpenBrowser(ssoUrl); + + // Step 4: Wait for the redirect callback with the token + var token = await WaitForTokenAsync(listener, cancellationToken).ConfigureAwait(false); + + // Step 5: Complete authentication with token and proof key + var authData = new LoginRequestData + { + AUTHENTICATOR = "EXTERNALBROWSER", + LOGIN_NAME = user, + TOKEN = token, + PROOF_KEY = proofKey + }; + + return await _loginClient.LoginAsync(account, authData, config, cancellationToken).ConfigureAwait(false); + } + finally + { + listener.Stop(); + } + } + + static void ValidateRequirements(ConnectionConfig config) + { + var missing = new List(); + if (string.IsNullOrEmpty(config.Account)) + missing.Add("account"); + if (string.IsNullOrEmpty(config.User)) + missing.Add("user"); + + if (missing.Count > 0) + throw new ArgumentException($"External-browser SSO authentication requires: {string.Join(", ", missing)}.", nameof(config)); + } + + private async Task<(string SsoUrl, string ProofKey)> GetSsoUrlAndProofKeyAsync( + string account, + string user, + int localPort, + CancellationToken cancellationToken) + { + var authenticatorUrl = _loginClient.BuildUrl(account, SnowflakeLoginClient.AuthenticatorEndpoint); + var authenticatorRequest = new LoginRequestBody + { + Data = new LoginRequestData + { + ACCOUNT_NAME = account, + LOGIN_NAME = user, + AUTHENTICATOR = "EXTERNALBROWSER", + BROWSER_MODE_REDIRECT_PORT = localPort.ToString(), + CLIENT_APP_ID = ".NET", + CLIENT_APP_VERSION = "3.1.0", + CLIENT_ENVIRONMENT = ClientEnvironment.Create(), + SESSION_PARAMETERS = new Dictionary + { + { "DOTNET_QUERY_RESULT_FORMAT", "ARROW" } + } + } + }; + + try + { + var response = await _httpClient.PostAsJsonAsync(authenticatorUrl, authenticatorRequest, cancellationToken).ConfigureAwait(false); + var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var responseContent = JsonSerializer.Deserialize(responseBody, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (responseContent?.Data?.SsoUrl == null) + throw new AdbcException($"Failed to retrieve SSO URL from Snowflake. Response: {responseBody}"); + + if (responseContent.Data.ProofKey == null) + throw new AdbcException($"Failed to retrieve proof key from Snowflake. Response: {responseBody}"); + + return (responseContent.Data.SsoUrl, responseContent.Data.ProofKey); + } + catch (HttpRequestException ex) + { + throw new AdbcException($"Failed to get SSO URL from Snowflake: {ex.Message}", ex); + } + } + + private async Task WaitForTokenAsync( + HttpListener listener, + CancellationToken cancellationToken) + { + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(DefaultBrowserTimeout); + + // When the wait is abandoned (timeout or caller cancellation), stopping the listener + // faults this still-pending accept; observe it so it can never surface through + // TaskScheduler.UnobservedTaskException. + Task contextTask = listener.GetContextAsync(); + _ = ObserveAbandonedAcceptAsync(contextTask); + + try + { + var context = await contextTask.WaitAsync(timeoutCts.Token).ConfigureAwait(false); + + var query = context.Request.Url?.Query; + string? token = null; + + if (query != null && query.StartsWith(TOKEN_QUERY_PREFIX, StringComparison.Ordinal)) + { + token = Uri.UnescapeDataString(query.Substring(TOKEN_QUERY_PREFIX.Length)); + } + + if (string.IsNullOrEmpty(token)) + { + await SendResponseAsync(context, ErrorHtml).ConfigureAwait(false); + throw new AdbcException("No authentication token received from Snowflake SSO. " + + $"Received query: {query}"); + } + + await SendResponseAsync(context, SuccessHtml).ConfigureAwait(false); + return token; + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new AdbcException( + $"Browser authentication timed out after {DefaultBrowserTimeout.TotalSeconds} seconds. " + + "Please ensure your browser completed the SSO login."); + } + } + + /// + /// Awaits the listener accept purely to observe it: after the redirect wait is abandoned, + /// the fault raised by stopping the listener is expected and must not go unobserved. On + /// the success path the main flow has already consumed the context; awaiting again is a + /// no-op. + /// + private static async Task ObserveAbandonedAcceptAsync(Task contextTask) + { + try + { + await contextTask.ConfigureAwait(false); + } + catch + { + // Expected when the listener is stopped with the accept still pending. + } + } + + private static async Task SendResponseAsync(HttpListenerContext context, string html) + { + var responseBytes = System.Text.Encoding.UTF8.GetBytes(html); + context.Response.ContentType = "text/html; charset=UTF-8"; + context.Response.ContentLength64 = responseBytes.Length; + await context.Response.OutputStream.WriteAsync(responseBytes).ConfigureAwait(false); + context.Response.Close(); + } + + private static HttpListener CreateHttpListener(int port) + { + var listener = new HttpListener(); + // Bind both 127.0.0.1 and localhost to handle either redirect target + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + listener.Prefixes.Add($"http://localhost:{port}/"); + return listener; + } + + private static int GetRandomUnusedPort() + { + using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + socket.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + return ((IPEndPoint)socket.LocalEndPoint!).Port; + } + + private static void OpenBrowser(string url) + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = url, + UseShellExecute = true + }); + } + catch (Exception ex) + { + throw new AdbcException( + $"Failed to open browser for SSO authentication. " + + $"Please manually open: {url}", ex); + } + } +} diff --git a/csharp/src/Native/Services/ConnectionPool/ConnectionPoolEntry.cs b/csharp/src/Native/Services/ConnectionPool/ConnectionPoolEntry.cs new file mode 100644 index 0000000..305c724 --- /dev/null +++ b/csharp/src/Native/Services/ConnectionPool/ConnectionPoolEntry.cs @@ -0,0 +1,54 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Concurrent; +using System.Threading; +using AdbcDrivers.Snowflake.Native.Configuration; + +namespace AdbcDrivers.Snowflake.Native.Services.ConnectionPool; + +/// +/// Represents a single connection pool for a specific configuration. +/// +internal class ConnectionPoolEntry(ConnectionConfig config) +{ + /// + /// Gets the connection configuration for this pool. + /// + internal ConnectionConfig Config { get; } = config; + + /// + /// Gets the dictionary of active connections currently in use. + /// + internal ConcurrentDictionary ActiveConnections { get; } = new(); + + /// + /// Gets the stack of idle connections available for reuse. + /// + internal ConcurrentStack IdleConnections { get; } = new(); + + /// + /// Gets the semaphore that enforces the maximum pool size. + /// + internal SemaphoreSlim CapacitySemaphore { get; } = new( + config.PoolConfig.MaxPoolSize, + config.PoolConfig.MaxPoolSize); + + /// + /// Lock object for synchronizing access to idle connections. + /// + internal readonly object IdleLock = new(); +} diff --git a/csharp/src/Native/Services/ConnectionPool/ConnectionPoolManager.cs b/csharp/src/Native/Services/ConnectionPool/ConnectionPoolManager.cs new file mode 100644 index 0000000..ace3eb3 --- /dev/null +++ b/csharp/src/Native/Services/ConnectionPool/ConnectionPoolManager.cs @@ -0,0 +1,452 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +using Apache.Arrow.Adbc; + +namespace AdbcDrivers.Snowflake.Native.Services.ConnectionPool; + +/// +/// Implements connection pooling for Snowflake connections. +/// +internal class ConnectionPoolManager : IConnectionPoolManager +{ + private readonly IAuthenticationService _authService; + private readonly ISessionLifecycle? _sessionLifecycle; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly ConcurrentDictionary _pools; + // Signals the background maintenance loop (idle eviction + keep-alive heartbeats) to stop; the + // Cancel() lives in Dispose(), and the CTS itself is disposed only after the loop has exited. + private readonly CancellationTokenSource _cleanupCts = new(); + private readonly object _cleanupStartLock = new(); + private Task? _cleanupTask; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The authentication service. + /// + /// Optional collaborator for server-side session upkeep: it heartbeats idle keep-alive connections + /// (so long-idle pooled sessions don't lapse to master-token expiry) and closes a connection's + /// session when the pool discards it (so sessions aren't orphaned until they time out). + /// + /// + /// Clock used for pool timekeeping (idle/lifetime checks, heartbeat scheduling, the background + /// timer). Defaults to ; tests inject a fake to drive the loop + /// deterministically. + /// + /// Logger for pool maintenance events (heartbeat/cleanup failures). + public ConnectionPoolManager( + IAuthenticationService authService, + ISessionLifecycle? sessionLifecycle = null, + TimeProvider? timeProvider = null, + ILogger? logger = null) + { + _authService = authService ?? throw new ArgumentNullException(nameof(authService)); + _sessionLifecycle = sessionLifecycle; + _timeProvider = timeProvider ?? TimeProvider.System; + _logger = logger ?? NullLogger.Instance; + _pools = new ConcurrentDictionary(); + } + + /// + public async Task AcquireConnectionAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(config); + + EnsureCleanupStarted(); + + var poolKey = GeneratePoolKey(config); + var poolEntry = _pools.GetOrAdd(poolKey, static (_, cfg) => new ConnectionPoolEntry(cfg), config); + + await WaitForCapacityAsync(poolEntry, config, cancellationToken).ConfigureAwait(false); + + try + { + if (TryAcquireIdleConnection(poolEntry, out var idleConnection)) + return idleConnection!; + + var newConnection = await CreateConnectionAsync(poolKey, config, cancellationToken).ConfigureAwait(false); + poolEntry.ActiveConnections.TryAdd(newConnection.ConnectionId, newConnection); + return newConnection; + } + catch + { + poolEntry.CapacitySemaphore.Release(); + throw; + } + } + + /// + /// Waits (bounded by ) for a capacity permit on + /// the pool entry. On return the caller + /// holds one permit — the right to one active connection — which is returned by + /// (or by the acquire failure path). Throws + /// if the pool stays at capacity past the timeout; a cancelled token + /// propagates as . Neither a timeout nor a cancellation + /// consumes a permit. + /// + private static async Task WaitForCapacityAsync( + ConnectionPoolEntry poolEntry, ConnectionConfig config, CancellationToken cancellationToken) + { + bool entered = await poolEntry.CapacitySemaphore + .WaitAsync(config.PoolConfig.AcquireTimeout, cancellationToken).ConfigureAwait(false); + + if (!entered) + throw new AdbcException( + $"Timed out after {config.PoolConfig.AcquireTimeout.TotalSeconds:0}s waiting for an available " + + $"connection; the pool is at capacity (max {config.PoolConfig.MaxPoolSize})."); + } + + // Takes the evaluation instant as a parameter (rather than reading _timeProvider itself) so the + // clock is never touched while a caller holds poolEntry.IdleLock — no external call under a lock, + // and every connection in one operation is judged against the same instant. + private static bool IsConnectionValid(IPooledConnection connection, DateTimeOffset now) + { + return connection is { IsDisposed: false, IsFaulted: false, IsTokenExpired: false } && + (now - connection.CreatedAt) <= connection.Config.PoolConfig.MaxConnectionLifetime; + } + + /// + /// Seats the caller — who must already hold a capacity permit — on a valid idle connection if + /// one exists. Idle connections hold no permit, so the stale ones discarded along the way + /// involve no permit accounting. + /// + private bool TryAcquireIdleConnection(ConnectionPoolEntry poolEntry, out IPooledConnection? idleConnection) + { + var now = _timeProvider.GetUtcNow(); + List? stale = null; + idleConnection = null; + + lock (poolEntry.IdleLock) + { + while (poolEntry.IdleConnections.TryPop(out var connection)) + { + if (IsConnectionValid(connection, now)) + { + connection.UpdateLastUsedAt(); + poolEntry.ActiveConnections.TryAdd(connection.ConnectionId, connection); + idleConnection = connection; + break; + } + + (stale ??= []).Add(connection); + } + } + + // Dispose OUTSIDE the lock: Dispose best-effort closes the server-side session — a bounded + // network wait — and holding IdleLock across it would stall every other acquire/release on + // this pool entry for up to 5s per stale connection. + if (stale != null) + { + foreach (var connection in stale) + connection.Dispose(); + } + + return idleConnection != null; + } + + public void ReleaseConnection(IPooledConnection connection) + { + ArgumentNullException.ThrowIfNull(connection); + + if (!_pools.TryGetValue(connection.PoolKey, out var poolEntry)) + return; + + // The permit travels with the active connection; a connection that isn't active (double + // release, or already discarded) has no permit to return. + if (!poolEntry.ActiveConnections.TryRemove(connection.ConnectionId, out _)) + return; + + if (IsConnectionValid(connection, _timeProvider.GetUtcNow())) + poolEntry.IdleConnections.Push(connection); + else + connection.Dispose(); + + // Return the permit only after the connection is seated on the idle stack (or discarded), + // so a waiter woken by this release always finds the capacity it was promised. + poolEntry.CapacitySemaphore.Release(); + } + + /// + /// Disposes the connection pool and all pooled connections. + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + _cleanupCts.Cancel(); + + if (_cleanupTask != null) + { + try + { + _cleanupTask.Wait(TimeSpan.FromSeconds(5)); + } + catch (AggregateException) + { + // The cleanup loop observed cancellation while shutting down — expected. + } + } + + // Dispose the CTS only after the cleanup loop has stopped using its token. + _cleanupCts.Dispose(); + + foreach (var poolEntry in _pools.Values) + { + foreach (var connection in poolEntry.ActiveConnections.Values) + connection.Dispose(); + foreach (var connection in poolEntry.IdleConnections) + connection.Dispose(); + + poolEntry.CapacitySemaphore.Dispose(); + } + + _pools.Clear(); + } + + private void EnsureCleanupStarted() + { + if (_cleanupTask != null) return; + lock (_cleanupStartLock) + { + _cleanupTask ??= CleanupLoopAsync(); + } + } + + private async Task CleanupLoopAsync() + { + var timer = new PeriodicTimer(TimeSpan.FromSeconds(60), _timeProvider); + CancellationToken token = _cleanupCts.Token; + try + { + while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) + { + try + { + Cleanup(); + await HeartbeatIdleConnectionsAsync(token).ConfigureAwait(false); + } + catch (Exception ex) + { + // Swallow to keep the maintenance loop alive. + _logger.LogWarning(ex, "Pool maintenance pass failed; will retry on the next tick."); + } + } + } + catch (OperationCanceledException) + { + } + finally + { + timer.Dispose(); + } + } + + private void Cleanup() + { + if (_disposed) + return; + + foreach (var poolEntry in _pools.Values) + { + var now = _timeProvider.GetUtcNow(); + var connectionsToKeep = new List(); + var connectionsToRemove = new List(); + lock (poolEntry.IdleLock) + { + while (poolEntry.IdleConnections.TryPop(out var connection)) + { + var idleTime = now - connection.LastUsedAt; + if (!IsConnectionValid(connection, now) || + idleTime > poolEntry.Config.PoolConfig.IdleTimeout) + connectionsToRemove.Add(connection); + else + connectionsToKeep.Add(connection); + } + + foreach (IPooledConnection pooledConnection in connectionsToKeep) + { + poolEntry.IdleConnections.Push(pooledConnection); + } + } + + foreach (var connection in connectionsToRemove) + connection.Dispose(); + } + } + + /// + /// True when a connection has keep-alive enabled and has had no query or heartbeat for at least + /// its configured heartbeat frequency. + /// + internal static bool IsHeartbeatDue(IPooledConnection connection, DateTimeOffset now) + { + if (!connection.Config.ClientSessionKeepAlive) + return false; + + var lastActivity = connection.LastUsedAt > connection.LastHeartbeatAt + ? connection.LastUsedAt + : connection.LastHeartbeatAt; + return now - lastActivity >= connection.Config.HeartbeatFrequency; + } + + /// + /// Pings the keep-alive endpoint for every idle connection that is due. Idle-only by design: + /// idle connections have no in-flight query, so a heartbeat can't race the reactive token renewal + /// a running query may trigger. + /// + private Task HeartbeatIdleConnectionsAsync(CancellationToken token) + { + if (_sessionLifecycle == null) + return Task.CompletedTask; + + // ConcurrentStack.ToArray is a lock-free snapshot; a connection acquired between here and the + // heartbeat will have a fresh LastUsedAt and so won't be due. + var idle = new List(); + foreach (var poolEntry in _pools.Values) + idle.AddRange(poolEntry.IdleConnections.ToArray()); + + return HeartbeatDueConnectionsAsync(idle, _sessionLifecycle.HeartbeatAsync, _timeProvider.GetUtcNow(), token, _logger); + } + + /// + /// Heartbeats each connection that is due (see ), recording the + /// heartbeat on success. Pure over the supplied connections and clock so the scheduling can be + /// tested without the background timer. Best-effort: a failing heartbeat is swallowed so one bad + /// connection doesn't stop the rest — it is recovered by reactive renewal on the next query. + /// + internal static async Task HeartbeatDueConnectionsAsync( + IEnumerable connections, + Func heartbeat, + DateTimeOffset now, + CancellationToken token, + ILogger? logger = null) + { + foreach (var connection in connections) + { + if (token.IsCancellationRequested) + return; + if (connection.IsDisposed || !IsHeartbeatDue(connection, now)) + continue; + + try + { + await heartbeat(connection.AuthToken, connection.Config, token).ConfigureAwait(false); + connection.RecordHeartbeat(); + } + catch (Exception ex) + { + // Best-effort keep-alive; a transient failure is recovered by reactive renewal later. + logger?.LogWarning(ex, "Keep-alive heartbeat failed for pooled connection {ConnectionId}.", + connection.ConnectionId); + } + } + } + + private async Task CreateConnectionAsync( + string poolKey, + ConnectionConfig config, + CancellationToken cancellationToken) + { + // LoginTimeout bounds the network round trips; an external-browser login also has a + // human in the loop, so the interactive step gets the SSO browser allowance on top — + // otherwise the default 60s login timeout would fire before the 120s browser timeout + // ever could. + TimeSpan loginTimeout = config.Authentication.Type is AuthenticationType.Sso or AuthenticationType.ExternalBrowser + ? config.LoginTimeout + SsoAuthenticator.DefaultBrowserTimeout + : config.LoginTimeout; + + using var loginCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + loginCts.CancelAfter(loginTimeout); + + AuthenticationToken authToken; + try + { + authToken = await _authService.AuthenticateAsync(config, loginCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new AdbcException($"Login timed out after {loginTimeout.TotalSeconds:0}s."); + } + + return new PooledConnection( + Guid.NewGuid().ToString(), + poolKey, + authToken, + config, + _sessionLifecycle, + _timeProvider); + } + + /// + /// Builds the key that decides which connections may be pooled together. Connections share a key + /// only when reusing one for the other is safe — so the key spans everything that determines the + /// authenticated session's identity: the account/user/db/schema/warehouse/role, the auth type and + /// a fingerprint of its secret, and the network endpoint. It deliberately excludes client-side-only + /// settings (keep-alive/heartbeat cadence, query timeout, compression, pool sizing) that don't + /// change the server session, so they don't needlessly fragment the pool. + /// + internal static string GeneratePoolKey(ConnectionConfig config) + { + var auth = config.Authentication; + var network = config.Network; + return string.Join('|', + config.Account, config.User, config.Database, config.Schema, config.Warehouse, config.Role, + auth.Type, HashSecret(CredentialSecret(auth)), + network.Host, network.Port, network.Protocol, network.NoProxy, network.TlsSkipVerify); + } + + /// + /// The secret that distinguishes one credential from another for the current auth type, or null + /// when the type carries no static secret (SSO / external browser). + /// + private static string? CredentialSecret(AuthenticationConfig auth) => auth.Type switch + { + AuthenticationType.UsernamePassword => auth.Password, + AuthenticationType.OAuth or AuthenticationType.Pat => auth.Token, + AuthenticationType.KeyPair => $"{auth.PrivateKey ?? auth.PrivateKeyPath}{auth.PrivateKeyPassphrase}", + _ => null, + }; + + /// + /// Hashes a credential into a fingerprint so different secrets land in different pools without the + /// raw secret ever appearing in the key string (which could otherwise surface in logs or dumps). + /// + private static string HashSecret(string? secret) => + string.IsNullOrEmpty(secret) + ? string.Empty + : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(secret))); + +} diff --git a/csharp/src/Native/Services/ConnectionPool/IConnectionPoolManager.cs b/csharp/src/Native/Services/ConnectionPool/IConnectionPoolManager.cs new file mode 100644 index 0000000..759c30e --- /dev/null +++ b/csharp/src/Native/Services/ConnectionPool/IConnectionPoolManager.cs @@ -0,0 +1,44 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; + +namespace AdbcDrivers.Snowflake.Native.Services.ConnectionPool; + +/// +/// Manages connection pooling for Snowflake connections. +/// +internal interface IConnectionPoolManager : IDisposable +{ + /// + /// Acquires a connection from the pool. + /// + /// The connection configuration. + /// The cancellation token. + /// A pooled connection. + Task AcquireConnectionAsync( + ConnectionConfig config, + CancellationToken cancellationToken = default); + + /// + /// Releases a connection back to the pool. + /// + /// The connection to release. + void ReleaseConnection(IPooledConnection connection); +} diff --git a/csharp/src/Native/Services/ConnectionPool/IPooledConnection.cs b/csharp/src/Native/Services/ConnectionPool/IPooledConnection.cs new file mode 100644 index 0000000..5dc9769 --- /dev/null +++ b/csharp/src/Native/Services/ConnectionPool/IPooledConnection.cs @@ -0,0 +1,90 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.Authentication; + +namespace AdbcDrivers.Snowflake.Native.Services.ConnectionPool; + +/// +/// Represents a pooled connection. +/// +internal interface IPooledConnection : IDisposable +{ + /// + /// Gets the connection ID. + /// + string ConnectionId { get; } + + /// + /// Gets the pool key this connection was created under. Computed once at creation (it hashes + /// the credential), so releases don't recompute it. + /// + string PoolKey { get; } + + /// + /// Gets the authentication token for this connection. + /// + AuthenticationToken AuthToken { get; } + + /// + /// Gets the connection configuration. + /// + ConnectionConfig Config { get; } + + /// + /// Gets the time when the connection was created. + /// + DateTimeOffset CreatedAt { get; } + + /// + /// Gets or sets the time when the connection was last used. + /// + DateTimeOffset LastUsedAt { get; } + + /// + /// Updates the last used timestamp (internal use only). + /// + internal void UpdateLastUsedAt(); + + /// + /// Gets the time of the last successful keep-alive heartbeat (or creation, if none yet). + /// + DateTimeOffset LastHeartbeatAt { get; } + + /// + /// Records that a keep-alive heartbeat just succeeded (internal use only). + /// + internal void RecordHeartbeat(); + + /// + /// Gets a value indicating whether the connection is disposed. + /// + bool IsDisposed { get; } + + /// + /// Gets a value indicating whether the authentication token is expired. + /// + bool IsTokenExpired { get; } + + /// + /// Gets or sets a value indicating whether the connection is faulted (its session is unusable + /// or in an unknown state), so the pool discards it on release instead of reusing it. + /// Set-once: there is no un-faulting a connection. + /// + bool IsFaulted { get; internal set; } +} diff --git a/csharp/src/Native/Services/ConnectionPool/ISessionLifecycle.cs b/csharp/src/Native/Services/ConnectionPool/ISessionLifecycle.cs new file mode 100644 index 0000000..07f323c --- /dev/null +++ b/csharp/src/Native/Services/ConnectionPool/ISessionLifecycle.cs @@ -0,0 +1,39 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.Authentication; + +namespace AdbcDrivers.Snowflake.Native.Services.ConnectionPool; + +/// +/// Server-side session operations the connection pool needs to maintain and tear down pooled +/// sessions, without the pool having to know about the transport/query layer. +/// +internal interface ISessionLifecycle +{ + /// + /// Pings the session keep-alive endpoint for the given token so an idle session doesn't lapse. + /// + Task HeartbeatAsync(AuthenticationToken token, ConnectionConfig config, CancellationToken cancellationToken = default); + + /// + /// Closes the server-side session for the given token so it isn't orphaned until it times out. + /// + Task CloseAsync(AuthenticationToken token, ConnectionConfig config, CancellationToken cancellationToken = default); +} diff --git a/csharp/src/Native/Services/ConnectionPool/PooledConnection.cs b/csharp/src/Native/Services/ConnectionPool/PooledConnection.cs new file mode 100644 index 0000000..dffecd9 --- /dev/null +++ b/csharp/src/Native/Services/ConnectionPool/PooledConnection.cs @@ -0,0 +1,126 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Threading; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.Authentication; + +namespace AdbcDrivers.Snowflake.Native.Services.ConnectionPool; + +/// +/// Represents a pooled Snowflake connection. +/// +internal class PooledConnection : IPooledConnection +{ + private DateTimeOffset _lastUsedAt; + private DateTimeOffset _lastHeartbeatAt; + private bool _disposed; + private readonly ISessionLifecycle? _sessionLifecycle; + private readonly TimeProvider _timeProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The connection ID. + /// the unique identifier for the connection pool + /// The authentication token. + /// The connection configuration. + /// + /// Optional session-lifecycle collaborator; its is + /// invoked when the pool discards this connection (not when it is returned to the idle pool for + /// reuse), so the server-side session isn't orphaned. + /// + /// Clock for the connection's timestamps. Defaults to . + public PooledConnection( + string connectionId, + string poolKey, + AuthenticationToken authToken, + ConnectionConfig config, + ISessionLifecycle? sessionLifecycle = null, + TimeProvider? timeProvider = null) + { + ConnectionId = connectionId ?? throw new ArgumentNullException(nameof(connectionId)); + PoolKey = poolKey ?? throw new ArgumentNullException(nameof(poolKey)); + AuthToken = authToken ?? throw new ArgumentNullException(nameof(authToken)); + Config = config ?? throw new ArgumentNullException(nameof(config)); + _timeProvider = timeProvider ?? TimeProvider.System; + CreatedAt = _timeProvider.GetUtcNow(); + _lastUsedAt = CreatedAt; + _lastHeartbeatAt = CreatedAt; + _sessionLifecycle = sessionLifecycle; + } + + public string ConnectionId { get; } + + public string PoolKey { get; } + + public AuthenticationToken AuthToken { get; } + + public ConnectionConfig Config { get; } + + public DateTimeOffset CreatedAt { get; } + + public DateTimeOffset LastUsedAt => _lastUsedAt; + + public DateTimeOffset LastHeartbeatAt => _lastHeartbeatAt; + + public bool IsDisposed => _disposed; + + // Keyed on the master-token expiry (not the ~1h session), because a session-expired connection is + // still usable via reactive renewal until the master lapses — so the pool should only discard it + // once it's truly beyond recovery. Evaluated on the pool's clock so all pool timekeeping shares one. + public bool IsTokenExpired => _timeProvider.GetUtcNow() >= AuthToken.MasterExpiresAt; + + public bool IsFaulted { get; set; } + + /// + /// Updates the last used timestamp (internal use only). + /// + void IPooledConnection.UpdateLastUsedAt() => _lastUsedAt = _timeProvider.GetUtcNow(); + + /// + /// Records that a keep-alive heartbeat just succeeded (internal use only). + /// + void IPooledConnection.RecordHeartbeat() => _lastHeartbeatAt = _timeProvider.GetUtcNow(); + + /// + /// Disposes the connection. + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + // Best-effort: close the server-side session so it isn't orphaned until it times out. + // Bounded so a slow/hung close can't stall teardown; a failure just lets the session expire. + if (_sessionLifecycle != null) + { + try + { + _sessionLifecycle.CloseAsync(AuthToken, Config, CancellationToken.None).Wait(TimeSpan.FromSeconds(5)); + } + catch + { + // ignore — closing the session is best-effort + } + } + + GC.SuppressFinalize(this); + } +} diff --git a/csharp/src/Native/Services/Query/ChunkedArrowArrayStream.cs b/csharp/src/Native/Services/Query/ChunkedArrowArrayStream.cs new file mode 100644 index 0000000..e74bc36 --- /dev/null +++ b/csharp/src/Native/Services/Query/ChunkedArrowArrayStream.cs @@ -0,0 +1,261 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Apache.Arrow; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using Ipc = Apache.Arrow.Ipc; + +namespace AdbcDrivers.Snowflake.Native.Services.Query; + +internal sealed class ChunkedArrowArrayStream : Ipc.IArrowArrayStream +{ + private readonly Channel _channel; + private readonly CancellationTokenSource _cts; + private readonly Task _prefetchTask; + private Ipc.ArrowStreamReader? _currentReader; + private Stream? _currentStream; + private bool _disposed; + + public Schema Schema { get; } + + private ChunkedArrowArrayStream( + Schema schema, + Ipc.ArrowStreamReader firstReader, + Stream firstStream, + Channel channel, + CancellationTokenSource cts, + Task prefetchTask) + { + Schema = schema; + _currentReader = firstReader; + _currentStream = firstStream; + _channel = channel; + _cts = cts; + _prefetchTask = prefetchTask; + } + + public static async Task CreateAsync( + IRestApiClient apiClient, + AuthenticationToken authToken, + string? rowSetBase64, + List? chunks, + Dictionary? chunkHeaders, + string? qrmk, + CancellationToken cancellationToken, + int prefetchConcurrency = 10) + { + var chunkList = (chunks ?? []).Where(c => !string.IsNullOrWhiteSpace(c.Url)).ToList(); + + Stream firstStream; + Ipc.ArrowStreamReader firstReader; + + if (!string.IsNullOrEmpty(rowSetBase64)) + { + var arrowBytes = Convert.FromBase64String(rowSetBase64); + firstStream = new MemoryStream(arrowBytes); + firstReader = new Ipc.ArrowStreamReader(firstStream); + } + else if (chunkList.Count > 0) + { + var first = chunkList[0]; + chunkList.RemoveAt(0); + firstStream = await apiClient.GetArrowStreamAsync(first.Url, authToken, chunkHeaders, qrmk, cancellationToken).ConfigureAwait(false); + firstReader = new Ipc.ArrowStreamReader(firstStream); + } + else + { + throw new InvalidOperationException("Arrow result format was requested, but neither rowsetBase64 nor chunks were present."); + } + + var schema = firstReader.Schema; + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var bufferSize = Math.Min(chunkList.Count, prefetchConcurrency); + var channel = Channel.CreateBounded(new BoundedChannelOptions(Math.Max(bufferSize, 1)) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.Wait + }); + + Task prefetchTask; + if (chunkList.Count > 0) + { + prefetchTask = StartPrefetchAsync(apiClient, authToken, chunkHeaders, qrmk, chunkList, channel, cts, prefetchConcurrency); + } + else + { + // No external chunks (the whole result is inline in rowsetBase64). Complete the + // channel now so that, once the inline batch is consumed, ReadNextRecordBatchAsync's + // WaitToReadAsync returns false instead of blocking forever waiting for a writer. + channel.Writer.TryComplete(); + prefetchTask = Task.CompletedTask; + } + + return new ChunkedArrowArrayStream(schema, firstReader, firstStream, channel, cts, prefetchTask); + } + + private static Task StartPrefetchAsync( + IRestApiClient apiClient, + AuthenticationToken authToken, + Dictionary? chunkHeaders, + string? qrmk, + List chunks, + Channel channel, + CancellationTokenSource cts, + int maxConcurrency) + { + return Task.Run(async () => + { + // Sliding window of launched-but-not-yet-handed-off downloads. A chunk keeps its + // window slot from launch until the channel accepts it, so total resident chunks are + // bounded at ~2x maxConcurrency (window + channel) no matter how slowly the consumer + // reads — a slow consumer back-pressures the downloads instead of the whole result + // set accumulating in memory. (Releasing slots at download *completion* would let a + // slow consumer buffer every chunk of the result set.) The trade-off is head-of-line: + // while the oldest download is still in flight, later completed chunks hold their + // slots and no new downloads launch; with roughly uniform chunk sizes this keeps the + // pipe ~full for a fast consumer. + var window = new Queue>(maxConcurrency); + int next = 0; + + try + { + while (next < chunks.Count || window.Count > 0) + { + while (next < chunks.Count && window.Count < maxConcurrency) + { + cts.Token.ThrowIfCancellationRequested(); + window.Enqueue(DownloadChunkAsync(apiClient, authToken, chunkHeaders, qrmk, chunks[next], cts.Token)); + next++; + } + + PrefetchedChunk chunk = await window.Dequeue().ConfigureAwait(false); + try + { + await channel.Writer.WriteAsync(chunk, cts.Token).ConfigureAwait(false); + } + catch + { + // The consumer never received it; it is ours to clean up. + await chunk.Stream.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + channel.Writer.TryComplete(); + } + catch (OperationCanceledException) + { + cts.Cancel(); + channel.Writer.TryComplete(); + } + catch (Exception ex) + { + // Stop the sibling downloads promptly rather than leaving them running + // orphaned, and surface the failure to the consumer. + cts.Cancel(); + channel.Writer.TryComplete(ex); + } + finally + { + // Settle any downloads still in the window (siblings in flight after a failure / + // cancellation), disposing the buffers they produced so they don't leak and their + // exceptions don't go unobserved. + while (window.Count > 0) + { + try { await (await window.Dequeue().ConfigureAwait(false)).Stream.DisposeAsync().ConfigureAwait(false); } + catch { /* cancelled or failed download -- nothing to dispose */ } + } + } + }); + } + + private static async Task DownloadChunkAsync( + IRestApiClient apiClient, + AuthenticationToken authToken, + Dictionary? chunkHeaders, + string? qrmk, + ChunkInfo chunk, + CancellationToken cancellationToken) + { + // GetArrowStreamAsync returns as soon as the HTTP headers arrive (and only + // wraps the live network/gzip stream). Fully buffer the chunk here so the + // expensive part -- the body transfer + decompression -- happens in parallel + // across the prefetch workers, not serially on the consumer thread. The + // consumer then just does CPU-bound Arrow decode from memory. + await using var netStream = await apiClient.GetArrowStreamAsync(chunk.Url, authToken, chunkHeaders, qrmk, cancellationToken).ConfigureAwait(false); + // Pre-size from the server-reported uncompressed size: these are multi-megabyte + // (large-object-heap) buffers, so growth-doubling would copy each one several times. + var buffer = new MemoryStream(Math.Max(chunk.UncompressedSize, 0)); + await netStream.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); + buffer.Position = 0; + return new PrefetchedChunk(buffer); + } + + public async ValueTask ReadNextRecordBatchAsync(CancellationToken cancellationToken = default) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_currentReader != null) + { + var batch = await _currentReader.ReadNextRecordBatchAsync(cancellationToken).ConfigureAwait(false); + if (batch != null) + return batch; + DisposeCurrentReaderAndStream(); + } + + if (!await _channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false)) + return null; + + if (!_channel.Reader.TryRead(out var nextChunk)) + return null; + + _currentStream = nextChunk.Stream; + _currentReader = new Ipc.ArrowStreamReader(_currentStream); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _cts.Cancel(); + DisposeCurrentReaderAndStream(); + while (_channel.Reader.TryRead(out var chunk)) + chunk.Stream.Dispose(); + try { _prefetchTask.GetAwaiter().GetResult(); } catch { } + _cts.Dispose(); + } + + private void DisposeCurrentReaderAndStream() + { + try { _currentReader?.Dispose(); } finally { _currentStream?.Dispose(); } + _currentReader = null; + _currentStream = null; + } + + private readonly record struct PrefetchedChunk(Stream Stream); +} diff --git a/csharp/src/Native/Services/Query/EmptyArrowArrayStream.cs b/csharp/src/Native/Services/Query/EmptyArrowArrayStream.cs new file mode 100644 index 0000000..89b61d8 --- /dev/null +++ b/csharp/src/Native/Services/Query/EmptyArrowArrayStream.cs @@ -0,0 +1,40 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Threading; +using System.Threading.Tasks; +using Apache.Arrow; +using Ipc = Apache.Arrow.Ipc; + +namespace AdbcDrivers.Snowflake.Native.Services.Query; + +/// +/// An that carries a schema but yields no record batches. +/// Used for successful queries whose result set is empty: Snowflake sends neither +/// rowsetBase64 nor chunks for a zero-row Arrow result, so the schema is built from the +/// response's rowtype metadata instead. +/// +internal sealed class EmptyArrowArrayStream(Schema schema) : Ipc.IArrowArrayStream +{ + public Schema Schema { get; } = schema; + + public ValueTask ReadNextRecordBatchAsync(CancellationToken cancellationToken = default) => + new((RecordBatch?)null); + + public void Dispose() + { + } +} diff --git a/csharp/src/Native/Services/Query/IQueryExecutor.cs b/csharp/src/Native/Services/Query/IQueryExecutor.cs new file mode 100644 index 0000000..3ff9a78 --- /dev/null +++ b/csharp/src/Native/Services/Query/IQueryExecutor.cs @@ -0,0 +1,76 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Services.Authentication; + +namespace AdbcDrivers.Snowflake.Native.Services.Query; + +/// +/// Provides query execution services for Snowflake connections. +/// +internal interface IQueryExecutor +{ + /// + /// Executes a query and returns the result. + /// + /// The query request. + /// The cancellation token. + /// The query result. + Task ExecuteQueryAsync(QueryRequest request, CancellationToken cancellationToken = default); + + /// + /// Describes (prepares) a statement without executing it, returning its result schema. + /// + /// The query request describing the statement. + /// The cancellation token. + /// A prepared statement with the result schema populated. + Task DescribeAsync(QueryRequest request, CancellationToken cancellationToken = default); + + /// + /// Mints a fresh session token from the master token (POST /session/token-request), + /// replacing the token's session token in place. Queries do this automatically when they hit a + /// session-expired response; it's exposed for explicit/proactive renewal and for tests. + /// Distinct from , which keeps the existing session alive and only + /// renews as a fallback. + /// + /// The token to renew; its session token is replaced in place. + /// The cancellation token. + Task RenewSessionAsync(AuthenticationToken authToken, CancellationToken cancellationToken = default); + + /// + /// Keeps the session alive by pinging /session/heartbeat with the current session token + /// (resets the server-side idle clock — it does not mint a new token). If the session has + /// already expired it falls back to . Intended for a periodic + /// keep-alive timer so long-idle connections never reach master-token expiry. + /// + /// The session's authentication token. + /// The cancellation token. + Task HeartbeatAsync(AuthenticationToken authToken, CancellationToken cancellationToken = default); + + /// + /// Cancels a running query by aborting the request it was submitted with + /// (POST /queries/v1/abort-request). Snowflake keys the abort on the original + /// , not the queryId it returns, so the caller must hold the id it + /// submitted the query with. The abort is authenticated with the session token. + /// + /// The request id the running query was submitted with. + /// The session's authentication token, used to authenticate the abort. + /// The cancellation token. + /// A task representing the cancellation operation. + Task CancelQueryAsync(string requestId, AuthenticationToken authToken, CancellationToken cancellationToken = default); +} diff --git a/csharp/src/Native/Services/Query/PreparedStatement.cs b/csharp/src/Native/Services/Query/PreparedStatement.cs new file mode 100644 index 0000000..40a737e --- /dev/null +++ b/csharp/src/Native/Services/Query/PreparedStatement.cs @@ -0,0 +1,32 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using Apache.Arrow; + +namespace AdbcDrivers.Snowflake.Native.Services.Query; + +/// +/// Represents a prepared statement. Snowflake's protocol reports only the result columns from a +/// describe — never bind-parameter types — so there is deliberately no parameter schema here +/// (GetParameterSchema throws NotImplemented for the same reason). +/// +internal class PreparedStatement +{ + /// + /// Gets or sets the result schema (if known). + /// + public Schema? ResultSchema { get; set; } +} diff --git a/csharp/src/Native/Services/Query/QueryError.cs b/csharp/src/Native/Services/Query/QueryError.cs new file mode 100644 index 0000000..f74d158 --- /dev/null +++ b/csharp/src/Native/Services/Query/QueryError.cs @@ -0,0 +1,41 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; + +namespace AdbcDrivers.Snowflake.Native.Services.Query; + +/// +/// Represents a query execution error. +/// +internal class QueryError +{ + /// + /// Gets or sets the error code. + /// + public string ErrorCode { get; set; } = string.Empty; + + /// + /// Gets or sets the error message. + /// + public string Message { get; set; } = string.Empty; + + /// + /// Gets or sets the originating exception, when the failure came from one — carried so the + /// statement layer can rethrow with the full stack/inner chain instead of a flattened message. + /// + public Exception? Exception { get; set; } +} diff --git a/csharp/src/Native/Services/Query/QueryExecutor.cs b/csharp/src/Native/Services/Query/QueryExecutor.cs new file mode 100644 index 0000000..d2ab8d5 --- /dev/null +++ b/csharp/src/Native/Services/Query/QueryExecutor.cs @@ -0,0 +1,427 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using AdbcDrivers.Snowflake.Native.Services.TypeConversion; +using Microsoft.Extensions.Logging; + +using Apache.Arrow; +using Apache.Arrow.Adbc; +using Apache.Arrow.Types; + +namespace AdbcDrivers.Snowflake.Native.Services.Query; + +/// +/// Implements query execution for Snowflake connections. +/// +internal class QueryExecutor : IQueryExecutor +{ + private readonly IRestApiClient _apiClient; + private readonly QueryResultFactory _resultFactory; + private readonly string _accountUrl; + private readonly ILogger _logger; + private readonly Action _onConnectionFault; + // Serializes session renewal on this connection so concurrent statements don't double-renew. + private readonly SemaphoreSlim _renewLock = new(1, 1); + private const string QueryEndpoint = "/queries/v1/query-request"; + private const string AbortEndpoint = "/queries/v1/abort-request"; + private const string TokenRequestEndpoint = "/session/token-request"; + private const string HeartbeatEndpoint = "/session/heartbeat"; + + // GS error code Snowflake returns when the session token has expired. + const string SessionExpiredCode = "390112"; + + // GS error code Snowflake returns when the master token has also expired; the session cannot + // be recovered by renewal — the user must authenticate again. + const string MasterTokenExpiredCode = "390114"; + + // GS codes Snowflake returns while a query is still executing server-side (the query outlived + // the synchronous response window); the response carries a getResultUrl to poll instead of a + // result. 333334 is the async/detached variant of 333333. + const string QueryInProgressCode = "333333"; + const string QueryInProgressAsyncCode = "333334"; + + /// + /// Initializes a new instance of the class. + /// + /// The REST API client. + /// The Snowflake/Arrow type converter. + /// The Snowflake account identifier. + /// The network configuration. + /// The ILogger instance for logging. + /// + /// Invoked when a failure leaves the session unusable or in an unknown state — a transport-level + /// error mid-request, a failed renewal, or a session-fatal GS code — so the owner (the pooled + /// connection) can be flagged for discard instead of being reused. Ordinary SQL errors and + /// caller cancellations do not trigger it. + /// + public QueryExecutor( + IRestApiClient apiClient, + ITypeConverter typeConverter, + string account, + Configuration.NetworkConfig? network, + ILogger logger, + Action onConnectionFault) + { + ArgumentNullException.ThrowIfNull(apiClient); + ArgumentNullException.ThrowIfNull(typeConverter); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(onConnectionFault); + ArgumentException.ThrowIfNullOrEmpty(account); + + _apiClient = apiClient; + _resultFactory = new QueryResultFactory(apiClient, typeConverter); + _logger = logger; + _onConnectionFault = onConnectionFault; + + _accountUrl = SnowflakeAccountUrl.Build(account, network); + } + + /// + public async Task ExecuteQueryAsync( + QueryRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrEmpty(request.Statement, nameof(request.Statement)); + ArgumentNullException.ThrowIfNull(request.AuthToken); + var authToken = request.AuthToken; + + try + { + var response = await PostQueryWithRenewalAsync(request, describeOnly: false, authToken, cancellationToken).ConfigureAwait(false); + + if (!response.Success || response.Data == null) + return CreateFailedResponseResult(response); + + var data = response.Data; + ResultShape shape = QueryResultFactory.Classify(data); + _logger.LogDebug( + "ResultShape={ResultShape}, QueryResultFormat={QueryResultFormat}, HasRowSetBase64={HasRowSetBase64}, ChunkCount={ChunkCount}, HasRowSet={HasRowSet}, HasRowType={HasRowType}", + shape, + data.QueryResultFormat, + !string.IsNullOrEmpty(data.RowSetBase64), + data.Chunks?.Count ?? 0, + data.RowSet != null, + data.RowType != null); + + return await _resultFactory.CreateResultAsync(shape, data, authToken, request.PrefetchConcurrency, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return QueryResult.Cancelled(); + } + catch (Exception ex) + { + return QueryResult.Failed("EXECUTION_ERROR", $"Query execution failed: {ex.Message}", ex); + } + } + + private static QueryResult CreateFailedResponseResult(ApiResponse response) => + QueryResult.Failed(response.Code ?? "UNKNOWN", response.Message ?? "Query execution failed."); + + /// + public async Task DescribeAsync( + QueryRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrEmpty(request.Statement, nameof(request.Statement)); + ArgumentNullException.ThrowIfNull(request.AuthToken); + + // Snowflake's internal protocol has no dedicated prepare endpoint; a statement is + // described (compiled without executing) by sending it to the query-request endpoint + // with describeOnly=true. The response's rowtype is the result schema. + var response = await PostQueryWithRenewalAsync(request, describeOnly: true, request.AuthToken, cancellationToken).ConfigureAwait(false); + + if (!response.Success || response.Data == null) + throw new AdbcException($"Failed to describe statement: {response.Message ?? "Unknown error"}"); + + return new PreparedStatement + { + ResultSchema = _resultFactory.BuildSchemaFromRowType(response.Data.RowType) + }; + } + + private SnowflakeQueryRequestBody BuildQueryRequest(QueryRequest request, out string endpoint, bool describeOnly = false) + { + var queryRequest = RequestBuilder.BuildQueryRequest( + request.Statement, + request.Database, + request.Schema, + request.Warehouse, + request.Role, + request.QueryTag, + (int)request.Timeout.TotalSeconds, + request.Bindings, + request.IsMultiStatement, + describeOnly); + + // A caller-supplied request id lets the statement abort this exact request later; the + // request_guid is per-attempt and is regenerated on the renewal retry. + var requestId = string.IsNullOrEmpty(request.RequestId) ? Guid.NewGuid().ToString() : request.RequestId; + var requestGuid = Guid.NewGuid().ToString(); + var startTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); + endpoint = $"{_accountUrl}{QueryEndpoint}?requestId={requestId}&request_guid={requestGuid}&startTime={startTime}"; + var sessionId = request.AuthToken?.SessionId; + if (!string.IsNullOrEmpty(sessionId)) + endpoint += $"&sid={sessionId}"; + return queryRequest; + } + + /// + /// Posts a query/describe request (renewing an expired session token and retrying once — see + /// ) and classifies any failure for the pool: outcomes that leave + /// the session unusable or in an unknown state fault the pooled connection so it is discarded + /// instead of reused; a caller cancellation or an ordinary statement error does not. + /// + private async Task> PostQueryWithRenewalAsync( + QueryRequest request, bool describeOnly, AuthenticationToken authToken, CancellationToken cancellationToken) + { + ApiResponse response; + try + { + response = await PostQueryCoreAsync(request, describeOnly, authToken, cancellationToken).ConfigureAwait(false); + response = await WaitForQueryCompletionAsync(response, authToken, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // The caller cancelled; the session itself is still good. + throw; + } + catch + { + // A transport-level failure (network error, timeout, malformed response) or a failed + // renewal: the session's state is unknown or unusable, so the pooled connection must + // not be handed to the next caller. + _onConnectionFault(); + throw; + } + + // The session is fatally rejected: 390112 that couldn't be renewed (no master token, or the + // renewed token was rejected again on retry), or 390114 (master token expired too). + if (!IsSessionFatal(response)) + return response; + + _logger.LogDebug("Snowflake session is unrecoverable (code {Code}); faulting the connection.", response.Code); + _onConnectionFault(); + + return response; + } + + /// + /// Posts the query/describe request; on a session-expired response (390112) it renews the + /// session token with the master token and retries once, rebuilding the request with a fresh + /// request id so the retry is not treated as a duplicate of the rejected attempt. + /// + private async Task> PostQueryCoreAsync( + QueryRequest request, bool describeOnly, AuthenticationToken authToken, CancellationToken cancellationToken) + { + var body = BuildQueryRequest(request, out string endpoint, describeOnly); + // The session token this attempt authenticates with; renewal only proceeds if it's still + // current (so concurrent statements don't each renew after the same expiry). + string? tokenUsed = authToken.SessionToken; + var response = await _apiClient.PostAsync( + endpoint, body, authToken, cancellationToken).ConfigureAwait(false); + + if (!IsSessionExpired(response) || string.IsNullOrEmpty(authToken.MasterToken)) + return response; + + _logger.LogDebug("Snowflake session token expired (code {Code}); renewing and retrying.", response.Code); + await RenewSessionCoreAsync(authToken, renewIfSessionTokenIs: tokenUsed, cancellationToken).ConfigureAwait(false); + + body = BuildQueryRequest(request, out endpoint, describeOnly); + return await _apiClient.PostAsync( + endpoint, body, authToken, cancellationToken).ConfigureAwait(false); + } + + /// + /// Completes a query that outlived the synchronous response window. Snowflake then answers + /// with a query-in-progress GS code and a getResultUrl; each GET on that URL + /// long-polls until the server either finishes the query or hands out the next URL. + /// Mirrors gosnowflake's ping-pong loop. A session token that expires while the query runs + /// is renewed and the same URL re-polled. + /// + private async Task> WaitForQueryCompletionAsync( + ApiResponse response, AuthenticationToken authToken, CancellationToken cancellationToken) + { + while (IsQueryInProgress(response)) + { + string? resultUrl = response.Data?.GetResultUrl; + if (string.IsNullOrEmpty(resultUrl)) + throw new AdbcException("Query is in progress but the response carried no result URL to poll."); + + _logger.LogDebug("Query in progress (code {Code}); polling {ResultUrl}.", response.Code, resultUrl); + + string? tokenUsed = authToken.SessionToken; + response = await _apiClient.GetAsync( + $"{_accountUrl}{resultUrl}", authToken, cancellationToken).ConfigureAwait(false); + + if (IsSessionExpired(response) && !string.IsNullOrEmpty(authToken.MasterToken)) + { + _logger.LogDebug("Session token expired while polling; renewing and re-polling."); + await RenewSessionCoreAsync(authToken, renewIfSessionTokenIs: tokenUsed, cancellationToken).ConfigureAwait(false); + response = await _apiClient.GetAsync( + $"{_accountUrl}{resultUrl}", authToken, cancellationToken).ConfigureAwait(false); + } + } + + return response; + } + + /// + /// True when a response reports the query is still executing server-side (GS codes 333333 / + /// 333334) and the final result must be fetched from the response's getResultUrl. + /// + private static bool IsQueryInProgress(ApiResponse response) => + string.Equals(response.Code, QueryInProgressCode, StringComparison.Ordinal) || + string.Equals(response.Code, QueryInProgressAsyncCode, StringComparison.Ordinal); + + /// + /// True when a response indicates the session token has expired (GS code 390112). + /// + internal static bool IsSessionExpired(ApiResponse response) => + response is { Success: false } && string.Equals(response.Code, SessionExpiredCode, StringComparison.Ordinal); + + /// + /// True when a response indicates the session can no longer authenticate requests: the session + /// token is expired (390112 — fatal here because renewal was either impossible or has already + /// been attempted) or the master token is expired (390114). + /// + static bool IsSessionFatal(ApiResponse response) => + response is { Success: false } && + (string.Equals(response.Code, SessionExpiredCode, StringComparison.Ordinal) || + string.Equals(response.Code, MasterTokenExpiredCode, StringComparison.Ordinal)); + + /// + /// Renews an expired session token in place via /session/token-request. Snowflake issues + /// a short-lived session token (~1h) backed by a longer master token (~4h); when the session + /// token expires the still-valid master token mints a new one. The renewal request is itself + /// authenticated with the master token. + /// + /// + public async Task HeartbeatAsync(AuthenticationToken authToken, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(authToken); + + var requestId = Guid.NewGuid().ToString(); + var requestGuid = Guid.NewGuid().ToString(); + var endpoint = $"{_accountUrl}{HeartbeatEndpoint}?requestId={requestId}&request_guid={requestGuid}"; + + var response = await _apiClient.PostAsync( + endpoint, EmptyRequestBody.Instance, authToken, cancellationToken).ConfigureAwait(false); + + // The heartbeat keeps the session alive; if the session token has already expired the + // heartbeat itself comes back 390112, so renew with the master token. + if (IsSessionExpired(response)) + { + if (!string.IsNullOrEmpty(authToken.MasterToken)) + await RenewSessionAsync(authToken, cancellationToken).ConfigureAwait(false); + return; + } + + if (!response.Success) + throw new AdbcException($"Snowflake heartbeat failed (code {response.Code ?? "unknown"})."); + } + + /// + public Task RenewSessionAsync(AuthenticationToken authToken, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(authToken); + // Proactive renewal (e.g. heartbeat): renew unconditionally. + return RenewSessionCoreAsync(authToken, renewIfSessionTokenIs: null, cancellationToken); + } + + /// + /// Renews the session token under a per-connection lock so concurrent statements serialize. + /// When is non-null, the renewal is skipped if the + /// session token has already changed (another caller renewed it after the same expiry) — the + /// caller then just retries with the current token. Mirrors gosnowflake's renewal guard. + /// + private async Task RenewSessionCoreAsync(AuthenticationToken authToken, string? renewIfSessionTokenIs, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(authToken.MasterToken)) + throw new AdbcException("Cannot renew the Snowflake session: no master token is available."); + + await _renewLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (renewIfSessionTokenIs != null && authToken.SessionToken != renewIfSessionTokenIs) + return; + + var requestId = Guid.NewGuid().ToString(); + var requestGuid = Guid.NewGuid().ToString(); + var endpoint = $"{_accountUrl}{TokenRequestEndpoint}?requestId={requestId}&request_guid={requestGuid}"; + + var body = new SnowflakeRenewSessionBody { OldSessionToken = authToken.SessionToken }; + // Authenticate the renewal with the master token by carrying it in the auth-header slot. + var masterAuth = new AuthenticationToken { SessionToken = authToken.MasterToken }; + + var response = await _apiClient.PostAsync( + endpoint, body, masterAuth, cancellationToken).ConfigureAwait(false); + + if (!response.Success || string.IsNullOrEmpty(response.Data?.SessionToken)) + { + // The server rejected the renewal, so the session cannot authenticate any further + // requests — flag the pooled connection so it is discarded rather than reused. + _onConnectionFault(); + throw new AdbcException($"Failed to renew the Snowflake session token (code {response.Code ?? "unknown"})."); + } + + authToken.SessionToken = response.Data.SessionToken; + if (!string.IsNullOrEmpty(response.Data.MasterToken)) + authToken.MasterToken = response.Data.MasterToken; + // Renewal returns fresh session + master validities; roll both ceilings forward. + if (response.Data.ValidityInSeconds > 0) + authToken.ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(response.Data.ValidityInSeconds); + if (response.Data.MasterValidityInSeconds > 0) + authToken.MasterExpiresAt = DateTimeOffset.UtcNow.AddSeconds(response.Data.MasterValidityInSeconds); + } + finally + { + _renewLock.Release(); + } + } + + /// + public async Task CancelQueryAsync(string requestId, AuthenticationToken authToken, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(requestId); + ArgumentNullException.ThrowIfNull(authToken); + + // The abort request carries its own fresh requestId/guid; the running query is identified + // by the requestId echoed in the body. + var abortRequestId = Guid.NewGuid().ToString(); + var requestGuid = Guid.NewGuid().ToString(); + var endpoint = $"{_accountUrl}{AbortEndpoint}?requestId={abortRequestId}&request_guid={requestGuid}"; + + var body = RequestBuilder.BuildCancelRequest(requestId); + var response = await _apiClient.PostAsync( + endpoint, body, authToken, cancellationToken).ConfigureAwait(false); + + // A successful abort returns success; if the query already finished there is simply nothing + // to cancel. Surface other failures so a genuinely broken abort isn't silently swallowed. + if (!response.Success) + throw new AdbcException($"Failed to cancel the Snowflake query (code {response.Code ?? "unknown"})."); + } + +} diff --git a/csharp/src/Native/Services/Query/QueryRequest.cs b/csharp/src/Native/Services/Query/QueryRequest.cs new file mode 100644 index 0000000..c3392d1 --- /dev/null +++ b/csharp/src/Native/Services/Query/QueryRequest.cs @@ -0,0 +1,90 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.Transport; + +namespace AdbcDrivers.Snowflake.Native.Services.Query; + +/// +/// Represents a query execution request. +/// +internal class QueryRequest +{ + /// + /// Gets or sets the SQL statement to execute. + /// + public string Statement { get; set; } = string.Empty; + + /// + /// Gets or sets the database context for the query. + /// + public string? Database { get; set; } + + /// + /// Gets or sets the schema context for the query. + /// + public string? Schema { get; set; } + + /// + /// Gets or sets the warehouse to use for query execution. + /// + public string? Warehouse { get; set; } + + /// + /// Gets or sets the role to use for query execution. + /// + public string? Role { get; set; } + + /// + /// Gets or sets the query tag surfaced in the Snowsight query history for this statement. + /// + public string? QueryTag { get; set; } + + /// + /// Gets or sets the query timeout. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets how many result-set chunks to download in parallel while streaming the result. + /// + public int PrefetchConcurrency { get; set; } = 10; + + /// + /// Gets or sets the positional bind variables for the statement's '?' placeholders. + /// + public Dictionary Bindings { get; set; } = new(); + + /// + /// Gets or sets a value indicating whether this is a multi-statement query. + /// + public bool IsMultiStatement { get; set; } + + /// + /// Gets or sets the request id to submit the query with. When set, the same id can later be + /// passed to to abort this specific request. + /// When null, the executor generates one per attempt. + /// + public string? RequestId { get; set; } + + /// + /// Gets or sets the authentication token for the request. + /// + public AuthenticationToken? AuthToken { get; set; } +} diff --git a/csharp/src/Native/Services/Query/QueryResult.cs b/csharp/src/Native/Services/Query/QueryResult.cs new file mode 100644 index 0000000..7c6dfbf --- /dev/null +++ b/csharp/src/Native/Services/Query/QueryResult.cs @@ -0,0 +1,91 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using Apache.Arrow.Ipc; + +namespace AdbcDrivers.Snowflake.Native.Services.Query; + +/// +/// Represents the result of a query execution. +/// +internal class QueryResult +{ + /// + /// Gets or sets the query execution status. + /// + public QueryStatus Status { get; set; } + + /// + /// Gets or sets the Arrow array stream containing the results. + /// + public IArrowArrayStream? ResultStream { get; set; } + + /// + /// Gets or sets the number of rows affected or returned. + /// + public long RowCount { get; set; } + + /// + /// Gets or sets the affected-row count parsed from a DML statement's row-count summary, + /// or null when the statement was not DML. Kept separate from and + /// so a DML result can carry both its summary result set (for + /// ExecuteQuery) and the count (for ExecuteUpdate). + /// + public long? AffectedRows { get; set; } + + /// + /// Gets or sets any errors that occurred during execution. + /// + public List Errors { get; set; } = []; + + /// Creates a successful result carrying a result-set stream. + /// The Arrow stream with the result set. + /// The row count to report (returned rows, or the affected count for DML). + /// The DML affected-row count; null for non-DML statements. + public static QueryResult Success(IArrowArrayStream resultStream, long rowCount, long? affectedRows = null) => + new() + { + Status = QueryStatus.Success, + ResultStream = resultStream, + RowCount = rowCount, + AffectedRows = affectedRows + }; + + /// Creates a failed result with a single error. + public static QueryResult Failed(string errorCode, string message, Exception? exception = null) => + new() + { + Status = QueryStatus.Failed, + Errors = + [ + new QueryError + { + ErrorCode = errorCode, + Message = message, + Exception = exception + } + ] + }; + + /// Creates a cancelled result (no stream, no errors). + public static QueryResult Cancelled() => + new() + { + Status = QueryStatus.Cancelled + }; +} diff --git a/csharp/src/Native/Services/Query/QueryResultFactory.cs b/csharp/src/Native/Services/Query/QueryResultFactory.cs new file mode 100644 index 0000000..f7f0a9b --- /dev/null +++ b/csharp/src/Native/Services/Query/QueryResultFactory.cs @@ -0,0 +1,330 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using AdbcDrivers.Snowflake.Native.Services.TypeConversion; + +using Apache.Arrow; +using Apache.Arrow.Types; + +namespace AdbcDrivers.Snowflake.Native.Services.Query; + +/// +/// The shapes a successful query response can take. classifies +/// a response into one of these and builds the matching ; the checks in +/// run in declaration order. +/// +internal enum ResultShape +{ + /// Arrow data, inline (rowsetBase64) and/or in downloadable chunks: a SELECT that returned rows. + ArrowData, + + /// Arrow format with no data at all: a SELECT that matched zero rows; only the rowtype metadata carries the schema. + EmptyArrow, + + /// The JSON affected-count summary row of a DML statement (INSERT/UPDATE/DELETE/MERGE). + DmlSummary, + + /// Any other JSON rowset: command output such as DDL status messages, USE/ALTER SESSION, SHOW. + CommandRowSet, + + /// + /// Neither Arrow data nor a rowset — a response the driver cannot represent as a result: + /// shapes it does not support (multi-statement parents) or malformed payloads. Surfaced + /// as a failed result, not a success. (Query-in-progress responses never reach + /// classification — the executor polls them to completion first.) + /// + Unsupported, +} + +/// +/// Turns a successful Snowflake query response into the that matches +/// its . Owns all result materialization — Arrow stream assembly, +/// empty-result schemas, DML summaries, command rowsets — leaving +/// with the transport and session concerns. +/// +internal sealed class QueryResultFactory(IRestApiClient apiClient, ITypeConverter typeConverter) +{ + private readonly IRestApiClient _apiClient = apiClient ?? throw new ArgumentNullException(nameof(apiClient)); + private readonly ITypeConverter _typeConverter = typeConverter ?? throw new ArgumentNullException(nameof(typeConverter)); + + internal static ResultShape Classify(SnowflakeQueryResponse data) + { + if (HasArrowData(data)) + return ResultShape.ArrowData; + + if (IsZeroRowArrowResult(data)) + return ResultShape.EmptyArrow; + + if (TryGetDmlAffectedRows(data, out _)) + return ResultShape.DmlSummary; + + if (data is { RowType: { Count: > 0 }, RowSet: not null }) + return ResultShape.CommandRowSet; + + return ResultShape.Unsupported; + } + + internal async Task CreateResultAsync( + ResultShape shape, + SnowflakeQueryResponse data, + AuthenticationToken authToken, + int prefetchConcurrency, + CancellationToken cancellationToken) + { + return shape switch + { + ResultShape.ArrowData => await CreateArrowStreamResultAsync(data, authToken, prefetchConcurrency, cancellationToken).ConfigureAwait(false), + ResultShape.EmptyArrow => CreateEmptyArrowResult(data), + ResultShape.DmlSummary => CreateDmlSummaryResult(data), + ResultShape.CommandRowSet => CreateCommandRowSetResult(data), + ResultShape.Unsupported => CreateUnsupportedShapeResult(data), + _ => throw new NotSupportedException($"Result shape {shape} has no handler.") + }; + } + + private static bool HasArrowData(SnowflakeQueryResponse data) => + !string.IsNullOrEmpty(data.RowSetBase64) || (data.Chunks?.Count > 0); + + /// + /// Arrow format with rowtype metadata but no row data anywhere: a SELECT that matched zero + /// rows. Requiring the rowset to be absent or empty keeps this check order-independent of + /// the JSON-rowset shapes — a response carrying JSON rows (a DML summary or command + /// output) can never classify as an empty Arrow result, whatever format it reports. + /// + private static bool IsZeroRowArrowResult(SnowflakeQueryResponse data) => + IsArrowFormat(data) + && data is { RowType: { Count: > 0 }, RowSet: not { Count: > 0 } }; + + private static bool IsArrowFormat(SnowflakeQueryResponse data) => + string.Equals(data.QueryResultFormat, "arrow", StringComparison.OrdinalIgnoreCase); + + /// + /// Detects a DML row-count summary result and sums the affected-row counts. + /// Snowflake returns DML results as a JSON rowset whose columns are named + /// "number of rows inserted" / "...updated" / "...deleted" (MERGE returns several). + /// The summary is normally a single row, but every row is summed so a multi-row + /// summary would still report the full count. + /// + internal static bool TryGetDmlAffectedRows(SnowflakeQueryResponse data, out long affectedRows) + { + affectedRows = 0; + + List? rowTypes = data.RowType; + List>? rowSet = data.RowSet; + if (rowTypes == null || rowTypes.Count == 0 || rowSet == null || rowSet.Count == 0) + return false; + + foreach (RowType rowType in rowTypes) + { + if (rowType.Name == null || + !rowType.Name.StartsWith("number of ", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + } + + long total = 0; + foreach (List? row in rowSet) + { + if (row == null) + continue; + + foreach (string cell in row) + { + if (long.TryParse(cell, NumberStyles.Integer, CultureInfo.InvariantCulture, out long count)) + total += count; + } + } + + affectedRows = total; + return true; + } + + private async Task CreateArrowStreamResultAsync( + SnowflakeQueryResponse data, + AuthenticationToken authToken, + int prefetchConcurrency, + CancellationToken cancellationToken) + { + var arrayStream = await ChunkedArrowArrayStream.CreateAsync( + _apiClient, + authToken, + data.RowSetBase64, + data.Chunks, + data.ChunkHeaders, + data.Qrmk, + cancellationToken, + prefetchConcurrency).ConfigureAwait(false); + + // Apply Snowflake-specific result fixups (e.g. rescaling FIXED-with-scale integer + // columns to Decimal128) before exposing the stream. + return QueryResult.Success(new SnowflakeResultArrowStream(arrayStream), data.Returned ?? 0); + } + + /// + /// Builds the result for a zero-row Arrow response: an empty stream whose schema is built + /// from the rowtype metadata, so callers read an empty result set instead of failing on a + /// missing stream. The schema matches what a non-empty result would surface, because the + /// type converter applies the same FIXED sizing rule as the result decoder. + /// + private QueryResult CreateEmptyArrowResult(SnowflakeQueryResponse data) + { + Schema? schema; + try + { + schema = BuildSchemaFromRowType(data.RowType); + } + catch (NotSupportedException ex) + { + // A rowtype column type the converter cannot map (e.g. a type newer than the + // driver). A non-empty result would pass such a column through in whatever Arrow + // encoding Snowflake sends, but with zero rows there is no wire type to fall back + // on — fail with the real reason instead of a generic execution error. + return QueryResult.Failed( + "UNSUPPORTED_RESULT_SCHEMA", + "The statement returned zero rows and the driver cannot build the result schema " + + $"from rowtype metadata: {ex.Message}", + ex); + } + + if (schema == null) + return CreateUnsupportedShapeResult(data); + + return QueryResult.Success(new EmptyArrowArrayStream(schema), data.Returned ?? 0); + } + + /// + /// Builds the result for a DML statement. The affected-count summary row is surfaced as a + /// result set (an Int64 column per count, matching the Go driver) so ExecuteQuery on a DML + /// statement returns something readable; carries the + /// summed count for ExecuteUpdate. + /// + private static QueryResult CreateDmlSummaryResult(SnowflakeQueryResponse data) + { + if (!TryGetDmlAffectedRows(data, out long affectedRows)) + throw new NotSupportedException("Response is not a DML row-count summary."); + + // TryGetDmlAffectedRows validated that rowtype and rowset are present and non-empty. + // The summary is normally a single row, but every row is surfaced. + List rowTypes = data.RowType!; + List> rowSet = data.RowSet!; + + var fields = new List(rowTypes.Count); + var columns = new List(rowTypes.Count); + for (int i = 0; i < rowTypes.Count; i++) + { + fields.Add(new Field(rowTypes[i].Name ?? string.Empty, Int64Type.Default, nullable: true)); + var builder = new Int64Array.Builder(); + foreach (List? row in rowSet) + { + if (row != null && i < row.Count && long.TryParse(row[i], NumberStyles.Integer, CultureInfo.InvariantCulture, out long count)) + builder.Append(count); + else + builder.AppendNull(); + } + columns.Add(builder.Build()); + } + + return QueryResult.Success( + new InMemoryArrowStream(new Schema(fields, null), columns), + rowCount: affectedRows, + affectedRows: affectedRows); + } + + /// + /// Surfaces a JSON rowset (DDL/USE status messages, SHOW output, ...) as a result set of + /// string columns. SELECT results always arrive as Arrow (the session forces + /// DOTNET_QUERY_RESULT_FORMAT=ARROW), so a JSON rowset is a command output whose wire + /// values are strings. + /// + private static QueryResult CreateCommandRowSetResult(SnowflakeQueryResponse data) + { + List rowTypes = data.RowType!; + List> rowSet = data.RowSet!; + + var fields = new List(rowTypes.Count); + var columns = new List(rowTypes.Count); + for (int i = 0; i < rowTypes.Count; i++) + { + fields.Add(new Field(rowTypes[i].Name ?? string.Empty, StringType.Default, nullable: true)); + var builder = new StringArray.Builder(); + foreach (List? row in rowSet) + { + string? value = row != null && i < row.Count ? row[i] : null; + if (value == null) + builder.AppendNull(); + else + builder.Append(value); + } + columns.Add(builder.Build()); + } + + return QueryResult.Success( + new InMemoryArrowStream(new Schema(fields, null), columns), + data.Returned ?? rowSet.Count); + } + + /// + /// Builds a failed result for a response the driver cannot represent (see + /// ). Failing here is deliberate: returning a + /// stream-less success would let ExecuteUpdate report a bogus completed-with-0-rows for a + /// statement that did something the caller cannot observe. + /// + private static QueryResult CreateUnsupportedShapeResult(SnowflakeQueryResponse data) => + QueryResult.Failed( + "UNSUPPORTED_RESULT_SHAPE", + "The query succeeded but returned a response the driver cannot represent " + + $"(queryResultFormat={data.QueryResultFormat ?? ""}, hasRowType={data.RowType != null}, hasRowSet={data.RowSet != null}). " + + "Multi-statement requests are not supported."); + + /// + /// Builds an Arrow schema from the response's rowtype metadata (used for describe results + /// and zero-row result sets), applying the same FIXED sizing rule as the result decoder so + /// described and materialized schemas always agree. + /// + internal Schema? BuildSchemaFromRowType(List? rowTypes) + { + if (rowTypes == null || rowTypes.Count == 0) + return null; + + var fields = new List(rowTypes.Count); + foreach (RowType rowType in rowTypes) + { + var snowflakeType = new SnowflakeDataType + { + TypeName = rowType.Type ?? string.Empty, + Precision = rowType.Precision, + Scale = rowType.Scale, + Length = rowType.Length, + IsNullable = rowType.Nullable ?? true + }; + + fields.Add(new Field( + rowType.Name ?? string.Empty, + _typeConverter.ConvertSnowflakeTypeToArrow(snowflakeType), + rowType.Nullable ?? true)); + } + + return new Schema(fields, null); + } +} diff --git a/csharp/src/Native/Services/Query/QueryStatus.cs b/csharp/src/Native/Services/Query/QueryStatus.cs new file mode 100644 index 0000000..0213173 --- /dev/null +++ b/csharp/src/Native/Services/Query/QueryStatus.cs @@ -0,0 +1,38 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +namespace AdbcDrivers.Snowflake.Native.Services.Query; + +/// +/// Represents query execution status. +/// +internal enum QueryStatus +{ + /// + /// Query completed successfully. + /// + Success, + + /// + /// Query failed with an error. + /// + Failed, + + /// + /// Query was cancelled. + /// + Cancelled +} diff --git a/csharp/src/Native/Services/Query/SnowflakeQueryResponse.cs b/csharp/src/Native/Services/Query/SnowflakeQueryResponse.cs new file mode 100644 index 0000000..cf382cb --- /dev/null +++ b/csharp/src/Native/Services/Query/SnowflakeQueryResponse.cs @@ -0,0 +1,93 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace AdbcDrivers.Snowflake.Native.Services.Query; + +internal sealed class SnowflakeQueryResponse +{ + [JsonPropertyName("queryId")] + public string? QueryId { get; set; } + + /// + /// The relative URL to poll for the final result when the query outlives the synchronous + /// response window (the response then carries a query-in-progress GS code). + /// + [JsonPropertyName("getResultUrl")] + public string? GetResultUrl { get; set; } + + [JsonPropertyName("rowtype")] + public List? RowType { get; set; } + + [JsonPropertyName("rowset")] + public List>? RowSet { get; set; } + + [JsonPropertyName("rowsetBase64")] + public string? RowSetBase64 { get; set; } + + [JsonPropertyName("queryResultFormat")] + public string? QueryResultFormat { get; set; } + + [JsonPropertyName("returned")] + public long? Returned { get; set; } + + [JsonPropertyName("chunks")] + public List? Chunks { get; set; } + + [JsonPropertyName("chunkHeaders")] + public Dictionary? ChunkHeaders { get; set; } + + [JsonPropertyName("qrmk")] + public string? Qrmk { get; set; } +} + +internal sealed class ChunkInfo +{ + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("rowCount")] + public int RowCount { get; set; } + + [JsonPropertyName("uncompressedSize")] + public int UncompressedSize { get; set; } + + [JsonPropertyName("compressedSize")] + public int CompressedSize { get; set; } +} + +internal sealed class RowType +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("length")] + public int? Length { get; set; } + + [JsonPropertyName("precision")] + public int? Precision { get; set; } + + [JsonPropertyName("scale")] + public int? Scale { get; set; } + + [JsonPropertyName("nullable")] + public bool? Nullable { get; set; } +} diff --git a/csharp/src/Native/Services/Query/SnowflakeResultArrowStream.cs b/csharp/src/Native/Services/Query/SnowflakeResultArrowStream.cs new file mode 100644 index 0000000..99bc02a --- /dev/null +++ b/csharp/src/Native/Services/Query/SnowflakeResultArrowStream.cs @@ -0,0 +1,421 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Numerics; +using System.Threading; +using System.Threading.Tasks; +using Apache.Arrow; +using Apache.Arrow.Types; +using Ipc = Apache.Arrow.Ipc; + +namespace AdbcDrivers.Snowflake.Native.Services.Query; + +/// +/// Decorates a result stream to apply Snowflake-specific Arrow fixups, normalizing the encoding- +/// dependent shapes Snowflake puts on the wire into stable, value-independent Arrow types. The +/// Snowflake type is read from each field's logicalType metadata. +/// +/// +/// FIXED (NUMBER/INT/DECIMAL) arrives as an integer sized to the values +/// (Int8/16/32/64), with the declared precision and scale in the metadata. It is normalized by the +/// declared precision (not the values, so the result schema is stable across runs and chunks): +/// +/// scale > 0 (e.g. 9.99 arrives as 999 scale=2); +/// scale == 0 → the narrowest integer guaranteed to hold the precision: +/// (≤ 9), (≤ 18), else +/// (a NUMBER(38,0) can exceed Int64). +/// +/// TIME arrives as an integer of seconds-of-day × 10^scale; it is rescaled to +/// nanoseconds. +/// TIMESTAMP_NTZ/LTZ/TZ arrive either as a single integer (the timestamp in +/// 10^-scale units) or as a struct: epoch (seconds) plus, when the scale needs it, a +/// fraction (nanoseconds) and, for TZ, a timezone field. They are decoded to +/// nanoseconds (NTZ has no zone; LTZ/TZ carry the UTC instant). The +/// per-row TZ offset is not representable in a single Arrow column, so it is dropped after being +/// applied to reach the UTC instant. Nanosecond timestamps cannot represent dates beyond ~2262. +/// Columns of any other type, and FIXED columns whose wire type already matches the target, +/// are passed through unchanged. Field metadata is preserved on rewritten columns. +/// +internal sealed class SnowflakeResultArrowStream : Ipc.IArrowArrayStream +{ + private const string LogicalTypeKey = "logicalType"; + private const string PrecisionKey = "precision"; + private const string ScaleKey = "scale"; + + private const string FixedLogicalType = "FIXED"; + private const string TimeLogicalType = "TIME"; + private const string TimestampNtzLogicalType = "TIMESTAMP_NTZ"; + private const string TimestampLtzLogicalType = "TIMESTAMP_LTZ"; + private const string TimestampTzLogicalType = "TIMESTAMP_TZ"; + + private const string Utc = "UTC"; + + // Largest decimal precision guaranteed to fit each integer width: Int32 holds 9 full digits + // (max 2,147,483,647), Int64 holds 18 (max 9,223,372,036,854,775,807). + private const int MaxInt32Precision = 9; + private const int MaxInt64Precision = 18; + + private const long NanosecondsPerSecond = 1_000_000_000L; + + private readonly Ipc.IArrowArrayStream _inner; + private readonly List _transforms; + + public Schema Schema { get; } + + public SnowflakeResultArrowStream(Ipc.IArrowArrayStream inner) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + (_transforms, Schema) = Analyze(inner.Schema); + } + + public async ValueTask ReadNextRecordBatchAsync(CancellationToken cancellationToken = default) + { + RecordBatch? batch = await _inner.ReadNextRecordBatchAsync(cancellationToken).ConfigureAwait(false); + if (batch == null || _transforms.Count == 0) + return batch; + + // Rebuild only the transformed columns; reuse the rest. The pass-through columns become + // owned by the returned batch, so the source batch must NOT be disposed; the columns we + // replace are disposed individually instead. _transforms is in ascending Index order. + var columns = new IArrowArray[batch.ColumnCount]; + int next = 0; + for (int i = 0; i < batch.ColumnCount; i++) + { + if (next < _transforms.Count && _transforms[next].Index == i) + { + IArrowArray source = batch.Column(i); + columns[i] = _transforms[next].Convert(source); + source.Dispose(); + next++; + } + else + { + columns[i] = batch.Column(i); + } + } + + return new RecordBatch(Schema, columns, batch.Length); + } + + public void Dispose() => _inner.Dispose(); + + private static (List Transforms, Schema Schema) Analyze(Schema schema) + { + var transforms = new List(); + var fields = new List(schema.FieldsList.Count); + + for (int i = 0; i < schema.FieldsList.Count; i++) + { + Field field = schema.FieldsList[i]; + if (!TryGetLogicalType(field, out string logicalType, out int precision, out int scale)) + { + fields.Add(field); + continue; + } + + int columnScale = scale; + Field outField = field; + + switch (logicalType) + { + case FixedLogicalType when IsIntegerType(field.DataType): + { + IArrowType target = FixedTargetType(precision, scale); + if (field.DataType.TypeId != target.TypeId) + { + transforms.Add(new ColumnTransform(i, source => ConvertFixed(source, target, columnScale))); + outField = new Field(field.Name, target, field.IsNullable, field.Metadata); + } + break; + } + + case TimeLogicalType: + { + var target = new Time64Type(TimeUnit.Nanosecond); + transforms.Add(new ColumnTransform(i, source => ConvertTime(source, target, columnScale))); + outField = new Field(field.Name, target, field.IsNullable, field.Metadata); + break; + } + + case TimestampNtzLogicalType: + case TimestampLtzLogicalType: + case TimestampTzLogicalType: + { + // TZ carries a per-row offset field (dropped — we store the UTC instant); NTZ + // has no zone, LTZ/TZ are tagged UTC. + bool hasTimezoneField = logicalType == TimestampTzLogicalType; + string? timezone = logicalType == TimestampNtzLogicalType ? null : Utc; + var target = new TimestampType(TimeUnit.Nanosecond, timezone); + transforms.Add(new ColumnTransform(i, source => ConvertTimestamp(source, target, columnScale, hasTimezoneField))); + outField = new Field(field.Name, target, field.IsNullable, field.Metadata); + break; + } + } + + fields.Add(outField); + } + + return (transforms, new Schema(fields, schema.Metadata)); + } + + private static bool TryGetLogicalType(Field field, out string logicalType, out int precision, out int scale) + { + logicalType = string.Empty; + precision = 38; + scale = 0; + + if (!field.HasMetadata + || !field.Metadata.TryGetValue(LogicalTypeKey, out string? logical) + || string.IsNullOrEmpty(logical)) + { + return false; + } + + logicalType = logical.ToUpperInvariant(); + + if (field.Metadata.TryGetValue(PrecisionKey, out string? p) + && int.TryParse(p, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedPrecision) + && parsedPrecision > 0) + { + precision = parsedPrecision; + } + + field.Metadata.TryGetValue(ScaleKey, out string? s); + int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out scale); + return true; + } + + private static IArrowType FixedTargetType(int precision, int scale) + { + if (scale > 0) + return new Decimal128Type(precision, scale); + if (precision <= MaxInt32Precision) + return Int32Type.Default; + if (precision <= MaxInt64Precision) + return Int64Type.Default; + return new Decimal128Type(precision, scale); + } + + private static IArrowArray ConvertFixed(IArrowArray source, IArrowType target, int scale) => target switch + { + Int32Type => ToInt32(source), + Int64Type => ToInt64(source), + Decimal128Type decimalType => RescaleToDecimal(source, decimalType, scale), + _ => throw new NotSupportedException($"Unexpected target type {target} for a FIXED column.") + }; + + private static Int32Array ToInt32(IArrowArray source) + { + (ArrowBuffer values, ArrowBuffer validity, int nullCount) = source switch + { + Int8Array a => NarrowToInt32(a.Values, a), + Int16Array a => NarrowToInt32(a.Values, a), + Int32Array a => NarrowToInt32(a.Values, a), + Int64Array a => NarrowToInt32(a.Values, a), + _ => throw UnexpectedIntegerArray(source) + }; + + return new Int32Array(values, validity, source.Length, nullCount, 0); + } + + private static Int64Array ToInt64(IArrowArray source) + { + (ArrowBuffer values, ArrowBuffer validity, int nullCount) = ScaleToLong(source, multiplier: 1); + return new Int64Array(values, validity, source.Length, nullCount, 0); + } + + private static Decimal128Array RescaleToDecimal(IArrowArray source, Decimal128Type type, int scale) + { + decimal scaleFactor = 1m; + for (int k = 0; k < scale; k++) + scaleFactor *= 10m; + + var builder = new Decimal128Array.Builder(type); + builder.Reserve(source.Length); + switch (source) + { + case Int8Array a: AppendRescaled(a.Values, a, builder, scale, scaleFactor); break; + case Int16Array a: AppendRescaled(a.Values, a, builder, scale, scaleFactor); break; + case Int32Array a: AppendRescaled(a.Values, a, builder, scale, scaleFactor); break; + case Int64Array a: AppendRescaled(a.Values, a, builder, scale, scaleFactor); break; + default: throw UnexpectedIntegerArray(source); + } + + return builder.Build(); + } + + private static void AppendRescaled( + ReadOnlySpan src, Apache.Arrow.Array array, Decimal128Array.Builder builder, int scale, decimal scaleFactor) + where T : struct, INumber + { + for (int i = 0; i < src.Length; i++) + { + if (array.IsNull(i)) + { + builder.AppendNull(); + } + else + { + decimal value = decimal.CreateChecked(src[i]); + builder.Append(scale > 0 ? value / scaleFactor : value); + } + } + } + + private static Time64Array ConvertTime(IArrowArray source, Time64Type type, int scale) + { + (ArrowBuffer values, ArrowBuffer validity, int nullCount) = ScaleToLong(source, PowerOfTen(9 - scale)); + return new Time64Array(type, values, validity, source.Length, nullCount, 0); + } + + private static TimestampArray ConvertTimestamp(IArrowArray source, TimestampType type, int scale, bool hasTimezoneField) + { + long multiplier = PowerOfTen(9 - scale); + ArrowBuffer values; + ArrowBuffer validity; + int nullCount; + + if (source is StructArray structArray) + { + // Field 0 is always the epoch. A timezone-carrying value's trailing field is the + // per-row offset, which we drop (the stored value is the UTC instant). A separate + // nanosecond fraction sits at field 1 for every shape except the 2-field timezone + // struct, where the epoch already holds the whole timestamp in 10^-scale units. The + // shape is determined by the known logical type + field count, never by field names. + var epoch = (Int64Array)structArray.Fields[0]; + bool hasFraction = !hasTimezoneField || structArray.Fields.Count >= 3; + + ReadOnlySpan epochValues = epoch.Values; + var builder = new ArrowBuffer.Builder(structArray.Length); + if (hasFraction) + { + ReadOnlySpan fractionValues = ((Int32Array)structArray.Fields[1]).Values; + for (int i = 0; i < epochValues.Length; i++) + builder.Append(unchecked(epochValues[i] * NanosecondsPerSecond + fractionValues[i] * multiplier)); + } + else + { + for (int i = 0; i < epochValues.Length; i++) + builder.Append(unchecked(epochValues[i] * multiplier)); + } + + // Null slots computed garbage above (harmless unchecked arithmetic); the validity + // bitmap is what marks them null. + values = builder.Build(); + (validity, nullCount) = CloneValidity(structArray); + } + else + { + // single integer: the whole timestamp in 10^-scale units. + (values, validity, nullCount) = ScaleToLong(source, multiplier); + } + + return new TimestampArray(type, values, validity, source.Length, nullCount, 0); + } + + /// + /// Rescales every integer slot into a long buffer (value × multiplier), dispatching on the + /// concrete array type once per column instead of per row. Null slots produce garbage values + /// (the widening read never throws and the multiply is unchecked); the validity bitmap is what + /// marks them null. + /// + private static (ArrowBuffer Values, ArrowBuffer Validity, int NullCount) ScaleToLong(IArrowArray source, long multiplier) => source switch + { + Int8Array a => ScaleCore(a.Values, a, multiplier), + Int16Array a => ScaleCore(a.Values, a, multiplier), + Int32Array a => ScaleCore(a.Values, a, multiplier), + Int64Array a => ScaleCore(a.Values, a, multiplier), + _ => throw UnexpectedIntegerArray(source) + }; + + private static (ArrowBuffer, ArrowBuffer, int) ScaleCore(ReadOnlySpan src, Apache.Arrow.Array array, long multiplier) + where T : struct, INumber + { + var values = new ArrowBuffer.Builder(src.Length); + for (int i = 0; i < src.Length; i++) + values.Append(unchecked(long.CreateChecked(src[i]) * multiplier)); + + (ArrowBuffer validity, int nullCount) = CloneValidity(array); + return (values.Build(), validity, nullCount); + } + + private static (ArrowBuffer, ArrowBuffer, int) NarrowToInt32(ReadOnlySpan src, Apache.Arrow.Array array) + where T : struct, INumber + { + // checked narrowing: a FIXED(precision ≤ 9) value always fits an Int32, so an overflow + // here means corrupt data and should throw (same semantics as the old per-row cast). Null + // slots are skipped rather than computed, since their garbage could spuriously overflow. + var values = new ArrowBuffer.Builder(src.Length); + if (array.NullCount == 0) + { + for (int i = 0; i < src.Length; i++) + values.Append(int.CreateChecked(src[i])); + return (values.Build(), ArrowBuffer.Empty, 0); + } + + var validity = new ArrowBuffer.BitmapBuilder(src.Length); + for (int i = 0; i < src.Length; i++) + { + if (array.IsNull(i)) + { + values.Append(0); + validity.Append(false); + } + else + { + values.Append(int.CreateChecked(src[i])); + validity.Append(true); + } + } + + return (values.Build(), validity.Build(), array.NullCount); + } + + /// + /// Reproduces the source array's validity as a fresh bitmap (the source's own buffer cannot be + /// shared, because the source array is disposed after conversion). All-valid columns skip the + /// bitmap entirely — Arrow treats an empty validity buffer as "no nulls". + /// + private static (ArrowBuffer Validity, int NullCount) CloneValidity(Apache.Arrow.Array array) + { + if (array.NullCount == 0) + return (ArrowBuffer.Empty, 0); + + var validity = new ArrowBuffer.BitmapBuilder(array.Length); + for (int i = 0; i < array.Length; i++) + validity.Append(array.IsValid(i)); + return (validity.Build(), array.NullCount); + } + + private static NotSupportedException UnexpectedIntegerArray(IArrowArray array) => + new($"Unexpected array type {array.GetType().Name} for an integer column."); + + private static long PowerOfTen(int exponent) + { + long result = 1; + for (int i = 0; i < exponent; i++) + result *= 10; + return result; + } + + private static bool IsIntegerType(IArrowType type) => + type is Int8Type or Int16Type or Int32Type or Int64Type; + + private readonly record struct ColumnTransform(int Index, Func Convert); +} diff --git a/csharp/src/Native/Services/Session/SnowflakeSessionClient.cs b/csharp/src/Native/Services/Session/SnowflakeSessionClient.cs new file mode 100644 index 0000000..369c978 --- /dev/null +++ b/csharp/src/Native/Services/Session/SnowflakeSessionClient.cs @@ -0,0 +1,70 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.ConnectionPool; +using AdbcDrivers.Snowflake.Native.Services.Query; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using AdbcDrivers.Snowflake.Native.Services.TypeConversion; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AdbcDrivers.Snowflake.Native.Services.Session; + +/// +/// Implements the session operations the connection pool needs (keep-alive heartbeat, close) over the +/// shared . This is the single place that knows how to assemble the transport +/// for those operations, so the database and the pool don't have to. Heartbeat runs through a +/// (which owns the endpoint and renew-on-expiry fallback); close runs +/// through . The executor is built per call because it is keyed on +/// the connection's account/network, which vary by config. +/// +internal sealed class SnowflakeSessionClient( + SnowflakeLoginClient loginClient, + HttpClient httpClient, + ILoggerFactory? loggerFactory = null) + : ISessionLifecycle +{ + private readonly SnowflakeLoginClient _loginClient = loginClient ?? throw new ArgumentNullException(nameof(loginClient)); + private readonly HttpClient _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + private readonly ILoggerFactory _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + + /// + public Task HeartbeatAsync(AuthenticationToken token, ConnectionConfig config, CancellationToken cancellationToken = default) + { + var executor = new QueryExecutor( + new RestApiClient(_httpClient, config.EnableCompression, + logger: _loggerFactory.CreateLogger()), + TypeConverter.Shared, + config.Account, + config.Network, + _loggerFactory.CreateLogger(), + // This executor serves the pool's own idle heartbeats, not a checked-out connection, so + // there is no connection to fault: the pool swallows a failed heartbeat and the session + // recovers via reactive renewal on the next query. + onConnectionFault: static () => { }); + return executor.HeartbeatAsync(token, cancellationToken); + } + + /// + public Task CloseAsync(AuthenticationToken token, ConnectionConfig config, CancellationToken cancellationToken = default) => + _loginClient.CloseSessionAsync(token, config, cancellationToken); +} diff --git a/csharp/src/Native/Services/SnowflakeAccountUrl.cs b/csharp/src/Native/Services/SnowflakeAccountUrl.cs new file mode 100644 index 0000000..65da9e8 --- /dev/null +++ b/csharp/src/Native/Services/SnowflakeAccountUrl.cs @@ -0,0 +1,54 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +namespace AdbcDrivers.Snowflake.Native.Services; + +/// +/// Builds the base account URL for Snowflake API requests. +/// +internal static class SnowflakeAccountUrl +{ + /// + /// Builds the HTTPS base URL for a Snowflake account. + /// Handles privatelink accounts correctly by only treating the value as a + /// full hostname if it already contains 'snowflakecomputing.com'. + /// + internal static string Build(string account) + { + return account.Contains("snowflakecomputing.com", System.StringComparison.OrdinalIgnoreCase) + ? $"https://{account}" + : $"https://{account}.snowflakecomputing.com"; + } + + /// + /// Builds the base URL for a Snowflake account, using explicit host/port/protocol if provided. + /// + internal static string Build(string account, Configuration.NetworkConfig? network) + { + if (network != null && !string.IsNullOrEmpty(network.Host)) + { + var port = network.Port != 443 ? $":{network.Port}" : string.Empty; + return $"{network.Protocol}://{network.Host}{port}"; + } + + var protocol = network?.Protocol ?? "https"; + var host = account.Contains("snowflakecomputing.com", System.StringComparison.OrdinalIgnoreCase) + ? account + : $"{account}.snowflakecomputing.com"; + var portSuffix = (network != null && network.Port != 443) ? $":{network.Port}" : string.Empty; + return $"{protocol}://{host}{portSuffix}"; + } +} diff --git a/csharp/src/Native/Services/Transport/ApiResponse.cs b/csharp/src/Native/Services/Transport/ApiResponse.cs new file mode 100644 index 0000000..cba76fc --- /dev/null +++ b/csharp/src/Native/Services/Transport/ApiResponse.cs @@ -0,0 +1,48 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +namespace AdbcDrivers.Snowflake.Native.Services.Transport; + +/// +/// Represents an API response from Snowflake. +/// +/// The response data type. +internal class ApiResponse +{ + /// + /// Gets or sets a value indicating whether the request was successful. + /// + [System.Text.Json.Serialization.JsonPropertyName("success")] + public bool Success { get; set; } + + /// + /// Gets or sets the response message. + /// + [System.Text.Json.Serialization.JsonPropertyName("message")] + public string? Message { get; set; } + + /// + /// Gets or sets the response data. + /// + [System.Text.Json.Serialization.JsonPropertyName("data")] + public T? Data { get; set; } + + /// + /// Gets or sets the error code (if any). + /// + [System.Text.Json.Serialization.JsonPropertyName("code")] + public string? Code { get; set; } +} diff --git a/csharp/src/Native/Services/Transport/IRestApiClient.cs b/csharp/src/Native/Services/Transport/IRestApiClient.cs new file mode 100644 index 0000000..5e8b570 --- /dev/null +++ b/csharp/src/Native/Services/Transport/IRestApiClient.cs @@ -0,0 +1,73 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.IO; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Services.Authentication; + +namespace AdbcDrivers.Snowflake.Native.Services.Transport; + +/// +/// Provides HTTP communication with Snowflake's REST API. +/// +internal interface IRestApiClient +{ + /// + /// Sends a POST request to the specified endpoint. + /// + /// The request body type. + /// The response type. + /// The API endpoint. + /// The request payload. + /// The authentication token. + /// The cancellation token. + /// The API response. + Task> PostAsync( + string endpoint, + TRequest request, + AuthenticationToken token, + CancellationToken cancellationToken = default); + + /// + /// Sends a GET request to the specified endpoint and reads a Snowflake API envelope + /// (used e.g. to poll a long-running query's result URL). + /// + /// The response type. + /// The API endpoint. + /// The authentication token. + /// The cancellation token. + /// The API response. + Task> GetAsync( + string endpoint, + AuthenticationToken token, + CancellationToken cancellationToken = default); + + /// + /// Gets an Arrow stream from the specified URL. + /// + /// The URL to fetch the Arrow stream from. + /// The authentication token. + /// The cancellation token. + /// A stream containing Arrow data. + Task GetArrowStreamAsync( + string url, + AuthenticationToken token, + Dictionary? chunkHeaders = null, + string? qrmk = null, + CancellationToken cancellationToken = default); +} diff --git a/csharp/src/Native/Services/Transport/RequestBuilder.cs b/csharp/src/Native/Services/Transport/RequestBuilder.cs new file mode 100644 index 0000000..cf4df74 --- /dev/null +++ b/csharp/src/Native/Services/Transport/RequestBuilder.cs @@ -0,0 +1,104 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace AdbcDrivers.Snowflake.Native.Services.Transport; + +/// +/// Builds request bodies for the Snowflake query API. +/// +internal static class RequestBuilder +{ + /// + /// Builds a query execution request. + /// + /// The SQL statement to execute. + /// The database name (optional). + /// The schema name (optional). + /// The warehouse name (optional). + /// The role name (optional). + /// The query tag surfaced in the Snowsight query history (optional). + /// The query timeout in seconds (optional). + /// Positional bind variables (optional). + /// Whether this is a multi-statement query. + /// Whether to only describe (compile) the statement and return its metadata without executing it. + /// A query execution request body. + public static SnowflakeQueryRequestBody BuildQueryRequest( + string statement, + string? database = null, + string? schema = null, + string? warehouse = null, + string? role = null, + string? queryTag = null, + int? timeout = null, + Dictionary? bindings = null, + bool isMultiStatement = false, + bool describeOnly = false) + { + if (string.IsNullOrEmpty(statement)) + throw new ArgumentException("Statement cannot be null or empty.", nameof(statement)); + + var sessionParams = new Dictionary(); + + if (!string.IsNullOrEmpty(database)) + sessionParams[SessionParameterNames.Database] = database; + + if (!string.IsNullOrEmpty(schema)) + sessionParams[SessionParameterNames.Schema] = schema; + + if (!string.IsNullOrEmpty(warehouse)) + sessionParams[SessionParameterNames.Warehouse] = warehouse; + + if (!string.IsNullOrEmpty(role)) + sessionParams[SessionParameterNames.Role] = role; + + if (!string.IsNullOrEmpty(queryTag)) + sessionParams[SessionParameterNames.QueryTag] = queryTag; + + if (timeout.HasValue && timeout.Value > 0) + sessionParams[SessionParameterNames.StatementTimeoutInSeconds] = timeout.Value.ToString(CultureInfo.InvariantCulture); + + sessionParams[SessionParameterNames.QueryResultFormat] = SessionParameterValues.ArrowResultFormat; + + if (isMultiStatement) + sessionParams[SessionParameterNames.MultiStatementCount] = SessionParameterValues.VariableStatementCount; + + return new SnowflakeQueryRequestBody + { + SqlText = statement, + AsyncExec = false, + DescribeOnly = describeOnly, + Parameters = sessionParams, + Bindings = bindings is { Count: > 0 } ? bindings : null, + }; + } + + /// + /// Builds a query cancellation request. + /// + /// The request id the query was submitted with. + /// A query cancellation request body. + public static SnowflakeCancelRequestBody BuildCancelRequest(string requestId) + { + return string.IsNullOrEmpty(requestId) + ? throw new ArgumentException("Request id cannot be null or empty.", nameof(requestId)) + : new SnowflakeCancelRequestBody { RequestId = requestId }; + } + +} diff --git a/csharp/src/Native/Services/Transport/RestApiClient.cs b/csharp/src/Native/Services/Transport/RestApiClient.cs new file mode 100644 index 0000000..398154e --- /dev/null +++ b/csharp/src/Native/Services/Transport/RestApiClient.cs @@ -0,0 +1,283 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.IO; +using System.IO.Compression; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AdbcDrivers.Snowflake.Native.Services.Transport; + +/// +/// Implements HTTP communication with Snowflake's REST API. +/// +internal class RestApiClient : IRestApiClient +{ + readonly HttpClient _httpClient; + readonly bool _enableCompression; + readonly int _maxRetries; + readonly TimeSpan _baseRetryDelay; + readonly ILogger _logger; + + // Serializer options backed by the source-generated context (see SnowflakeJsonContext). + static readonly JsonSerializerOptions JsonOptions = new() + { + TypeInfoResolver = SnowflakeJsonContext.Default + }; + + // Header values are immutable; build them once instead of re-parsing per request. + static readonly MediaTypeWithQualityHeaderValue SnowflakeAccept = new("application/snowflake"); + static readonly MediaTypeWithQualityHeaderValue ArrowStreamAccept = new("application/vnd.apache.arrow.stream"); + static readonly StringWithQualityHeaderValue GzipEncoding = new("gzip"); + static readonly StringWithQualityHeaderValue DeflateEncoding = new("deflate"); + + /// The driver's own version, reported in the user agent and login payload. + static readonly string DriverVersion = + typeof(RestApiClient).Assembly.GetName().Version?.ToString(3) ?? "1.0.0"; + + // The server requires the leading ".NET/{version}" product token to enable Arrow results; the + // OS comment and runtime token are derived from the actual environment rather than hardcoded. + static readonly ProductInfoHeaderValue[] UserAgent = + [ + new(".NET", DriverVersion), + new( + $"({System.Runtime.InteropServices.RuntimeInformation.OSDescription.Replace('(', '[').Replace(')', ']').Trim()})"), + new(".NETCoreApp", Environment.Version.ToString(2)), + ]; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client. + /// Whether to enable compression. + /// Maximum number of retries for transient errors. + /// Base delay for exponential backoff. + /// Logger for transport-level events (retried transient failures). + public RestApiClient( + HttpClient httpClient, + bool enableCompression = true, + int maxRetries = 3, + TimeSpan? baseRetryDelay = null, + ILogger? logger = null) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _enableCompression = enableCompression; + _maxRetries = maxRetries; + _baseRetryDelay = baseRetryDelay ?? TimeSpan.FromMilliseconds(100); + _logger = logger ?? NullLogger.Instance; + } + + /// + public async Task> PostAsync( + string endpoint, + TRequest request, + AuthenticationToken token, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint, nameof(endpoint)); + ArgumentNullException.ThrowIfNull(token, nameof(token)); + + return await ExecuteWithRetryAsync(async () => + { + using var requestMessage = new HttpRequestMessage(HttpMethod.Post, endpoint); + ConfigureRequest(requestMessage, token); + + requestMessage.Content = JsonContent.Create(request, options: JsonOptions); + AddCompressionHeadersIfEnabled(requestMessage); + + using var response = await _httpClient.SendAsync( + requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + return await ReadApiResponseAsync(response, cancellationToken).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> GetAsync( + string endpoint, + AuthenticationToken token, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint, nameof(endpoint)); + ArgumentNullException.ThrowIfNull(token, nameof(token)); + + return await ExecuteWithRetryAsync(async () => + { + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, endpoint); + ConfigureRequest(requestMessage, token); + AddCompressionHeadersIfEnabled(requestMessage); + + using var response = await _httpClient.SendAsync( + requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + return await ReadApiResponseAsync(response, cancellationToken).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task GetArrowStreamAsync( + string url, + AuthenticationToken token, + Dictionary? chunkHeaders = null, + string? qrmk = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(url, nameof(url)); + ArgumentNullException.ThrowIfNull(token, nameof(token)); + + return await ExecuteWithRetryAsync(async () => + { + using var requestMessage = new HttpRequestMessage(HttpMethod.Get, url); + if (chunkHeaders == null && string.IsNullOrEmpty(qrmk)) + { + ConfigureRequest(requestMessage, token); + } + else + { + if (chunkHeaders != null) + { + foreach (var header in chunkHeaders) + { + requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + else + { + requestMessage.Headers.TryAddWithoutValidation("x-amz-server-side-encryption-customer-algorithm", + "AES256"); + requestMessage.Headers.TryAddWithoutValidation("x-amz-server-side-encryption-customer-key", qrmk); + } + } + + requestMessage.Headers.Accept.Add(ArrowStreamAccept); + + var response = await _httpClient.SendAsync( + requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + // The response is deliberately not disposed: the caller owns the returned live body + // stream, and disposing the stream releases the connection. + return await GetResponseStreamAsync(response, cancellationToken).ConfigureAwait(false); + }, cancellationToken).ConfigureAwait(false); + } + + void AddCompressionHeadersIfEnabled(HttpRequestMessage request) + { + if (!_enableCompression) return; + + request.Headers.AcceptEncoding.Add(GzipEncoding); + request.Headers.AcceptEncoding.Add(DeflateEncoding); + } + + async Task GetResponseStreamAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + if (response.Content.Headers.ContentEncoding.Contains("gzip")) + return new GZipStream(stream, CompressionMode.Decompress); + + if (response.Content.Headers.ContentEncoding.Contains("deflate")) + return new DeflateStream(stream, CompressionMode.Decompress); + + return stream; + } + + async Task> ReadApiResponseAsync(HttpResponseMessage response, + CancellationToken cancellationToken) + { + await using var stream = await GetResponseStreamAsync(response, cancellationToken).ConfigureAwait(false); + return await JsonSerializer.DeserializeAsync>(stream, JsonOptions, cancellationToken) + .ConfigureAwait(false) + ?? throw new InvalidOperationException("Failed to deserialize API response."); + } + + void ConfigureRequest(HttpRequestMessage request, AuthenticationToken token) + { + request.Headers.Add("Authorization", $"Snowflake Token=\"{token.SessionToken}\""); + request.Headers.Accept.Add(SnowflakeAccept); + + foreach (var part in UserAgent) + request.Headers.UserAgent.Add(part); + } + + async Task ExecuteWithRetryAsync( + Func> operation, + CancellationToken cancellationToken) + { + Exception? lastException = null; + + for (var attempt = 0; attempt < _maxRetries; attempt++) + { + try + { + return await operation().ConfigureAwait(false); + } + catch (HttpRequestException ex) when (IsTransientError(ex) && attempt < _maxRetries - 1) + { + lastException = ex; + LogRetry(ex, attempt); + await DelayAsync(attempt, cancellationToken).ConfigureAwait(false); + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException && attempt < _maxRetries - 1) + { + lastException = ex; + LogRetry(ex, attempt); + await DelayAsync(attempt, cancellationToken).ConfigureAwait(false); + } + } + + throw lastException ?? new InvalidOperationException("Operation failed after retries."); + } + + void LogRetry(Exception ex, int attempt) => + _logger.LogWarning(ex, "Transient failure on Snowflake request (attempt {Attempt} of {MaxAttempts}); retrying.", + attempt + 1, _maxRetries); + + async Task DelayAsync(int attempt, CancellationToken cancellationToken) + { + var delay = TimeSpan.FromMilliseconds( + _baseRetryDelay.TotalMilliseconds * Math.Pow(2, attempt) + + Random.Shared.Next(0, 100)); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + + static bool IsTransientError(HttpRequestException ex) + { + // Check for transient HTTP status codes + if (ex.StatusCode.HasValue) + { + var statusCode = (int)ex.StatusCode.Value; + return statusCode == 408 || // Request Timeout + statusCode == 429 || // Too Many Requests + statusCode == 503 || // Service Unavailable + statusCode == 504; // Gateway Timeout + } + + // Check for network-related errors + return ex.InnerException is System.Net.Sockets.SocketException || + ex.InnerException is IOException; + } +} diff --git a/csharp/src/Native/Services/Transport/SnowflakeJsonContext.cs b/csharp/src/Native/Services/Transport/SnowflakeJsonContext.cs new file mode 100644 index 0000000..5fd61e0 --- /dev/null +++ b/csharp/src/Native/Services/Transport/SnowflakeJsonContext.cs @@ -0,0 +1,36 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Text.Json.Serialization; +using AdbcDrivers.Snowflake.Native.Services.Query; + +namespace AdbcDrivers.Snowflake.Native.Services.Transport; + +/// +/// Source-generated serializer metadata for the query-protocol wire model (a small, closed set of +/// types), used by . Compile-time generation removes the reflection +/// warm-up and per-call metadata allocations of the default serializer and keeps this path +/// trimming/AOT-safe. The login path (SnowflakeLoginClient) intentionally stays on the +/// reflection-based web defaults — it runs once per connection and relies on case-insensitive +/// matching that these models don't. +/// +[JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(ApiResponse))] +[JsonSerializable(typeof(SnowflakeQueryRequestBody))] +[JsonSerializable(typeof(SnowflakeCancelRequestBody))] +[JsonSerializable(typeof(SnowflakeRenewSessionBody))] +[JsonSerializable(typeof(EmptyRequestBody))] +internal sealed partial class SnowflakeJsonContext : JsonSerializerContext; diff --git a/csharp/src/Native/Services/Transport/SnowflakeProtocolConstants.cs b/csharp/src/Native/Services/Transport/SnowflakeProtocolConstants.cs new file mode 100644 index 0000000..2065edf --- /dev/null +++ b/csharp/src/Native/Services/Transport/SnowflakeProtocolConstants.cs @@ -0,0 +1,60 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +namespace AdbcDrivers.Snowflake.Native.Services.Transport; + +/// +/// Names of the session-level settings sent in the parameters map of a query request. +/// +internal static class SessionParameterNames +{ + public const string Database = "DATABASE"; + public const string Schema = "SCHEMA"; + public const string Warehouse = "WAREHOUSE"; + public const string Role = "ROLE"; + public const string StatementTimeoutInSeconds = "STATEMENT_TIMEOUT_IN_SECONDS"; + public const string QueryResultFormat = "DOTNET_QUERY_RESULT_FORMAT"; + public const string MultiStatementCount = "MULTI_STATEMENT_COUNT"; + public const string QueryTag = "QUERY_TAG"; +} + +/// +/// Fixed values used for session parameters. +/// +internal static class SessionParameterValues +{ + /// Requests query results in Apache Arrow format. + public const string ArrowResultFormat = "ARROW"; + + /// Allows a variable number of statements in a multi-statement request. + public const string VariableStatementCount = "0"; +} + +/// +/// Snowflake data-type names used when binding parameter values. +/// +internal static class BindTypeNames +{ + public const string Text = "TEXT"; + public const string Fixed = "FIXED"; + public const string Real = "REAL"; + public const string Boolean = "BOOLEAN"; + public const string Date = "DATE"; + public const string Time = "TIME"; + public const string TimestampNtz = "TIMESTAMP_NTZ"; + public const string TimestampLtz = "TIMESTAMP_LTZ"; + public const string Binary = "BINARY"; +} diff --git a/csharp/src/Native/Services/Transport/SnowflakeRequestBodies.cs b/csharp/src/Native/Services/Transport/SnowflakeRequestBodies.cs new file mode 100644 index 0000000..73d3552 --- /dev/null +++ b/csharp/src/Native/Services/Transport/SnowflakeRequestBodies.cs @@ -0,0 +1,157 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AdbcDrivers.Snowflake.Native.Services.Transport; + +/// +/// Request body for the query-execution endpoint. describeOnly compiles the statement +/// and returns its result metadata (rowtype) without executing it. +/// +internal sealed class SnowflakeQueryRequestBody +{ + [JsonPropertyName("sqlText")] + public required string SqlText { get; init; } + + [JsonPropertyName("asyncExec")] + public bool AsyncExec { get; init; } + + [JsonPropertyName("describeOnly")] + public bool DescribeOnly { get; init; } + + /// Session-level settings (see ). + [JsonPropertyName("parameters")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Parameters { get; init; } + + /// Positional bind variables keyed "1", "2", ... matching the '?' placeholders. + [JsonPropertyName("bindings")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? Bindings { get; init; } +} + +/// +/// A bound parameter with its Snowflake data type (see ): either a +/// scalar () or, for array binding / executemany, one wire value per row +/// ( — the server then executes the statement once per row). +/// +[JsonConverter(typeof(SnowflakeBindingJsonConverter))] +internal sealed record SnowflakeBinding(string Type, string? Value) +{ + /// Per-row values for an array bind; null for a scalar bind. + public IReadOnlyList? Values { get; init; } + + public SnowflakeBinding(string type, IReadOnlyList values) + : this(type, (string?)null) => Values = values; +} + +/// +/// Writes a binding as {"type": "...", "value": ...} where value is a string (or +/// null) for a scalar bind and an array of strings/nulls for an array bind — the two shapes +/// Snowflake's bind protocol accepts under the same key. Bindings are request-only, so reading +/// is not supported. +/// +internal sealed class SnowflakeBindingJsonConverter : JsonConverter +{ + public override void Write(Utf8JsonWriter writer, SnowflakeBinding binding, JsonSerializerOptions options) + { + writer.WriteStartObject(); + writer.WriteString("type", binding.Type); + + if (binding.Values is { } rows) + { + writer.WritePropertyName("value"); + writer.WriteStartArray(); + foreach (string? row in rows) + { + if (row == null) + writer.WriteNullValue(); + else + writer.WriteStringValue(row); + } + writer.WriteEndArray(); + } + else if (binding.Value == null) + { + writer.WriteNull("value"); + } + else + { + writer.WriteString("value", binding.Value); + } + + writer.WriteEndObject(); + } + + public override SnowflakeBinding Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException("Bindings are serialized into requests, never read back."); +} + +/// +/// An intentionally empty request body (e.g. the heartbeat POST), typed so the source-generated +/// serializer can handle it without falling back to object. +/// +internal sealed class EmptyRequestBody +{ + internal static readonly EmptyRequestBody Instance = new(); +} + +/// +/// Request body for cancelling a running query. Snowflake aborts by the requestId the +/// query was submitted with (not the queryId it returns), so the original request id is echoed here. +/// +internal sealed class SnowflakeCancelRequestBody +{ + [JsonPropertyName("requestId")] + public required string RequestId { get; init; } +} + +/// +/// Request body for renewing an expired session token via /session/token-request. The +/// request is authenticated with the master token (not the expired session token). +/// +internal sealed class SnowflakeRenewSessionBody +{ + [JsonPropertyName("oldSessionToken")] + public string? OldSessionToken { get; init; } + + [JsonPropertyName("requestType")] + public string RequestType { get; init; } = "RENEW"; +} + +/// +/// Data returned by /session/token-request: the fresh session token (and refreshed master +/// token) with their validity windows. +/// +internal sealed class SnowflakeRenewSessionData +{ + [JsonPropertyName("sessionToken")] + public string? SessionToken { get; init; } + + // Session-token validity ("ST"); the renew endpoint names it differently from the login response. + [JsonPropertyName("validityInSecondsST")] + public int ValidityInSeconds { get; init; } + + [JsonPropertyName("masterToken")] + public string? MasterToken { get; init; } + + [JsonPropertyName("validityInSecondsMT")] + public int MasterValidityInSeconds { get; init; } +} diff --git a/csharp/src/Native/Services/TypeConversion/ITypeConverter.cs b/csharp/src/Native/Services/TypeConversion/ITypeConverter.cs new file mode 100644 index 0000000..988de11 --- /dev/null +++ b/csharp/src/Native/Services/TypeConversion/ITypeConverter.cs @@ -0,0 +1,41 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using Apache.Arrow.Types; + +using Apache.Arrow; + +namespace AdbcDrivers.Snowflake.Native.Services.TypeConversion; + +/// +/// Converts between Snowflake and Arrow data types. +/// +internal interface ITypeConverter +{ + /// + /// Converts a Snowflake data type to an Arrow type. + /// + /// The Snowflake data type. + /// The corresponding Arrow type. + IArrowType ConvertSnowflakeTypeToArrow(SnowflakeDataType snowflakeType); + + /// + /// Converts an Arrow record batch to Snowflake parameter bindings. + /// + /// The Arrow record batch. + /// A parameter set for Snowflake query execution. + ParameterSet ConvertArrowBatchToParameters(RecordBatch batch); +} diff --git a/csharp/src/Native/Services/TypeConversion/ParameterSet.cs b/csharp/src/Native/Services/TypeConversion/ParameterSet.cs new file mode 100644 index 0000000..7533834 --- /dev/null +++ b/csharp/src/Native/Services/TypeConversion/ParameterSet.cs @@ -0,0 +1,32 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Generic; +using AdbcDrivers.Snowflake.Native.Services.Transport; + +namespace AdbcDrivers.Snowflake.Native.Services.TypeConversion; + +/// +/// Represents a set of positional bind variables for Snowflake query execution, keyed by +/// 1-based placeholder position ("1", "2", ...) to match the '?' placeholders in the SQL. +/// +internal class ParameterSet +{ + /// + /// Gets or sets the bind variables, keyed by 1-based placeholder position. + /// + public Dictionary Parameters { get; init; } = new(); +} diff --git a/csharp/src/Native/Services/TypeConversion/SnowflakeDataType.cs b/csharp/src/Native/Services/TypeConversion/SnowflakeDataType.cs new file mode 100644 index 0000000..9a365ff --- /dev/null +++ b/csharp/src/Native/Services/TypeConversion/SnowflakeDataType.cs @@ -0,0 +1,84 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +namespace AdbcDrivers.Snowflake.Native.Services.TypeConversion; + +/// +/// Represents a Snowflake data type with metadata. +/// +internal class SnowflakeDataType +{ + /// + /// Gets or sets the type name. + /// + public string TypeName { get; set; } = string.Empty; + + /// + /// Gets or sets the precision (for numeric types). + /// + public int? Precision { get; set; } + + /// + /// Gets or sets the scale (for numeric types). + /// + public int? Scale { get; set; } + + /// + /// Gets or sets the length (for string/binary types). + /// + public int? Length { get; set; } + + /// + /// Gets or sets whether the type is nullable. + /// + public bool IsNullable { get; set; } = true; + + /// + /// Gets or sets the timezone (for timestamp types). + /// + public string? Timezone { get; set; } + + /// + /// Gets the Snowflake type code. + /// + public SnowflakeTypeCode TypeCode => ParseTypeCode(TypeName); + + private static SnowflakeTypeCode ParseTypeCode(string typeName) + { + return typeName.ToUpperInvariant() switch + { + "FIXED" or "NUMBER" or "DECIMAL" or "NUMERIC" => SnowflakeTypeCode.Number, + "INTEGER" or "INT" or "BIGINT" or "SMALLINT" or "TINYINT" or "BYTEINT" => SnowflakeTypeCode.Integer, + "FLOAT" or "FLOAT4" or "FLOAT8" => SnowflakeTypeCode.Float, + "DOUBLE" or "DOUBLE PRECISION" or "REAL" => SnowflakeTypeCode.Double, + "VARCHAR" or "STRING" or "TEXT" or "CHAR" or "CHARACTER" => SnowflakeTypeCode.Varchar, + "BINARY" or "VARBINARY" => SnowflakeTypeCode.Binary, + "BOOLEAN" => SnowflakeTypeCode.Boolean, + "DATE" => SnowflakeTypeCode.Date, + "TIME" => SnowflakeTypeCode.Time, + "TIMESTAMP" or "DATETIME" => SnowflakeTypeCode.Timestamp, + "TIMESTAMP_LTZ" => SnowflakeTypeCode.TimestampLtz, + "TIMESTAMP_NTZ" => SnowflakeTypeCode.TimestampNtz, + "TIMESTAMP_TZ" => SnowflakeTypeCode.TimestampTz, + "VARIANT" => SnowflakeTypeCode.Variant, + "OBJECT" => SnowflakeTypeCode.Object, + "ARRAY" => SnowflakeTypeCode.Array, + "GEOGRAPHY" => SnowflakeTypeCode.Geography, + "GEOMETRY" => SnowflakeTypeCode.Geometry, + _ => SnowflakeTypeCode.Unknown + }; + } +} diff --git a/csharp/src/Native/Services/TypeConversion/SnowflakeTypeCode.cs b/csharp/src/Native/Services/TypeConversion/SnowflakeTypeCode.cs new file mode 100644 index 0000000..98ee968 --- /dev/null +++ b/csharp/src/Native/Services/TypeConversion/SnowflakeTypeCode.cs @@ -0,0 +1,43 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +namespace AdbcDrivers.Snowflake.Native.Services.TypeConversion; + +/// +/// Snowflake type codes. +/// +internal enum SnowflakeTypeCode +{ + Unknown, + Number, + Integer, + Float, + Double, + Varchar, + Binary, + Boolean, + Date, + Time, + Timestamp, + TimestampLtz, + TimestampNtz, + TimestampTz, + Variant, + Object, + Array, + Geography, + Geometry +} diff --git a/csharp/src/Native/Services/TypeConversion/TypeConverter.cs b/csharp/src/Native/Services/TypeConversion/TypeConverter.cs new file mode 100644 index 0000000..cd2a3dc --- /dev/null +++ b/csharp/src/Native/Services/TypeConversion/TypeConverter.cs @@ -0,0 +1,213 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using Apache.Arrow.Types; + +using Apache.Arrow; + +namespace AdbcDrivers.Snowflake.Native.Services.TypeConversion; + +/// +/// Implements type conversion between Snowflake and Arrow formats. +/// +internal class TypeConverter : ITypeConverter +{ + /// Shared instance — the converter is stateless, so one serves every consumer. + internal static TypeConverter Shared { get; } = new(); + + /// + public IArrowType ConvertSnowflakeTypeToArrow(SnowflakeDataType snowflakeType) + { + ArgumentNullException.ThrowIfNull(snowflakeType); + + return snowflakeType.TypeCode switch + { + SnowflakeTypeCode.Boolean => BooleanType.Default, + + // INTEGER/INT/BIGINT/SMALLINT/TINYINT/BYTEINT are all NUMBER(38,0) in Snowflake, so + // they size by precision the same way FIXED/NUMBER does. + SnowflakeTypeCode.Integer or + SnowflakeTypeCode.Number => FixedToArrowType( + snowflakeType.Precision.GetValueOrDefault(38), + snowflakeType.Scale.GetValueOrDefault(0)), + + SnowflakeTypeCode.Float => FloatType.Default, + + SnowflakeTypeCode.Double => DoubleType.Default, + + SnowflakeTypeCode.Varchar => StringType.Default, + + SnowflakeTypeCode.Binary => BinaryType.Default, + + SnowflakeTypeCode.Date => Date32Type.Default, + + SnowflakeTypeCode.Time => TimeType.Nanosecond, + + SnowflakeTypeCode.Timestamp or + SnowflakeTypeCode.TimestampNtz => new TimestampType(TimeUnit.Nanosecond, timezone: (string?)null), + + SnowflakeTypeCode.TimestampLtz => new TimestampType(TimeUnit.Nanosecond, timezone: "UTC"), + + // The result decoder stores TIMESTAMP_TZ as its UTC instant (a single Arrow column + // cannot carry a per-row offset), so the described type matches: Timestamp[ns] "UTC". + SnowflakeTypeCode.TimestampTz => new TimestampType(TimeUnit.Nanosecond, timezone: "UTC"), + + SnowflakeTypeCode.Variant or + SnowflakeTypeCode.Object => StringType.Default, // JSON as string + + SnowflakeTypeCode.Array => new ListType(StringType.Default), // Array of JSON strings + + SnowflakeTypeCode.Geography or + SnowflakeTypeCode.Geometry => StringType.Default, // GeoJSON as string + + _ => throw new NotSupportedException($"Snowflake type {snowflakeType.TypeName} is not supported.") + }; + } + + // Largest decimal precision guaranteed to fit each integer width (Int32 holds 9 full digits, + // Int64 holds 18). + private const int MaxFixedInt32Precision = 9; + private const int MaxFixedInt64Precision = 18; + + /// + /// Sizes a FIXED (NUMBER/DECIMAL) column to a stable Arrow type from its declared precision and + /// scale — the same rule the result decoder (SnowflakeResultArrowStream) applies, so the + /// schema this describes matches what a query actually returns: scale > 0 → Decimal128; + /// scale 0 → Int32 (precision ≤ 9) / Int64 (≤ 18) / Decimal128 (a NUMBER(38,0) can exceed Int64). + /// + private static IArrowType FixedToArrowType(int precision, int scale) + { + if (scale > 0) + return new Decimal128Type(precision, scale); + if (precision <= MaxFixedInt32Precision) + return Int32Type.Default; + if (precision <= MaxFixedInt64Precision) + return Int64Type.Default; + return new Decimal128Type(precision, scale); + } + + /// + public ParameterSet ConvertArrowBatchToParameters(RecordBatch batch) + { + ArgumentNullException.ThrowIfNull(batch); + + var parameters = new Dictionary(); + + if (batch.Length <= 0) + return new ParameterSet { Parameters = parameters }; + + for (var i = 0; i < batch.Schema.FieldsList.Count; i++) + { + // Snowflake binds '?' placeholders positionally: each column is the parameter + // at its 1-based ordinal, keyed "1", "2", ... (not by column name). A single-row + // batch binds scalar values; a multi-row batch binds one value array per + // parameter and the server executes the statement once per row (executemany). + var key = (i + 1).ToString(CultureInfo.InvariantCulture); + parameters[key] = batch.Length == 1 + ? ToBinding(batch.Column(i), 0) + : ToArrayBinding(batch.Column(i), batch.Length); + } + + return new ParameterSet { Parameters = parameters }; + } + + /// + /// Converts a whole Arrow column into an array bind: the same per-value wire format as a + /// scalar bind (), one entry per row. The bind type is derived from + /// the column's Arrow type, so it is identical for every row. + /// + private SnowflakeBinding ToArrayBinding(IArrowArray column, int rowCount) + { + var values = new string?[rowCount]; + string type = string.Empty; + for (int row = 0; row < rowCount; row++) + { + SnowflakeBinding rowBinding = ToBinding(column, row); + type = rowBinding.Type; + values[row] = rowBinding.Value; + } + + return new SnowflakeBinding(type, values); + } + + /// + /// Converts a single Arrow array value into a Snowflake bind variable (type + string value). + /// + private SnowflakeBinding ToBinding(IArrowArray array, int index) + { + // Keyed off the Arrow array type (not the CLR value), since a DateTime alone can't tell a + // DATE bind from a TIMESTAMP. A null value keeps the column's bind type. The string value + // formats match Snowflake's bind protocol: DATE = ms since epoch, TIME = ns of day, + // TIMESTAMP = ns since epoch, BINARY = hex. + bool isNull = array.IsNull(index); + return array switch + { + BooleanArray a => new SnowflakeBinding(BindTypeNames.Boolean, isNull ? null : (a.GetValue(index)!.Value ? "true" : "false")), + Int8Array a => new SnowflakeBinding(BindTypeNames.Fixed, isNull ? null : a.GetValue(index)!.Value.ToString(CultureInfo.InvariantCulture)), + Int16Array a => new SnowflakeBinding(BindTypeNames.Fixed, isNull ? null : a.GetValue(index)!.Value.ToString(CultureInfo.InvariantCulture)), + Int32Array a => new SnowflakeBinding(BindTypeNames.Fixed, isNull ? null : a.GetValue(index)!.Value.ToString(CultureInfo.InvariantCulture)), + Int64Array a => new SnowflakeBinding(BindTypeNames.Fixed, isNull ? null : a.GetValue(index)!.Value.ToString(CultureInfo.InvariantCulture)), + UInt8Array a => new SnowflakeBinding(BindTypeNames.Fixed, isNull ? null : a.GetValue(index)!.Value.ToString(CultureInfo.InvariantCulture)), + UInt16Array a => new SnowflakeBinding(BindTypeNames.Fixed, isNull ? null : a.GetValue(index)!.Value.ToString(CultureInfo.InvariantCulture)), + UInt32Array a => new SnowflakeBinding(BindTypeNames.Fixed, isNull ? null : a.GetValue(index)!.Value.ToString(CultureInfo.InvariantCulture)), + UInt64Array a => new SnowflakeBinding(BindTypeNames.Fixed, isNull ? null : a.GetValue(index)!.Value.ToString(CultureInfo.InvariantCulture)), + Decimal128Array a => new SnowflakeBinding(BindTypeNames.Fixed, isNull ? null : a.GetValue(index)?.ToString(CultureInfo.InvariantCulture)), + Decimal256Array a => new SnowflakeBinding(BindTypeNames.Fixed, isNull ? null : a.GetString(index)), + FloatArray a => new SnowflakeBinding(BindTypeNames.Real, isNull ? null : a.GetValue(index)!.Value.ToString(CultureInfo.InvariantCulture)), + DoubleArray a => new SnowflakeBinding(BindTypeNames.Real, isNull ? null : a.GetValue(index)!.Value.ToString(CultureInfo.InvariantCulture)), + StringArray a => new SnowflakeBinding(BindTypeNames.Text, isNull ? null : a.GetString(index)), + BinaryArray a => new SnowflakeBinding(BindTypeNames.Binary, isNull ? null : Convert.ToHexString(a.GetBytes(index)).ToLowerInvariant()), + Date32Array a => new SnowflakeBinding(BindTypeNames.Date, isNull ? null : DateMillisSinceEpoch(a.GetDateTime(index)!.Value).ToString(CultureInfo.InvariantCulture)), + Date64Array a => new SnowflakeBinding(BindTypeNames.Date, isNull ? null : DateMillisSinceEpoch(a.GetDateTime(index)!.Value).ToString(CultureInfo.InvariantCulture)), + Time32Array a => new SnowflakeBinding(BindTypeNames.Time, isNull ? null : NanosecondsOfDay(a.Values[index], ((Time32Type)a.Data.DataType).Unit).ToString(CultureInfo.InvariantCulture)), + Time64Array a => new SnowflakeBinding(BindTypeNames.Time, isNull ? null : NanosecondsOfDay(a.Values[index], ((Time64Type)a.Data.DataType).Unit).ToString(CultureInfo.InvariantCulture)), + TimestampArray a => ToTimestampBinding(a, index, isNull), + _ => throw new NotSupportedException($"Binding an Arrow {array.GetType().Name} parameter is not supported.") + }; + } + + private static readonly DateTime UnixEpoch = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + private static SnowflakeBinding ToTimestampBinding(TimestampArray array, int index, bool isNull) + { + var type = (TimestampType)array.Data.DataType; + // No zone → wall-clock NTZ; a zone means the stored value is the UTC instant → bind as LTZ + // (Arrow can't carry a per-row offset, so TZ would lose nothing meaningful over LTZ here). + string bindType = type.Timezone == null ? BindTypeNames.TimestampNtz : BindTypeNames.TimestampLtz; + if (isNull) + return new SnowflakeBinding(bindType, (string?)null); + + long nanos = array.Values[index] * NanosecondsPerUnit(type.Unit); + return new SnowflakeBinding(bindType, nanos.ToString(CultureInfo.InvariantCulture)); + } + + private static long DateMillisSinceEpoch(DateTime date) => + (long)(date.Date - UnixEpoch).TotalMilliseconds; + + private static long NanosecondsOfDay(long rawValue, TimeUnit unit) => rawValue * NanosecondsPerUnit(unit); + + private static long NanosecondsPerUnit(TimeUnit unit) => unit switch + { + TimeUnit.Second => 1_000_000_000L, + TimeUnit.Millisecond => 1_000_000L, + TimeUnit.Microsecond => 1_000L, + _ => 1L // Nanosecond + }; +} diff --git a/csharp/src/Native/SnowflakeConnection.GetObjects.cs b/csharp/src/Native/SnowflakeConnection.GetObjects.cs new file mode 100644 index 0000000..7ba2060 --- /dev/null +++ b/csharp/src/Native/SnowflakeConnection.GetObjects.cs @@ -0,0 +1,434 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Globalization; +using AdbcDrivers.Snowflake.Native.Services.Query; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using Apache.Arrow.Ipc; + +using Apache.Arrow; +using Apache.Arrow.Adbc; +using Apache.Arrow.Adbc.Extensions; +using Apache.Arrow.Types; + +namespace AdbcDrivers.Snowflake.Native; + +public sealed partial class SnowflakeConnection +{ + /// + /// Gets database objects (catalogs, schemas, tables, columns) as the ADBC + /// hierarchical result. Built by querying INFORMATION_SCHEMA and assembling the + /// nested Arrow structure level by level, mirroring the Apache C# ADBC drivers. + /// + /// + /// Table constraints report names and types only; per-constraint column names and FK + /// referenced-column usage are not populated (Snowflake's INFORMATION_SCHEMA lacks + /// KEY_COLUMN_USAGE -- that needs SHOW PRIMARY/UNIQUE/IMPORTED KEYS + RESULT_SCAN). + /// + public override IArrowArrayStream GetObjects(GetObjectsDepth depth, string? catalogPattern, string? dbSchemaPattern, + string? tableNamePattern, IReadOnlyList? tableTypes, string? columnNamePattern) + { + ThrowIfDisposed(); + + IArrowArray[] dataArrays = GetCatalogs( + depth, catalogPattern, dbSchemaPattern, tableNamePattern, tableTypes, columnNamePattern); + + return new InMemoryArrowStream(StandardSchemas.GetObjectsSchema, dataArrays); + } + + private IArrowArray[] GetCatalogs(GetObjectsDepth depth, string? catalogPattern, string? dbSchemaPattern, + string? tableNamePattern, IReadOnlyList? tableTypes, string? columnNamePattern) + { + var catalogNameBuilder = new StringArray.Builder(); + var catalogDbSchemasValues = new List(); + + var binds = new List(); + string sql = "SELECT DATABASE_NAME::VARCHAR AS name FROM INFORMATION_SCHEMA.DATABASES"; + if (!string.IsNullOrEmpty(catalogPattern)) + { + sql += " WHERE DATABASE_NAME ILIKE ?"; + binds.Add(catalogPattern); + } + sql += " ORDER BY DATABASE_NAME"; + + foreach (Dictionary row in RunMetadataQuery(sql, binds)) + { + string? catalog = row["name"]; + if (catalog == null) + continue; + + catalogNameBuilder.Append(catalog); + catalogDbSchemasValues.Add(depth == GetObjectsDepth.Catalogs + ? null + : GetDbSchemas(depth, catalog, dbSchemaPattern, tableNamePattern, tableTypes, columnNamePattern)); + } + + return + [ + catalogNameBuilder.Build(), + catalogDbSchemasValues.BuildListArrayForType(new StructType(StandardSchemas.DbSchemaSchema)) + ]; + } + + private StructArray GetDbSchemas(GetObjectsDepth depth, string catalog, string? dbSchemaPattern, + string? tableNamePattern, IReadOnlyList? tableTypes, string? columnNamePattern) + { + var dbSchemaNameBuilder = new StringArray.Builder(); + var dbSchemaTablesValues = new List(); + var nullBitmap = new ArrowBuffer.BitmapBuilder(); + int length = 0; + + var binds = new List(); + string sql = $"SELECT SCHEMA_NAME::VARCHAR AS name FROM {QuoteIdentifier(catalog)}.INFORMATION_SCHEMA.SCHEMATA"; + if (!string.IsNullOrEmpty(dbSchemaPattern)) + { + sql += " WHERE SCHEMA_NAME ILIKE ?"; + binds.Add(dbSchemaPattern); + } + sql += " ORDER BY SCHEMA_NAME"; + + foreach (Dictionary row in RunMetadataQuery(sql, binds)) + { + string? schemaName = row["name"]; + if (schemaName == null) + continue; + + dbSchemaNameBuilder.Append(schemaName); + nullBitmap.Append(true); + length++; + + dbSchemaTablesValues.Add(depth == GetObjectsDepth.DbSchemas + ? null + : GetTableSchemas(depth, catalog, schemaName, tableNamePattern, tableTypes, columnNamePattern)); + } + + IArrowArray[] dataArrays = + [ + dbSchemaNameBuilder.Build(), + dbSchemaTablesValues.BuildListArrayForType(new StructType(StandardSchemas.TableSchema)) + ]; + + return new StructArray(new StructType(StandardSchemas.DbSchemaSchema), length, dataArrays, nullBitmap.Build()); + } + + private StructArray GetTableSchemas(GetObjectsDepth depth, string catalog, string dbSchema, + string? tableNamePattern, IReadOnlyList? tableTypes, string? columnNamePattern) + { + var tableNameBuilder = new StringArray.Builder(); + var tableTypeBuilder = new StringArray.Builder(); + var tableColumnsValues = new List(); + var tableConstraintsValues = new List(); + var nullBitmap = new ArrowBuffer.BitmapBuilder(); + int length = 0; + + var binds = new List { dbSchema }; + string sql = $"SELECT TABLE_NAME::VARCHAR AS name, TABLE_TYPE::VARCHAR AS type " + + $"FROM {QuoteIdentifier(catalog)}.INFORMATION_SCHEMA.TABLES " + + "WHERE TABLE_SCHEMA ILIKE ?"; + if (!string.IsNullOrEmpty(tableNamePattern)) + { + sql += " AND TABLE_NAME ILIKE ?"; + binds.Add(tableNamePattern); + } + sql += " ORDER BY TABLE_NAME"; + + foreach (Dictionary row in RunMetadataQuery(sql, binds)) + { + string? tableName = row["name"]; + if (tableName == null) + continue; + + string tableType = NormalizeTableType(row["type"]); + if (tableTypes is { Count: > 0 } && !ContainsIgnoreCase(tableTypes, tableType)) + continue; + + tableNameBuilder.Append(tableName); + tableTypeBuilder.Append(tableType); + nullBitmap.Append(true); + length++; + + // Columns and constraints are populated at All depth. + tableColumnsValues.Add(depth == GetObjectsDepth.All + ? GetColumns(catalog, dbSchema, tableName, columnNamePattern) + : null); + tableConstraintsValues.Add(depth == GetObjectsDepth.All + ? GetConstraints(catalog, dbSchema, tableName) + : null); + } + + IArrowArray[] dataArrays = + [ + tableNameBuilder.Build(), + tableTypeBuilder.Build(), + tableColumnsValues.BuildListArrayForType(new StructType(StandardSchemas.ColumnSchema)), + tableConstraintsValues.BuildListArrayForType(new StructType(StandardSchemas.ConstraintSchema)) + ]; + + return new StructArray(new StructType(StandardSchemas.TableSchema), length, dataArrays, nullBitmap.Build()); + } + + private StructArray GetColumns(string catalog, string dbSchema, string tableName, string? columnNamePattern) + { + var columnNameBuilder = new StringArray.Builder(); + var ordinalPositionBuilder = new Int32Array.Builder(); + var remarksBuilder = new StringArray.Builder(); + var xdbcDataTypeBuilder = new Int16Array.Builder(); + var xdbcTypeNameBuilder = new StringArray.Builder(); + var xdbcColumnSizeBuilder = new Int32Array.Builder(); + var xdbcDecimalDigitsBuilder = new Int16Array.Builder(); + var xdbcNumPrecRadixBuilder = new Int16Array.Builder(); + var xdbcNullableBuilder = new Int16Array.Builder(); + var xdbcColumnDefBuilder = new StringArray.Builder(); + var xdbcSqlDataTypeBuilder = new Int16Array.Builder(); + var xdbcDatetimeSubBuilder = new Int16Array.Builder(); + var xdbcCharOctetLengthBuilder = new Int32Array.Builder(); + var xdbcIsNullableBuilder = new StringArray.Builder(); + var xdbcScopeCatalogBuilder = new StringArray.Builder(); + var xdbcScopeSchemaBuilder = new StringArray.Builder(); + var xdbcScopeTableBuilder = new StringArray.Builder(); + var xdbcIsAutoincrementBuilder = new BooleanArray.Builder(); + var xdbcIsGeneratedColumnBuilder = new BooleanArray.Builder(); + var nullBitmap = new ArrowBuffer.BitmapBuilder(); + int length = 0; + + string sql = "SELECT COLUMN_NAME::VARCHAR AS column_name, ORDINAL_POSITION::VARCHAR AS ordinal_position, " + + "COMMENT::VARCHAR AS remarks, DATA_TYPE::VARCHAR AS type_name, IS_NULLABLE::VARCHAR AS is_nullable, " + + "CHARACTER_MAXIMUM_LENGTH::VARCHAR AS char_max_length, NUMERIC_PRECISION::VARCHAR AS numeric_precision, " + + "NUMERIC_SCALE::VARCHAR AS numeric_scale, NUMERIC_PRECISION_RADIX::VARCHAR AS numeric_precision_radix, " + + "DATETIME_PRECISION::VARCHAR AS datetime_precision, CHARACTER_OCTET_LENGTH::VARCHAR AS char_octet_length, " + + "COLUMN_DEFAULT::VARCHAR AS column_default " + + $"FROM {QuoteIdentifier(catalog)}.INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_SCHEMA ILIKE ? AND TABLE_NAME ILIKE ?"; + var binds = new List { dbSchema, tableName }; + if (!string.IsNullOrEmpty(columnNamePattern)) + { + sql += " AND COLUMN_NAME ILIKE ?"; + binds.Add(columnNamePattern); + } + sql += " ORDER BY ORDINAL_POSITION"; + + foreach (Dictionary row in RunMetadataQuery(sql, binds)) + { + string? columnName = row["column_name"]; + if (columnName == null) + continue; + + columnNameBuilder.Append(columnName); + AppendNullableInt32(ordinalPositionBuilder, row["ordinal_position"]); + AppendNullableString(remarksBuilder, row["remarks"]); + xdbcDataTypeBuilder.AppendNull(); + AppendNullableString(xdbcTypeNameBuilder, row["type_name"]); + // xdbc_column_size: character length if present, else numeric precision. + AppendNullableInt32(xdbcColumnSizeBuilder, row["char_max_length"] ?? row["numeric_precision"]); + AppendNullableInt16(xdbcDecimalDigitsBuilder, row["numeric_scale"]); + AppendNullableInt16(xdbcNumPrecRadixBuilder, row["numeric_precision_radix"]); + xdbcNullableBuilder.Append((short)(IsYes(row["is_nullable"]) ? 1 : 0)); + AppendNullableString(xdbcColumnDefBuilder, row["column_default"]); + xdbcSqlDataTypeBuilder.AppendNull(); + AppendNullableInt16(xdbcDatetimeSubBuilder, row["datetime_precision"]); + AppendNullableInt32(xdbcCharOctetLengthBuilder, row["char_octet_length"]); + AppendNullableString(xdbcIsNullableBuilder, row["is_nullable"]); + xdbcScopeCatalogBuilder.AppendNull(); + xdbcScopeSchemaBuilder.AppendNull(); + xdbcScopeTableBuilder.AppendNull(); + xdbcIsAutoincrementBuilder.AppendNull(); + xdbcIsGeneratedColumnBuilder.AppendNull(); + nullBitmap.Append(true); + length++; + } + + IArrowArray[] dataArrays = + [ + columnNameBuilder.Build(), + ordinalPositionBuilder.Build(), + remarksBuilder.Build(), + xdbcDataTypeBuilder.Build(), + xdbcTypeNameBuilder.Build(), + xdbcColumnSizeBuilder.Build(), + xdbcDecimalDigitsBuilder.Build(), + xdbcNumPrecRadixBuilder.Build(), + xdbcNullableBuilder.Build(), + xdbcColumnDefBuilder.Build(), + xdbcSqlDataTypeBuilder.Build(), + xdbcDatetimeSubBuilder.Build(), + xdbcCharOctetLengthBuilder.Build(), + xdbcIsNullableBuilder.Build(), + xdbcScopeCatalogBuilder.Build(), + xdbcScopeSchemaBuilder.Build(), + xdbcScopeTableBuilder.Build(), + xdbcIsAutoincrementBuilder.Build(), + xdbcIsGeneratedColumnBuilder.Build() + ]; + + return new StructArray(new StructType(StandardSchemas.ColumnSchema), length, dataArrays, nullBitmap.Build()); + } + + private StructArray GetConstraints(string catalog, string dbSchema, string tableName) + { + var constraintNameBuilder = new StringArray.Builder(); + var constraintTypeBuilder = new StringArray.Builder(); + var constraintColumnNamesValues = new List(); + var constraintColumnUsageValues = new List(); + var nullBitmap = new ArrowBuffer.BitmapBuilder(); + int length = 0; + + // Snowflake's INFORMATION_SCHEMA exposes TABLE_CONSTRAINTS but NOT KEY_COLUMN_USAGE, + // so only constraint names/types are available here. Per-constraint column names and + // FK column-usage would require SHOW PRIMARY/UNIQUE/IMPORTED KEYS + RESULT_SCAN. + var binds = new List { dbSchema, tableName }; + string sql = "SELECT CONSTRAINT_NAME::VARCHAR AS constraint_name, " + + "CONSTRAINT_TYPE::VARCHAR AS constraint_type " + + $"FROM {QuoteIdentifier(catalog)}.INFORMATION_SCHEMA.TABLE_CONSTRAINTS " + + "WHERE TABLE_SCHEMA ILIKE ? AND TABLE_NAME ILIKE ? " + + "ORDER BY CONSTRAINT_NAME"; + + foreach (Dictionary row in RunMetadataQuery(sql, binds)) + { + string? name = row["constraint_name"]; + if (name == null) + continue; + + constraintNameBuilder.Append(name); + constraintTypeBuilder.Append(row["constraint_type"] ?? string.Empty); + constraintColumnNamesValues.Add(new StringArray.Builder().Build()); + constraintColumnUsageValues.Add(null); + nullBitmap.Append(true); + length++; + } + + IArrowArray[] dataArrays = + [ + constraintNameBuilder.Build(), + constraintTypeBuilder.Build(), + constraintColumnNamesValues.BuildListArrayForType(StringType.Default), + constraintColumnUsageValues.BuildListArrayForType(new StructType(StandardSchemas.UsageSchema)) + ]; + + return new StructArray(new StructType(StandardSchemas.ConstraintSchema), length, dataArrays, nullBitmap.Build()); + } + + private static void AppendNullableString(StringArray.Builder builder, string? value) + { + if (value == null) + builder.AppendNull(); + else + builder.Append(value); + } + + private static void AppendNullableInt32(Int32Array.Builder builder, string? value) + { + if (int.TryParse(value, out int parsed)) + builder.Append(parsed); + else + builder.AppendNull(); + } + + private static void AppendNullableInt16(Int16Array.Builder builder, string? value) + { + if (short.TryParse(value, out short parsed)) + builder.Append(parsed); + else + builder.AppendNull(); + } + + private static bool IsYes(string? value) => string.Equals(value, "YES", StringComparison.OrdinalIgnoreCase); + + /// + /// Runs a metadata query (whose columns are all cast to VARCHAR) and materializes + /// the result rows as string dictionaries keyed by column name (case-insensitive). + /// + private List> RunMetadataQuery(string sql, IReadOnlyList? bindValues = null) + { + if (_queryExecutor == null || _pooledConnection == null) + throw new AdbcException("Connection is not properly initialized."); + + var request = new QueryRequest + { + Statement = sql, + Warehouse = _config.Warehouse, + Role = _config.Role, + Timeout = _config.QueryTimeout, + AuthToken = _pooledConnection.AuthToken + }; + + // Bind the '?' placeholders as positional (1-based) bind variables. All metadata + // filter values are strings, so the bind type is TEXT. Server-side binding means a + // caller-supplied pattern can never be parsed as SQL. + if (bindValues != null) + { + for (int i = 0; i < bindValues.Count; i++) + { + request.Bindings[(i + 1).ToString(CultureInfo.InvariantCulture)] = + new SnowflakeBinding(BindTypeNames.Text, bindValues[i]); + } + } + + var result = _queryExecutor.ExecuteQueryAsync(request).GetAwaiter().GetResult(); + if (result.Status == QueryStatus.Failed) + { + string message = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown error"; + throw new AdbcException($"Metadata query failed: {message}"); + } + + var rows = new List>(); + if (result.ResultStream == null) + return rows; + + using IArrowArrayStream stream = result.ResultStream; + Schema schema = stream.Schema; + + while (true) + { + RecordBatch? batch = stream.ReadNextRecordBatchAsync().GetAwaiter().GetResult(); + if (batch == null) + break; + + using (batch) + { + for (int r = 0; r < batch.Length; r++) + { + var row = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int c = 0; c < schema.FieldsList.Count; c++) + row[schema.FieldsList[c].Name] = GetStringValue(batch.Column(c), r); + rows.Add(row); + } + } + } + + return rows; + } + + private static string? GetStringValue(IArrowArray array, int index) => + array is StringArray stringArray ? stringArray.GetString(index) : null; + + private static string NormalizeTableType(string? informationSchemaType) => + string.Equals(informationSchemaType, "BASE TABLE", StringComparison.OrdinalIgnoreCase) + ? "TABLE" + : informationSchemaType ?? string.Empty; + + private static bool ContainsIgnoreCase(IReadOnlyList values, string value) + { + foreach (string candidate in values) + { + if (string.Equals(candidate, value, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/csharp/src/Native/SnowflakeConnection.cs b/csharp/src/Native/SnowflakeConnection.cs new file mode 100644 index 0000000..2bf5223 --- /dev/null +++ b/csharp/src/Native/SnowflakeConnection.cs @@ -0,0 +1,454 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Apache.Arrow.Ipc; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.ConnectionPool; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using AdbcDrivers.Snowflake.Native.Services.TypeConversion; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Services.Query; + +using Apache.Arrow; +using Apache.Arrow.Adbc; +using Apache.Arrow.Adbc.Extensions; +using Apache.Arrow.Types; + +namespace AdbcDrivers.Snowflake.Native; + +/// +/// Snowflake connection implementation for ADBC. +/// +public sealed partial class SnowflakeConnection : AdbcConnection +{ + private readonly ConnectionConfig _config; + private readonly IConnectionPoolManager _connectionPool; + private IPooledConnection? _pooledConnection; + private readonly IQueryExecutor? _queryExecutor; + private bool _disposed; + private bool _autocommit = true; + private readonly ILogger _logger; + + internal SnowflakeConnection(ConnectionConfig config, IConnectionPoolManager connectionPool, + IPooledConnection pooledConnection, IQueryExecutor queryExecutor, + ILogger logger) + { + _config = config; + _connectionPool = connectionPool; + _pooledConnection = pooledConnection; + _queryExecutor = queryExecutor; + _logger = logger; + } + + /// + /// Asynchronously creates and initializes a new SnowflakeConnection. The token cancels the + /// wait for pool capacity and the login round trip. + /// + internal static async Task CreateAsync(ConnectionConfig config, HttpClient httpClient, IConnectionPoolManager connectionPool, ILoggerFactory? loggerFactory = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(connectionPool); + loggerFactory ??= NullLoggerFactory.Instance; + var log = loggerFactory.CreateLogger(); + + log.LogDebug("Acquiring pooled connection for user {User} account {Account}", config.User, config.Account); + var pooledConnection = await connectionPool.AcquireConnectionAsync(config, cancellationToken).ConfigureAwait(false); + if (pooledConnection is null) + { + throw new AdbcException("Failed to acquire pooled connection."); + } + log.LogInformation("Acquired pooled connection {ConnectionId}", pooledConnection.ConnectionId); + + var apiClient = new RestApiClient(httpClient, config.EnableCompression, + logger: loggerFactory.CreateLogger()); + var typeConverter = TypeConverter.Shared; + + var queryExecutor = new QueryExecutor(apiClient, typeConverter, config.Account, config.Network, + loggerFactory.CreateLogger(), () => pooledConnection.IsFaulted = true); + + return new SnowflakeConnection(config, connectionPool, pooledConnection, queryExecutor, log); + } + + /// + /// Creates a new statement for executing queries. + /// + /// An AdbcStatement instance. + public override AdbcStatement CreateStatement() + { + ThrowIfDisposed(); + + if (_pooledConnection == null || _queryExecutor == null) + throw new AdbcException("Connection is not properly initialized."); + + return new SnowflakeStatement(_config, _pooledConnection, _queryExecutor); + } + + /// + /// The session's authentication token (session/master tokens). Exposed for tests and proactive + /// session management (e.g. heartbeat). + /// + internal Services.Authentication.AuthenticationToken? AuthToken => _pooledConnection?.AuthToken; + + /// + /// Proactively renews this connection's session token using the master token. + /// + internal Task RenewSessionAsync(CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + if (_pooledConnection == null || _queryExecutor == null) + throw new AdbcException("Connection is not properly initialized."); + + return _queryExecutor.RenewSessionAsync(_pooledConnection.AuthToken, cancellationToken); + } + + /// + /// Pings the session heartbeat endpoint to keep this connection's session alive. + /// + internal Task HeartbeatAsync(CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + if (_pooledConnection == null || _queryExecutor == null) + throw new AdbcException("Connection is not properly initialized."); + + return _queryExecutor.HeartbeatAsync(_pooledConnection.AuthToken, cancellationToken); + } + + + /// + /// The table types reported by the Snowflake driver. Matches the Go driver's + /// ListTableTypes (TABLE, VIEW). + /// + private static readonly string[] SnowflakeTableTypes = ["TABLE", "VIEW"]; + + /// + /// Gets the supported table types. + /// + /// An IArrowArrayStream containing the table types. + public override IArrowArrayStream GetTableTypes() + { + ThrowIfDisposed(); + + var tableTypesBuilder = new StringArray.Builder(); + tableTypesBuilder.AppendRange(SnowflakeTableTypes); + + IArrowArray[] dataArrays = [tableTypesBuilder.Build()]; + + return new InMemoryArrowStream(StandardSchemas.TableTypesSchema, dataArrays); + } + + /// + /// Gets the Arrow schema for a specific table. + /// + /// The catalog name (database). + /// The schema name. + /// The table name. + /// The Arrow schema for the table. + public override Schema GetTableSchema(string? catalog, string? dbSchema, string tableName) + { + ThrowIfDisposed(); + + ArgumentException.ThrowIfNullOrWhiteSpace(tableName); + + if (_queryExecutor == null || _pooledConnection == null) + throw new AdbcException("Connection is not properly initialized."); + + var parts = new List(3); + if (!string.IsNullOrEmpty(catalog)) + parts.Add(QuoteIdentifier(catalog)); + if (!string.IsNullOrEmpty(dbSchema)) + parts.Add(QuoteIdentifier(dbSchema)); + parts.Add(QuoteIdentifier(tableName)); + string fullyQualifiedTable = string.Join(".", parts); + + // describeOnly compiles "SELECT * FROM " and returns the column metadata + // (rowtype) without executing, so this requires no warehouse and fetches no rows. + var request = new QueryRequest + { + Statement = $"SELECT * FROM {fullyQualifiedTable}", + Warehouse = _config.Warehouse, + Role = _config.Role, + Timeout = _config.QueryTimeout, + AuthToken = _pooledConnection.AuthToken + }; + + PreparedStatement prepared = _queryExecutor.DescribeAsync(request).GetAwaiter().GetResult(); + + return prepared.ResultSchema + ?? throw new AdbcException($"Unable to determine schema for table '{tableName}'."); + } + + /// + /// Quotes a Snowflake identifier (matches the Go driver's quoteIdentifier). + /// + private static string QuoteIdentifier(string identifier) => + "\"" + identifier.Replace("\"", "\"\"") + "\""; + + /// The driver name reported by GetInfo. + private const string InfoDriverName = "ADBC Snowflake Driver"; + + /// The database vendor name reported by GetInfo. + private const string InfoVendorName = "Snowflake"; + + private static readonly string InfoDriverVersion = + typeof(SnowflakeConnection).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + + private static readonly string InfoDriverArrowVersion = + typeof(IArrowArray).Assembly.GetName().Version?.ToString() ?? "0.0.0"; + + private static readonly AdbcInfoCode[] InfoSupportedCodes = + [ + AdbcInfoCode.VendorName, + AdbcInfoCode.DriverName, + AdbcInfoCode.DriverVersion, + AdbcInfoCode.DriverArrowVersion + ]; + + /// + /// Gets metadata about the driver and database. + /// + /// The info codes to fetch; if empty, all supported codes are returned. + /// An IArrowArrayStream of (info_name, info_value) rows. + public override IArrowArrayStream GetInfo(IReadOnlyList codes) + { + ThrowIfDisposed(); + ArgumentNullException.ThrowIfNull(codes); + + if (codes.Count == 0) + codes = InfoSupportedCodes; + + const int stringValueTypeId = 0; + + var infoUnionType = new UnionType( + [ + new Field("string_value", StringType.Default, true), + new Field("bool_value", BooleanType.Default, true), + new Field("int64_value", Int64Type.Default, true), + new Field("int32_bitmask", Int32Type.Default, true), + new Field("string_list", new ListType(new Field("item", StringType.Default, true)), false), + new Field("int32_to_int32_list_map", + new ListType(new Field("entries", new StructType([ + new Field("key", Int32Type.Default, false), + new Field("value", Int32Type.Default, true) + ]), false)), + true) + ], + [0, 1, 2, 3, 4, 5], + UnionMode.Dense); + + var infoNameBuilder = new UInt32Array.Builder(); + var typeBuilder = new ArrowBuffer.Builder(); + var offsetBuilder = new ArrowBuffer.Builder(); + var stringValueBuilder = new StringArray.Builder(); + int nullCount = 0; + + foreach (AdbcInfoCode code in codes) + { + infoNameBuilder.Append((uint)code); + typeBuilder.Append((byte)stringValueTypeId); + offsetBuilder.Append(stringValueBuilder.Length); + + string? value = code switch + { + AdbcInfoCode.DriverName => InfoDriverName, + AdbcInfoCode.DriverVersion => InfoDriverVersion, + AdbcInfoCode.DriverArrowVersion => InfoDriverArrowVersion, + AdbcInfoCode.VendorName => InfoVendorName, + _ => null + }; + + if (value is null) + { + stringValueBuilder.AppendNull(); + nullCount++; + } + else + { + stringValueBuilder.Append(value); + } + } + + var entryType = new StructType([ + new Field("key", Int32Type.Default, false), + new Field("value", Int32Type.Default, true) + ]); + + var entriesDataArray = new StructArray( + entryType, + 0, + [new Int32Array.Builder().Build(), new Int32Array.Builder().Build()], + new ArrowBuffer.BitmapBuilder().Build()); + + IArrowArray[] childArrays = + [ + stringValueBuilder.Build(), + new BooleanArray.Builder().Build(), + new Int64Array.Builder().Build(), + new Int32Array.Builder().Build(), + new ListArray.Builder(StringType.Default).Build(), + new List { entriesDataArray }.BuildListArrayForType(entryType) + ]; + + var infoValue = new DenseUnionArray( + infoUnionType, + codes.Count, + childArrays, + typeBuilder.Build(), + offsetBuilder.Build(), + nullCount); + + IArrowArray[] dataArrays = + [ + infoNameBuilder.Build(), + infoValue + ]; + + return new InMemoryArrowStream(StandardSchemas.GetInfoSchema, dataArrays); + } + + /// + /// Sets a connection option. Supported: . + /// Snowflake sessions default to autocommit on; disabling it opens a transaction scope + /// that / end. Re-enabling autocommit first + /// commits any pending work (the ADBC contract). + /// + public override void SetOption(string key, string value) + { + ThrowIfDisposed(); + + if (!string.Equals(key, AdbcOptions.Connection.Autocommit, StringComparison.Ordinal)) + throw AdbcException.NotImplemented($"Option '{key}' is not supported."); + + bool enable = AdbcOptions.GetEnabled(value); + if (enable == _autocommit) + return; + + if (enable) + ExecuteSessionStatement("COMMIT"); + + ExecuteSessionStatement($"ALTER SESSION SET AUTOCOMMIT = {(enable ? "TRUE" : "FALSE")}"); + _autocommit = enable; + } + + /// + /// Commits the current transaction. Valid only while autocommit is disabled. + /// + public override void Commit() + { + ThrowIfDisposed(); + ThrowIfAutocommit(); + ExecuteSessionStatement("COMMIT"); + } + + /// + /// Rolls back the current transaction. Valid only while autocommit is disabled. + /// + public override void Rollback() + { + ThrowIfDisposed(); + ThrowIfAutocommit(); + ExecuteSessionStatement("ROLLBACK"); + } + + private void ThrowIfAutocommit() + { + if (_autocommit) + throw new AdbcException( + $"No transaction is in progress: autocommit is enabled. Disable {AdbcOptions.Connection.Autocommit} first."); + } + + /// + /// Runs a session-scoped statement (COMMIT/ROLLBACK/ALTER SESSION) and throws on failure. + /// Sync-over-async at the ADBC boundary (SetOption/Commit/Rollback have no async forms); + /// safe to block — the async core awaits with ConfigureAwait(false) throughout. + /// + private void ExecuteSessionStatement(string sql) + { + if (_pooledConnection == null || _queryExecutor == null) + throw new AdbcException("Connection is not properly initialized."); + + var request = new QueryRequest + { + Statement = sql, + Timeout = _config.QueryTimeout, + AuthToken = _pooledConnection.AuthToken + }; + + var result = _queryExecutor.ExecuteQueryAsync(request).GetAwaiter().GetResult(); + result.ResultStream?.Dispose(); + + if (result.Status != QueryStatus.Success) + { + string message = result.Errors.Count > 0 ? result.Errors[0].Message : "Unknown error"; + throw new AdbcException($"'{sql}' failed: {message}"); + } + } + + /// + /// Disposes the connection and releases any resources. + /// + public override void Dispose() + { + if (!_disposed) + { + if (_pooledConnection != null) + { + if (!_autocommit) + ResetTransactionStateBestEffort(); + + _connectionPool.ReleaseConnection(_pooledConnection); + _pooledConnection = null; + } + _logger.LogDebug("Disposing SnowflakeConnection for account {Account}", _config.Account); + _disposed = true; + } + base.Dispose(); + } + + /// + /// A connection released mid-transaction must not hand uncommitted work — or a session + /// stuck in AUTOCOMMIT=FALSE — to the pool's next borrower: roll back and restore + /// autocommit, and discard the connection if that fails (matching gosnowflake's + /// release behavior). + /// + private void ResetTransactionStateBestEffort() + { + try + { + ExecuteSessionStatement("ROLLBACK"); + ExecuteSessionStatement("ALTER SESSION SET AUTOCOMMIT = TRUE"); + _autocommit = true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to reset transaction state on release; discarding the pooled connection."); + _pooledConnection!.IsFaulted = true; + } + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } +} diff --git a/csharp/src/Native/SnowflakeDatabase.cs b/csharp/src/Native/SnowflakeDatabase.cs new file mode 100644 index 0000000..beed2af --- /dev/null +++ b/csharp/src/Native/SnowflakeDatabase.cs @@ -0,0 +1,135 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; +using Microsoft.Extensions.Logging; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.ConnectionPool; +using AdbcDrivers.Snowflake.Native.Services.Session; +using Apache.Arrow.Adbc; + +namespace AdbcDrivers.Snowflake.Native; + +/// +/// Snowflake database implementation for ADBC. +/// +public sealed class SnowflakeDatabase : AdbcDatabase +{ + private readonly IReadOnlyDictionary? _parameters; + private readonly IConnectionPoolManager _connectionPool; + private readonly HttpClient _httpClient; + private readonly ILoggerFactory? _loggerFactory; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The ADBC connection parameters. + /// Custom HttpMessageHandler. When provided, the caller retains ownership + /// and is responsible for disposing it after this instance is disposed. + /// The handler must remain alive for the lifetime of this instance. + /// Logger factory for driver diagnostics (connection lifecycle, + /// transport retries, pool maintenance). Like the handler, it can only be supplied by + /// constructing directly — an object can't ride the ADBC + /// string-dictionary Open. When null the driver logs nothing. + public SnowflakeDatabase(IReadOnlyDictionary? parameters = null, HttpMessageHandler? handler = null, ILoggerFactory? loggerFactory = null) + { + _parameters = parameters; + _loggerFactory = loggerFactory; + _httpClient = CreateHttpClient(parameters, handler, loggerFactory); + + var loginClient = new SnowflakeLoginClient(_httpClient); + var basicAuth = new BasicAuthenticator(loginClient); + var keyPairAuth = new KeyPairAuthenticator(loginClient); + var oauthAuth = new OAuthAuthenticator(loginClient); + var patAuth = new PatAuthenticator(loginClient); + var ssoAuth = new SsoAuthenticator(loginClient, _httpClient); + + var authService = new AuthenticationService(basicAuth, keyPairAuth, oauthAuth, patAuth, ssoAuth); + var sessionClient = new SnowflakeSessionClient(loginClient, _httpClient, _loggerFactory); + _connectionPool = new ConnectionPoolManager(authService, sessionClient, + logger: _loggerFactory?.CreateLogger()); + } + + private static HttpClient CreateHttpClient( + IReadOnlyDictionary? parameters, HttpMessageHandler? handler, ILoggerFactory? loggerFactory) + { + var network = ConnectionStringParser.ParseNetworkConfig(parameters); + + if (handler != null) + { + if ((network.TlsSkipVerify || network.NoProxy) && loggerFactory != null) + { + loggerFactory.CreateLogger().LogWarning( + "Network settings (tls_skip_verify, no_proxy) are ignored when a custom HttpMessageHandler is provided."); + } + + return new HttpClient(handler, disposeHandler: false); + } + + var defaultHandler = new HttpClientHandler(); + if (network.TlsSkipVerify) + defaultHandler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; + + if (network.NoProxy) + defaultHandler.UseProxy = false; + + return new HttpClient(defaultHandler, disposeHandler: true); + } + + /// + /// Creates a new connection to the Snowflake database. + /// + /// Connection-specific parameters that override database parameters. + /// An AdbcConnection instance. + public override AdbcConnection Connect(IReadOnlyDictionary? parameters) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return ConnectAsync(parameters).GetAwaiter().GetResult(); + } + + /// + /// Asynchronously create a new connection to the Snowflake database. The token cancels the + /// wait for pool capacity and the login round trip. + /// + public async Task ConnectAsync(IReadOnlyDictionary? parameters, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + var config = ConnectionStringParser.ParseParameters(parameters, _parameters); + return await SnowflakeConnection.CreateAsync(config, _httpClient, _connectionPool, _loggerFactory, cancellationToken).ConfigureAwait(false); + } + + + /// + /// Disposes the database and releases any resources. + /// + public override void Dispose() + { + if (!_disposed) + { + // Pool first: its best-effort session closes still need a live client. + _connectionPool.Dispose(); + _httpClient.Dispose(); + _disposed = true; + } + base.Dispose(); + } +} diff --git a/csharp/src/Native/SnowflakeDriver.cs b/csharp/src/Native/SnowflakeDriver.cs new file mode 100644 index 0000000..f3a8c87 --- /dev/null +++ b/csharp/src/Native/SnowflakeDriver.cs @@ -0,0 +1,39 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using Apache.Arrow.Adbc; + +namespace AdbcDrivers.Snowflake.Native; + +/// +/// Native C# Snowflake driver implementation for Apache Arrow ADBC. +/// +public sealed class SnowflakeDriver : AdbcDriver +{ + /// + /// Opens a database connection using the provided parameters. + /// + /// The driver-specific parameters. + /// An AdbcDatabase instance. + /// Thrown when the parameters are invalid. + public override AdbcDatabase Open(IReadOnlyDictionary parameters) + { + ArgumentNullException.ThrowIfNull(parameters); + return new SnowflakeDatabase(parameters); + } +} diff --git a/csharp/src/Native/SnowflakeStatement.cs b/csharp/src/Native/SnowflakeStatement.cs new file mode 100644 index 0000000..f3422be --- /dev/null +++ b/csharp/src/Native/SnowflakeStatement.cs @@ -0,0 +1,339 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.ConnectionPool; +using AdbcDrivers.Snowflake.Native.Services.Query; +using AdbcDrivers.Snowflake.Native.Services.TypeConversion; + +using Apache.Arrow; +using Apache.Arrow.Adbc; + +// Disambiguate from AdbcDrivers.Snowflake.Native.Services.Query.QueryResult. +using QueryResult = Apache.Arrow.Adbc.QueryResult; + +namespace AdbcDrivers.Snowflake.Native; + +/// +/// Snowflake statement implementation for ADBC. +/// +public sealed class SnowflakeStatement : AdbcStatement +{ + internal const string QueryTagOption = "adbc.snowflake.statement.query_tag"; + + private readonly ConnectionConfig _config; + private readonly IPooledConnection _pooledConnection; + private readonly IQueryExecutor _queryExecutor; + private readonly ITypeConverter _typeConverter; + private string? _queryTag; + private RecordBatch? _boundParameters; + // The request id of the in-flight query, set before each execution so Cancel (called from + // another thread) can abort that specific request. Volatile for cross-thread visibility. + private volatile string? _currentRequestId; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The connection configuration. + /// The pooled connection. + /// The query executor. + internal SnowflakeStatement( + ConnectionConfig config, + IPooledConnection pooledConnection, + IQueryExecutor queryExecutor) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _pooledConnection = pooledConnection ?? throw new ArgumentNullException(nameof(pooledConnection)); + _queryExecutor = queryExecutor ?? throw new ArgumentNullException(nameof(queryExecutor)); + _typeConverter = TypeConverter.Shared; + _queryTag = config.QueryTag; + } + + /// + /// Sets a statement option. Supported: — the query tag surfaced in + /// the Snowsight query history; an empty value clears it. + /// + public override void SetOption(string key, string value) + { + ThrowIfDisposed(); + + if (!string.Equals(key, QueryTagOption, StringComparison.Ordinal)) + throw AdbcException.NotImplemented($"Option '{key}' is not supported."); + + _queryTag = string.IsNullOrEmpty(value) ? null : value; + } + + /// + /// Binds parameters to the statement using a RecordBatch. + /// + /// The RecordBatch containing parameter values. + /// The schema of the RecordBatch. + public override void Bind(RecordBatch batch, Schema schema) + { + ThrowIfDisposed(); + + ArgumentNullException.ThrowIfNull(batch); + ArgumentNullException.ThrowIfNull(schema); + + if (ReferenceEquals(_boundParameters, batch)) + return; + + _boundParameters?.Dispose(); + _boundParameters = batch; + } + + /// + /// Executes the query and returns a QueryResult. + /// + /// A QueryResult containing the query results. + public override QueryResult ExecuteQuery() + { + // Use async-first pattern: sync version calls async with proper blocking + return ExecuteQueryAsync().AsTask().GetAwaiter().GetResult(); + } + + /// + /// Executes the query asynchronously and returns a QueryResult. + /// + /// A QueryResult containing the query results. + public override async ValueTask ExecuteQueryAsync() + { + ThrowIfDisposed(); + + if (string.IsNullOrWhiteSpace(SqlQuery)) + throw new InvalidOperationException("SQL query must be set before execution."); + + try + { + // Build query request + var request = new QueryRequest + { + Statement = SqlQuery, + Database = _config.Database, + Schema = _config.Schema, + Warehouse = _config.Warehouse, + Role = _config.Role, + QueryTag = _queryTag, + Timeout = _config.QueryTimeout, + PrefetchConcurrency = _config.PrefetchConcurrency, + RequestId = NewRequestId(), + AuthToken = _pooledConnection.AuthToken + }; + + // Add bound parameters if any + if (_boundParameters != null) + { + var parameterSet = _typeConverter.ConvertArrowBatchToParameters(_boundParameters); + foreach (var kvp in parameterSet.Parameters) + request.Bindings[kvp.Key] = kvp.Value; + } + + // Execute query + var result = await _queryExecutor.ExecuteQueryAsync(request).ConfigureAwait(false); + + if (result.Status == QueryStatus.Cancelled) + throw new AdbcException("Query was cancelled."); + + if (result.Status != QueryStatus.Success) + throw ToAdbcException("Query failed", result); + + // Every Success shape from the executor carries a stream (unsupported response + // shapes fail before reaching here). + return new QueryResult(result.RowCount, result.ResultStream!); + } + catch (AdbcException) + { + throw; + } + catch (Exception ex) + { + throw new AdbcException($"Query execution failed: {ex.Message}", ex); + } + } + + /// + /// Executes an update query and returns the number of affected rows. + /// + /// An UpdateResult containing the number of affected rows. + public override UpdateResult ExecuteUpdate() + { + // Use async-first pattern: sync version calls async with proper blocking + return ExecuteUpdateAsync().GetAwaiter().GetResult(); + } + + /// + /// Executes an update query asynchronously and returns the number of affected rows. + /// + /// An UpdateResult containing the number of affected rows. + public override async Task ExecuteUpdateAsync() + { + ThrowIfDisposed(); + + if (string.IsNullOrWhiteSpace(SqlQuery)) + throw new InvalidOperationException("SQL query must be set before execution."); + + try + { + // Build query request + var request = new QueryRequest + { + Statement = SqlQuery, + Database = _config.Database, + Schema = _config.Schema, + Warehouse = _config.Warehouse, + Role = _config.Role, + QueryTag = _queryTag, + Timeout = _config.QueryTimeout, + PrefetchConcurrency = _config.PrefetchConcurrency, + RequestId = NewRequestId(), + AuthToken = _pooledConnection.AuthToken + }; + + // Add bound parameters if any + if (_boundParameters != null) + { + var parameterSet = _typeConverter.ConvertArrowBatchToParameters(_boundParameters); + foreach (var kvp in parameterSet.Parameters) + request.Bindings[kvp.Key] = kvp.Value; + } + + // Execute update + var result = await _queryExecutor.ExecuteQueryAsync(request).ConfigureAwait(false); + + if (result.Status == QueryStatus.Cancelled) + throw new AdbcException("Update was cancelled."); + + if (result.Status != QueryStatus.Success) + throw ToAdbcException("Update failed", result); + + // DML statements report the affected-row count parsed from the JSON row-count + // summary in QueryExecutor. Any other statement (SELECT, DDL status rows, ...) + // affects no rows, so report -1 (unknown/not applicable) per the ADBC contract. + long affectedRows = result.AffectedRows ?? -1; + + // ExecuteUpdate has no use for the result set (DML/DDL surface theirs for + // ExecuteQuery); release it rather than hold the batch until finalization. + result.ResultStream?.Dispose(); + return new UpdateResult(affectedRows); + } + catch (AdbcException) + { + throw; + } + catch (Exception ex) + { + throw new AdbcException($"Update execution failed: {ex.Message}", ex); + } + } + + /// + /// Cancels the query currently executing on this statement, if any. Safe to call from a + /// different thread than the one running the query (the typical use). No-ops when nothing is + /// executing. The server-side abort is best-effort: a query that has already finished is not + /// an error. + /// + public override void Cancel() => CancelAsync().GetAwaiter().GetResult(); + + /// + /// Asynchronous form of . + /// + public async Task CancelAsync() + { + ThrowIfDisposed(); + + var requestId = _currentRequestId; + if (string.IsNullOrEmpty(requestId)) + return; + + await _queryExecutor.CancelQueryAsync(requestId, _pooledConnection.AuthToken).ConfigureAwait(false); + } + + // Generates and records the request id for the execution that is about to start, so a + // concurrent Cancel can abort exactly this request. + private string NewRequestId() + { + var requestId = Guid.NewGuid().ToString(); + _currentRequestId = requestId; + return requestId; + } + + /// + /// Builds the failure exception from a failed result, carrying the originating exception as the + /// inner exception (when there was one) so the full stack survives instead of just its message. + /// + private static AdbcException ToAdbcException(string prefix, Services.Query.QueryResult result) + { + var errorMessages = result.Errors.Count > 0 + ? string.Join("; ", result.Errors.ConvertAll(e => $"[{e.ErrorCode}] {e.Message}")) + : "Unknown error"; + var cause = result.Errors.Find(e => e.Exception != null)?.Exception; + return cause is null + ? new AdbcException($"{prefix}: {errorMessages}") + : new AdbcException($"{prefix}: {errorMessages}", cause); + } + + /// + /// Prepares the statement for execution. + /// + /// + /// Snowflake has no server-side prepare step -- statements are compiled when they are + /// executed -- so this only validates that a query has been set and otherwise does nothing. + /// + public override void Prepare() + { + ThrowIfDisposed(); + + if (string.IsNullOrWhiteSpace(SqlQuery)) + throw new InvalidOperationException("SQL query must be set before preparation."); + } + + /// + /// Gets the parameter schema for a prepared statement. + /// + /// + /// Not supported: Snowflake's protocol does not report the types of a statement's bind + /// parameters, so a parameter schema cannot be determined. + /// + public override Schema GetParameterSchema() + { + ThrowIfDisposed(); + + throw AdbcException.NotImplemented("Snowflake does not provide a parameter schema."); + } + + /// + /// Disposes the statement and releases any resources. + /// + public override void Dispose() + { + if (!_disposed) + { + _boundParameters?.Dispose(); + _boundParameters = null; + + _disposed = true; + } + base.Dispose(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } +} diff --git a/csharp/src/Native/readme.md b/csharp/src/Native/readme.md new file mode 100644 index 0000000..a05649c --- /dev/null +++ b/csharp/src/Native/readme.md @@ -0,0 +1,264 @@ + + +# Native C# Snowflake Driver for Apache Arrow ADBC + +A from-scratch C# implementation of an [ADBC](https://arrow.apache.org/adbc/) driver for +Snowflake. Unlike the Interop package (which loads the Go driver through a native library), this +driver talks to Snowflake's REST API directly from managed code and returns results as Apache +Arrow record batches. + +- **Target framework:** .NET 8.0 +- **Assembly / package:** `AdbcDrivers.Snowflake.Native` +- **Result format:** Arrow end-to-end (Snowflake's Arrow wire format, streamed in chunks with + bounded parallel prefetch) + +## Quick start (ADBC API) + +Connections are configured with an ADBC parameter dictionary: + +```csharp +using AdbcDrivers.Snowflake.Native; + +var parameters = new Dictionary +{ + ["adbc.snowflake.sql.account"] = "myorg-myaccount", + ["username"] = "MYUSER", + ["password"] = "...", + ["adbc.snowflake.sql.warehouse"] = "COMPUTE_WH", + ["adbc.snowflake.sql.db"] = "MYDB", + ["adbc.snowflake.sql.schema"] = "PUBLIC", +}; + +var driver = new SnowflakeDriver(); +using var database = driver.Open(parameters); +using var connection = database.Connect(new Dictionary()); +using var statement = connection.CreateStatement(); + +statement.SqlQuery = "SELECT N_NATIONKEY, N_NAME FROM SNOWFLAKE_SAMPLE_DATA.TPCH_SF1.NATION"; +var result = statement.ExecuteQuery(); // or await statement.ExecuteQueryAsync() + +using var stream = result.Stream!; // IArrowArrayStream +while (await stream.ReadNextRecordBatchAsync() is { } batch) +{ + using (batch) + { + // process the Arrow RecordBatch + } +} +``` + +DML goes through `ExecuteUpdate()` (returns the affected-row count); a long-running query can be +aborted from another thread with `statement.Cancel()`. + +### Bind parameters + +`?` placeholders bind positionally from an Arrow batch. A single-row batch binds scalar values: + +```csharp +statement.SqlQuery = "SELECT * FROM ORDERS WHERE O_ORDERKEY = ? AND O_ORDERDATE > ?"; +var schema = new Schema( + [new Field("k", Int64Type.Default, true), new Field("d", Date32Type.Default, true)], null); +using var batch = new RecordBatch(schema, + [ + new Int64Array.Builder().Append(42).Build(), + new Date32Array.Builder().Append(new DateTime(2024, 1, 1)).Build(), + ], 1); +statement.Bind(batch, schema); +``` + +A **multi-row** batch uses Snowflake's array binding (`executemany`): each parameter is sent as +one value array and the server executes the statement once per row — a whole batch in a single +round trip: + +```csharp +statement.SqlQuery = "INSERT INTO ORDERS (O_ORDERKEY, O_ORDERDATE) VALUES (?, ?)"; +using var batch = new RecordBatch(schema, + [ + new Int64Array.Builder().Append(1).Append(2).Append(3).Build(), + new Date32Array.Builder() + .Append(new DateTime(2024, 1, 1)).Append(new DateTime(2024, 1, 2)).AppendNull().Build(), + ], 3); +statement.Bind(batch, schema); +var result = statement.ExecuteUpdate(); // result.AffectedRows == 3 +``` + +Supported bind types (identical for scalar and array binds, nulls preserved per row): Boolean, +Int8–64 / UInt8–64, Float/Double, Decimal128/256, String, Binary, Date32/64, Time32/64, +Timestamp. A bound batch stays attached to the statement across executions until replaced. + +### Authentication + +Select with `adbc.snowflake.sql.auth_type`. Canonical values follow the +[ADBC Snowflake driver reference](https://arrow.apache.org/adbc/current/driver/snowflake.html); +the connector-net-style spellings in parentheses are accepted as aliases: + +| `auth_type` | Method | Additional keys | +|---|---|---| +| `auth_snowflake` (`snowflake`) — default | Username/password | `username`, `password` | +| `auth_jwt` (`snowflake_jwt`, `jwt`) | RSA key pair | `…client_option.jwt_private_key` (path to PEM file) or `…client_option.jwt_private_key_pkcs8_value` (inline PEM), + `…_pkcs8_password` for encrypted keys | +| `auth_oauth` (`oauth`) | OAuth 2.0 access token | `…client_option.auth_token` | +| `auth_pat` (`programmatic_access_token`, `pat`) | Programmatic access token (requires the user to be under a network policy) | `…client_option.auth_token` | +| `auth_ext_browser` (`externalbrowser`) | Browser-based SSO | — | + +`auth_okta`, `auth_mfa`, and `auth_wif` are recognized as canonical ADBC values but not yet +supported. + +### ADO.NET client + +The driver also works behind the `Apache.Arrow.Adbc.Client` `DbConnection` layer: + +```csharp +using AdbcClient = Apache.Arrow.Adbc.Client; + +using var connection = new AdbcClient.AdbcConnection( + new SnowflakeDriver(), parameters, new Dictionary()); +connection.Open(); +using var command = connection.CreateCommand(); +command.CommandText = "SELECT 1"; +using var reader = command.ExecuteReader(); +``` + +Note: `NUMBER(38,0)` surfaces through the client as `System.Data.SqlTypes.SqlDecimal` +(a CLR `decimal` cannot hold 38 digits); narrower precisions surface as `int`/`long`. + +### Custom HTTP handling + +Consumers who need control over the HTTP stack (corporate proxies, custom TLS, resilience +handlers, DNS rotation) construct `SnowflakeDatabase` directly and pass an +`HttpMessageHandler`. The driver always owns the `HttpClient` it builds on top; the caller +keeps ownership of the handler, which must outlive the database: + +```csharp +// DNS-rotation-friendly without any DI infrastructure: +using var handler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }; +using var database = new SnowflakeDatabase(parameters, handler); + +// Or, in a DI app, hand the pooling problem to the factory: +var handler = httpMessageHandlerFactory.CreateHandler(); // IHttpMessageHandlerFactory +using var database = new SnowflakeDatabase(parameters, handler); +``` + +When a custom handler is supplied, the network options (`…client_option.tls_skip_verify`, +`…client_option.no_proxy`) are **not** applied — the handler is the caller's configuration +(a warning is logged if both are present and a logger factory was provided). + +### Logging + +Pass an `ILoggerFactory` to the `SnowflakeDatabase` constructor. Like the handler, it can't ride the ADBC string-dictionary `Open` — logging +requires constructing the database directly. The driver references +`Microsoft.Extensions.Logging.Abstractions` only and never builds a provider; without a factory +it logs nothing. Connection lifecycle and query execution log at Debug/Information; otherwise +best-effort failures — transport retries on transient errors, keep-alive heartbeat failures, +pool-maintenance errors — surface at Warning. + +## Connection options + +Keys follow the [ADBC Snowflake driver reference](https://arrow.apache.org/adbc/current/driver/snowflake.html) +where an official key exists; pool keys are this driver's own (`adbc.snowflake.pool.*`). + +| Key | Meaning | Default | +|-----|---------|---------| +| `adbc.snowflake.sql.account` | Account identifier (**required**) | — | +| `username` / `password` | Credentials for password auth | — | +| `adbc.snowflake.sql.db` / `.schema` / `.warehouse` / `.role` | Session context | — | +| `adbc.snowflake.statement.query_tag` | Query tag shown in the Snowsight query history; connection-level default, overridable per statement via `SetOption` | — | +| `adbc.connection.catalog` / `adbc.connection.db_schema` | Canonical ADBC current catalog/schema (take precedence over the `sql.db`/`sql.schema` aliases) | — | +| `adbc.snowflake.sql.auth_type` | See Authentication above | `snowflake` | +| `adbc.snowflake.sql.client_option.jwt_private_key` | Key-pair auth: path to the private-key PEM file | — | +| `adbc.snowflake.sql.client_option.jwt_private_key_pkcs8_value` / `_password` | Key-pair auth: inline PEM / passphrase for encrypted keys | — | +| `adbc.snowflake.sql.client_option.auth_token` | Access token (OAuth or PAT, per `auth_type`) | — | +| `adbc.snowflake.sql.uri.host` / `.port` / `.protocol` | Endpoint override (PrivateLink etc.) | account URL | +| `adbc.snowflake.sql.client_option.tls_skip_verify` | Skip TLS certificate validation (**test only**) | `false` | +| `adbc.snowflake.sql.client_option.no_proxy` | Bypass the system proxy | `false` | +| `adbc.snowflake.sql.client_option.request_timeout` | Per-statement timeout, seconds (`STATEMENT_TIMEOUT_IN_SECONDS`) | 300 | +| `adbc.snowflake.sql.client_option.login_timeout` | Login/auth round-trip timeout, seconds | 60 | +| `adbc.snowflake.sql.client_option.enable_compression` | gzip/deflate response compression | `true` | +| `adbc.snowflake.sql.client_option.keep_session_alive` | Heartbeat idle pooled sessions so they never lapse to master-token expiry | `false` | +| `adbc.snowflake.sql.client_option.keep_session_alive_heartbeat_frequency` | Heartbeat interval, seconds (clamped 900–3600) | 3600 | +| `adbc.snowflake.rpc.prefetch_concurrency` | Parallel result-chunk downloads | 10 | +| `adbc.snowflake.pool.max_size` | Max pooled connections per distinct config | 10 | +| `adbc.snowflake.pool.idle_timeout` | Idle eviction (seconds or `30s`/`10m`/`1h`) | 10m | +| `adbc.snowflake.pool.acquire_timeout` | Max wait for a free connection when the pool is full | 120s | +| `adbc.snowflake.pool.max_lifetime` | Max connection lifetime | 1h | + +## Sessions and pooling + +Connections are pooled per distinct configuration (account, user, credential fingerprint, +database/schema/warehouse/role, endpoint). Session tokens (~1 h) are renewed transparently from +the master token when a query hits expiry; with `keep_session_alive` enabled, idle pooled +connections are heartbeated in the background so the ~4 h master window rolls forward +indefinitely. Server-side sessions are closed when the pool discards a connection. + +## Testing + +The suite is split by xUnit trait so the offline half runs anywhere (including CI) with no +Snowflake account: + +| Category | What it covers | Requires | +|---|---|---| +| `Unit` (~150 tests) | Offline: type mapping and bind wire formats, option parsing, pool scheduling (deterministic via an injected `TimeProvider`/fake clock), chunk-prefetch back-pressure (fake HTTP client serving in-memory Arrow), result-decode fixups, request-body construction | Nothing | +| `Integration` (~77 tests) | Live against a real account: connect/lifecycle, statements + binds + cancellation, the wire type-decode matrix (`SELECT ` per type), metadata/`GetObjects` content checks against `SNOWFLAKE_SAMPLE_DATA` (TPC-H), the ADO.NET client layer, session renewal + heartbeat | `SNOWFLAKE_TEST_CONFIG_FILE` → JSON config (account, credentials, warehouse; a **writable** database/schema for the DML and client tests) | + +```bash +dotnet test csharp/test/Native --filter "Category=Unit" +SNOWFLAKE_TEST_CONFIG_FILE=/path/to/config.json dotnet test csharp/test/Native --filter "Category=Integration" +``` + +Per-file breakdown: [test suite readme](../../test/Native/readme.md). Session-lifecycle +behaviour (token renewal and keep-alive heartbeats) was validated with a long-running console +harness over 6–13 h live runs; the harness has since been removed. + +## Performance + +Benchmarked head-to-head against the Go driver (loaded via the Interop package) using an +**identical harness** — same query, same row limits, measuring execute + drain of every Arrow +batch (`BenchmarkTests` in both test suites; `csharp/run_benchmark.ps1` automates the +comparison). + +Representative results (multi-run means — native n=5, interop n=10 — 2026-07-14, Release/net8.0): + +| Rows fetched | Native C# | Interop (Go) | Native / Interop | +|---|---|---|---| +| 100 | 143 ms | 116 ms | 1.23× | +| 1,000 | 244 ms | 306 ms | 0.80× | +| 1,000,000 | 2,813 ms | 3,699 ms | **0.76×** | + +At scale the native driver runs at parity or ahead of the Go driver (bounded parallel chunk +prefetch, pre-sized buffers, per-column decode loops); the small-query gap is +connection-establishment overhead, not the data path. + +**Environment:** Intel Core Ultra 9 285H (16 cores / 16 threads), 32 GB RAM, Windows 11, +.NET 8 Release; default warehouse. Wall-clock over a live network varies heavily between runs — +in this session the *unchanged* Go driver's 1M-row runs ranged 2.2–9.8 s (sd ≈ 2.5 s) — so all +comparisons use interleaved native/interop runs and multi-run means, never cross-session +absolutes. + +## Known Limitations + +- **Live catalog/schema switching**: catalog/schema are honored at `Connect`; `SetOption` after + connect supports only `adbc.connection.autocommit`. +- **Not implemented**: PUT/GET stage file transfer +- Semi-structured types (VARIANT/OBJECT/ARRAY) are returned as JSON strings; GEOGRAPHY/GEOMETRY as GeoJSON strings. +- Very slow consumption of very large results can outlive the chunk URLs' presigned validity. +- OTEL Tracing to be added +- Additional Auth methods + +## License + +Licensed under the Apache License, Version 2.0. diff --git a/csharp/test/Interop/BenchmarkTests.cs b/csharp/test/Interop/BenchmarkTests.cs new file mode 100644 index 0000000..c3c3ddc --- /dev/null +++ b/csharp/test/Interop/BenchmarkTests.cs @@ -0,0 +1,63 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace AdbcDrivers.Snowflake.Interop.Tests; + +public class BenchmarkTests +{ + private readonly ITestOutputHelper _output; + private readonly SnowflakeTestConfiguration _testConfig; + + public BenchmarkTests(ITestOutputHelper output) + { + _output = output; + _testConfig = SnowflakeTestingUtils.TestConfiguration; + } + + [SkippableTheory] + [InlineData(100)] + [InlineData(1000)] + [InlineData(1000000)] + public async Task BaselineQueryPerformance(int limit) + { + Skip.If(string.IsNullOrEmpty(_testConfig.DriverPath), "Driver path not configured"); + Skip.If(string.IsNullOrWhiteSpace(_testConfig.Query), "No query configured"); + var driver = SnowflakeTestingUtils.GetSnowflakeAdbcDriver(_testConfig, out var parameters); + parameters["adbc.snowflake.sql.client_option.tls_skip_verify"] = "true"; + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"{_testConfig.Query} LIMIT {limit}"; + var stopwatch = Stopwatch.StartNew(); + var result = await statement.ExecuteQueryAsync(); + using var stream = result.Stream!; + long totalRows = 0; + int batchCount = 0; + while (await stream.ReadNextRecordBatchAsync() is { } batch) + { + using (batch) { batchCount++; totalRows += batch.Length; } + } + stopwatch.Stop(); + _output.WriteLine($"[INTEROP] Fetched {totalRows} rows in {batchCount} batches with {stream.Schema.FieldsList.Count} columns for limit {limit} in {stopwatch.Elapsed.TotalMilliseconds:F0} ms."); + Assert.Equal(limit, totalRows); + } +} diff --git a/csharp/test/Interop/SnowflakeTestConfiguration.cs b/csharp/test/Interop/SnowflakeTestConfiguration.cs index 49639e8..f7f9954 100644 --- a/csharp/test/Interop/SnowflakeTestConfiguration.cs +++ b/csharp/test/Interop/SnowflakeTestConfiguration.cs @@ -102,6 +102,8 @@ internal class SnowflakeTestConfiguration : TestConfiguration /// [JsonPropertyName("roleInfo")] public RoleInfo? RoleInfo { get; set; } + + } public class SnowflakeAuthentication diff --git a/csharp/test/Native/AdbcDrivers.Snowflake.Native.Tests.csproj b/csharp/test/Native/AdbcDrivers.Snowflake.Native.Tests.csproj new file mode 100644 index 0000000..3a5c145 --- /dev/null +++ b/csharp/test/Native/AdbcDrivers.Snowflake.Native.Tests.csproj @@ -0,0 +1,28 @@ + + + net8.0 + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/csharp/test/Native/BindCases.cs b/csharp/test/Native/BindCases.cs new file mode 100644 index 0000000..e758b84 --- /dev/null +++ b/csharp/test/Native/BindCases.cs @@ -0,0 +1,123 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Linq; + +using Apache.Arrow; +using Apache.Arrow.Types; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// One row per Arrow array type the driver can bind. Single source of truth for both layers +/// of bind testing: asserts the offline wire format, and the +/// live StatementTests.CanBindParameter theory binds the same array against the real +/// server. Adding or removing a bindable type is a one-line edit here. +/// +public sealed record BindCase( + string Name, + Func BuildArray, + string ExpectedBindType, + string? ExpectedValue, + string? LivePredicate); + +public static class BindCases +{ + /// DATE = ms since epoch, TIME = ns of day, TIMESTAMP = ns since epoch, BINARY = lower hex. + static readonly IReadOnlyList All = + [ + new("Boolean", + () => new BooleanArray.Builder().Append(true).Build(), + "BOOLEAN", "true", "? = TRUE"), + new("Int8", + () => new Int8Array.Builder().Append((sbyte)7).Build(), + "FIXED", "7", "? = 7"), + new("Int16", + () => new Int16Array.Builder().Append((short)1234).Build(), + "FIXED", "1234", "? = 1234"), + new("Int32", + () => new Int32Array.Builder().Append(42).Build(), + "FIXED", "42", "? = 42"), + new("Int64", + () => new Int64Array.Builder().Append(9999999999L).Build(), + "FIXED", "9999999999", "? = 9999999999"), + // Unsigned ints and Date64/Decimal256 share a wire format with already-live-proven types + // (UInt* → FIXED string, Date64 → ms, Decimal256 → FIXED string), so they are offline-only. + new("UInt8", + () => new UInt8Array.Builder().Append((byte)7).Build(), + "FIXED", "7", null), + new("UInt16", + () => new UInt16Array.Builder().Append((ushort)1234).Build(), + "FIXED", "1234", null), + new("UInt32", + () => new UInt32Array.Builder().Append(42u).Build(), + "FIXED", "42", null), + new("UInt64", + () => new UInt64Array.Builder().Append(9999999999UL).Build(), + "FIXED", "9999999999", null), + new("Float", + () => new FloatArray.Builder().Append(1.5f).Build(), + "REAL", "1.5", "? = 1.5"), + new("Double", + () => new DoubleArray.Builder().Append(1.5).Build(), + "REAL", "1.5", "? = 1.5"), + new("Decimal128", + () => new Decimal128Array.Builder(new Decimal128Type(10, 2)).Append(9.99m).Build(), + "FIXED", "9.99", "? = 9.99"), + new("Decimal256", + () => new Decimal256Array.Builder(new Decimal256Type(10, 2)).Append(9.99m).Build(), + "FIXED", "9.99", null), + new("String", + () => new StringArray.Builder().Append("hi").Build(), + "TEXT", "hi", "? = 'hi'"), + new("Binary", + () => new BinaryArray.Builder().Append([0xAB, 0xCD]).Build(), + "BINARY", "abcd", "? = TO_BINARY('ABCD','HEX')"), + new("Date32", + () => new Date32Array.Builder().Append(new DateTime(2020, 1, 1)).Build(), + "DATE", "1577836800000", "? = '2020-01-01'::DATE"), + new("Date64", + () => new Date64Array.Builder().Append(new DateTime(2020, 1, 1)).Build(), + "DATE", "1577836800000", null), + new("Time32", + () => new Time32Array.Builder(new Time32Type()).Append(45_296_789).Build(), + "TIME", "45296789000000", "? = '12:34:56.789'::TIME"), + new("Time64", + () => new Time64Array.Builder(new Time64Type()).Append(45_296_000_000_000L).Build(), + "TIME", "45296000000000", "? = '12:34:56'::TIME"), + new("TimestampNtz", + () => new TimestampArray.Builder(new TimestampType(TimeUnit.Nanosecond, (string?)null)) + .Append(new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero)).Build(), + "TIMESTAMP_NTZ", "1577836800000000000", "? = '2020-01-01 00:00:00'::TIMESTAMP_NTZ"), + // TIMESTAMP_LTZ binds an instant; the offline assertion proves the wire format. A live + // equality check would depend on session time zone, so it is left to the NTZ case. + new("TimestampLtz", + () => new TimestampArray.Builder(new TimestampType(TimeUnit.Nanosecond, "UTC")) + .Append(new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero)).Build(), + "TIMESTAMP_LTZ", "1577836800000000000", null) + ]; + + /// Every case, as xunit theory data (the serializable case name). + public static IEnumerable Names() => All.Select(c => new object[] { c.Name }); + + /// Only the cases that carry a live equality predicate. + public static IEnumerable LiveNames() => + All.Where(c => c.LivePredicate != null).Select(c => new object[] { c.Name }); + + public static BindCase Get(string name) => All.Single(c => c.Name == name); +} diff --git a/csharp/test/Native/ChunkedArrowArrayStreamTests.cs b/csharp/test/Native/ChunkedArrowArrayStreamTests.cs new file mode 100644 index 0000000..41d9c6c --- /dev/null +++ b/csharp/test/Native/ChunkedArrowArrayStreamTests.cs @@ -0,0 +1,175 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.Query; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using Apache.Arrow; +using Apache.Arrow.Ipc; +using Apache.Arrow.Types; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Offline tests for 's prefetch scheduling, using a fake +/// that serves in-memory Arrow chunks. Focus: the download window +/// must bound memory when the consumer is slow, without losing chunks or ordering. +/// +[Trait("Category", "Unit")] +public class ChunkedArrowArrayStreamTests +{ + private static readonly AuthenticationToken Token = new() { SessionToken = "session" }; + + /// Serializes a single-column Arrow stream whose one batch holds the given value. + private static byte[] ArrowChunkBytes(int value) + { + var schema = new Schema([new Field("v", Int32Type.Default, false)], null); + using var batch = new RecordBatch(schema, [new Int32Array.Builder().Append(value).Build()], 1); + using var buffer = new MemoryStream(); + using (var writer = new ArrowStreamWriter(buffer, schema)) + { + writer.WriteRecordBatch(batch); + writer.WriteEnd(); + } + return buffer.ToArray(); + } + + private static List Chunks(int count) + { + var chunks = new List(count); + for (int i = 0; i < count; i++) + chunks.Add(new ChunkInfo { Url = $"https://chunks.test/{i}", UncompressedSize = 128 }); + return chunks; + } + + /// + /// Fake client: serves chunk i as an Arrow stream containing the value i, counting the + /// download calls it has received. + /// + private sealed class FakeChunkClient : IRestApiClient + { + private int _downloads; + + public int Downloads => Volatile.Read(ref _downloads); + + public Task> PostAsync( + string endpoint, TRequest request, AuthenticationToken token, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task> GetAsync( + string endpoint, AuthenticationToken token, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + + public Task GetArrowStreamAsync( + string url, AuthenticationToken token, Dictionary? chunkHeaders = null, + string? qrmk = null, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _downloads); + int index = int.Parse(url[(url.LastIndexOf('/') + 1)..]); + return Task.FromResult(new MemoryStream(ArrowChunkBytes(index))); + } + } + + [Fact] + public async Task SlowConsumer_BoundsResidentChunks_ToWindowPlusChannel() + { + // Given 30 chunks with prefetch concurrency 4 and a consumer that reads NOTHING yet. + // Downloads complete instantly, so without handoff-gated slots the prefetcher would + // race through all 30; with the window it must stall once window (4) + channel (4) + // + the one blocked in WriteAsync are occupied. + const int chunkCount = 30; + const int concurrency = 4; + var client = new FakeChunkClient(); + + using var stream = await ChunkedArrowArrayStream.CreateAsync( + client, Token, + rowSetBase64: Convert.ToBase64String(ArrowChunkBytes(-1)), // inline first batch; all 30 go to the prefetcher + Chunks(chunkCount), chunkHeaders: null, qrmk: null, + CancellationToken.None, concurrency); + + // When the prefetcher is given time to run as far as it can without any reads + await Task.Delay(500); + int downloadsWhileStalled = client.Downloads; + + // Then it launched at most window + channel + 1 (the write in flight), far below all 30 + Assert.InRange(downloadsWhileStalled, concurrency, 2 * concurrency + 1); + + // And when the consumer finally drains the stream, every chunk arrives exactly once, in order + var values = new List(); + while (await stream.ReadNextRecordBatchAsync() is { } batch) + { + using (batch) + values.Add(((Int32Array)batch.Column(0)).GetValue(0)!.Value); + } + + Assert.Equal(chunkCount + 1, values.Count); + Assert.Equal(-1, values[0]); // the inline batch + for (int i = 0; i < chunkCount; i++) + Assert.Equal(i, values[i + 1]); + Assert.Equal(chunkCount, client.Downloads); + } + + [Fact] + public async Task FastConsumer_ReceivesEveryChunkInOrder() + { + // Given 25 chunks and a consumer that drains as fast as batches arrive + const int chunkCount = 25; + var client = new FakeChunkClient(); + + using var stream = await ChunkedArrowArrayStream.CreateAsync( + client, Token, + rowSetBase64: Convert.ToBase64String(ArrowChunkBytes(-1)), + Chunks(chunkCount), chunkHeaders: null, qrmk: null, + CancellationToken.None, prefetchConcurrency: 6); + + var values = new List(); + while (await stream.ReadNextRecordBatchAsync() is { } batch) + { + using (batch) + values.Add(((Int32Array)batch.Column(0)).GetValue(0)!.Value); + } + + // Then all chunks arrive exactly once, in order, each downloaded exactly once + Assert.Equal(chunkCount + 1, values.Count); + for (int i = 0; i < chunkCount; i++) + Assert.Equal(i, values[i + 1]); + Assert.Equal(chunkCount, client.Downloads); + } + + [Fact] + public async Task Dispose_WithUnconsumedChunks_ReturnsPromptly() + { + // Given a stream whose prefetcher is stalled on a slow consumer (nothing read) + var client = new FakeChunkClient(); + var stream = await ChunkedArrowArrayStream.CreateAsync( + client, Token, + rowSetBase64: Convert.ToBase64String(ArrowChunkBytes(-1)), + Chunks(20), chunkHeaders: null, qrmk: null, + CancellationToken.None, prefetchConcurrency: 4); + await Task.Delay(200); // let the window fill and the writer block + + // When the stream is disposed mid-flight, it unwinds the prefetcher promptly (no hang) + var dispose = Task.Run(stream.Dispose); + Assert.True(await Task.WhenAny(dispose, Task.Delay(TimeSpan.FromSeconds(5))) == dispose, + "Dispose did not complete within 5s — prefetch task is stuck."); + } +} diff --git a/csharp/test/Native/Configuration/ConnectionStringParserTests.cs b/csharp/test/Native/Configuration/ConnectionStringParserTests.cs new file mode 100644 index 0000000..940be55 --- /dev/null +++ b/csharp/test/Native/Configuration/ConnectionStringParserTests.cs @@ -0,0 +1,497 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using AdbcDrivers.Snowflake.Native.Configuration; +using Xunit; + +using Apache.Arrow; +using Apache.Arrow.Adbc; + +namespace AdbcDrivers.Snowflake.Native.Tests.Configuration; + +[Trait("Category", "Unit")] +public class ConnectionStringParserTests +{ + private static Dictionary ParseConnectionString(string connectionString) + { + var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase); + var pairs = connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries); + + foreach (var pair in pairs) + { + var parts = pair.Split('=', 2); + if (parts.Length == 2) + { + parameters[parts[0].Trim()] = parts[1].Trim(); + } + } + + return parameters; + } + + [Fact] + public void Parse_WithValidBasicConnectionString_ShouldReturnValidConfig() + { + // Arrange + var parameters = ParseConnectionString("adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;adbc.snowflake.sql.db=testdb"); + + // Act + var config = ConnectionStringParser.ParseParameters(parameters); + + // Assert + Assert.NotNull(config); + Assert.Equal("testaccount", config.Account); + Assert.Equal("testuser", config.User); + Assert.Equal("testdb", config.Database); + Assert.Equal(AuthenticationType.UsernamePassword, config.Authentication.Type); + Assert.Equal("testpass", config.Authentication.Password); + } + + [Theory] + [InlineData("auth_jwt")] // canonical (ADBC Snowflake driver reference) + [InlineData("snowflake_jwt")] // connector-net alias + [InlineData("jwt")] // shorthand alias + public void Parse_WithKeyPairAuthentication_ShouldReturnValidConfig(string authType) + { + // Arrange + var parameters = ParseConnectionString($"adbc.snowflake.sql.account=testaccount;username=testuser;adbc.snowflake.sql.auth_type={authType};adbc.snowflake.sql.client_option.jwt_private_key_pkcs8_value=PRIVATE_KEY_CONTENT"); + + // Act + var config = ConnectionStringParser.ParseParameters(parameters); + + // Assert + Assert.NotNull(config); + Assert.Equal("testaccount", config.Account); + Assert.Equal("testuser", config.User); + Assert.Equal(AuthenticationType.KeyPair, config.Authentication.Type); + Assert.Equal("PRIVATE_KEY_CONTENT", config.Authentication.PrivateKey); + } + + [Theory] + [InlineData("auth_snowflake")] + [InlineData("snowflake")] + public void Parse_WithExplicitPasswordAuthType_ShouldReturnValidConfig(string authType) + { + var parameters = ParseConnectionString( + $"adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;adbc.snowflake.sql.auth_type={authType}"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.Equal(AuthenticationType.UsernamePassword, config.Authentication.Type); + } + + [Theory] + [InlineData("auth_oauth")] + [InlineData("oauth")] + public void Parse_WithOAuthAuthentication_ShouldReturnValidConfig(string authType) + { + // Arrange + var parameters = ParseConnectionString($"adbc.snowflake.sql.account=testaccount;username=testuser;adbc.snowflake.sql.auth_type={authType};adbc.snowflake.sql.client_option.auth_token=test_token"); + + // Act + var config = ConnectionStringParser.ParseParameters(parameters); + + // Assert + Assert.NotNull(config); + Assert.Equal("testaccount", config.Account); + Assert.Equal("testuser", config.User); + Assert.Equal(AuthenticationType.OAuth, config.Authentication.Type); + Assert.Equal("test_token", config.Authentication.Token); + } + + [Theory] + [InlineData("auth_pat")] + [InlineData("programmatic_access_token")] + [InlineData("pat")] + public void Parse_WithPatAuthentication_ShouldReturnValidConfig(string authType) + { + // Arrange - a PAT rides the same auth_token option as OAuth; auth_type selects how + // it is presented to Snowflake. + var parameters = ParseConnectionString( + $"adbc.snowflake.sql.account=testaccount;username=testuser;adbc.snowflake.sql.auth_type={authType};adbc.snowflake.sql.client_option.auth_token=test_pat"); + + // Act + var config = ConnectionStringParser.ParseParameters(parameters); + + // Assert + Assert.Equal(AuthenticationType.Pat, config.Authentication.Type); + Assert.Equal("test_pat", config.Authentication.Token); + } + + [Theory] + [InlineData("auth_okta")] + [InlineData("auth_mfa")] + [InlineData("auth_wif")] + public void Parse_WithRecognizedButUnsupportedAuthType_SaysSoExplicitly(string authType) + { + // Canonical ADBC values the driver doesn't implement yet must be distinguishable + // from a typo. + var parameters = ParseConnectionString( + $"adbc.snowflake.sql.account=testaccount;username=testuser;adbc.snowflake.sql.auth_type={authType}"); + + var ex = Assert.Throws(() => ConnectionStringParser.ParseParameters(parameters)); + Assert.Contains("not supported by this driver yet", ex.Message); + } + + [Fact] + public void Parse_WithRequestTimeout_SetsQueryTimeout() + { + // Arrange - request_timeout maps to the statement/query timeout (STATEMENT_TIMEOUT_IN_SECONDS) + var parameters = ParseConnectionString("adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;adbc.snowflake.sql.client_option.request_timeout=300"); + + // Act + var config = ConnectionStringParser.ParseParameters(parameters); + + // Assert + Assert.NotNull(config); + Assert.Equal(TimeSpan.FromSeconds(300), config.QueryTimeout); + } + + [Fact] + public void Parse_WithoutKeepAlive_DefaultsToOffAndOneHour() + { + var parameters = ParseConnectionString("adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.False(config.ClientSessionKeepAlive); + Assert.Equal(TimeSpan.FromHours(1), config.HeartbeatFrequency); + } + + [Fact] + public void Parse_WithKeepAliveEnabled_SetsFlagAndFrequency() + { + // Given keep-alive on with a 1800s (30m) heartbeat frequency inside the allowed band + var parameters = ParseConnectionString( + "adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;" + + "adbc.snowflake.sql.client_option.keep_session_alive=true;" + + "adbc.snowflake.sql.client_option.keep_session_alive_heartbeat_frequency=1800"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.True(config.ClientSessionKeepAlive); + Assert.Equal(TimeSpan.FromMinutes(30), config.HeartbeatFrequency); + } + + [Fact] + public void Parse_WithOutOfRangeHeartbeatFrequency_ClampsToBand() + { + // Given a frequency far below the 15-minute floor, it clamps up rather than letting the + // session be hammered (or, at the other extreme, lapse). + var parameters = ParseConnectionString( + "adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;" + + "adbc.snowflake.sql.client_option.keep_session_alive=true;" + + "adbc.snowflake.sql.client_option.keep_session_alive_heartbeat_frequency=5"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.Equal(TimeSpan.FromMinutes(15), config.HeartbeatFrequency); + } + + [Fact] + public void Parse_WithoutAcquireTimeout_DefaultsTo120Seconds() + { + var parameters = ParseConnectionString("adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.Equal(TimeSpan.FromSeconds(120), config.PoolConfig.AcquireTimeout); + } + + [Fact] + public void Parse_WithPoolAcquireTimeout_SetsAcquireTimeout() + { + var parameters = ParseConnectionString("adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;adbc.snowflake.pool.acquire_timeout=30"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.Equal(TimeSpan.FromSeconds(30), config.PoolConfig.AcquireTimeout); + Assert.Equal(TimeSpan.FromMinutes(10), config.PoolConfig.IdleTimeout); // unchanged (default) + } + + [Fact] + public void Parse_WithPoolMaxSize_SetsMaxPoolSize() + { + var parameters = ParseConnectionString("adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;adbc.snowflake.pool.max_size=20"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.Equal(20, config.PoolConfig.MaxPoolSize); + } + + [Fact] + public void Parse_WithQueryTag_SetsConnectionDefault() + { + var parameters = ParseConnectionString( + "adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;" + + "adbc.snowflake.statement.query_tag=etl-nightly"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.Equal("etl-nightly", config.QueryTag); + } + + [Fact] + public void Parse_WithoutQueryTag_LeavesItNull() + { + var parameters = ParseConnectionString("adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.Null(config.QueryTag); + } + + [Fact] + public void Parse_WithLoginTimeoutAndPrefetch_SetsBoth() + { + var parameters = ParseConnectionString( + "adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;" + + "adbc.snowflake.sql.client_option.login_timeout=45;adbc.snowflake.rpc.prefetch_concurrency=4"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.Equal(TimeSpan.FromSeconds(45), config.LoginTimeout); + Assert.Equal(4, config.PrefetchConcurrency); + } + + [Fact] + public void Parse_WithoutOptionalTimeouts_UsesDefaults() + { + var parameters = ParseConnectionString("adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.Equal(TimeSpan.FromSeconds(60), config.LoginTimeout); + Assert.Equal(10, config.PrefetchConcurrency); + } + + [Fact] + public void Parse_WithSsoProperties_ShouldReturnValidConfig() + { + // Arrange - SSO not currently supported in ADBC standard, removing this test + var parameters = ParseConnectionString("adbc.snowflake.sql.account=testaccount;username=testuser;adbc.snowflake.sql.auth_type=auth_ext_browser"); + + // Act + var config = ConnectionStringParser.ParseParameters(parameters); + + // Assert + Assert.NotNull(config); + Assert.Equal(AuthenticationType.ExternalBrowser, config.Authentication.Type); + } + + [Fact] + public void Parse_WithStandardConnectionCatalogAndSchema_SetsDatabaseAndSchema() + { + // The canonical ADBC connection options map to Snowflake's current database/schema + var parameters = ParseConnectionString( + "adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;" + + "adbc.connection.catalog=MYDB;adbc.connection.db_schema=MYSCHEMA"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.Equal("MYDB", config.Database); + Assert.Equal("MYSCHEMA", config.Schema); + } + + [Fact] + public void Parse_StandardConnectionCatalog_TakesPrecedenceOverDriverAlias() + { + // adbc.connection.catalog wins over the adbc.snowflake.sql.db alias when both are present + var parameters = ParseConnectionString( + "adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;" + + "adbc.snowflake.sql.db=ALIAS_DB;adbc.connection.catalog=CANONICAL_DB"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.Equal("CANONICAL_DB", config.Database); + } + + [Fact] + public void Parse_ConnectionCatalog_OverridesDatabaseDefaultSchema() + { + // A per-connection catalog/schema (via Connect) overrides the database-level default + var databaseDefaults = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "adbc.snowflake.sql.account", "testaccount" }, + { "username", "testuser" }, + { "password", "testpass" }, + { "adbc.snowflake.sql.db", "DEFAULT_DB" }, + { "adbc.snowflake.sql.schema", "DEFAULT_SCHEMA" }, + }; + var connectionParams = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "adbc.connection.catalog", "CONN_DB" }, + { "adbc.connection.db_schema", "CONN_SCHEMA" }, + }; + + var config = ConnectionStringParser.ParseParameters(connectionParams, databaseDefaults); + + Assert.Equal("CONN_DB", config.Database); + Assert.Equal("CONN_SCHEMA", config.Schema); + } + + [Fact] + public void Parse_WithTlsSkipVerify_SetsFlag() + { + var parameters = ParseConnectionString( + "adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;" + + "adbc.snowflake.sql.client_option.tls_skip_verify=true"); + + var config = ConnectionStringParser.ParseParameters(parameters); + + Assert.True(config.Network.TlsSkipVerify); + } + + [Theory] + [InlineData("adbc.snowflake.sql.client_option.request_timeout", "30x")] + [InlineData("adbc.snowflake.sql.client_option.login_timeout", "abc")] + [InlineData("adbc.snowflake.rpc.prefetch_concurrency", "lots")] + [InlineData("adbc.snowflake.sql.client_option.keep_session_alive_heartbeat_frequency", "soon")] + [InlineData("adbc.snowflake.pool.max_size", "20.5")] + [InlineData("adbc.snowflake.sql.uri.port", "https")] + public void Parse_WithNonNumericValue_ThrowsRatherThanSilentlyIgnoring(string key, string value) + { + var parameters = ParseConnectionString( + $"adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;{key}={value}"); + + var ex = Assert.Throws(() => ConnectionStringParser.ParseParameters(parameters)); + Assert.Contains(key, ex.Message); + Assert.Contains("whole number", ex.Message); + } + + [Theory] + [InlineData("adbc.snowflake.sql.client_option.enable_compression", "yes")] + [InlineData("adbc.snowflake.sql.client_option.keep_session_alive", "1")] + [InlineData("adbc.snowflake.sql.client_option.tls_skip_verify", "on")] + public void Parse_WithNonBooleanValue_ThrowsRatherThanSilentlyIgnoring(string key, string value) + { + var parameters = ParseConnectionString( + $"adbc.snowflake.sql.account=testaccount;username=testuser;password=testpass;{key}={value}"); + + var ex = Assert.Throws(() => ConnectionStringParser.ParseParameters(parameters)); + Assert.Contains(key, ex.Message); + Assert.Contains("'true' or 'false'", ex.Message); + } + + [Fact] + public void Parse_WithNullParameters_ShouldThrowArgumentException() + { + // Act & Assert - null parameters result in empty dictionary which fails validation + var exception = Assert.Throws(() => ConnectionStringParser.ParseParameters(null)); + Assert.Contains("account", exception.Message); + } + + [Fact] + public void Parse_WithMissingRequiredParameter_ShouldThrowArgumentException() + { + // Arrange + var parameters = ParseConnectionString("username=testuser;password=testpass"); // Missing account + + // Act & Assert + var exception = Assert.Throws(() => ConnectionStringParser.ParseParameters(parameters)); + Assert.Contains("account", exception.Message); + } + + [Fact] + public void Parse_WithInvalidAuthenticator_ShouldThrowArgumentException() + { + // Arrange + var parameters = ParseConnectionString("adbc.snowflake.sql.account=testaccount;username=testuser;adbc.snowflake.sql.auth_type=invalid_auth"); + + // Act & Assert + var exception = Assert.Throws(() => ConnectionStringParser.ParseParameters(parameters)); + Assert.Contains("Unsupported auth_type", exception.Message); + } + + [Fact] + public void Parse_WithCaseInsensitiveParameters_ShouldReturnValidConfig() + { + // Arrange + var parameters = ParseConnectionString("ADBC.SNOWFLAKE.SQL.ACCOUNT=testaccount;Username=testuser;PASSWORD=testpass;ADBC.SNOWFLAKE.SQL.DB=testdb"); + + // Act + var config = ConnectionStringParser.ParseParameters(parameters); + + // Assert + Assert.NotNull(config); + Assert.Equal("testaccount", config.Account); + Assert.Equal("testuser", config.User); + Assert.Equal("testdb", config.Database); + Assert.Equal("testpass", config.Authentication.Password); + } + + [Fact] + public void ParseParameters_WithConnectionOverrides_ShouldMergeCorrectly() + { + // Arrange - database parameters + var databaseParams = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "adbc.snowflake.sql.account", "testaccount" }, + { "username", "testuser" }, + { "password", "testpass" }, + { "adbc.snowflake.sql.warehouse", "DEFAULT_WH" }, + { "adbc.snowflake.sql.db", "DEFAULT_DB" } + }; + + // Connection-specific overrides + var connectionParams = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "adbc.snowflake.sql.warehouse", "ANALYTICS_WH" }, // Override + { "adbc.snowflake.sql.schema", "PUBLIC" } // New parameter + }; + + // Act + var config = ConnectionStringParser.ParseParameters(connectionParams, databaseParams); + + // Assert + Assert.NotNull(config); + Assert.Equal("testaccount", config.Account); + Assert.Equal("testuser", config.User); + Assert.Equal("ANALYTICS_WH", config.Warehouse); // Overridden value + Assert.Equal("DEFAULT_DB", config.Database); // From database params + Assert.Equal("PUBLIC", config.Schema); // From connection params + } + + [Fact] + public void ParseParameters_WithCaseInsensitiveMerge_ShouldHandleCorrectly() + { + // Arrange - test that case-insensitive merge works correctly + var databaseParams = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "ADBC.SNOWFLAKE.SQL.ACCOUNT", "testaccount" }, + { "Username", "testuser" }, + { "ADBC.SNOWFLAKE.SQL.WAREHOUSE", "DEFAULT_WH" } + }; + + var connectionParams = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { "adbc.snowflake.sql.warehouse", "OVERRIDE_WH" }, // Different casing, should override + { "password", "testpass" } + }; + + // Act + var config = ConnectionStringParser.ParseParameters(connectionParams, databaseParams); + + // Assert + Assert.NotNull(config); + Assert.Equal("testaccount", config.Account); + Assert.Equal("testuser", config.User); + Assert.Equal("OVERRIDE_WH", config.Warehouse); // Should use connection override despite case difference + Assert.Equal("testpass", config.Authentication.Password); + } +} diff --git a/csharp/test/Native/ConnectionPool/ConnectionPoolManagerTests.cs b/csharp/test/Native/ConnectionPool/ConnectionPoolManagerTests.cs new file mode 100644 index 0000000..edf2de3 --- /dev/null +++ b/csharp/test/Native/ConnectionPool/ConnectionPoolManagerTests.cs @@ -0,0 +1,558 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.ConnectionPool; +using Apache.Arrow.Adbc; +using Microsoft.Extensions.Time.Testing; +using NSubstitute; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests.ConnectionPool; + +[Trait("Category", "Unit")] +public class ConnectionPoolManagerTests +{ + /// Minimal in-memory for exercising the due-logic. + private sealed class FakePooledConnection : IPooledConnection + { + public ConnectionConfig Config { get; init; } = new(); + public DateTimeOffset LastUsedAt { get; init; } + public DateTimeOffset LastHeartbeatAt { get; set; } + public int RecordHeartbeatCount { get; private set; } + + public string ConnectionId => "fake"; + public string PoolKey => "fake-pool-key"; + public AuthenticationToken AuthToken { get; } = new(); + public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow; + public bool IsDisposed { get; init; } + public bool IsTokenExpired => false; + public bool IsFaulted { get; set; } + void IPooledConnection.UpdateLastUsedAt() { } + void IPooledConnection.RecordHeartbeat() + { + RecordHeartbeatCount++; + LastHeartbeatAt = DateTimeOffset.UtcNow; + } + public void Dispose() { } + } + + /// A keep-alive connection that has been idle long enough to be due at . + private static FakePooledConnection DueConnection(DateTimeOffset now) => + new() + { + Config = new ConnectionConfig { ClientSessionKeepAlive = true, HeartbeatFrequency = TimeSpan.FromMinutes(15) }, + LastUsedAt = now - TimeSpan.FromMinutes(20), + LastHeartbeatAt = now - TimeSpan.FromMinutes(20), + }; + + [Fact] + public void IsHeartbeatDue_WhenKeepAliveDisabled_IsNeverDue() + { + // Given keep-alive off, even a long-idle connection is never due + var now = DateTimeOffset.UtcNow; + var connection = new FakePooledConnection + { + Config = new ConnectionConfig { ClientSessionKeepAlive = false, HeartbeatFrequency = TimeSpan.FromMinutes(15) }, + LastUsedAt = now - TimeSpan.FromHours(2), + LastHeartbeatAt = now - TimeSpan.FromHours(2), + }; + + Assert.False(ConnectionPoolManager.IsHeartbeatDue(connection, now)); + } + + [Fact] + public void IsHeartbeatDue_WhenIdleBeyondFrequency_IsDue() + { + // Given keep-alive on and no activity for longer than the frequency + var now = DateTimeOffset.UtcNow; + var connection = new FakePooledConnection + { + Config = new ConnectionConfig { ClientSessionKeepAlive = true, HeartbeatFrequency = TimeSpan.FromMinutes(15) }, + LastUsedAt = now - TimeSpan.FromMinutes(20), + LastHeartbeatAt = now - TimeSpan.FromMinutes(20), + }; + + Assert.True(ConnectionPoolManager.IsHeartbeatDue(connection, now)); + } + + [Fact] + public void IsHeartbeatDue_WithRecentActivity_IsNotDue() + { + // Given a recent query (LastUsedAt) even though the last heartbeat is old, the session is + // already warm — activity counts as a heartbeat, so none is needed + var now = DateTimeOffset.UtcNow; + var connection = new FakePooledConnection + { + Config = new ConnectionConfig { ClientSessionKeepAlive = true, HeartbeatFrequency = TimeSpan.FromMinutes(15) }, + LastUsedAt = now - TimeSpan.FromMinutes(1), + LastHeartbeatAt = now - TimeSpan.FromHours(1), + }; + + Assert.False(ConnectionPoolManager.IsHeartbeatDue(connection, now)); + } + + [Fact] + public void IsHeartbeatDue_ExactlyAtBoundary_IsDue() + { + // When elapsed time equals the heartbeat frequency exactly, should be due (uses >=) + var now = DateTimeOffset.UtcNow; + var frequency = TimeSpan.FromMinutes(15); + var connection = new FakePooledConnection + { + Config = new ConnectionConfig { ClientSessionKeepAlive = true, HeartbeatFrequency = frequency }, + LastUsedAt = now - frequency, + LastHeartbeatAt = now - frequency, + }; + + Assert.True(ConnectionPoolManager.IsHeartbeatDue(connection, now)); + } + + [Fact] + public void IsHeartbeatDue_WithRecentHeartbeat_IsNotDue() + { + // LastHeartbeatAt is recent even though LastUsedAt is old — not due + var now = DateTimeOffset.UtcNow; + var connection = new FakePooledConnection + { + Config = new ConnectionConfig { ClientSessionKeepAlive = true, HeartbeatFrequency = TimeSpan.FromMinutes(15) }, + LastUsedAt = now - TimeSpan.FromHours(2), + LastHeartbeatAt = now - TimeSpan.FromMinutes(5), + }; + + Assert.False(ConnectionPoolManager.IsHeartbeatDue(connection, now)); + } + + [Fact] + public async Task Pool_WithNoHeartbeatDelegate_DisposesCleanly() + { + // Create pool with no session-lifecycle collaborator, acquire+release, dispose — no crash + var authService = Substitute.For(); + authService.AuthenticateAsync(Arg.Any(), Arg.Any()) + .Returns(new AuthenticationToken + { + SessionToken = "session", + MasterToken = "master", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + MasterExpiresAt = DateTimeOffset.UtcNow.AddHours(4), + }); + + var pool = new ConnectionPoolManager(authService, sessionLifecycle: null); + try + { + var config = new ConnectionConfig + { + Account = "test", + User = "user", + ClientSessionKeepAlive = true, + HeartbeatFrequency = TimeSpan.FromMinutes(15), + }; + + var connection = await pool.AcquireConnectionAsync(config); + pool.ReleaseConnection(connection); + } + finally + { + pool.Dispose(); + } + } + + [Fact] + public async Task HeartbeatDueConnections_FiresForDueConnection_AndRecordsHeartbeat() + { + // Given one due keep-alive connection + var now = DateTimeOffset.UtcNow; + var connection = DueConnection(now); + var calls = 0; + + // When the heartbeat pass runs + await ConnectionPoolManager.HeartbeatDueConnectionsAsync( + new[] { connection }, + (_, _, _) => { calls++; return Task.CompletedTask; }, + now, + CancellationToken.None); + + // Then the delegate fired once and the heartbeat was recorded (resetting the due clock) + Assert.Equal(1, calls); + Assert.Equal(1, connection.RecordHeartbeatCount); + } + + [Fact] + public async Task HeartbeatDueConnections_SkipsDisabledNotDueAndDisposed() + { + // Given connections that should each be skipped for a different reason + var now = DateTimeOffset.UtcNow; + var keepAliveOff = new FakePooledConnection + { + Config = new ConnectionConfig { ClientSessionKeepAlive = false, HeartbeatFrequency = TimeSpan.FromMinutes(15) }, + LastUsedAt = now - TimeSpan.FromHours(1), + LastHeartbeatAt = now - TimeSpan.FromHours(1), + }; + var recentlyUsed = new FakePooledConnection + { + Config = new ConnectionConfig { ClientSessionKeepAlive = true, HeartbeatFrequency = TimeSpan.FromMinutes(15) }, + LastUsedAt = now - TimeSpan.FromMinutes(1), + LastHeartbeatAt = now - TimeSpan.FromMinutes(1), + }; + var disposed = new FakePooledConnection + { + Config = new ConnectionConfig { ClientSessionKeepAlive = true, HeartbeatFrequency = TimeSpan.FromMinutes(15) }, + LastUsedAt = now - TimeSpan.FromHours(1), + LastHeartbeatAt = now - TimeSpan.FromHours(1), + IsDisposed = true, + }; + var calls = 0; + + // When the heartbeat pass runs + await ConnectionPoolManager.HeartbeatDueConnectionsAsync( + new[] { keepAliveOff, recentlyUsed, disposed }, + (_, _, _) => { calls++; return Task.CompletedTask; }, + now, + CancellationToken.None); + + // Then none are heartbeated + Assert.Equal(0, calls); + Assert.Equal(0, keepAliveOff.RecordHeartbeatCount); + Assert.Equal(0, recentlyUsed.RecordHeartbeatCount); + Assert.Equal(0, disposed.RecordHeartbeatCount); + } + + [Fact] + public async Task HeartbeatDueConnections_WhenTokenCancelled_FiresNothing() + { + // Given a due connection but an already-cancelled token (e.g. the pool is being disposed) + var now = DateTimeOffset.UtcNow; + var connection = DueConnection(now); + var calls = 0; + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + // When the heartbeat pass runs under that token + await ConnectionPoolManager.HeartbeatDueConnectionsAsync( + new[] { connection }, + (_, _, _) => { calls++; return Task.CompletedTask; }, + now, + cts.Token); + + // Then nothing is heartbeated + Assert.Equal(0, calls); + Assert.Equal(0, connection.RecordHeartbeatCount); + } + + [Fact] + public async Task BackgroundLoop_OnTick_HeartbeatsDueIdleConnection() + { + // Given a pool on a fake clock with a heartbeat delegate and a keep-alive connection. The + // token's expiry is evaluated on the same clock, so it stays valid as fake time advances. + var fakeTime = new FakeTimeProvider(); + var authService = Substitute.For(); + authService.AuthenticateAsync(Arg.Any(), Arg.Any()) + .Returns(new AuthenticationToken + { + SessionToken = "session", + MasterToken = "master", + ExpiresAt = fakeTime.GetUtcNow().AddHours(1), + MasterExpiresAt = fakeTime.GetUtcNow().AddHours(4), + }); + + var heartbeatFired = new TaskCompletionSource(); + var sessionLifecycle = Substitute.For(); + sessionLifecycle + .HeartbeatAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(_ => { heartbeatFired.TrySetResult(); return Task.CompletedTask; }); + using var pool = new ConnectionPoolManager( + authService, + sessionLifecycle, + timeProvider: fakeTime); + + var config = new ConnectionConfig + { + Account = "test", + User = "user", + ClientSessionKeepAlive = true, + HeartbeatFrequency = TimeSpan.FromSeconds(30), // due before one 60s loop tick, within the 10m idle timeout + }; + + // Acquire then release so the connection sits idle in the pool; this also starts the loop. + var connection = await pool.AcquireConnectionAsync(config); + pool.ReleaseConnection(connection); + + // When the background loop's 60s timer ticks once (the connection is now 60s idle, past 30s) + fakeTime.Advance(TimeSpan.FromSeconds(60)); + + // Then the loop heartbeats the idle connection (the delegate completes the signal) + await heartbeatFired.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task HeartbeatDueConnections_WhenOneHeartbeatThrows_ContinuesWithTheRest() + { + // Given two due connections where the first connection's heartbeat fails + var now = DateTimeOffset.UtcNow; + var failing = DueConnection(now); + var succeeding = DueConnection(now); + var attempts = 0; + + // When the heartbeat pass runs + await ConnectionPoolManager.HeartbeatDueConnectionsAsync( + new[] { failing, succeeding }, + (_, _, _) => + { + attempts++; + return attempts == 1 + ? Task.FromException(new InvalidOperationException("transient")) + : Task.CompletedTask; + }, + now, + CancellationToken.None); + + // Then the failure is swallowed, the second connection is still attempted, and only the + // successful one records a heartbeat + Assert.Equal(2, attempts); + Assert.Equal(0, failing.RecordHeartbeatCount); + Assert.Equal(1, succeeding.RecordHeartbeatCount); + } + + [Fact] + public async Task AcquireConnection_WhenPoolExhausted_TimesOutWithAdbcException() + { + var authService = Substitute.For(); + authService.AuthenticateAsync(Arg.Any(), Arg.Any()) + .Returns(new AuthenticationToken + { + SessionToken = "session", + MasterToken = "master", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + MasterExpiresAt = DateTimeOffset.UtcNow.AddHours(4), + }); + + using var pool = new ConnectionPoolManager(authService); + var config = new ConnectionConfig + { + Account = "test", + User = "user", + PoolConfig = new ConnectionPoolConfig { MaxPoolSize = 1, AcquireTimeout = TimeSpan.FromMilliseconds(100) }, + }; + + // The first acquire takes the pool's only slot and is deliberately not released. + _ = await pool.AcquireConnectionAsync(config); + + // The second acquire finds the pool at capacity and times out instead of hanging forever. + var ex = await Assert.ThrowsAsync(() => pool.AcquireConnectionAsync(config)); + Assert.Contains("capacity", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task AcquireConnection_WaiterIsWokenByReleaseToIdle() + { + // Pool of 1: a second acquire must block, then complete promptly (not after AcquireTimeout) + // when the first connection is released to idle — and it must reuse that connection. + var authService = SubstituteAuthService(); + using var pool = new ConnectionPoolManager(authService); + var config = new ConnectionConfig + { + Account = "test", + User = "user", + PoolConfig = new ConnectionPoolConfig { MaxPoolSize = 1, AcquireTimeout = TimeSpan.FromSeconds(30) }, + }; + + var first = await pool.AcquireConnectionAsync(config); + Task waiter = pool.AcquireConnectionAsync(config); + await Task.Delay(200); + Assert.False(waiter.IsCompleted, "waiter should be blocked while the pool is at capacity"); + + pool.ReleaseConnection(first); + + // Well under the 30s AcquireTimeout, so completion proves the release woke the waiter. + var second = await waiter.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(first.ConnectionId, second.ConnectionId); + } + + [Fact] + public async Task AcquireConnection_WaiterIsWokenByReleaseOfFaultedConnection() + { + // A discarded (faulted) connection must also free capacity for a blocked waiter, which + // then creates a fresh connection. + var authService = SubstituteAuthService(); + using var pool = new ConnectionPoolManager(authService); + var config = new ConnectionConfig + { + Account = "test", + User = "user", + PoolConfig = new ConnectionPoolConfig { MaxPoolSize = 1, AcquireTimeout = TimeSpan.FromSeconds(30) }, + }; + + var first = await pool.AcquireConnectionAsync(config); + Task waiter = pool.AcquireConnectionAsync(config); + await Task.Delay(200); + Assert.False(waiter.IsCompleted, "waiter should be blocked while the pool is at capacity"); + + first.IsFaulted = true; + pool.ReleaseConnection(first); + + var second = await waiter.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(first.IsDisposed); + Assert.NotEqual(first.ConnectionId, second.ConnectionId); + } + + [Fact] + public async Task ReleaseConnection_Twice_ReturnsOnlyOnePermit() + { + // Double release must be a no-op (the connection is no longer active), not a second permit: + // with a pool of 1, two subsequent held acquires would otherwise both succeed. + var authService = SubstituteAuthService(); + using var pool = new ConnectionPoolManager(authService); + var config = new ConnectionConfig + { + Account = "test", + User = "user", + PoolConfig = new ConnectionPoolConfig { MaxPoolSize = 1, AcquireTimeout = TimeSpan.FromMilliseconds(200) }, + }; + + var connection = await pool.AcquireConnectionAsync(config); + pool.ReleaseConnection(connection); + pool.ReleaseConnection(connection); + + // One permit available: the first acquire succeeds and holds it; the second must time out. + _ = await pool.AcquireConnectionAsync(config); + await Assert.ThrowsAsync(() => pool.AcquireConnectionAsync(config)); + } + + private static IAuthenticationService SubstituteAuthService() + { + var authService = Substitute.For(); + authService.AuthenticateAsync(Arg.Any(), Arg.Any()) + .Returns(_ => new AuthenticationToken + { + SessionToken = "session", + MasterToken = "master", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + MasterExpiresAt = DateTimeOffset.UtcNow.AddHours(4), + }); + return authService; + } + + [Fact] + public async Task ReleaseConnection_WhenFaulted_DiscardsInsteadOfPooling() + { + var authService = Substitute.For(); + authService.AuthenticateAsync(Arg.Any(), Arg.Any()) + .Returns(_ => new AuthenticationToken + { + SessionToken = "session", + MasterToken = "master", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + MasterExpiresAt = DateTimeOffset.UtcNow.AddHours(4), + }); + + using var pool = new ConnectionPoolManager(authService); + var config = new ConnectionConfig { Account = "test", User = "user" }; + + // A connection marked faulted (e.g. the executor hit a transport failure mid-query) must be + // disposed on release, and the next acquire must get a fresh connection, not the faulted one. + var faulted = await pool.AcquireConnectionAsync(config); + faulted.IsFaulted = true; + pool.ReleaseConnection(faulted); + + var next = await pool.AcquireConnectionAsync(config); + + Assert.True(faulted.IsDisposed); + Assert.NotEqual(faulted.ConnectionId, next.ConnectionId); + } + + // ---- GeneratePoolKey ---- + + private static ConnectionConfig BasicConfig() => new() + { + Account = "acct", + User = "user", + Database = "db", + Schema = "sch", + Warehouse = "wh", + Role = "role", + Authentication = new AuthenticationConfig { Type = AuthenticationType.UsernamePassword, Password = "secret" }, + }; + + [Fact] + public void GeneratePoolKey_IdenticalConfigs_ProduceTheSameKey() + { + Assert.Equal( + ConnectionPoolManager.GeneratePoolKey(BasicConfig()), + ConnectionPoolManager.GeneratePoolKey(BasicConfig())); + } + + [Fact] + public void GeneratePoolKey_DifferentPassword_ProducesDifferentKeys() + { + // Same user, different password → must not share a session + var a = BasicConfig(); + var b = BasicConfig(); + b.Authentication = new AuthenticationConfig { Type = AuthenticationType.UsernamePassword, Password = "other" }; + + Assert.NotEqual(ConnectionPoolManager.GeneratePoolKey(a), ConnectionPoolManager.GeneratePoolKey(b)); + } + + [Fact] + public void GeneratePoolKey_DifferentOAuthTokenWithEmptyUser_ProducesDifferentKeys() + { + // OAuth derives the user from the token, so User is blank; keying on User alone would let two + // different tokens share one pooled session. The credential fingerprint must separate them. + var a = BasicConfig(); + a.User = string.Empty; + a.Authentication = new AuthenticationConfig { Type = AuthenticationType.OAuth, Token = "token-A" }; + var b = BasicConfig(); + b.User = string.Empty; + b.Authentication = new AuthenticationConfig { Type = AuthenticationType.OAuth, Token = "token-B" }; + + Assert.NotEqual(ConnectionPoolManager.GeneratePoolKey(a), ConnectionPoolManager.GeneratePoolKey(b)); + } + + [Fact] + public void GeneratePoolKey_DifferentHost_ProducesDifferentKeys() + { + var a = BasicConfig(); + var b = BasicConfig(); + b.Network = new NetworkConfig { Host = "other.snowflakecomputing.com" }; + + Assert.NotEqual(ConnectionPoolManager.GeneratePoolKey(a), ConnectionPoolManager.GeneratePoolKey(b)); + } + + [Fact] + public void GeneratePoolKey_KeepAliveDifferenceOnly_ProducesTheSameKey() + { + // Keep-alive is client-side only (not sent at login), so it must not fragment the pool + var a = BasicConfig(); + var b = BasicConfig(); + b.ClientSessionKeepAlive = true; + b.HeartbeatFrequency = TimeSpan.FromMinutes(15); + + Assert.Equal(ConnectionPoolManager.GeneratePoolKey(a), ConnectionPoolManager.GeneratePoolKey(b)); + } + + [Fact] + public void GeneratePoolKey_DoesNotContainTheRawSecret() + { + // The password is hashed into the key, never embedded verbatim + var config = BasicConfig(); + config.Authentication = new AuthenticationConfig { Type = AuthenticationType.UsernamePassword, Password = "hunter2" }; + + Assert.DoesNotContain("hunter2", ConnectionPoolManager.GeneratePoolKey(config)); + } +} diff --git a/csharp/test/Native/ConnectionPool/PooledConnectionTests.cs b/csharp/test/Native/ConnectionPool/PooledConnectionTests.cs new file mode 100644 index 0000000..6100073 --- /dev/null +++ b/csharp/test/Native/ConnectionPool/PooledConnectionTests.cs @@ -0,0 +1,59 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.ConnectionPool; +using Microsoft.Extensions.Time.Testing; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests.ConnectionPool; + +[Trait("Category", "Unit")] +public class PooledConnectionTests +{ + [Fact] + public void IsTokenExpired_IsKeyedOnMasterExpiry_AgainstTheInjectedClock() + { + // Given a connection whose session token expires in 1h but the master (recoverability + // ceiling) expires in 4h — eviction should track the master, since a session-expired + // connection is still usable via renewal until the master lapses. + var fakeTime = new FakeTimeProvider(); + var token = new AuthenticationToken + { + SessionToken = "session", + ExpiresAt = fakeTime.GetUtcNow().AddHours(1), + MasterExpiresAt = fakeTime.GetUtcNow().AddHours(4), + }; + var connection = new PooledConnection( + "id", + "pool-key", + token, + new ConnectionConfig { Account = "test" }, + sessionLifecycle: null, + timeProvider: fakeTime); + + // Then it stays valid past the session expiry, and only flips once the master lapses + Assert.False(connection.IsTokenExpired); + + fakeTime.Advance(TimeSpan.FromHours(2)); // session (1h) is long gone, master (4h) still alive + Assert.False(connection.IsTokenExpired); + + fakeTime.Advance(TimeSpan.FromHours(2) + TimeSpan.FromMinutes(1)); // now past the 4h master + Assert.True(connection.IsTokenExpired); + } +} diff --git a/csharp/test/Native/Integration/BenchmarkTests.cs b/csharp/test/Native/Integration/BenchmarkTests.cs new file mode 100644 index 0000000..66ba70f --- /dev/null +++ b/csharp/test/Native/Integration/BenchmarkTests.cs @@ -0,0 +1,73 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace AdbcDrivers.Snowflake.Native.Tests.Integration; + +/// +/// Query throughput benchmark for the native C# Snowflake driver. Mirrors the Interop +/// BenchmarkTests (which exercises the Go driver) so the two can be compared +/// apples-to-apples: same configured query, same row limits, same end-to-end measurement +/// (execute + stream all batches). The only intentional differences are the driver under +/// test and the [NATIVE]/[INTEROP] log label. +/// +/// Requires a live Snowflake instance; set SNOWFLAKE_TEST_CONFIG_FILE with a query +/// against a table large enough to satisfy the largest limit. +/// +[Trait("Category", "Integration")] +public class BenchmarkTests +{ + private readonly ITestOutputHelper _output; + private readonly IntegrationTestConfiguration _testConfig; + + public BenchmarkTests(ITestOutputHelper output) + { + _output = output; + _testConfig = IntegrationTestingUtils.TestConfiguration; + } + + [SkippableTheory] + [InlineData(100)] + [InlineData(1000)] + [InlineData(1000000)] + public async Task BaselineQueryPerformance(int limit) + { + Skip.If(string.IsNullOrEmpty(_testConfig.Account), "Account not configured"); + Skip.If(string.IsNullOrWhiteSpace(_testConfig.Query), "No query configured"); + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfig, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"{_testConfig.Query} LIMIT {limit}"; + var stopwatch = Stopwatch.StartNew(); + var result = await statement.ExecuteQueryAsync(); + using var stream = result.Stream!; + long totalRows = 0; + int batchCount = 0; + while (await stream.ReadNextRecordBatchAsync() is { } batch) + { + using (batch) { batchCount++; totalRows += batch.Length; } + } + stopwatch.Stop(); + _output.WriteLine($"[NATIVE] Fetched {totalRows} rows in {batchCount} batches with {stream.Schema.FieldsList.Count} columns for limit {limit} in {stopwatch.Elapsed.TotalMilliseconds:F0} ms."); + Assert.Equal(limit, totalRows); + } +} diff --git a/csharp/test/Native/Integration/ClientTests.cs b/csharp/test/Native/Integration/ClientTests.cs new file mode 100644 index 0000000..9fa6393 --- /dev/null +++ b/csharp/test/Native/Integration/ClientTests.cs @@ -0,0 +1,258 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Data.Common; +using AdbcDrivers.Snowflake.Native; +using Xunit; +using Xunit.Abstractions; + +using AdbcClient = Apache.Arrow.Adbc.Client; + +namespace AdbcDrivers.Snowflake.Native.Tests.Integration; + +/// +/// Tests the ADO.NET client layer (Apache.Arrow.Adbc.Client) over the native +/// Snowflake driver — i.e. using the driver as a standard System.Data.Common provider: +/// a DbConnection / DbCommand / DbDataReader that returns CLR values. +/// +/// This is the counterpart to the Arrow-native ADBC API suites (, +/// , , which use +/// AdbcConnection/AdbcStatement returning Arrow RecordBatches); these verify +/// the parts that only the client layer runs: +/// +/// connection-string parsing into driver parameters, +/// the DbDataReader row/column iteration model (Read, ordinals, FieldCount), +/// conversion of Arrow arrays into boxed CLR values across column types. +/// +/// +/// Each test is self-contained: it creates and populates a session-scoped TEMPORARY +/// table (auto-dropped at session end), so the suite needs no sample data, seeding, or test +/// ordering. It does require a writable database/schema (config metadata.catalog +/// / metadata.schema); without one, the tests Skip. Requires a live account; set +/// SNOWFLAKE_TEST_CONFIG_FILE. +/// +/// If you do not already have a writable schema, create one once and point the config at it: +/// +/// CREATE DATABASE IF NOT EXISTS ADBC_TEST; -- a PUBLIC schema is created automatically +/// +/// then in the test config: "metadata": { "catalog": "ADBC_TEST", "schema": "PUBLIC" }. +/// The tests create only temporary tables there — nothing is seeded or left behind. +/// +[Trait("Category", "Integration")] +public class ClientTests +{ + private readonly ITestOutputHelper _output; + private readonly IntegrationTestConfiguration _testConfiguration; + + public ClientTests(ITestOutputHelper output) + { + _output = output; + _testConfiguration = IntegrationTestingUtils.TestConfiguration; + + Skip.If(string.IsNullOrEmpty(_testConfiguration.Account), + $"Cannot execute test configuration from environment variable `{IntegrationTestingUtils.SnowflakeTestConfigVariable}`"); + Skip.If(string.IsNullOrWhiteSpace(_testConfiguration.Metadata.Catalog) + || string.IsNullOrWhiteSpace(_testConfiguration.Metadata.Schema), + "ClientTests require a writable database/schema (metadata.catalog / metadata.schema)."); + } + + /// + /// The reader walks every row of a multi-row result via Read() and returns them in the + /// queried ORDER BY sequence. (CLR string conversion itself is covered by the type-contract + /// theory; this test is about row iteration and ordering.) + /// + [SkippableFact] + public void Reader_IteratesAllRowsInOrder() + { + // Given a two-row table queried with an explicit order + using var connection = OpenClientConnection(); + string table = CreateAndPopulateTempTable(connection); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT NAME FROM {table} ORDER BY ID"; + + // When every row is read in sequence + using var reader = command.ExecuteReader(); + var names = new List(); + while (reader.Read()) + names.Add(reader.GetString(0)); + + // Then they come back in the queried order + Assert.Equal(new[] { "alpha", "beta" }, names); + } + + /// + /// The reader exposes the result's column count and names (FieldCount / GetName + /// by ordinal). + /// + [SkippableFact] + public void Reader_ExposesColumnMetadata() + { + // Given a five-column result + using var connection = OpenClientConnection(); + string table = CreateAndPopulateTempTable(connection); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT ID, NAME, ACTIVE, AMOUNT, PRICE FROM {table}"; + + // When the reader is opened + using var reader = command.ExecuteReader(); + + // Then it exposes the column count and names + Assert.Equal(5, reader.FieldCount); + Assert.Equal("ID", reader.GetName(0)); + Assert.Equal("NAME", reader.GetName(1)); + Assert.Equal("ACTIVE", reader.GetName(2)); + Assert.Equal("AMOUNT", reader.GetName(3)); + Assert.Equal("PRICE", reader.GetName(4)); + } + + [SkippableFact] + public void Reader_CountStar_ReturnsRowCount() + { + // Given a two-row table (AdbcCommand.ExecuteScalar throws NotImplementedException in the + // client layer, so the single aggregate value is read via the reader instead) + using var connection = OpenClientConnection(); + string table = CreateAndPopulateTempTable(connection); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM {table}"; + + // When the COUNT(*) is read + using var reader = command.ExecuteReader(); + + // Then it returns the row count + Assert.True(reader.Read()); + Assert.Equal(2, Convert.ToInt32(reader.GetValue(0))); + } + + [SkippableFact] + public void ConnectionString_OpensAndQueries() + { + // Given driver parameters round-tripped through a connection string (rather than passed + // directly) — this is what exercises the client's connection-string parsing path + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + var builder = new DbConnectionStringBuilder(useOdbcRules: true); + foreach (var kvp in parameters) + builder[kvp.Key] = kvp.Value; + + // When a connection is opened from that string and queried + using var connection = new AdbcClient.AdbcConnection(builder.ConnectionString) { AdbcDriver = driver }; + connection.Open(); + string table = CreateAndPopulateTempTable(connection); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT COUNT(*) FROM {table}"; + using var reader = command.ExecuteReader(); + + // Then it connects and returns results + Assert.True(reader.Read()); + Assert.Equal(2, Convert.ToInt32(reader.GetValue(0))); + } + + /// + /// ExecuteNonQuery runs DML and returns the affected-row count (2 inserted rows here). + /// + [SkippableFact] + public void ExecuteNonQuery_RunsDml() + { + // Given an empty temporary table + using var connection = OpenClientConnection(); + string table = $"{_testConfiguration.Metadata.Catalog}.{_testConfiguration.Metadata.Schema}.CLIENT_DML_{Guid.NewGuid():N}"; + using (var create = connection.CreateCommand()) + { + create.CommandText = $"CREATE TEMPORARY TABLE {table} (id INT)"; + create.ExecuteNonQuery(); + } + + // When two rows are inserted via ExecuteNonQuery + using var insert = connection.CreateCommand(); + insert.CommandText = $"INSERT INTO {table} (id) VALUES (1), (2)"; + int affected = insert.ExecuteNonQuery(); + + // Then it reports the affected-row count + _output.WriteLine($"ExecuteNonQuery reported {affected} affected rows"); + Assert.Equal(2, affected); + } + + /// + /// The end-to-end CLR type contract: the a consumer gets from the reader + /// for each Snowflake type. The integer cases are the payoff of the driver's precision-driven + /// Arrow sizing (scale-0 NUMBER ≤ 9 → int, ≤ 18 → long, else Decimal128); the rest is the + /// upstream client's Arrow → CLR mapping, pinned here only as the seam. A NUMBER that can + /// exceed long surfaces as (the default + /// DecimalBehavior): it holds the full NUMBER(38) range but is not IConvertible, so read + /// it via ((SqlDecimal)value).Value rather than Convert.*. Value correctness of + /// the decode is owned by ; the client's own conversion matrix + /// (and the DecimalBehavior toggle) is covered upstream. + /// + [SkippableTheory] + [InlineData("123::NUMBER(9,0)", typeof(int))] + [InlineData("123::NUMBER(18,0)", typeof(long))] + [InlineData("123::NUMBER(38,0)", typeof(System.Data.SqlTypes.SqlDecimal))] + [InlineData("9.99::NUMBER(10,2)", typeof(System.Data.SqlTypes.SqlDecimal))] + [InlineData("'x'::VARCHAR", typeof(string))] + [InlineData("TRUE::BOOLEAN", typeof(bool))] + [InlineData("1.5::FLOAT", typeof(double))] + public void Reader_YieldsExpectedClrTypePerSnowflakeType(string sqlLiteral, Type expectedClrType) + { + // Given a single typed literal + using var connection = OpenClientConnection(); + using var command = connection.CreateCommand(); + command.CommandText = $"SELECT {sqlLiteral} AS V"; + + // When it is read through the client + using var reader = command.ExecuteReader(); + Assert.True(reader.Read()); + + // Then the CLR value has the expected type + Assert.Equal(expectedClrType, reader.GetValue(0).GetType()); + } + + private AdbcClient.AdbcConnection OpenClientConnection() + { + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + var connection = new AdbcClient.AdbcConnection(driver, parameters, new Dictionary()); + connection.Open(); + return connection; + } + + /// + /// Creates a session-scoped temporary table with a row of each common column type and two + /// rows of data, returning the fully-qualified table name. Self-contained: the temporary + /// table is visible only on this connection's session and is dropped when it closes. + /// + private string CreateAndPopulateTempTable(AdbcClient.AdbcConnection connection) + { + string table = $"{_testConfiguration.Metadata.Catalog}.{_testConfiguration.Metadata.Schema}.CLIENT_T_{Guid.NewGuid():N}"; + + using (var create = connection.CreateCommand()) + { + create.CommandText = + $"CREATE TEMPORARY TABLE {table} " + + "(ID NUMBER(38,0), NAME VARCHAR, ACTIVE BOOLEAN, AMOUNT FLOAT, PRICE NUMBER(10,2))"; + create.ExecuteNonQuery(); + } + + using (var insert = connection.CreateCommand()) + { + insert.CommandText = + $"INSERT INTO {table} (ID, NAME, ACTIVE, AMOUNT, PRICE) VALUES " + + "(1, 'alpha', TRUE, 3.5, 9.99), (2, 'beta', FALSE, 7.25, 0.01)"; + insert.ExecuteNonQuery(); + } + + return table; + } +} diff --git a/csharp/test/Native/Integration/ConnectionTests.cs b/csharp/test/Native/Integration/ConnectionTests.cs new file mode 100644 index 0000000..e4b458f --- /dev/null +++ b/csharp/test/Native/Integration/ConnectionTests.cs @@ -0,0 +1,144 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Generic; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests.Integration; + +/// +/// Live connectivity / lifecycle smoke for the native Snowflake driver: that the +/// Driver → Database → Connection open path actually works against a real account. +/// This is the fast first-line diagnostic that isolates "cannot connect" from "a query failed" — +/// every other integration suite relies on this path as setup but does not assert it directly. +/// +/// Deeper behaviour lives in the concern-specific suites: statement execution / update / bind in +/// ; query results and the metadata methods (GetObjects / +/// GetTableSchema / GetTableTypes / GetInfo) in ; over-the-wire +/// type decoding in ; and the ADO.NET client layer in +/// . Offline parameter validation is in SnowflakeDriverTests. +/// +/// Requires a live Snowflake instance; set SNOWFLAKE_TEST_CONFIG_FILE. +/// +[Trait("Category", "Integration")] +public class ConnectionTests +{ + private readonly IntegrationTestConfiguration _testConfiguration; + + public ConnectionTests() + { + _testConfiguration = IntegrationTestingUtils.TestConfiguration; + + Skip.If(string.IsNullOrEmpty(_testConfiguration.Account), + $"Cannot execute test configuration from environment variable `{IntegrationTestingUtils.SnowflakeTestConfigVariable}`"); + } + + [SkippableFact] + public void DisposingDatabase_ClosesSessionWithoutError() + { + // Given a connected database (a live server-side session) + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + var database = driver.Open(parameters); + var connection = database.Connect(new Dictionary()); + connection.Dispose(); // returns the pooled connection to idle — session kept for reuse + + // When the database is disposed, the pool discards its pooled connections and best-effort + // closes their server-side sessions (POST /session?delete=true). + // Then it completes without throwing. + var exception = Record.Exception(database.Dispose); + Assert.Null(exception); + } + + [SkippableFact] + public void OpenAndConnect_Succeeds() + { + // Given driver parameters + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + + // When a connection is opened + using var connection = database.Connect(new Dictionary()); + + // Then it succeeds + Assert.NotNull(connection); + } + + [SkippableFact] + public void OpenAndConnect_WithKeyPair_Succeeds() + { + // Requires an auth_jwt block in the test configuration (a user with an RSA public key + // registered via ALTER USER ... SET RSA_PUBLIC_KEY and its private key on disk/inline). + JwtAuthentication? jwt = _testConfiguration.Authentication.SnowflakeJwt; + Skip.If(jwt is null, "No auth_jwt block in the test configuration"); + + // Given a configuration carrying ONLY key-pair credentials, so the parameter mapping + // cannot fall back to password auth. The top-level role is deliberately NOT copied: + // it belongs to the password test user; the service user runs under its own + // DEFAULT_ROLE. (Database/schema are omitted for the same reason — this test only + // needs SELECT 1.) + var keyPairOnly = new IntegrationTestConfiguration + { + Account = _testConfiguration.Account, + Warehouse = _testConfiguration.Warehouse, + TlsSkipVerify = _testConfiguration.TlsSkipVerify, + Authentication = new SnowflakeAuthentication { SnowflakeJwt = jwt }, + }; + + // When a connection is opened and a query is run over the JWT-authenticated session + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(keyPairOnly, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + statement.SqlQuery = "SELECT 1"; + var result = statement.ExecuteQuery(); + + // Then the login and the query both succeed + Assert.NotNull(result.Stream); + result.Stream.Dispose(); + } + + [SkippableFact] + public void OpenAndConnect_WithPat_Succeeds() + { + // Requires an auth_pat block in the test configuration: a programmatic access token + // for a user that is subject to a network policy (Snowflake rejects PAT logins + // otherwise). + PatAuthentication? pat = _testConfiguration.Authentication.Pat; + Skip.If(pat is null, "No auth_pat block in the test configuration"); + + // Given a configuration carrying ONLY the PAT credentials — no role/database/schema, + // so the service user's own defaults apply (as with the key-pair test). + var patOnly = new IntegrationTestConfiguration + { + Account = _testConfiguration.Account, + Warehouse = _testConfiguration.Warehouse, + TlsSkipVerify = _testConfiguration.TlsSkipVerify, + Authentication = new SnowflakeAuthentication { Pat = pat }, + }; + + // When a connection is opened and a query is run over the PAT-authenticated session + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(patOnly, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + statement.SqlQuery = "SELECT 1"; + var result = statement.ExecuteQuery(); + + // Then the login and the query both succeed + Assert.NotNull(result.Stream); + result.Stream.Dispose(); + } +} diff --git a/csharp/test/Native/Integration/IntegrationTestConfiguration.cs b/csharp/test/Native/Integration/IntegrationTestConfiguration.cs new file mode 100644 index 0000000..43df060 --- /dev/null +++ b/csharp/test/Native/Integration/IntegrationTestConfiguration.cs @@ -0,0 +1,164 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Text.Json.Serialization; + +using Apache.Arrow; +using Apache.Arrow.Adbc; +using Apache.Arrow.Adbc.Tests; + +namespace AdbcDrivers.Snowflake.Native.Tests.Integration; + +/// +/// Configuration settings for working with native Snowflake driver. +/// Uses the same JSON format as Interop tests for compatibility. +/// +internal class IntegrationTestConfiguration : TestConfiguration +{ + /// + /// The Snowflake account. + /// + [JsonPropertyName("account")] + public string Account { get; set; } = string.Empty; + + /// + /// The Snowflake host (optional, derived from account if not provided). + /// + [JsonPropertyName("host")] + public string Host { get; set; } = string.Empty; + + /// + /// The Snowflake database. + /// + [JsonPropertyName("database")] + public string Database { get; set; } = string.Empty; + + /// + /// The Snowflake schema. + /// + [JsonPropertyName("schema")] + public string Schema { get; set; } = string.Empty; + + /// + /// The Snowflake user. + /// + [JsonPropertyName("user")] + public string User { get; set; } = string.Empty; + + /// + /// The Snowflake password (if using). + /// + [JsonPropertyName("password")] + public string Password { get; set; } = string.Empty; + + /// + /// The Snowflake warehouse. + /// + [JsonPropertyName("warehouse")] + public string Warehouse { get; set; } = string.Empty; + + /// + /// The Snowflake role. + /// + [JsonPropertyName("role")] + public string Role { get; set; } = string.Empty; + + /// + /// The snowflake Authentication + /// + [JsonPropertyName("authentication")] + public SnowflakeAuthentication Authentication { get; set; } = new(); + + /// + /// Whether to skip TLS certificate verification (e.g., for privatelink endpoints). + /// + [JsonPropertyName("tls_skip_verify")] + public bool TlsSkipVerify { get; set; } = false; + + +} + +public class SnowflakeAuthentication +{ + const string AuthOAuth = "auth_oauth"; + const string AuthJwt = "auth_jwt"; + const string AuthPat = "auth_pat"; + const string AuthSnowflake = "auth_snowflake"; + const string AuthExternalBrowser = "auth_externalbrowser"; + + [JsonPropertyName(AuthOAuth)] + public OAuthAuthentication? OAuth { get; set; } + + [JsonPropertyName(AuthJwt)] + public JwtAuthentication? SnowflakeJwt { get; set; } + + [JsonPropertyName(AuthPat)] + public PatAuthentication? Pat { get; set; } + + [JsonPropertyName(AuthSnowflake)] + public DefaultAuthentication? Default { get; set; } + + [JsonPropertyName(AuthExternalBrowser)] + public ExternalBrowserAuthentication? ExternalBrowser { get; set; } +} + +public class PatAuthentication +{ + [JsonPropertyName("user")] + public string User { get; set; } = string.Empty; + + [JsonPropertyName("token")] + public string Token { get; set; } = string.Empty; +} + +public class OAuthAuthentication +{ + [JsonPropertyName("token")] + public string Token { get; set; } = string.Empty; + + [JsonPropertyName("user")] + public string User { get; set; } = string.Empty; +} + +public class JwtAuthentication +{ + [JsonPropertyName("private_key")] + public string PrivateKey { get; set; } = string.Empty; + + [JsonPropertyName("private_key_file")] + public string PrivateKeyFile { get; set; } = string.Empty; + + [JsonPropertyName("private_key_pwd")] + public string PrivateKeyPassPhrase { get; set; } = string.Empty; + + [JsonPropertyName("user")] + public string User { get; set; } = string.Empty; +} + +public class DefaultAuthentication +{ + [JsonPropertyName("user")] + public string User { get; set; } = string.Empty; + + [JsonPropertyName("password")] + public string Password { get; set; } = string.Empty; +} + +public class ExternalBrowserAuthentication +{ + [JsonPropertyName("user")] + public string User { get; set; } = string.Empty; +} diff --git a/csharp/test/Native/Integration/IntegrationTestingUtils.cs b/csharp/test/Native/Integration/IntegrationTestingUtils.cs new file mode 100644 index 0000000..2eeb37a --- /dev/null +++ b/csharp/test/Native/Integration/IntegrationTestingUtils.cs @@ -0,0 +1,166 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using Apache.Arrow.Adbc; +using Apache.Arrow.Adbc.Tests; + +namespace AdbcDrivers.Snowflake.Native.Tests.Integration; + +internal static class IntegrationTestingUtils +{ + internal static readonly IntegrationTestConfiguration TestConfiguration; + + internal const string SnowflakeTestConfigVariable = "SNOWFLAKE_TEST_CONFIG_FILE"; + + internal const string RunSlowTestsVariable = "SNOWFLAKE_RUN_SLOW_TESTS"; + + /// + /// True when deliberately slow tests (multi-minute wall-clock waits, e.g. the long-running + /// query polling test) should run. Off by default so the ordinary suite stays fast; enable + /// with SNOWFLAKE_RUN_SLOW_TESTS=1 (or true). + /// + internal static bool RunSlowTests + { + get + { + string? value = Environment.GetEnvironmentVariable(RunSlowTestsVariable); + return string.Equals(value, "1", StringComparison.Ordinal) + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + } + } + + static IntegrationTestingUtils() + { + TestConfiguration = new IntegrationTestConfiguration(); + if (!string.IsNullOrEmpty(TestConfiguration.Account)) return; + try + { + TestConfiguration = Utils.LoadTestConfiguration(SnowflakeTestConfigVariable); + } + catch (InvalidOperationException ex) + { + Console.WriteLine($"Cannot load test configuration from environment variable `{SnowflakeTestConfigVariable}`"); + Console.WriteLine(ex.Message); + TestConfiguration = new IntegrationTestConfiguration(); + } + } + + /// + /// Gets the native Snowflake ADBC driver with settings from the + /// . + /// + /// + /// + /// + internal static AdbcDriver GetSnowflakeAdbcDriver( + IntegrationTestConfiguration testConfiguration, + out Dictionary parameters) + { + parameters = new Dictionary + { + { "adbc.snowflake.sql.account", Parameter(testConfiguration.Account, "account") } + }; + + // Add username if provided (not required for OAuth) + if (!string.IsNullOrWhiteSpace(testConfiguration.User)) + { + parameters["username"] = testConfiguration.User; + } + + // Add authentication + if (testConfiguration.Authentication.Default is not null) + { + parameters["username"] = Parameter(testConfiguration.Authentication.Default.User, "user"); + parameters["password"] = Parameter(testConfiguration.Authentication.Default.Password, "password"); + } + else if (testConfiguration.Authentication.SnowflakeJwt is not null) + { + parameters["username"] = Parameter(testConfiguration.Authentication.SnowflakeJwt.User, "user"); + parameters["adbc.snowflake.sql.auth_type"] = "auth_jwt"; + + if (!string.IsNullOrWhiteSpace(testConfiguration.Authentication.SnowflakeJwt.PrivateKeyFile)) + { + parameters["adbc.snowflake.sql.client_option.jwt_private_key"] = testConfiguration.Authentication.SnowflakeJwt.PrivateKeyFile; + } + else if (!string.IsNullOrWhiteSpace(testConfiguration.Authentication.SnowflakeJwt.PrivateKey)) + { + parameters["adbc.snowflake.sql.client_option.jwt_private_key_pkcs8_value"] = testConfiguration.Authentication.SnowflakeJwt.PrivateKey; + } + + if (!string.IsNullOrWhiteSpace(testConfiguration.Authentication.SnowflakeJwt.PrivateKeyPassPhrase)) + { + parameters["adbc.snowflake.sql.client_option.jwt_private_key_pkcs8_password"] = testConfiguration.Authentication.SnowflakeJwt.PrivateKeyPassPhrase; + } + } + else if (testConfiguration.Authentication.Pat is not null) + { + parameters["username"] = Parameter(testConfiguration.Authentication.Pat.User, "user"); + parameters["adbc.snowflake.sql.auth_type"] = "auth_pat"; + parameters["adbc.snowflake.sql.client_option.auth_token"] = Parameter(testConfiguration.Authentication.Pat.Token, "pat_token"); + } + else if (testConfiguration.Authentication.OAuth is not null) + { + parameters["username"] = Parameter(testConfiguration.Authentication.OAuth.User, "user"); + parameters["adbc.snowflake.sql.auth_type"] = "auth_oauth"; + parameters["adbc.snowflake.sql.client_option.auth_token"] = Parameter(testConfiguration.Authentication.OAuth.Token, "oauth_token"); + } + else if (testConfiguration.Authentication.ExternalBrowser is not null) + { + parameters["username"] = Parameter(testConfiguration.Authentication.ExternalBrowser.User, "user"); + parameters["adbc.snowflake.sql.auth_type"] = "auth_ext_browser"; + } + else + { + // Fallback to top-level user/password + parameters["password"] = Parameter(testConfiguration.Password, "password"); + } + + // Add optional parameters + if (!string.IsNullOrWhiteSpace(testConfiguration.Database)) + { + parameters["adbc.snowflake.sql.db"] = testConfiguration.Database; + } + + if (!string.IsNullOrWhiteSpace(testConfiguration.Schema)) + { + parameters["adbc.snowflake.sql.schema"] = testConfiguration.Schema; + } + + if (!string.IsNullOrWhiteSpace(testConfiguration.Warehouse)) + { + parameters["adbc.snowflake.sql.warehouse"] = testConfiguration.Warehouse; + } + + if (!string.IsNullOrWhiteSpace(testConfiguration.Role)) + { + parameters["adbc.snowflake.sql.role"] = testConfiguration.Role; + } + + if (testConfiguration.TlsSkipVerify) + { + parameters["adbc.snowflake.sql.client_option.tls_skip_verify"] = "true"; + } + + return new SnowflakeDriver(); + } + + private static string Parameter(string? value, string parameterName) + { + return value ?? throw new ArgumentNullException(parameterName); + } +} diff --git a/csharp/test/Native/Integration/QueryAndMetadataTests.cs b/csharp/test/Native/Integration/QueryAndMetadataTests.cs new file mode 100644 index 0000000..cb3c2e6 --- /dev/null +++ b/csharp/test/Native/Integration/QueryAndMetadataTests.cs @@ -0,0 +1,404 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native; +using Xunit; +using Xunit.Abstractions; + +using Apache.Arrow; +using Apache.Arrow.Adbc; +using Apache.Arrow.Types; + +namespace AdbcDrivers.Snowflake.Native.Tests.Integration; + +/// +/// End-to-end tests of the driver's query execution and metadata functionality: +/// running queries, streaming results (including multi-chunk), error propagation, and the +/// metadata methods (GetObjects / GetTableSchema / GetTableTypes / GetInfo). +/// +/// These run against the shared, read-only SNOWFLAKE_SAMPLE_DATA (TPC-H SF1) dataset. +/// That dependency is deliberate: these tests need persistent catalog/table structure (for +/// GetObjects) and a large table (to force multi-chunk streaming) — things a session-scoped +/// temporary table can't provide. The dataset's fixed cardinalities/contents also let the +/// tests assert real, stable values rather than just non-null. The sample share is mounted +/// in every account by default, so no setup is required. +/// +/// Requires a live Snowflake instance with a usable warehouse; set +/// SNOWFLAKE_TEST_CONFIG_FILE. Each test is a that no-ops +/// when no account is configured. +/// +[Trait("Category", "Integration")] +public class QueryAndMetadataTests +{ + // SNOWFLAKE_SAMPLE_DATA is shared into every account by default and is read-only, + // so these identifiers and the row counts below are stable reference points. + private const string SampleDb = "SNOWFLAKE_SAMPLE_DATA"; + private const string SampleSchema = "TPCH_SF1"; + + private readonly ITestOutputHelper _output; + private readonly IntegrationTestConfiguration _testConfiguration; + + public QueryAndMetadataTests(ITestOutputHelper output) + { + _output = output; + _testConfiguration = IntegrationTestingUtils.TestConfiguration; + + Skip.If(string.IsNullOrEmpty(_testConfiguration.Account), + $"Cannot execute test configuration from environment variable `{IntegrationTestingUtils.SnowflakeTestConfigVariable}`"); + } + + // ---- Data path: known row counts and contents ---- + + [SkippableFact] + public async Task Query_Region_ReturnsFiveKnownRows() + { + // Given a query against TPC-H REGION (which always has exactly 5 rows with these names) + using var connection = Connect(); + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"SELECT R_NAME FROM {SampleDb}.{SampleSchema}.REGION ORDER BY R_NAME"; + + // When the name column is read + var result = await statement.ExecuteQueryAsync(); + List names = await ReadStringColumnAsync(result, columnIndex: 0); + + // Then all five names come back in order + Assert.Equal(5, names.Count); + Assert.Equal( + new[] { "AFRICA", "AMERICA", "ASIA", "EUROPE", "MIDDLE EAST" }, + names); + } + + [SkippableFact] + public async Task Query_Nation_ReturnsTwentyFiveRowsWithFourColumns() + { + // Given a SELECT * over TPC-H NATION + using var connection = Connect(); + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"SELECT * FROM {SampleDb}.{SampleSchema}.NATION"; + + // When the whole result is read + var result = await statement.ExecuteQueryAsync(); + (long rows, int columnCount, _) = await ReadAllAsync(result); + + // Then it has 25 rows and 4 columns + Assert.Equal(25, rows); + Assert.Equal(4, columnCount); + } + + [SkippableFact] + public async Task Query_WithNoMatchingRows_ReturnsEmptyStreamWithSchema() + { + // A zero-row SELECT still reports Arrow format, but the server sends no row data at + // all — only rowtype metadata. The driver must surface an empty stream carrying the + // result schema rather than fail with "no result stream was returned". + using var connection = Connect(); + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"SELECT R_REGIONKEY, R_NAME FROM {SampleDb}.{SampleSchema}.REGION WHERE 1 = 0"; + + var result = await statement.ExecuteQueryAsync(); + + Assert.NotNull(result.Stream); + using var stream = result.Stream; + Assert.Equal(2, stream.Schema.FieldsList.Count); + Assert.Equal("R_REGIONKEY", stream.Schema.FieldsList[0].Name); + Assert.Equal("R_NAME", stream.Schema.FieldsList[1].Name); + + long rows = 0; + while (await stream.ReadNextRecordBatchAsync() is { } batch) + { + using (batch) + rows += batch.Length; + } + + Assert.Equal(0, rows); + } + + [SkippableFact] + public async Task Query_Customer_StreamsAllChunks() + { + // Given a query over CUSTOMER — 150,000 rows in SF1, large enough to be returned as + // multiple Arrow chunks + using var connection = Connect(); + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"SELECT C_CUSTKEY FROM {SampleDb}.{SampleSchema}.CUSTOMER"; + + // When the entire stream is drained + var result = await statement.ExecuteQueryAsync(); + (long rows, int columnCount, int batchCount) = await ReadAllAsync(result); + _output.WriteLine($"CUSTOMER streamed {rows} rows across {batchCount} batch(es)"); + + // Then the exact total arrives, proving the chunk-download + streaming path works end to + // end (not just the first inline batch) + Assert.Equal(150_000, rows); + Assert.Equal(1, columnCount); + } + + [SkippableFact] + public void Query_InvalidSql_ThrowsAdbcException() + { + // Given a query referencing a non-existent table + using var connection = Connect(); + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"SELECT * FROM {SampleDb}.{SampleSchema}.NO_SUCH_TABLE_XYZ"; + + // When / Then executing it surfaces an AdbcException + Assert.Throws(statement.ExecuteQuery); + } + + // ---- Metadata: deterministic schema and object content ---- + + [SkippableFact] + public void GetTableSchema_Nation_HasExpectedColumnsAndTypes() + { + // Given a connection + using var connection = Connect(); + + // When the schema of NATION is described + Schema schema = connection.GetTableSchema(SampleDb, SampleSchema, "NATION"); + + // Then it is deterministic and agrees with the result decoder: the describe path sizes + // NUMBER by precision (TPC-H keys are NUMBER(38,0) -> Decimal128), VARCHAR -> Utf8 string. + Assert.Equal(4, schema.FieldsList.Count); + + Assert.Equal("N_NATIONKEY", schema.FieldsList[0].Name); + Assert.IsType(schema.FieldsList[0].DataType); + + Assert.Equal("N_NAME", schema.FieldsList[1].Name); + Assert.IsType(schema.FieldsList[1].DataType); + + Assert.Equal("N_REGIONKEY", schema.FieldsList[2].Name); + Assert.IsType(schema.FieldsList[2].DataType); + + Assert.Equal("N_COMMENT", schema.FieldsList[3].Name); + Assert.IsType(schema.FieldsList[3].DataType); + } + + [SkippableFact] + public async Task GetTableTypes_ReturnsTableAndView() + { + // Given a connection + using var connection = Connect(); + + // When the table types are read + using var stream = connection.GetTableTypes(); + List types = await ReadAllStringColumnAsync(stream, columnIndex: 0); + + // Then they include TABLE and VIEW + Assert.Contains("TABLE", types); + Assert.Contains("VIEW", types); + } + + [SkippableFact] + public async Task GetInfo_ReportsSnowflakeVendorName() + { + // Given a connection + using var connection = Connect(); + + // When the vendor name is requested via GetInfo + using var stream = connection.GetInfo(new List { AdbcInfoCode.VendorName }); + RecordBatch? batch = await stream.ReadNextRecordBatchAsync(); + + // Then it reports "Snowflake" (on the string_value union branch, type id 0) + Assert.NotNull(batch); + using (batch) + { + var infoValue = (DenseUnionArray)batch.Column(1); + var stringValues = (StringArray)infoValue.Fields[0]; + Assert.Equal("Snowflake", stringValues.GetString(0)); + } + } + + [SkippableFact] + public async Task GetObjects_All_ReturnsNationColumns() + { + // Given a connection + using var connection = Connect(); + + // When GetObjects is called at full depth for NATION + using var stream = connection.GetObjects( + AdbcConnection.GetObjectsDepth.All, + SampleDb, + SampleSchema, + "NATION", + null, + null); + RecordBatch? batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + + // Then navigating catalog -> schema -> table -> columns yields NATION's four columns + using (batch) + { + // catalog_name -> [db_schemas] -> [tables] -> [columns] + var catalogNames = (StringArray)batch.Column(0); + int ci = IndexOf(catalogNames, SampleDb); + Assert.True(ci >= 0, $"{SampleDb} not found in catalog list"); + + var dbSchemasList = (ListArray)batch.Column(1); + var schemas = (StructArray)dbSchemasList.GetSlicedValues(ci); + var schemaNames = (StringArray)schemas.Fields[0]; + int si = IndexOf(schemaNames, SampleSchema); + Assert.True(si >= 0, $"{SampleSchema} not found under {SampleDb}"); + + var tablesList = (ListArray)schemas.Fields[1]; + var tables = (StructArray)tablesList.GetSlicedValues(si); + var tableNames = (StringArray)tables.Fields[0]; + var tableTypes = (StringArray)tables.Fields[1]; + int ti = IndexOf(tableNames, "NATION"); + Assert.True(ti >= 0, "NATION not found in table list"); + Assert.Equal("TABLE", tableTypes.GetString(ti)); + + var columnsList = (ListArray)tables.Fields[2]; + var columns = (StructArray)columnsList.GetSlicedValues(ti); + var columnNames = (StringArray)columns.Fields[0]; + + var actual = new List(); + for (int i = 0; i < columnNames.Length; i++) + actual.Add(columnNames.GetString(i)); + + Assert.Equal(4, actual.Count); + Assert.Contains("N_NATIONKEY", actual); + Assert.Contains("N_NAME", actual); + Assert.Contains("N_REGIONKEY", actual); + Assert.Contains("N_COMMENT", actual); + } + } + + [SkippableFact] + public async Task GetObjects_Catalogs_ContainsSampleDatabase() + { + // Given a connection + using var connection = Connect(); + + // When GetObjects is called at catalog depth filtered to the sample database + using var stream = connection.GetObjects( + AdbcConnection.GetObjectsDepth.Catalogs, + SampleDb, + null, + null, + null, + null); + RecordBatch? batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + + // Then the catalog list contains it + using (batch) + { + var catalogNames = (StringArray)batch.Column(0); + Assert.True(IndexOf(catalogNames, SampleDb) >= 0); + } + } + + [SkippableFact] + public async Task GetObjects_MaliciousCatalogPattern_IsTreatedAsLiteralNotSql() + { + // Given a catalog pattern carrying a SQL-injection payload — if it were interpolated into + // SQL, the trailing "OR DATABASE_NAME='...'" would break out of the ILIKE literal and the + // real sample DB would come back + using var connection = Connect(); + string payload = $"x' OR DATABASE_NAME='{SampleDb}"; + + // When GetObjects is called with that pattern (server-side bound, not interpolated) + using var stream = connection.GetObjects( + AdbcConnection.GetObjectsDepth.Catalogs, + payload, + null, + null, + null, + null); + RecordBatch? batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + + // Then it is treated as a literal pattern that matches no database — the list is empty and + // must NOT contain SNOWFLAKE_SAMPLE_DATA + using (batch) + { + var catalogNames = (StringArray)batch.Column(0); + _output.WriteLine($"Injection payload returned {catalogNames.Length} catalog(s)"); + Assert.True(IndexOf(catalogNames, SampleDb) < 0, + "SQL injection succeeded: the malicious pattern returned the real database."); + Assert.Equal(0, catalogNames.Length); + } + } + + // ---- Helpers ---- + + private SnowflakeConnection Connect() + { + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + var database = driver.Open(parameters); + var connection = database.Connect(new Dictionary()); + return (SnowflakeConnection)connection; + } + + /// Reads the whole result stream, returning total rows, column count, and batch count. + private static async Task<(long Rows, int ColumnCount, int BatchCount)> ReadAllAsync(Apache.Arrow.Adbc.QueryResult result) + { + Assert.NotNull(result.Stream); + long total = 0; + int columnCount = 0; + int batchCount = 0; + + using var stream = result.Stream; + while (await stream.ReadNextRecordBatchAsync() is { } batch) + { + using (batch) + { + columnCount = batch.ColumnCount; + total += batch.Length; + batchCount++; + } + } + + return (total, columnCount, batchCount); + } + + private static async Task> ReadStringColumnAsync(Apache.Arrow.Adbc.QueryResult result, int columnIndex) + { + Assert.NotNull(result.Stream); + using var stream = result.Stream; + return await ReadAllStringColumnAsync(stream, columnIndex); + } + + private static async Task> ReadAllStringColumnAsync(Apache.Arrow.Ipc.IArrowArrayStream stream, int columnIndex) + { + var values = new List(); + while (await stream.ReadNextRecordBatchAsync() is { } batch) + { + using (batch) + { + var column = (StringArray)batch.Column(columnIndex); + for (int i = 0; i < column.Length; i++) + values.Add(column.GetString(i)); + } + } + + return values; + } + + private static int IndexOf(StringArray array, string value) + { + for (int i = 0; i < array.Length; i++) + { + if (string.Equals(array.GetString(i), value, StringComparison.Ordinal)) + return i; + } + + return -1; + } +} diff --git a/csharp/test/Native/Integration/SessionRenewalTests.cs b/csharp/test/Native/Integration/SessionRenewalTests.cs new file mode 100644 index 0000000..339a08e --- /dev/null +++ b/csharp/test/Native/Integration/SessionRenewalTests.cs @@ -0,0 +1,125 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Generic; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native; +using Xunit; +using Xunit.Abstractions; + +namespace AdbcDrivers.Snowflake.Native.Tests.Integration; + +/// +/// Live tests for session-token renewal against the real /session/token-request endpoint. +/// Snowflake issues a short-lived session token (~1h) backed by a longer master token (~4h); the +/// driver renews the session token with the master token, reactively when a query is rejected with +/// the session-expired code (390112). Forcing a natural expiry would take ~1h, so these drive the +/// renewal directly. Requires a live account; set SNOWFLAKE_TEST_CONFIG_FILE. +/// +[Trait("Category", "Integration")] +public class SessionRenewalTests +{ + private readonly ITestOutputHelper _output; + private readonly IntegrationTestConfiguration _testConfiguration; + + public SessionRenewalTests(ITestOutputHelper output) + { + _output = output; + _testConfiguration = IntegrationTestingUtils.TestConfiguration; + + Skip.If(string.IsNullOrEmpty(_testConfiguration.Account), + $"Cannot execute test configuration from environment variable `{IntegrationTestingUtils.SnowflakeTestConfigVariable}`"); + } + + [SkippableFact] + public async Task RenewSession_IssuesADifferentWorkingSessionToken() + { + // Given an open connection with a live session token + using var connection = Connect(); + string? before = connection.AuthToken?.SessionToken; + Assert.False(string.IsNullOrEmpty(before), "expected a session token after connect"); + + // When the session is renewed via the master token (the real /session/token-request call) + await connection.RenewSessionAsync(); + + // Then a different session token is issued and it still works for a query + string? after = connection.AuthToken?.SessionToken; + _output.WriteLine($"session token changed on renewal: {before != after}"); + Assert.False(string.IsNullOrEmpty(after)); + Assert.NotEqual(before, after); + + using var statement = connection.CreateStatement(); + statement.SqlQuery = "SELECT 1 AS X"; + var result = await statement.ExecuteQueryAsync(); + using var stream = result.Stream!; + var batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + Assert.Equal(1, batch!.Length); + } + + [SkippableFact] + public async Task RenewedToken_IsUsedBySubsequentQueries() + { + // Given an open connection + using var connection = Connect(); + var token = connection.AuthToken!; + + // When the session is renewed and several queries run afterwards + await connection.RenewSessionAsync(); + string? renewed = token.SessionToken; + + // Then the connection keeps using the renewed token and queries keep working (the renewed + // token is shared in place, so every later statement picks it up) + for (int i = 0; i < 3; i++) + { + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"SELECT {i} AS X"; + var result = await statement.ExecuteQueryAsync(); + using var stream = result.Stream!; + var batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + } + + Assert.Equal(renewed, token.SessionToken); + } + + [SkippableFact] + public async Task Heartbeat_KeepsTheSessionUsable() + { + // Given an open connection + using var connection = Connect(); + + // When the session is heartbeated (the real /session/heartbeat call) + await connection.HeartbeatAsync(); + + // Then the session is still usable for a query + using var statement = connection.CreateStatement(); + statement.SqlQuery = "SELECT 1 AS X"; + var result = await statement.ExecuteQueryAsync(); + using var stream = result.Stream!; + var batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + Assert.Equal(1, batch!.Length); + } + + private SnowflakeConnection Connect() + { + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + var database = driver.Open(parameters); + var connection = database.Connect(new Dictionary()); + return (SnowflakeConnection)connection; + } +} diff --git a/csharp/test/Native/Integration/StatementTests.cs b/csharp/test/Native/Integration/StatementTests.cs new file mode 100644 index 0000000..412173a --- /dev/null +++ b/csharp/test/Native/Integration/StatementTests.cs @@ -0,0 +1,445 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +using Apache.Arrow; +using Apache.Arrow.Adbc; + +namespace AdbcDrivers.Snowflake.Native.Tests.Integration; + +/// +/// Statement-level baseline tests for the native Snowflake driver, mirroring the +/// Interop StatementTests. Covers the implemented statement surface: +/// execute query, execute update, prepare, and parameter-schema guarding. +/// +/// Requires a live Snowflake instance; set SNOWFLAKE_TEST_CONFIG_FILE. +/// +[Trait("Category", "Integration")] +public class StatementTests +{ + private readonly ITestOutputHelper _output; + private readonly IntegrationTestConfiguration _testConfiguration; + + public StatementTests(ITestOutputHelper output) + { + _output = output; + _testConfiguration = IntegrationTestingUtils.TestConfiguration; + + Skip.If(string.IsNullOrEmpty(_testConfiguration.Account), + $"Cannot execute test configuration from environment variable `{IntegrationTestingUtils.SnowflakeTestConfigVariable}`"); + } + + [SkippableFact] + public async Task CanExecuteQuery() + { + // Given a simple two-column query + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + statement.SqlQuery = "SELECT 1 AS X, 'two' AS Y"; + + // When it is executed and the first batch is read + var result = await statement.ExecuteQueryAsync(); + Assert.NotNull(result.Stream); + using var stream = result.Stream; + var batch = await stream.ReadNextRecordBatchAsync(); + + // Then the batch has the expected shape + Assert.NotNull(batch); + Assert.Equal(2, batch.ColumnCount); + Assert.Equal(1, batch.Length); + } + + [SkippableFact] + public void ExecuteUpdateOnSelectReturnsNoRowCount() + { + // Given a statement holding a SELECT (which affects no rows) + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + statement.SqlQuery = "SELECT 1"; + + // When it is run as an update + var result = statement.ExecuteUpdate(); + + // Then the driver reports -1 (unknown) per the ADBC contract + _output.WriteLine($"ExecuteUpdate on SELECT reported {result.AffectedRows} affected rows"); + Assert.Equal(-1, result.AffectedRows); + } + + [SkippableFact] + public void GetParameterSchema_IsNotSupported() + { + // Given a fresh statement + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + + // When / Then GetParameterSchema throws AdbcException(NotImplemented): Snowflake's protocol + // does not report bind-parameter types, so it is unsupported regardless of whether Prepare ran. + var ex = Assert.Throws(statement.GetParameterSchema); + Assert.Equal(AdbcStatusCode.NotImplemented, ex.Status); + } + + [SkippableFact] + public async Task CanPrepareAndExecute() + { + // Given a prepared statement + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + statement.SqlQuery = "SELECT 1 AS X"; + statement.Prepare(); + + // When it is executed and the first batch is read + var result = await statement.ExecuteQueryAsync(); + Assert.NotNull(result.Stream); + using var stream = result.Stream; + var batch = await stream.ReadNextRecordBatchAsync(); + + // Then it returns the single row + Assert.NotNull(batch); + Assert.Equal(1, batch.Length); + } + + [SkippableTheory] + [MemberData(nameof(BindCases.LiveNames), MemberType = typeof(BindCases))] + public Task CanBindParameter(string caseName) + { + // Each bindable Arrow type from the shared BindCases table, bound and compared by the + // real server. The row returns only if Snowflake accepted the wire format and the value + // matched the predicate, so this confirms the encoding end to end. + var bindCase = BindCases.Get(caseName); + return AssertBoundValueMatches(bindCase.LivePredicate!, bindCase.BuildArray()); + } + + /// + /// Binds one parameter and runs SELECT 'ok' WHERE <predicate>: the row only returns + /// if Snowflake accepted the bind wire format and the bound value compared equal. The bind field + /// is derived from the array's own type, so a case only has to supply the array and predicate. + /// + private async Task AssertBoundValueMatches(string predicate, IArrowArray value) + { + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"SELECT 'ok' AS V WHERE {predicate}"; + var schema = new Schema([new Field("p", value.Data.DataType, true)], null); + using var batch = new RecordBatch(schema, [value], value.Length); + + statement.Bind(batch, schema); + var result = await statement.ExecuteQueryAsync(); + Assert.NotNull(result.Stream); + using var stream = result.Stream!; + var read = await stream.ReadNextRecordBatchAsync(); + + Assert.NotNull(read); + Assert.Equal(1, read!.Length); + Assert.Equal("ok", ((StringArray)read.Column(0)).GetString(0)); + } + + [SkippableFact] + public async Task CanCancelRunningQuery() + { + // Given a query that runs long enough to be cancelled mid-flight + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + statement.SqlQuery = "SELECT SYSTEM$WAIT(10)"; + + // When it is started on a background thread and cancelled after it has begun running + var queryTask = Task.Run(statement.ExecuteQuery); + await Task.Delay(1000); + statement.Cancel(); + + // Then the running query terminates with a Snowflake cancellation error instead of completing + var ex = await Assert.ThrowsAsync(async () => await queryTask); + _output.WriteLine($"Cancelled query failed with: {ex.Message}"); + Assert.Contains("cancel", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [SkippableFact] + public void ExecuteUpdateOnDmlReturnsAffectedRowCount() + { + // Given a temporary table (requires a writable metadata.catalog / metadata.schema) + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + string table = string.Format( + "{0}.{1}.NATIVE_UPD_{2}", + _testConfiguration.Metadata.Catalog, + _testConfiguration.Metadata.Schema, + Guid.NewGuid().ToString("N")); + statement.SqlQuery = $"CREATE TEMPORARY TABLE {table} (id INT)"; + statement.ExecuteUpdate(); + + // When two rows are inserted and then deleted + statement.SqlQuery = $"INSERT INTO {table} (id) VALUES (1), (2)"; + var result = statement.ExecuteUpdate(); + statement.SqlQuery = $"DELETE FROM {table} WHERE id IN (1,2)"; + var result2 = statement.ExecuteUpdate(); + + // Then each reports its affected-row count, parsed from the JSON RowSet (or -1 if a + // driver/server version cannot determine it). Both affect 2 rows while the payload's + // Returned count is 1. + _output.WriteLine($"Insert reported {result.AffectedRows}, delete reported {result2.AffectedRows} affected rows"); + Assert.True(result.AffectedRows == 2 || result.AffectedRows == -1); + Assert.True(result2.AffectedRows == 2 || result2.AffectedRows == -1); + } + + [SkippableFact] + public void ExecuteUpdate_MultiRowBind_InsertsEveryRow() + { + // Array binding (executemany): one INSERT with a 3-row bound batch inserts 3 rows, + // including a null cell. + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + string table = string.Format( + "{0}.{1}.NATIVE_ARR_{2}", + _testConfiguration.Metadata.Catalog, + _testConfiguration.Metadata.Schema, + Guid.NewGuid().ToString("N")); + statement.SqlQuery = $"CREATE TEMPORARY TABLE {table} (id INT, name VARCHAR)"; + statement.ExecuteUpdate(); + + var schema = new Schema( + [ + new Field("id", Apache.Arrow.Types.Int64Type.Default, true), + new Field("name", Apache.Arrow.Types.StringType.Default, true), + ], null); + var ids = new Int64Array.Builder().Append(1).Append(2).Append(3).Build(); + var names = new StringArray.Builder().Append("alpha").AppendNull().Append("gamma").Build(); + using var batch = new RecordBatch(schema, [ids, names], 3); + + statement.SqlQuery = $"INSERT INTO {table} (id, name) VALUES (?, ?)"; + statement.Bind(batch, schema); + var result = statement.ExecuteUpdate(); + + _output.WriteLine($"Array-bind insert reported {result.AffectedRows} affected rows"); + Assert.Equal(3, result.AffectedRows); + + // A fresh statement: the original still carries the bound batch, which must not ride + // along with the verification query. + using var countStatement = connection.CreateStatement(); + Assert.Equal(3, CountRows(countStatement, table)); + + // Bindings persist across executions (ADBC semantics, matching gosnowflake): executing + // the same statement again re-binds the same batch and inserts three more rows. + var again = statement.ExecuteUpdate(); + Assert.Equal(3, again.AffectedRows); + Assert.Equal(6, CountRows(countStatement, table)); + } + + [SkippableFact] + public void ExecuteUpdate_MultiRowBind_EncodesTypedValuesPerRow() + { + // Array binds reuse the scalar per-value wire formats (DATE = ms since epoch, + // BINARY = hex, DECIMAL = plain string, ...). This proves the server decodes them in + // array form too — including a null cell per column — by reading the values back. + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + string table = string.Format( + "{0}.{1}.NATIVE_ARRT_{2}", + _testConfiguration.Metadata.Catalog, + _testConfiguration.Metadata.Schema, + Guid.NewGuid().ToString("N")); + statement.SqlQuery = $"CREATE TEMPORARY TABLE {table} (d DATE, num NUMBER(10,2), flag BOOLEAN, bin BINARY, s VARCHAR)"; + statement.ExecuteUpdate(); + + var schema = new Schema( + [ + new Field("d", Apache.Arrow.Types.Date32Type.Default, true), + new Field("num", new Apache.Arrow.Types.Decimal128Type(10, 2), true), + new Field("flag", Apache.Arrow.Types.BooleanType.Default, true), + new Field("bin", Apache.Arrow.Types.BinaryType.Default, true), + new Field("s", Apache.Arrow.Types.StringType.Default, true), + ], null); + var dates = new Date32Array.Builder().Append(new DateTime(2024, 1, 15)).AppendNull().Build(); + var nums = new Decimal128Array.Builder(new Apache.Arrow.Types.Decimal128Type(10, 2)).Append(12.34m).AppendNull().Build(); + var flags = new BooleanArray.Builder().Append(true).AppendNull().Build(); + var binBuilder = new BinaryArray.Builder(); + binBuilder.Append("\u07ad"u8.ToArray().AsSpan()); + binBuilder.AppendNull(); + var bins = binBuilder.Build(); + var strings = new StringArray.Builder().Append("row1").Append("row2").Build(); + using var batch = new RecordBatch(schema, [dates, nums, flags, bins, strings], 2); + + statement.SqlQuery = $"INSERT INTO {table} (d, num, flag, bin, s) VALUES (?, ?, ?, ?, ?)"; + statement.Bind(batch, schema); + var result = statement.ExecuteUpdate(); + Assert.Equal(2, result.AffectedRows); + + // Row 1 must match on every typed value; row 2 must be all-null except the string. + using var verify = connection.CreateStatement(); + verify.SqlQuery = $"SELECT COUNT(*) FROM {table} WHERE d = DATE '2024-01-15' AND num = 12.34 AND flag AND bin = TO_BINARY('DEAD', 'HEX') AND s = 'row1'"; + Assert.Equal(1, ExecuteScalarCount(verify)); + verify.SqlQuery = $"SELECT COUNT(*) FROM {table} WHERE d IS NULL AND num IS NULL AND flag IS NULL AND bin IS NULL AND s = 'row2'"; + Assert.Equal(1, ExecuteScalarCount(verify)); + } + + private static long ExecuteScalarCount(AdbcStatement statement) + { + var result = statement.ExecuteQuery(); + Assert.NotNull(result.Stream); + using var stream = result.Stream; + var batch = stream.ReadNextRecordBatchAsync().GetAwaiter().GetResult(); + Assert.NotNull(batch); + using (batch) + return ((Int64Array)batch.Column(0)).GetValue(0)!.Value; + } + + [SkippableFact] + public void Transactions_RollbackDiscardsAndCommitPersists() + { + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + + // DDL implicitly commits in Snowflake, so create the table BEFORE opening the scope. + string table = string.Format( + "{0}.{1}.NATIVE_TXN_{2}", + _testConfiguration.Metadata.Catalog, + _testConfiguration.Metadata.Schema, + Guid.NewGuid().ToString("N")); + statement.SqlQuery = $"CREATE TABLE {table} (id INT)"; + statement.ExecuteUpdate(); + + connection.SetOption(AdbcOptions.Connection.Autocommit, AdbcOptions.Disabled); + + // An insert that is rolled back leaves no rows... + statement.SqlQuery = $"INSERT INTO {table} (id) VALUES (1)"; + statement.ExecuteUpdate(); + connection.Rollback(); + Assert.Equal(0, CountRows(statement, table)); + + // ...and one that is committed persists. + statement.SqlQuery = $"INSERT INTO {table} (id) VALUES (2)"; + statement.ExecuteUpdate(); + connection.Commit(); + Assert.Equal(1, CountRows(statement, table)); + + connection.SetOption(AdbcOptions.Connection.Autocommit, AdbcOptions.Enabled); + } + + private static long CountRows(AdbcStatement statement, string table) + { + statement.SqlQuery = $"SELECT COUNT(*) FROM {table}"; + var result = statement.ExecuteQuery(); + Assert.NotNull(result.Stream); + using var stream = result.Stream; + var batch = stream.ReadNextRecordBatchAsync().GetAwaiter().GetResult(); + Assert.NotNull(batch); + using (batch) + return ((Int64Array)batch.Column(0)).GetValue(0)!.Value; + } + + [SkippableFact] + [Trait("Category", "Slow")] + public async Task LongRunningQuery_OutlivesSyncWindow_ReturnsResultViaPolling() + { + // A query that exceeds Snowflake's synchronous response window (~45s) returns a + // query-in-progress response with a getResultUrl; the driver must poll it to the + // final result instead of failing. SYSTEM$WAIT(50) makes that deterministic — and + // makes the test itself take ~50s, so it only runs when explicitly enabled. + Skip.IfNot(IntegrationTestingUtils.RunSlowTests, + $"Slow test (~50s wall clock); set {IntegrationTestingUtils.RunSlowTestsVariable}=1 to run."); + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + statement.SqlQuery = "SELECT SYSTEM$WAIT(50)"; + + var result = await statement.ExecuteQueryAsync(); + + Assert.NotNull(result.Stream); + using var stream = result.Stream; + var batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + using (batch) + { + string? value = ((StringArray)batch.Column(0)).GetString(0); + _output.WriteLine($"Long-running query returned: {value}"); + Assert.Contains("waited 50 seconds", value); + } + } + + [SkippableFact] + public async Task ExecuteQueryOnDmlAndDdlReturnsSummaryRows() + { + // Non-SELECT statements run through ExecuteQuery must return their JSON summary as a + // result set (parity with the Go driver), not fail for lack of an Arrow stream. + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + using var database = driver.Open(parameters); + using var connection = database.Connect(new Dictionary()); + using var statement = connection.CreateStatement(); + string table = string.Format( + "{0}.{1}.NATIVE_QRY_{2}", + _testConfiguration.Metadata.Catalog, + _testConfiguration.Metadata.Schema, + Guid.NewGuid().ToString("N")); + + // DDL via ExecuteQuery: one string status row + statement.SqlQuery = $"CREATE TEMPORARY TABLE {table} (id INT)"; + var ddlResult = await statement.ExecuteQueryAsync(); + Assert.NotNull(ddlResult.Stream); + using (var stream = ddlResult.Stream) + { + var batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + using (batch) + { + Assert.Equal(1, batch.Length); + string? status = ((StringArray)batch.Column(0)).GetString(0); + _output.WriteLine($"DDL status row: {status}"); + Assert.Contains("successfully created", status); + } + } + + // DML via ExecuteQuery: the affected-count summary row + statement.SqlQuery = $"INSERT INTO {table} (id) VALUES (1), (2)"; + var dmlResult = await statement.ExecuteQueryAsync(); + Assert.Equal(2, dmlResult.RowCount); + Assert.NotNull(dmlResult.Stream); + using (var stream = dmlResult.Stream) + { + Assert.Equal("number of rows inserted", stream.Schema.FieldsList[0].Name); + var batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + using (batch) + { + Assert.Equal(1, batch.Length); + Assert.Equal(2L, ((Int64Array)batch.Column(0)).GetValue(0)); + } + } + } +} diff --git a/csharp/test/Native/Integration/TypeDecodingTests.cs b/csharp/test/Native/Integration/TypeDecodingTests.cs new file mode 100644 index 0000000..f7f0fcb --- /dev/null +++ b/csharp/test/Native/Integration/TypeDecodingTests.cs @@ -0,0 +1,247 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native; +using Xunit; +using Xunit.Abstractions; + +using Apache.Arrow; +using Apache.Arrow.Types; + +namespace AdbcDrivers.Snowflake.Native.Tests.Integration; + +/// +/// The driver's over-the-wire result decoding — the canonical per-type matrix (layer 2 of +/// 3). For each Snowflake data type it runs SELECT <literal> and asserts the Arrow +/// type (and value) the result stream actually produces, via +/// . This is the source of truth for what +/// a query returns. +/// +/// See (layer 1: the offline describe-path name mapping) +/// and 's Reader_ConvertsColumnTypesToClr (layer 3: Arrow → CLR +/// via the ADO.NET client). The result-wire decode here may diverge from the describe-path +/// mapping — e.g. NUMBER(38,0) decodes to Decimal128 here but the describe path declares Int64. +/// +/// Uses pure literals, so it needs only a live account (no sample data, no writable schema); set +/// SNOWFLAKE_TEST_CONFIG_FILE. +/// +[Trait("Category", "Integration")] +public class TypeDecodingTests +{ + private readonly ITestOutputHelper _output; + private readonly IntegrationTestConfiguration _testConfiguration; + + public TypeDecodingTests(ITestOutputHelper output) + { + _output = output; + _testConfiguration = IntegrationTestingUtils.TestConfiguration; + + Skip.If(string.IsNullOrEmpty(_testConfiguration.Account), + $"Cannot execute test configuration from environment variable `{IntegrationTestingUtils.SnowflakeTestConfigVariable}`"); + } + + /// + /// Result-decode gaps that are not yet implemented. These are skipped (not failed) so the + /// backlog is visible; remove an entry once the decoder handles that type and the assertion + /// will start enforcing it. See . + /// + private static readonly Dictionary NotYetDecoded = new(); + + [SkippableTheory] + [InlineData("TRUE::BOOLEAN", typeof(BooleanType))] + // Scale-0 NUMBER is sized by its declared precision: ≤9 → Int32, ≤18 → Int64, else Decimal128. + [InlineData("123::NUMBER(9,0)", typeof(Int32Type))] + [InlineData("123::NUMBER(18,0)", typeof(Int64Type))] + [InlineData("42::NUMBER(38,0)", typeof(Decimal128Type))] + [InlineData("9.99::NUMBER(10,2)", typeof(Decimal128Type))] + [InlineData("1.5::FLOAT", typeof(DoubleType))] + [InlineData("'hello'::VARCHAR", typeof(StringType))] + [InlineData("TO_BINARY('AB','HEX')", typeof(BinaryType))] + [InlineData("'2020-01-01'::DATE", typeof(Date32Type))] + [InlineData("'12:34:56'::TIME", typeof(Time64Type))] + [InlineData("'2020-01-01 12:00:00'::TIMESTAMP_NTZ", typeof(TimestampType))] + [InlineData("'2020-01-01 12:00:00'::TIMESTAMP_LTZ", typeof(TimestampType))] + [InlineData("'2020-01-01 12:00:00 +00:00'::TIMESTAMP_TZ", typeof(TimestampType))] + [InlineData("TO_VARIANT(1)", typeof(StringType))] + [InlineData("OBJECT_CONSTRUCT('a', 1)", typeof(StringType))] + // Snowflake returns semi-structured ARRAY/OBJECT/VARIANT as a JSON string. + [InlineData("ARRAY_CONSTRUCT(1, 2)", typeof(StringType))] + public async Task ResultColumn_HasExpectedArrowType(string sqlLiteral, Type expectedArrowType) + { + Skip.If(NotYetDecoded.TryGetValue(sqlLiteral, out string? reason), reason); + + // Given a query of a single typed literal + using var connection = Connect(); + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"SELECT {sqlLiteral} AS V"; + + // When the result schema is read + var result = await statement.ExecuteQueryAsync(); + Assert.NotNull(result.Stream); + using var stream = result.Stream!; + + // Then the column has the expected Arrow type + IArrowType actual = stream.Schema.FieldsList[0].DataType; + _output.WriteLine($"{sqlLiteral} -> {actual.GetType().Name} (expected {expectedArrowType.Name})"); + Assert.IsType(expectedArrowType, actual); + } + + [SkippableFact] + public async Task ScaledNumber_DecodesValueWithScale() + { + // Given a scaled NUMBER query — Snowflake sends it as an integer (999) with the scale in + // field metadata, so the driver must rescale it to a Decimal128 to recover 9.99 + using var connection = Connect(); + using var statement = connection.CreateStatement(); + statement.SqlQuery = "SELECT 9.99::NUMBER(10,2) AS V"; + + // When the first batch is read + var result = await statement.ExecuteQueryAsync(); + Assert.NotNull(result.Stream); + using var stream = result.Stream!; + var batch = await stream.ReadNextRecordBatchAsync(); + + // Then the column is a Decimal128 carrying the real value + Assert.NotNull(batch); + var column = Assert.IsType(batch!.Column(0)); + Assert.Equal(9.99m, column.GetValue(0)); + } + + /// + /// Proves Snowflake reports the column's declared precision distinctly in the Arrow + /// field metadata (9 vs 18 vs 38), independent of the values. This is the foundation for + /// precision-driven sizing of scale-0 NUMBER (precision ≤ 9 → Int32, ≤ 18 → Int64, else + /// Decimal128): without a reliable declared precision that sizing would be unsafe. + /// + [SkippableTheory] + [InlineData("123::NUMBER(9,0)", 9)] + [InlineData("123::NUMBER(18,0)", 18)] + [InlineData("123::NUMBER(38,0)", 38)] + public async Task ScaleZeroNumber_ReportsDeclaredPrecisionInMetadata(string sqlLiteral, int expectedPrecision) + { + // Given a query of a precision-qualified NUMBER literal + using var connection = Connect(); + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"SELECT {sqlLiteral} AS V"; + + // When the result field metadata is read + var result = await statement.ExecuteQueryAsync(); + Assert.NotNull(result.Stream); + using var stream = result.Stream!; + Field field = stream.Schema.FieldsList[0]; + + // Then it reports FIXED with the declared precision + Assert.True(field.HasMetadata, "FIXED column should carry Snowflake field metadata."); + Assert.True(field.Metadata.TryGetValue("logicalType", out string? logicalType)); + Assert.Equal("FIXED", logicalType); + Assert.True(field.Metadata.TryGetValue("precision", out string? precision), + "FIXED column metadata should include 'precision'."); + _output.WriteLine($"{sqlLiteral} -> precision={precision} (expected {expectedPrecision})"); + Assert.Equal(expectedPrecision, int.Parse(precision!, System.Globalization.CultureInfo.InvariantCulture)); + } + + [SkippableFact] + public async Task ScaleZeroNumber_LargeValue_RoundTripsAsDecimal128() + { + // Given a 20-digit NUMBER(38,0) — larger than Int64.MaxValue (9,223,372,036,854,775,807), + // so sizing it to Int64 would overflow; it must decode to Decimal128 with the value intact + using var connection = Connect(); + using var statement = connection.CreateStatement(); + const string literal = "12345678901234567890"; + statement.SqlQuery = $"SELECT {literal}::NUMBER(38,0) AS V"; + + // When the first batch is read + var result = await statement.ExecuteQueryAsync(); + Assert.NotNull(result.Stream); + using var stream = result.Stream!; + var batch = await stream.ReadNextRecordBatchAsync(); + + // Then it is a Decimal128 with the full value preserved + Assert.NotNull(batch); + var column = Assert.IsType(batch!.Column(0)); + Assert.Equal(decimal.Parse(literal, System.Globalization.CultureInfo.InvariantCulture), column.GetValue(0)); + } + + /// + /// TIME decodes to Time64 nanoseconds-of-day, rescaled from the wire's 10^-scale units. + /// 12:34:56.789 = 45296.789 s = 45,296,789,000,000 ns. Asserts the raw value (and that the + /// reduced-scale TIME(3) wire form rescales identically to full precision). + /// + [SkippableTheory] + [InlineData("'12:34:56.789'::TIME(3)")] + [InlineData("'12:34:56.789'::TIME(9)")] + public async Task Time_DecodesToNanosecondsOfDay(string sqlLiteral) + { + // Given a TIME query + using var connection = Connect(); + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"SELECT {sqlLiteral} AS V"; + + // When the first batch is read + var result = await statement.ExecuteQueryAsync(); + using var stream = result.Stream!; + var batch = await stream.ReadNextRecordBatchAsync(); + + // Then it is a Time64 of nanoseconds-of-day + var column = Assert.IsType(batch!.Column(0)); + Assert.Equal(45_296_789_000_000L, column.Values[0]); + } + + /// + /// Each TIMESTAMP flavour decodes to a Timestamp[ns] carrying the correct UTC instant, read as + /// the raw nanosecond value to prove full precision (DateTimeOffset would round to 100 ns). + /// NTZ has no zone; LTZ/TZ are tagged UTC. The +05:00 input is normalized to its UTC instant. + /// + [SkippableTheory] + // 2020-03-04 12:34:56.123456789, treated as UTC (epoch 1583325296 s). + [InlineData("'2020-03-04 12:34:56.123456789'::TIMESTAMP_NTZ", 1583325296123456789L, null)] + // Reduced scale arrives as a single combined integer, not a struct; must rescale the same way. + [InlineData("'2020-03-04 12:34:56.123'::TIMESTAMP_NTZ(3)", 1583325296123000000L, null)] + // 12:34:56.123456789 +05:00 -> 07:34:56.123456789 UTC (epoch 1583307296 s). + [InlineData("'2020-03-04 12:34:56.123456789 +05:00'::TIMESTAMP_TZ", 1583307296123456789L, "UTC")] + [InlineData("'2020-03-04 12:34:56.123 +05:00'::TIMESTAMP_TZ(3)", 1583307296123000000L, "UTC")] + public async Task Timestamp_DecodesToUtcInstant(string sqlLiteral, long expectedNanos, string? expectedTimezone) + { + // Given a TIMESTAMP query + using var connection = Connect(); + using var statement = connection.CreateStatement(); + statement.SqlQuery = $"SELECT {sqlLiteral} AS V"; + + // When the result schema and first batch are read + var result = await statement.ExecuteQueryAsync(); + using var stream = result.Stream!; + + // Then the column is a Timestamp[ns] with the expected zone and UTC instant + var timestampType = Assert.IsType(stream.Schema.FieldsList[0].DataType); + Assert.Equal(TimeUnit.Nanosecond, timestampType.Unit); + Assert.Equal(expectedTimezone, timestampType.Timezone); + + var batch = await stream.ReadNextRecordBatchAsync(); + var column = Assert.IsType(batch!.Column(0)); + Assert.Equal(expectedNanos, column.Values[0]); + } + + private SnowflakeConnection Connect() + { + var driver = IntegrationTestingUtils.GetSnowflakeAdbcDriver(_testConfiguration, out var parameters); + var database = driver.Open(parameters); + var connection = database.Connect(new Dictionary()); + return (SnowflakeConnection)connection; + } +} diff --git a/csharp/test/Native/KeyPairAuthenticatorTests.cs b/csharp/test/Native/KeyPairAuthenticatorTests.cs new file mode 100644 index 0000000..dee5a77 --- /dev/null +++ b/csharp/test/Native/KeyPairAuthenticatorTests.cs @@ -0,0 +1,281 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using Apache.Arrow.Adbc; +using NSubstitute; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Offline tests for key-pair (SNOWFLAKE_JWT) authentication: the login JWT's claims and +/// signature (verifiable without a server by checking against the key that signed it), and +/// the AuthenticationService wiring that resolves an inline PEM vs. a private-key file path. +/// +[Trait("Category", "Unit")] +public class KeyPairAuthenticatorTests +{ + private const string Account = "testaccount"; + private const string User = "testuser"; + + private static (RSA Rsa, string Pem) CreateKey() + { + var rsa = RSA.Create(2048); + return (rsa, rsa.ExportPkcs8PrivateKeyPem()); + } + + private static JsonDocument DecodeSegment(string jwt, int segment) + { + string part = jwt.Split('.')[segment]; + return JsonDocument.Parse(FromBase64Url(part)); + } + + private static byte[] FromBase64Url(string value) + { + string padded = value.Replace('-', '+').Replace('_', '/'); + padded += (padded.Length % 4) switch { 2 => "==", 3 => "=", _ => string.Empty }; + return Convert.FromBase64String(padded); + } + + [Fact] + public void GenerateJwtToken_HasSnowflakeClaimShapes() + { + (RSA rsa, string pem) = CreateKey(); + using (rsa) + { + string jwt = KeyPairAuthenticator.GenerateJwtToken(Account, User, pem, passphrase: null); + + using JsonDocument header = DecodeSegment(jwt, 0); + Assert.Equal("RS256", header.RootElement.GetProperty("alg").GetString()); + Assert.Equal("JWT", header.RootElement.GetProperty("typ").GetString()); + + // Snowflake requires iss = ACCOUNT.USER.SHA256: + // and sub = ACCOUNT.USER, both upper-cased. + string fingerprint = Convert.ToBase64String(SHA256.HashData(rsa.ExportSubjectPublicKeyInfo())); + using JsonDocument payload = DecodeSegment(jwt, 1); + Assert.Equal($"TESTACCOUNT.TESTUSER.SHA256:{fingerprint}", payload.RootElement.GetProperty("iss").GetString()); + Assert.Equal("TESTACCOUNT.TESTUSER", payload.RootElement.GetProperty("sub").GetString()); + Assert.Equal(3600, payload.RootElement.GetProperty("exp").GetInt64() - payload.RootElement.GetProperty("iat").GetInt64()); + } + } + + [Fact] + public void GenerateJwtToken_SignatureVerifiesWithPublicKey() + { + (RSA rsa, string pem) = CreateKey(); + using (rsa) + { + string jwt = KeyPairAuthenticator.GenerateJwtToken(Account, User, pem, passphrase: null); + string[] parts = jwt.Split('.'); + Assert.Equal(3, parts.Length); + + bool valid = rsa.VerifyData( + Encoding.UTF8.GetBytes($"{parts[0]}.{parts[1]}"), + FromBase64Url(parts[2]), + HashAlgorithmName.SHA256, + RSASignaturePadding.Pkcs1); + Assert.True(valid); + } + } + + [Fact] + public void GenerateJwtToken_AccountWithRegionSuffix_UsesBareAccountLocator() + { + // Account identifiers can carry a region/cloud suffix (xy12345.eu-west-1); the JWT + // claims must use only the bare locator, like gosnowflake/connector-net. + (RSA rsa, string pem) = CreateKey(); + using (rsa) + { + string jwt = KeyPairAuthenticator.GenerateJwtToken("xy12345.eu-west-1", User, pem, passphrase: null); + + using JsonDocument payload = DecodeSegment(jwt, 1); + Assert.Equal("XY12345.TESTUSER", payload.RootElement.GetProperty("sub").GetString()); + Assert.StartsWith("XY12345.TESTUSER.SHA256:", payload.RootElement.GetProperty("iss").GetString()); + } + } + + [Fact] + public void GenerateJwtToken_EncryptedKey_DecryptsWithPassphrase() + { + (RSA rsa, string _) = CreateKey(); + using (rsa) + { + string encryptedPem = rsa.ExportEncryptedPkcs8PrivateKeyPem( + "key-passphrase", + new PbeParameters(PbeEncryptionAlgorithm.Aes256Cbc, HashAlgorithmName.SHA256, 100_000)); + + string jwt = KeyPairAuthenticator.GenerateJwtToken(Account, User, encryptedPem, "key-passphrase"); + Assert.Equal(3, jwt.Split('.').Length); + + Assert.Throws(() => + KeyPairAuthenticator.GenerateJwtToken(Account, User, encryptedPem, "wrong-passphrase")); + } + } + + [Fact] + public void GenerateJwtToken_InvalidPem_ThrowsAdbcException() + { + var ex = Assert.Throws(() => + KeyPairAuthenticator.GenerateJwtToken(Account, User, "not a pem key", passphrase: null)); + Assert.Contains("private key", ex.Message); + } + + // ---- Key-material resolution: inline PEM vs. private-key file path ---- + + private static ConnectionConfig KeyPairConfig(AuthenticationConfig authConfig) => new() + { + Account = Account, + User = User, + Authentication = authConfig, + }; + + [Fact] + public async Task ResolvePrivateKeyPem_InlineKey_PassesPemThroughUnchanged() + { + (RSA rsa, string pem) = CreateKey(); + using (rsa) + { + var authConfig = new AuthenticationConfig { PrivateKey = pem }; + Assert.Equal(pem, await KeyPairAuthenticator.ResolvePrivateKeyPemAsync(authConfig, CancellationToken.None)); + } + } + + [Fact] + public async Task ResolvePrivateKeyPem_KeyFilePath_ReadsFileContent() + { + (RSA rsa, string pem) = CreateKey(); + using (rsa) + { + string keyFile = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(keyFile, pem); + var authConfig = new AuthenticationConfig { PrivateKeyPath = keyFile }; + + // The file's CONTENT is resolved, never the path itself. + Assert.Equal(pem, await KeyPairAuthenticator.ResolvePrivateKeyPemAsync(authConfig, CancellationToken.None)); + } + finally + { + File.Delete(keyFile); + } + } + } + + [Fact] + public async Task ResolvePrivateKeyPem_MissingKeyFile_ThrowsAdbcException() + { + var authConfig = new AuthenticationConfig + { + PrivateKeyPath = Path.Combine(Path.GetTempPath(), "does-not-exist.p8"), + }; + + var ex = await Assert.ThrowsAsync( + () => KeyPairAuthenticator.ResolvePrivateKeyPemAsync(authConfig, CancellationToken.None)); + Assert.Contains("not found", ex.Message); + } + + // ---- Requirement validation: each authenticator reports everything missing at once ---- + + [Fact] + public void ValidateRequirements_KeyPair_ListsEveryMissingItem() + { + var config = new ConnectionConfig { Account = Account }; // no user, no key material + + var ex = Assert.Throws(() => KeyPairAuthenticator.ValidateRequirements(config)); + Assert.Contains("user", ex.Message); + Assert.Contains("private key", ex.Message); + } + + [Fact] + public void ValidateRequirements_KeyPair_CompleteConfig_DoesNotThrow() + { + var config = KeyPairConfig(new AuthenticationConfig { PrivateKey = "pem" }); + KeyPairAuthenticator.ValidateRequirements(config); + } + + [Fact] + public void ValidateRequirements_Basic_ListsEveryMissingItem() + { + var config = new ConnectionConfig { Account = Account }; // no user, no password + + var ex = Assert.Throws(() => BasicAuthenticator.ValidateRequirements(config)); + Assert.Contains("user", ex.Message); + Assert.Contains("password", ex.Message); + } + + [Fact] + public void ValidateRequirements_OAuth_DoesNotRequireUser() + { + // Snowflake derives the identity from the token, so a user-less config is valid. + var config = new ConnectionConfig + { + Account = Account, + Authentication = new AuthenticationConfig { Token = "token" }, + }; + OAuthAuthenticator.ValidateRequirements(config); + + var ex = Assert.Throws(() => OAuthAuthenticator.ValidateRequirements( + new ConnectionConfig { Account = Account })); + Assert.Contains("OAuth token", ex.Message); + } + + [Fact] + public void ValidateRequirements_Pat_RequiresUserAndToken() + { + // A PAT is bound to a user (unlike OAuth), so both must be present — and every + // missing item is reported at once. + var ex = Assert.Throws(() => PatAuthenticator.ValidateRequirements( + new ConnectionConfig { Account = Account })); + Assert.Contains("user", ex.Message); + Assert.Contains("programmatic access token", ex.Message); + + PatAuthenticator.ValidateRequirements(new ConnectionConfig + { + Account = Account, + User = User, + Authentication = new AuthenticationConfig { Token = "pat-token" }, + }); + } + + // ---- AuthenticationService: pure dispatch on the configured auth type ---- + + [Fact] + public async Task AuthenticationService_KeyPairType_DelegatesConfigToKeyPairAuthenticator() + { + var keyPairAuth = Substitute.For(); + var service = new AuthenticationService( + Substitute.For(), + keyPairAuth, + Substitute.For(), + Substitute.For(), + Substitute.For()); + var config = KeyPairConfig(new AuthenticationConfig { Type = AuthenticationType.KeyPair, PrivateKey = "pem" }); + + await service.AuthenticateAsync(config); + + await keyPairAuth.Received(1).AuthenticateAsync(config, Arg.Any()); + } +} diff --git a/csharp/test/Native/QueryExecutorCommandResultTests.cs b/csharp/test/Native/QueryExecutorCommandResultTests.cs new file mode 100644 index 0000000..c8f1cc0 --- /dev/null +++ b/csharp/test/Native/QueryExecutorCommandResultTests.cs @@ -0,0 +1,331 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.ConnectionPool; +using AdbcDrivers.Snowflake.Native.Services.Query; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using AdbcDrivers.Snowflake.Native.Services.TypeConversion; +using Apache.Arrow; +using Apache.Arrow.Types; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +using Ipc = Apache.Arrow.Ipc; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Tests for how non-Arrow (JSON) command results are surfaced, in parity with the Go driver: +/// DML statements expose their affected-count summary row as a result set (with the summed +/// count in for ExecuteUpdate), and other +/// JSON rowsets (DDL status messages etc.) come back as string columns instead of an error. +/// +[Trait("Category", "Unit")] +public class QueryExecutorCommandResultTests +{ + private readonly IRestApiClient _apiClient = Substitute.For(); + private readonly QueryExecutor _sut; + + public QueryExecutorCommandResultTests() + { + _sut = new QueryExecutor( + _apiClient, + TypeConverter.Shared, + "testaccount", + network: null, + NullLogger.Instance, + onConnectionFault: () => { }); + } + + private static AuthenticationToken CreateToken() => new() + { + SessionToken = "session-token-abc", + MasterToken = "master-token-123", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + }; + + private static QueryRequest Request(AuthenticationToken token) => new() + { + Statement = "INSERT INTO T VALUES (1), (2)", + AuthToken = token, + }; + + private void SetupQueryResponse(SnowflakeQueryResponse data) => + _apiClient + .PostAsync( + Arg.Is(e => e.Contains("/queries/v1/query-request")), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new ApiResponse { Success = true, Data = data }); + + [Fact] + public async Task ExecuteQueryAsync_DmlInsert_ReturnsSummaryRowAndAffectedCount() + { + SetupQueryResponse(new SnowflakeQueryResponse + { + QueryResultFormat = "json", + Returned = 1, + RowType = [new RowType { Name = "number of rows inserted", Type = "fixed", Precision = 19, Scale = 0 }], + RowSet = [["2"]], + }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Success, result.Status); + Assert.Equal(2, result.AffectedRows); + Assert.Equal(2, result.RowCount); + Assert.NotNull(result.ResultStream); + + using var stream = result.ResultStream; + Field field = Assert.Single(stream.Schema.FieldsList); + Assert.Equal("number of rows inserted", field.Name); + Assert.IsType(field.DataType); + + using RecordBatch? batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + Assert.Equal(1, batch.Length); + Assert.Equal(2L, ((Int64Array)batch.Column(0)).GetValue(0)); + Assert.Null(await stream.ReadNextRecordBatchAsync()); + } + + [Fact] + public async Task ExecuteQueryAsync_DmlMerge_ReturnsAllCountColumns() + { + SetupQueryResponse(new SnowflakeQueryResponse + { + QueryResultFormat = "json", + Returned = 1, + RowType = + [ + new RowType { Name = "number of rows inserted", Type = "fixed" }, + new RowType { Name = "number of rows updated", Type = "fixed" }, + ], + RowSet = [["3", "2"]], + }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(5, result.AffectedRows); + Assert.NotNull(result.ResultStream); + + using var stream = result.ResultStream; + using RecordBatch? batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + Assert.Equal(3L, ((Int64Array)batch.Column(0)).GetValue(0)); + Assert.Equal(2L, ((Int64Array)batch.Column(1)).GetValue(0)); + } + + [Fact] + public async Task ExecuteQueryAsync_MultiRowDmlSummary_SumsAndSurfacesAllRows() + { + // DML summaries are normally a single row, but the driver must not silently drop + // extra rows if the server ever sends them. + SetupQueryResponse(new SnowflakeQueryResponse + { + QueryResultFormat = "json", + Returned = 2, + RowType = [new RowType { Name = "number of rows inserted", Type = "fixed" }], + RowSet = [["2"], ["3"]], + }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(5, result.AffectedRows); + Assert.NotNull(result.ResultStream); + + using var stream = result.ResultStream; + using RecordBatch? batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + Assert.Equal(2, batch.Length); + var column = (Int64Array)batch.Column(0); + Assert.Equal(2L, column.GetValue(0)); + Assert.Equal(3L, column.GetValue(1)); + } + + [Fact] + public async Task ExecuteQueryAsync_JsonDdlStatus_ReturnsStatusRowAsStrings() + { + // DDL status results (e.g. CREATE TABLE) arrive as a JSON rowset; parity with the Go + // driver means surfacing the status row, not failing for lack of an Arrow stream. + SetupQueryResponse(new SnowflakeQueryResponse + { + QueryResultFormat = "json", + Returned = 1, + RowType = [new RowType { Name = "status", Type = "text" }], + RowSet = [["Table T successfully created."]], + }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Success, result.Status); + Assert.Null(result.AffectedRows); // not DML: ExecuteUpdate must report -1, not a count + Assert.NotNull(result.ResultStream); + + using var stream = result.ResultStream; + Field field = Assert.Single(stream.Schema.FieldsList); + Assert.Equal("status", field.Name); + Assert.IsType(field.DataType); + + using RecordBatch? batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + Assert.Equal(1, batch.Length); + Assert.Equal("Table T successfully created.", ((StringArray)batch.Column(0)).GetString(0)); + } + + [Fact] + public async Task ExecuteQueryAsync_JsonRowSetWithNullRowAndRaggedRow_SurfacesNulls() + { + // Defensive: a null row entry or a row with fewer cells than columns must become null + // values, not a crash. + SetupQueryResponse(new SnowflakeQueryResponse + { + QueryResultFormat = "json", + Returned = 3, + RowType = + [ + new RowType { Name = "A", Type = "text" }, + new RowType { Name = "B", Type = "text" }, + ], + RowSet = [["a1", "b1"], null!, ["a3"]], + }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Success, result.Status); + Assert.NotNull(result.ResultStream); + + using var stream = result.ResultStream; + using RecordBatch? batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + Assert.Equal(3, batch.Length); + var columnB = (StringArray)batch.Column(1); + Assert.Equal("b1", columnB.GetString(0)); + Assert.True(columnB.IsNull(1)); // null row + Assert.True(columnB.IsNull(2)); // ragged row + } + + // ---- Statement-level: how ExecuteQuery/ExecuteUpdate consume the executor result ---- + + private static SnowflakeStatement CreateStatement(Services.Query.QueryResult executorResult) + { + var executor = Substitute.For(); + executor.ExecuteQueryAsync(Arg.Any(), Arg.Any()) + .Returns(executorResult); + + var pooledConnection = Substitute.For(); + pooledConnection.AuthToken.Returns(CreateToken()); + + var statement = new SnowflakeStatement(new ConnectionConfig(), pooledConnection, executor); + statement.SqlQuery = "INSERT INTO T VALUES (1), (2)"; + return statement; + } + + [Fact] + public async Task ExecuteUpdate_DmlResult_ReportsAffectedRowsAndDisposesStream() + { + var stream = Substitute.For(); + using var statement = CreateStatement(new Services.Query.QueryResult + { + Status = QueryStatus.Success, + ResultStream = stream, + RowCount = 2, + AffectedRows = 2, + }); + + Apache.Arrow.Adbc.UpdateResult result = await statement.ExecuteUpdateAsync(); + + Assert.Equal(2, result.AffectedRows); + stream.Received(1).Dispose(); + } + + [Fact] + public async Task ExecuteUpdate_ResultSetWithoutAffectedRows_ReportsUnknown() + { + // A SELECT (or DDL status row) run through ExecuteUpdate: a result set with no DML + // count still reports -1 per the ADBC contract. + using var statement = CreateStatement(new Services.Query.QueryResult + { + Status = QueryStatus.Success, + ResultStream = new EmptyArrowArrayStream(new Schema([new Field("ID", Int32Type.Default, nullable: true)], null)), + RowCount = 0, + }); + + Apache.Arrow.Adbc.UpdateResult result = await statement.ExecuteUpdateAsync(); + + Assert.Equal(-1, result.AffectedRows); + } + + [Fact] + public async Task ExecuteQuery_CancelledResult_ThrowsCancelledError() + { + // A cancelled result is neither Success nor Failed and carries no stream; it must + // surface as a cancellation error, not a missing-stream error or a bogus result. + using var statement = CreateStatement(new Services.Query.QueryResult + { + Status = QueryStatus.Cancelled, + }); + + var ex = await Assert.ThrowsAsync( + async () => await statement.ExecuteQueryAsync()); + Assert.Contains("cancelled", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteUpdate_CancelledResult_ThrowsCancelledError() + { + // Previously a cancelled update surfaced as a successful UpdateResult(0). + using var statement = CreateStatement(new Services.Query.QueryResult + { + Status = QueryStatus.Cancelled, + }); + + var ex = await Assert.ThrowsAsync( + statement.ExecuteUpdateAsync); + Assert.Contains("cancelled", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ExecuteQuery_DmlResult_SummaryRowIsReadable() + { + var schema = new Schema([new Field("number of rows inserted", Int64Type.Default, nullable: true)], null); + var builder = new Int64Array.Builder(); + builder.Append(2); + using var statement = CreateStatement(new Services.Query.QueryResult + { + Status = QueryStatus.Success, + ResultStream = new InMemoryArrowStream(schema, [builder.Build()]), + RowCount = 2, + AffectedRows = 2, + }); + + Apache.Arrow.Adbc.QueryResult result = await statement.ExecuteQueryAsync(); + + Assert.Equal(2, result.RowCount); + Assert.NotNull(result.Stream); + using var stream = result.Stream; + using RecordBatch? batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + Assert.Equal(2L, ((Int64Array)batch.Column(0)).GetValue(0)); + } +} diff --git a/csharp/test/Native/QueryExecutorEmptyResultTests.cs b/csharp/test/Native/QueryExecutorEmptyResultTests.cs new file mode 100644 index 0000000..bacacef --- /dev/null +++ b/csharp/test/Native/QueryExecutorEmptyResultTests.cs @@ -0,0 +1,178 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.ConnectionPool; +using AdbcDrivers.Snowflake.Native.Services.Query; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using AdbcDrivers.Snowflake.Native.Services.TypeConversion; +using Apache.Arrow; +using Apache.Arrow.Types; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Tests for zero-row result handling. A SELECT that matches no rows still reports +/// queryResultFormat=arrow, but Snowflake sends neither rowsetBase64 nor chunks — only the +/// rowtype metadata. The executor must surface an empty stream carrying the rowtype schema +/// (not a null stream, which the statement treats as an error). +/// +[Trait("Category", "Unit")] +public class QueryExecutorEmptyResultTests +{ + private readonly IRestApiClient _apiClient = Substitute.For(); + private readonly QueryExecutor _sut; + + public QueryExecutorEmptyResultTests() + { + _sut = new QueryExecutor( + _apiClient, + TypeConverter.Shared, + "testaccount", + network: null, + NullLogger.Instance, + onConnectionFault: () => { }); + } + + private static AuthenticationToken CreateToken() => new() + { + SessionToken = "session-token-abc", + MasterToken = "master-token-123", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + }; + + private static QueryRequest Request(AuthenticationToken token) => new() + { + Statement = "SELECT * FROM T WHERE 1 = 0", + AuthToken = token, + }; + + private void SetupQueryResponse(SnowflakeQueryResponse data) => + _apiClient + .PostAsync( + Arg.Is(e => e.Contains("/queries/v1/query-request")), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new ApiResponse { Success = true, Data = data }); + + private static SnowflakeQueryResponse ZeroRowArrowResponse(string format = "arrow") => new() + { + QueryResultFormat = format, + RowSetBase64 = "", + Returned = 0, + RowType = + [ + new RowType { Name = "ID", Type = "fixed", Precision = 38, Scale = 0, Nullable = false }, + new RowType { Name = "NAME", Type = "text", Length = 100, Nullable = true }, + ], + }; + + [Theory] + [InlineData("arrow")] + [InlineData("ARROW")] // format comparison must be case-insensitive + public async Task ExecuteQueryAsync_ArrowFormatZeroRows_ReturnsEmptyStreamWithRowTypeSchema(string format) + { + SetupQueryResponse(ZeroRowArrowResponse(format)); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Success, result.Status); + Assert.Equal(0, result.RowCount); + Assert.NotNull(result.ResultStream); + + using var stream = result.ResultStream; + Assert.Equal(2, stream.Schema.FieldsList.Count); + // NUMBER(38,0) exceeds Int64, so it maps to Decimal128 — the same rule the result + // decoder applies to non-empty results, keeping the schema stable either way. + Assert.Equal("ID", stream.Schema.FieldsList[0].Name); + Assert.IsType(stream.Schema.FieldsList[0].DataType); + Assert.Equal("NAME", stream.Schema.FieldsList[1].Name); + Assert.IsType(stream.Schema.FieldsList[1].DataType); + + Assert.Null(await stream.ReadNextRecordBatchAsync()); + } + + [Fact] + public async Task ExecuteQueryAsync_ZeroRowsWithUnmappedColumnType_FailsWithSchemaError() + { + // A rowtype column type the converter cannot map (e.g. VECTOR): with zero rows there + // is no Arrow wire type to pass through, so the driver fails naming the real reason. + var data = ZeroRowArrowResponse(); + data.RowType = [new RowType { Name = "V", Type = "vector", Nullable = true }]; + SetupQueryResponse(data); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Failed, result.Status); + QueryError error = Assert.Single(result.Errors); + Assert.Equal("UNSUPPORTED_RESULT_SCHEMA", error.ErrorCode); + Assert.Contains("zero rows", error.Message); + } + + [Fact] + public async Task ExecuteQueryAsync_ArrowFormatZeroRowsWithoutRowType_FailsAsUnsupportedShape() + { + // Without rowtype there is no schema to build and no rowset to surface: the driver + // cannot represent the response, so it must fail explicitly rather than return a + // success with nothing in it. + var data = ZeroRowArrowResponse(); + data.RowType = null; + SetupQueryResponse(data); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Failed, result.Status); + Assert.Null(result.ResultStream); + QueryError error = Assert.Single(result.Errors); + Assert.Equal("UNSUPPORTED_RESULT_SHAPE", error.ErrorCode); + } + + [Fact] + public async Task SnowflakeStatement_EmptyResult_IsReadableWithoutError() + { + var schema = new Schema([new Field("ID", Int32Type.Default, nullable: true)], null); + var executor = Substitute.For(); + executor.ExecuteQueryAsync(Arg.Any(), Arg.Any()) + .Returns(new Services.Query.QueryResult + { + Status = QueryStatus.Success, + ResultStream = new EmptyArrowArrayStream(schema), + RowCount = 0, + }); + + var pooledConnection = Substitute.For(); + pooledConnection.AuthToken.Returns(CreateToken()); + + using var statement = new SnowflakeStatement(new ConnectionConfig(), pooledConnection, executor); + statement.SqlQuery = "SELECT ID FROM T WHERE 1 = 0"; + + Apache.Arrow.Adbc.QueryResult result = await statement.ExecuteQueryAsync(); + + Assert.NotNull(result.Stream); + using var stream = result.Stream; + Assert.Single(stream.Schema.FieldsList); + Assert.Null(await stream.ReadNextRecordBatchAsync()); + } +} diff --git a/csharp/test/Native/QueryExecutorFaultTests.cs b/csharp/test/Native/QueryExecutorFaultTests.cs new file mode 100644 index 0000000..7860a17 --- /dev/null +++ b/csharp/test/Native/QueryExecutorFaultTests.cs @@ -0,0 +1,234 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.Query; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using AdbcDrivers.Snowflake.Native.Services.TypeConversion; +using Apache.Arrow.Adbc; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Unit tests for the connection-fault callback: the executor must flag the pooled connection +/// when a failure leaves the session unusable or in an unknown state (transport error mid-query, +/// failed renewal, session-fatal GS code) — and must NOT flag it for ordinary SQL errors or a +/// caller-initiated cancellation, where the session remains healthy. +/// +[Trait("Category", "Unit")] +public class QueryExecutorFaultTests +{ + private readonly IRestApiClient _apiClient = Substitute.For(); + private readonly QueryExecutor _sut; + private int _faultCount; + + public QueryExecutorFaultTests() + { + _sut = new QueryExecutor( + _apiClient, + Substitute.For(), + "testaccount", + network: null, + NullLogger.Instance, + onConnectionFault: () => _faultCount++); + } + + private static AuthenticationToken CreateToken(string? masterToken = "master-token-123") => + new() + { + SessionToken = "session-token-abc", + MasterToken = masterToken, + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + }; + + private static QueryRequest Request(AuthenticationToken token) => new() + { + Statement = "SELECT 1", + AuthToken = token, + }; + + private void SetupQueryResponses(params ApiResponse[] responses) => + _apiClient + .PostAsync( + Arg.Is(e => e.Contains("/queries/v1/query-request")), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(responses[0], responses[1..]); + + private void SetupQueryThrows(Exception exception) => + _apiClient + .PostAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns>>( + _ => Task.FromException>(exception)); + + private void SetupRenewalResponse(ApiResponse response) => + _apiClient + .PostAsync( + Arg.Is(e => e.Contains("/session/token-request")), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(response); + + [Fact] + public async Task ExecuteQueryAsync_TransportFailure_FaultsConnection() + { + SetupQueryThrows(new HttpRequestException("connection reset")); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Failed, result.Status); + Assert.Equal(1, _faultCount); + } + + [Fact] + public async Task ExecuteQueryAsync_SqlError_DoesNotFaultConnection() + { + // A compilation/execution error is a statement problem; the session is still healthy. + SetupQueryResponses(new ApiResponse + { + Success = false, + Code = "001003", + Message = "SQL compilation error", + }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Failed, result.Status); + Assert.Equal(0, _faultCount); + } + + [Fact] + public async Task ExecuteQueryAsync_UnsupportedResultShape_DoesNotFaultConnection() + { + // A successful response the driver cannot represent (no Arrow data, no rowset) fails + // the statement, but the session itself is still healthy. + SetupQueryResponses(new ApiResponse + { + Success = true, + Data = new SnowflakeQueryResponse(), + }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Failed, result.Status); + Assert.Equal(0, _faultCount); + } + + [Fact] + public async Task ExecuteQueryAsync_SessionExpiredWithoutMasterToken_FaultsConnection() + { + // 390112 with no master token to renew from: the session cannot be recovered. + SetupQueryResponses(new ApiResponse { Success = false, Code = "390112" }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken(masterToken: null))); + + Assert.Equal(QueryStatus.Failed, result.Status); + Assert.Equal(1, _faultCount); + } + + [Fact] + public async Task ExecuteQueryAsync_MasterTokenExpired_FaultsConnection() + { + SetupQueryResponses(new ApiResponse { Success = false, Code = "390114" }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Failed, result.Status); + Assert.Equal(1, _faultCount); + } + + [Fact] + public async Task ExecuteQueryAsync_RenewalRejected_FaultsConnection() + { + SetupQueryResponses(new ApiResponse { Success = false, Code = "390112" }); + SetupRenewalResponse(new ApiResponse { Success = false, Code = "390114" }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Failed, result.Status); + Assert.True(_faultCount >= 1); + } + + [Fact] + public async Task ExecuteQueryAsync_SuccessfulRenewalAndRetry_DoesNotFaultConnection() + { + SetupQueryResponses( + new ApiResponse { Success = false, Code = "390112" }, + new ApiResponse + { + Success = true, + // A representable result shape (a command status rowset), so the retry + // classifies as a success rather than an unsupported response. + Data = new SnowflakeQueryResponse + { + QueryResultFormat = "json", + RowType = [new RowType { Name = "status", Type = "text" }], + RowSet = [["Statement executed successfully."]], + }, + }); + SetupRenewalResponse(new ApiResponse + { + Success = true, + Data = new SnowflakeRenewSessionData + { + SessionToken = "renewed-session-token", + ValidityInSeconds = 3600, + }, + }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Success, result.Status); + Assert.Equal(0, _faultCount); + } + + [Fact] + public async Task ExecuteQueryAsync_CallerCancellation_DoesNotFaultConnection() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + SetupQueryThrows(new OperationCanceledException(cts.Token)); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken()), cts.Token); + + Assert.Equal(QueryStatus.Cancelled, result.Status); + Assert.Equal(0, _faultCount); + } + + [Fact] + public async Task RenewSessionAsync_Rejected_FaultsConnection() + { + // Proactive renewal (not via a query) whose rejection also means the session is unusable. + SetupRenewalResponse(new ApiResponse { Success = false, Code = "390114" }); + + await Assert.ThrowsAsync(() => _sut.RenewSessionAsync(CreateToken())); + + Assert.Equal(1, _faultCount); + } +} diff --git a/csharp/test/Native/QueryExecutorHeartbeatTests.cs b/csharp/test/Native/QueryExecutorHeartbeatTests.cs new file mode 100644 index 0000000..7c1c392 --- /dev/null +++ b/csharp/test/Native/QueryExecutorHeartbeatTests.cs @@ -0,0 +1,178 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.Query; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using AdbcDrivers.Snowflake.Native.Services.TypeConversion; +using Apache.Arrow.Adbc; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Unit tests for . +/// +[Trait("Category", "Unit")] +public class QueryExecutorHeartbeatTests +{ + private readonly IRestApiClient _apiClient = Substitute.For(); + private readonly ITypeConverter _typeConverter = Substitute.For(); + private readonly QueryExecutor _sut; + + public QueryExecutorHeartbeatTests() + { + _sut = new QueryExecutor( + _apiClient, + _typeConverter, + "testaccount", + network: null, + NullLogger.Instance, + onConnectionFault: static () => { }); + } + + private static AuthenticationToken CreateToken(string? masterToken = "master-token-123") => + new() + { + SessionToken = "session-token-abc", + MasterToken = masterToken, + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + }; + + [Fact] + public async Task HeartbeatAsync_WhenSuccessful_DoesNotThrow() + { + var authToken = CreateToken(); + + _apiClient + .PostAsync( + Arg.Is(e => e.Contains("/session/heartbeat")), + Arg.Any(), + Arg.Is(authToken), + Arg.Any()) + .Returns(new ApiResponse { Success = true }); + + await _sut.HeartbeatAsync(authToken, CancellationToken.None); + + // No exception means success; verify the heartbeat endpoint was called exactly once. + await _apiClient.Received(1).PostAsync( + Arg.Is(e => e.Contains("/session/heartbeat")), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task HeartbeatAsync_WhenSessionExpired_RenewsWithMasterToken() + { + var authToken = CreateToken(masterToken: "my-master-token"); + + // First call: heartbeat returns session-expired + _apiClient + .PostAsync( + Arg.Is(e => e.Contains("/session/heartbeat")), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new ApiResponse { Success = false, Code = "390112" }); + + // Second call: token-request renewal succeeds + _apiClient + .PostAsync( + Arg.Is(e => e.Contains("/session/token-request")), + Arg.Any(), + Arg.Is(t => t.SessionToken == "my-master-token"), + Arg.Any()) + .Returns(new ApiResponse + { + Success = true, + Data = new SnowflakeRenewSessionData + { + SessionToken = "renewed-session-token", + MasterToken = "renewed-master-token", + ValidityInSeconds = 3600, + }, + }); + + await _sut.HeartbeatAsync(authToken, CancellationToken.None); + + // Verify the renewal was called with the master token + await _apiClient.Received(1).PostAsync( + Arg.Is(e => e.Contains("/session/token-request")), + Arg.Any(), + Arg.Is(t => t.SessionToken == "my-master-token"), + Arg.Any()); + + // Verify the auth token was updated + Assert.Equal("renewed-session-token", authToken.SessionToken); + Assert.Equal("renewed-master-token", authToken.MasterToken); + } + + [Fact] + public async Task HeartbeatAsync_WhenSessionExpiredButNoMasterToken_DoesNotThrow() + { + var authToken = CreateToken(masterToken: null); + + _apiClient + .PostAsync( + Arg.Is(e => e.Contains("/session/heartbeat")), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new ApiResponse { Success = false, Code = "390112" }); + + // Should not throw — just returns without renewal + await _sut.HeartbeatAsync(authToken, CancellationToken.None); + + // Verify no renewal call was attempted + await _apiClient.DidNotReceive().PostAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task HeartbeatAsync_WhenOtherFailure_ThrowsAdbcException() + { + var authToken = CreateToken(); + + _apiClient + .PostAsync( + Arg.Is(e => e.Contains("/session/heartbeat")), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new ApiResponse { Success = false, Code = "999999" }); + + var ex = await Assert.ThrowsAsync( + () => _sut.HeartbeatAsync(authToken, CancellationToken.None)); + + Assert.Contains("heartbeat failed", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task HeartbeatAsync_WhenNullAuthToken_ThrowsArgumentNullException() + { + await Assert.ThrowsAsync( + () => _sut.HeartbeatAsync(null!, CancellationToken.None)); + } +} diff --git a/csharp/test/Native/QueryExecutorInProgressTests.cs b/csharp/test/Native/QueryExecutorInProgressTests.cs new file mode 100644 index 0000000..c71d045 --- /dev/null +++ b/csharp/test/Native/QueryExecutorInProgressTests.cs @@ -0,0 +1,177 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.Query; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using AdbcDrivers.Snowflake.Native.Services.TypeConversion; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Tests for long-running query completion: when a query outlives Snowflake's synchronous +/// response window the server answers with a query-in-progress GS code (333333/333334) and a +/// getResultUrl; the executor must poll that URL (repeatedly, if the server keeps handing out +/// new URLs) until the final result arrives, renewing the session token if it expires mid-poll. +/// +[Trait("Category", "Unit")] +public class QueryExecutorInProgressTests +{ + private readonly IRestApiClient _apiClient = Substitute.For(); + private readonly QueryExecutor _sut; + private int _faultCount; + + public QueryExecutorInProgressTests() + { + _sut = new QueryExecutor( + _apiClient, + TypeConverter.Shared, + "testaccount", + network: null, + NullLogger.Instance, + onConnectionFault: () => _faultCount++); + } + + private static AuthenticationToken CreateToken() => new() + { + SessionToken = "session-token-abc", + MasterToken = "master-token-123", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + }; + + private static QueryRequest Request(AuthenticationToken token) => new() + { + Statement = "SELECT SYSTEM$WAIT(60)", + AuthToken = token, + }; + + private static ApiResponse InProgress(string resultUrl, string code = "333333") => new() + { + Success = true, + Code = code, + Data = new SnowflakeQueryResponse { GetResultUrl = resultUrl }, + }; + + private static ApiResponse CompletedStatus(string status) => new() + { + Success = true, + Data = new SnowflakeQueryResponse + { + QueryResultFormat = "json", + Returned = 1, + RowType = [new RowType { Name = "status", Type = "text" }], + RowSet = [[status]], + }, + }; + + private void SetupPost(ApiResponse response) => + _apiClient + .PostAsync( + Arg.Is(e => e.Contains("/queries/v1/query-request")), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(response); + + private void SetupGet(string urlFragment, params ApiResponse[] responses) => + _apiClient + .GetAsync( + Arg.Is(e => e.Contains(urlFragment)), + Arg.Any(), + Arg.Any()) + .Returns(responses[0], responses[1..]); + + [Fact] + public async Task ExecuteQueryAsync_InProgress_PollsResultUrlToCompletion() + { + SetupPost(InProgress("/queries/qid-1/result")); + SetupGet("/queries/qid-1/result", CompletedStatus("done")); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Success, result.Status); + Assert.NotNull(result.ResultStream); + result.ResultStream.Dispose(); + Assert.Equal(0, _faultCount); + } + + [Fact] + public async Task ExecuteQueryAsync_InProgressTwice_FollowsEachResultUrl() + { + // The server can answer a poll with another in-progress response carrying the next URL. + SetupPost(InProgress("/queries/qid-1/result")); + SetupGet("/queries/qid-1/result", InProgress("/queries/qid-1/result?disableOfflineChunks=true", code: "333334")); + SetupGet("disableOfflineChunks", CompletedStatus("done")); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Success, result.Status); + Assert.NotNull(result.ResultStream); + result.ResultStream.Dispose(); + } + + [Fact] + public async Task ExecuteQueryAsync_SessionExpiresMidPoll_RenewsAndRepolls() + { + SetupPost(InProgress("/queries/qid-1/result")); + SetupGet( + "/queries/qid-1/result", + new ApiResponse { Success = false, Code = "390112" }, + CompletedStatus("done")); + _apiClient + .PostAsync( + Arg.Is(e => e.Contains("/session/token-request")), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new ApiResponse + { + Success = true, + Data = new SnowflakeRenewSessionData { SessionToken = "renewed-token", ValidityInSeconds = 3600 }, + }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Success, result.Status); + Assert.NotNull(result.ResultStream); + result.ResultStream.Dispose(); + Assert.Equal(0, _faultCount); + } + + [Fact] + public async Task ExecuteQueryAsync_InProgressWithoutResultUrl_FailsAndFaultsConnection() + { + // Protocol anomaly: nothing to poll, and the query's server-side state is unknown. + SetupPost(new ApiResponse + { + Success = true, + Code = "333333", + Data = new SnowflakeQueryResponse(), + }); + + Services.Query.QueryResult result = await _sut.ExecuteQueryAsync(Request(CreateToken())); + + Assert.Equal(QueryStatus.Failed, result.Status); + Assert.Contains("no result URL", result.Errors[0].Message); + Assert.Equal(1, _faultCount); + } +} diff --git a/csharp/test/Native/QueryExecutorTests.cs b/csharp/test/Native/QueryExecutorTests.cs new file mode 100644 index 0000000..c56d781 --- /dev/null +++ b/csharp/test/Native/QueryExecutorTests.cs @@ -0,0 +1,117 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Generic; +using System.Text.Json; +using AdbcDrivers.Snowflake.Native.Services.Query; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Offline unit tests for result classification, especially +/// the DML affected-row detection that parses the JSON row-count summary. +/// +[Trait("Category", "Unit")] +public class QueryExecutorTests +{ + private static SnowflakeQueryResponse Response(string[] columnNames, params string[][] rows) + { + var rowType = new List(); + foreach (string name in columnNames) + rowType.Add(new RowType { Name = name }); + + var rowSet = new List>(); + foreach (string[] row in rows) + rowSet.Add([.. row]); + + return new SnowflakeQueryResponse { RowType = rowType, RowSet = rowSet }; + } + + [Fact] + public void TryGetDmlAffectedRows_Insert_ReturnsCount() + { + SnowflakeQueryResponse data = Response(["number of rows inserted"], ["2"]); + + Assert.True(QueryResultFactory.TryGetDmlAffectedRows(data, out long affected)); + Assert.Equal(2, affected); + } + + [Fact] + public void TryGetDmlAffectedRows_Merge_SumsAllCountColumns() + { + SnowflakeQueryResponse data = Response( + ["number of rows inserted", "number of rows updated"], + ["3", "2"]); + + Assert.True(QueryResultFactory.TryGetDmlAffectedRows(data, out long affected)); + Assert.Equal(5, affected); + } + + [Fact] + public void TryGetDmlAffectedRows_ReadsRowSetNotReturnedCount() + { + // A DELETE affecting 5 rows: RowSet carries 5 even though the payload is a single row. + SnowflakeQueryResponse data = Response(["number of rows deleted"], ["5"]); + data.Returned = 1; + + Assert.True(QueryResultFactory.TryGetDmlAffectedRows(data, out long affected)); + Assert.Equal(5, affected); + } + + [Fact] + public void TryGetDmlAffectedRows_NonDmlColumns_ReturnsFalse() + { + SnowflakeQueryResponse data = Response(["MY_COLUMN"], ["1"]); + + Assert.False(QueryResultFactory.TryGetDmlAffectedRows(data, out long affected)); + Assert.Equal(0, affected); + } + + [Fact] + public void TryGetDmlAffectedRows_EmptyResult_ReturnsFalse() + { + Assert.False(QueryResultFactory.TryGetDmlAffectedRows(new SnowflakeQueryResponse(), out long affected)); + Assert.Equal(0, affected); + } + + [Fact] + public void IsSessionExpired_TrueForExpiredTokenCode() + { + var response = new ApiResponse { Success = false, Code = "390112" }; + Assert.True(QueryExecutor.IsSessionExpired(response)); + } + + [Theory] + [InlineData(true, "390112")] // a successful response is never "expired", whatever the code + [InlineData(false, "000")] // a different (non-session-expired) error + [InlineData(false, null)] // no code at all + public void IsSessionExpired_FalseOtherwise(bool success, string? code) + { + var response = new ApiResponse { Success = success, Code = code }; + Assert.False(QueryExecutor.IsSessionExpired(response)); + } + + [Fact] + public void RenewSessionBody_SerializesAsRenewRequest() + { + string json = JsonSerializer.Serialize(new SnowflakeRenewSessionBody { OldSessionToken = "old-token" }); + + Assert.Contains("\"oldSessionToken\":\"old-token\"", json); + Assert.Contains("\"requestType\":\"RENEW\"", json); + } +} diff --git a/csharp/test/Native/RequestBuilderTests.cs b/csharp/test/Native/RequestBuilderTests.cs new file mode 100644 index 0000000..1df2743 --- /dev/null +++ b/csharp/test/Native/RequestBuilderTests.cs @@ -0,0 +1,130 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using AdbcDrivers.Snowflake.Native.Services.Transport; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Offline unit tests for request-body construction. +/// +[Trait("Category", "Unit")] +public class RequestBuilderTests +{ + private static SnowflakeQueryRequestBody BuildQuery( + string statement, + string? database = null, + string? schema = null, + string? warehouse = null, + string? role = null, + string? queryTag = null, + int? timeout = null, + Dictionary? bindings = null, + bool isMultiStatement = false, + bool describeOnly = false) + => RequestBuilder.BuildQueryRequest( + statement, database, schema, warehouse, role, queryTag, timeout, bindings, isMultiStatement, describeOnly); + + [Fact] + public void BuildQueryRequest_SetsCoreFieldsAndRequestsArrow() + { + SnowflakeQueryRequestBody request = BuildQuery("SELECT 1"); + + Assert.Equal("SELECT 1", request.SqlText); + Assert.False(request.AsyncExec); + Assert.False(request.DescribeOnly); + Assert.Null(request.Bindings); + + Assert.NotNull(request.Parameters); + Assert.Equal("ARROW", request.Parameters!["DOTNET_QUERY_RESULT_FORMAT"]); + } + + [Fact] + public void BuildQueryRequest_IncludesSessionParameters() + { + SnowflakeQueryRequestBody request = BuildQuery( + "SELECT 1", database: "DB", schema: "SC", warehouse: "WH", role: "R", timeout: 30); + + var parameters = request.Parameters!; + Assert.Equal("DB", parameters["DATABASE"]); + Assert.Equal("SC", parameters["SCHEMA"]); + Assert.Equal("WH", parameters["WAREHOUSE"]); + Assert.Equal("R", parameters["ROLE"]); + Assert.Equal("30", parameters["STATEMENT_TIMEOUT_IN_SECONDS"]); + } + + [Fact] + public void BuildQueryRequest_IncludesQueryTag() + { + SnowflakeQueryRequestBody request = BuildQuery("SELECT 1", queryTag: "etl-nightly"); + Assert.Equal("etl-nightly", request.Parameters!["QUERY_TAG"]); + } + + [Fact] + public void BuildQueryRequest_WithoutQueryTag_OmitsIt() + { + SnowflakeQueryRequestBody request = BuildQuery("SELECT 1"); + Assert.False(request.Parameters!.ContainsKey("QUERY_TAG")); + } + + [Fact] + public void BuildQueryRequest_DescribeOnly_SetsFlag() + { + SnowflakeQueryRequestBody request = BuildQuery("SELECT 1", describeOnly: true); + Assert.True(request.DescribeOnly); + } + + [Fact] + public void BuildQueryRequest_WithBindings_IncludesThem() + { + var bindings = new Dictionary { ["1"] = new("TEXT", "x") }; + SnowflakeQueryRequestBody request = BuildQuery("SELECT ?", bindings: bindings); + + Assert.NotNull(request.Bindings); + Assert.Same(bindings, request.Bindings); + Assert.Equal("TEXT", request.Bindings!["1"].Type); + Assert.Equal("x", request.Bindings!["1"].Value); + } + + [Fact] + public void BuildQueryRequest_MultiStatement_SetsCount() + { + SnowflakeQueryRequestBody request = BuildQuery("SELECT 1; SELECT 2", isMultiStatement: true); + Assert.Equal("0", request.Parameters!["MULTI_STATEMENT_COUNT"]); + } + + [Fact] + public void BuildQueryRequest_EmptyStatement_Throws() + { + Assert.Throws(() => RequestBuilder.BuildQueryRequest(string.Empty)); + } + + [Fact] + public void BuildCancelRequest_SetsRequestId() + { + SnowflakeCancelRequestBody request = RequestBuilder.BuildCancelRequest("request-123"); + Assert.Equal("request-123", request.RequestId); + } + + [Fact] + public void BuildCancelRequest_Empty_Throws() + { + Assert.Throws(() => RequestBuilder.BuildCancelRequest(string.Empty)); + } +} diff --git a/csharp/test/Native/SnowflakeAccountUrlTests.cs b/csharp/test/Native/SnowflakeAccountUrlTests.cs new file mode 100644 index 0000000..29389d3 --- /dev/null +++ b/csharp/test/Native/SnowflakeAccountUrlTests.cs @@ -0,0 +1,86 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Offline unit tests for URL building. +/// +[Trait("Category", "Unit")] +public class SnowflakeAccountUrlTests +{ + [Fact] + public void Build_PlainAccount_AppendsSnowflakeDomain() + { + Assert.Equal("https://xy12345.snowflakecomputing.com", SnowflakeAccountUrl.Build("xy12345")); + } + + [Fact] + public void Build_FullHostname_DoesNotDoubleAppend() + { + Assert.Equal( + "https://xy12345.snowflakecomputing.com", + SnowflakeAccountUrl.Build("xy12345.snowflakecomputing.com")); + } + + [Fact] + public void Build_FullHostname_IsCaseInsensitive() + { + Assert.Equal( + "https://xy12345.SNOWFLAKECOMPUTING.COM", + SnowflakeAccountUrl.Build("xy12345.SNOWFLAKECOMPUTING.COM")); + } + + [Fact] + public void Build_NullNetwork_BehavesLikePlainAccount() + { + Assert.Equal( + "https://xy12345.snowflakecomputing.com", + SnowflakeAccountUrl.Build("xy12345", network: null)); + } + + [Fact] + public void Build_NetworkHostOnDefaultPort_UsesHostNoPort() + { + var network = new NetworkConfig { Host = "myhost.example.com" }; + Assert.Equal("https://myhost.example.com", SnowflakeAccountUrl.Build("xy12345", network)); + } + + [Fact] + public void Build_NetworkHostWithNonDefaultPort_IncludesPort() + { + var network = new NetworkConfig { Host = "myhost.example.com", Port = 8080 }; + Assert.Equal("https://myhost.example.com:8080", SnowflakeAccountUrl.Build("xy12345", network)); + } + + [Fact] + public void Build_NetworkHostWithProtocolAndPort_UsesBoth() + { + var network = new NetworkConfig { Host = "localhost", Protocol = "http", Port = 80 }; + Assert.Equal("http://localhost:80", SnowflakeAccountUrl.Build("xy12345", network)); + } + + [Fact] + public void Build_NetworkWithoutHost_DerivesFromAccountWithPort() + { + var network = new NetworkConfig { Port = 8443 }; + Assert.Equal("https://xy12345.snowflakecomputing.com:8443", SnowflakeAccountUrl.Build("xy12345", network)); + } +} diff --git a/csharp/test/Native/SnowflakeConnectionTransactionTests.cs b/csharp/test/Native/SnowflakeConnectionTransactionTests.cs new file mode 100644 index 0000000..93abb06 --- /dev/null +++ b/csharp/test/Native/SnowflakeConnectionTransactionTests.cs @@ -0,0 +1,164 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Threading; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Configuration; +using AdbcDrivers.Snowflake.Native.Services.Authentication; +using AdbcDrivers.Snowflake.Native.Services.ConnectionPool; +using AdbcDrivers.Snowflake.Native.Services.Query; +using Apache.Arrow.Adbc; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Offline tests for connection transaction control: the autocommit option toggles the +/// session's AUTOCOMMIT setting, Commit/Rollback run only inside a transaction scope, and a +/// connection released mid-transaction is cleaned up (or discarded) so pooled reuse is safe. +/// +[Trait("Category", "Unit")] +public class SnowflakeConnectionTransactionTests +{ + private readonly IQueryExecutor _executor = Substitute.For(); + private readonly IConnectionPoolManager _pool = Substitute.For(); + private readonly IPooledConnection _pooled = Substitute.For(); + private readonly SnowflakeConnection _sut; + + public SnowflakeConnectionTransactionTests() + { + _pooled.AuthToken.Returns(new AuthenticationToken + { + SessionToken = "session-token", + MasterToken = "master-token", + ExpiresAt = DateTimeOffset.UtcNow.AddHours(1), + }); + _executor.ExecuteQueryAsync(Arg.Any(), Arg.Any()) + .Returns(new Services.Query.QueryResult { Status = QueryStatus.Success }); + + _sut = new SnowflakeConnection( + new ConnectionConfig(), _pool, _pooled, _executor, NullLogger.Instance); + } + + private Task Executed(string statement) => + _executor.Received(1).ExecuteQueryAsync( + Arg.Is(r => r.Statement == statement), Arg.Any()); + + [Fact] + public void Commit_WithAutocommitEnabled_Throws() + { + var ex = Assert.Throws(_sut.Commit); + Assert.Contains("autocommit", ex.Message); + _executor.DidNotReceiveWithAnyArgs().ExecuteQueryAsync(default!, default); + } + + [Fact] + public void Rollback_WithAutocommitEnabled_Throws() + { + Assert.Throws(_sut.Rollback); + _executor.DidNotReceiveWithAnyArgs().ExecuteQueryAsync(default!, default); + } + + [Fact] + public async Task SetOption_DisableAutocommit_AltersSession() + { + _sut.SetOption(AdbcOptions.Connection.Autocommit, AdbcOptions.Disabled); + + await Executed("ALTER SESSION SET AUTOCOMMIT = FALSE"); + } + + [Fact] + public void SetOption_SameValue_IsNoOp() + { + _sut.SetOption(AdbcOptions.Connection.Autocommit, AdbcOptions.Enabled); + + _executor.DidNotReceiveWithAnyArgs().ExecuteQueryAsync(default!, default); + } + + [Fact] + public async Task CommitAndRollback_InTransactionScope_RunTheStatements() + { + _sut.SetOption(AdbcOptions.Connection.Autocommit, AdbcOptions.Disabled); + + _sut.Commit(); + await Executed("COMMIT"); + + _sut.Rollback(); + await Executed("ROLLBACK"); + } + + [Fact] + public async Task SetOption_ReenableAutocommit_CommitsPendingWorkFirst() + { + _sut.SetOption(AdbcOptions.Connection.Autocommit, AdbcOptions.Disabled); + + _sut.SetOption(AdbcOptions.Connection.Autocommit, AdbcOptions.Enabled); + + // The ADBC contract: turning autocommit back on commits the pending transaction. + Received.InOrder(() => + { + _executor.ExecuteQueryAsync(Arg.Is(r => r.Statement == "COMMIT"), Arg.Any()); + _executor.ExecuteQueryAsync(Arg.Is(r => r.Statement == "ALTER SESSION SET AUTOCOMMIT = TRUE"), Arg.Any()); + }); + await Task.CompletedTask; + } + + [Fact] + public void SetOption_UnknownKey_ThrowsNotImplemented() + { + var ex = Assert.Throws(() => _sut.SetOption("adbc.connection.readonly", "true")); + Assert.Contains("not supported", ex.Message); + } + + [Fact] + public async Task Dispose_MidTransaction_RollsBackAndRestoresAutocommitBeforeRelease() + { + _sut.SetOption(AdbcOptions.Connection.Autocommit, AdbcOptions.Disabled); + + _sut.Dispose(); + + await Executed("ROLLBACK"); + await Executed("ALTER SESSION SET AUTOCOMMIT = TRUE"); + _pool.Received(1).ReleaseConnection(_pooled); + _pooled.DidNotReceive().IsFaulted = true; + } + + [Fact] + public void Dispose_TransactionResetFails_FaultsTheConnection() + { + _sut.SetOption(AdbcOptions.Connection.Autocommit, AdbcOptions.Disabled); + _executor.ExecuteQueryAsync(Arg.Any(), Arg.Any()) + .Returns(Services.Query.QueryResult.Failed("EXECUTION_ERROR", "connection reset")); + + _sut.Dispose(); + + // The session's transaction state is unknown; it must not be reused. + _pooled.Received().IsFaulted = true; + _pool.Received(1).ReleaseConnection(_pooled); + } + + [Fact] + public void Dispose_NoTransaction_DoesNotRunSessionStatements() + { + _sut.Dispose(); + + _executor.DidNotReceiveWithAnyArgs().ExecuteQueryAsync(default!, default); + _pool.Received(1).ReleaseConnection(_pooled); + } +} diff --git a/csharp/test/Native/SnowflakeDatabaseTests.cs b/csharp/test/Native/SnowflakeDatabaseTests.cs new file mode 100644 index 0000000..819e0d3 --- /dev/null +++ b/csharp/test/Native/SnowflakeDatabaseTests.cs @@ -0,0 +1,86 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Offline tests for the database's HttpMessageHandler contract: a caller-supplied handler +/// carries all driver traffic but is never disposed by the driver (ownership stays with the +/// caller — the shape IHttpMessageHandlerFactory requires), while a driver-built handler is +/// owned and disposed with the database. +/// +[Trait("Category", "Unit")] +public class SnowflakeDatabaseTests +{ + private sealed class FakeHandler : HttpMessageHandler + { + private int _requests; + + public int Requests => Volatile.Read(ref _requests); + + public bool Disposed { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Interlocked.Increment(ref _requests); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + } + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } + + private static readonly Dictionary Parameters = new() + { + ["adbc.snowflake.sql.account"] = "testaccount", + ["username"] = "testuser", + ["password"] = "testpass", + }; + + [Fact] + public void Dispose_DoesNotDisposeCallerSuppliedHandler() + { + using var handler = new FakeHandler(); + + var database = new SnowflakeDatabase(Parameters, handler); + database.Dispose(); + + // The caller retains ownership: the driver's dispose must leave the handler usable. + Assert.False(handler.Disposed); + } + + [Fact] + public void CustomHandler_CarriesDriverTraffic() + { + using var handler = new FakeHandler(); + using var database = new SnowflakeDatabase(Parameters, handler); + + // The handler fails every request, so the connect fails — but it must have been + // reached: proof the caller's handler carries the login traffic. + Assert.NotNull(Record.Exception(() => database.Connect(new Dictionary()))); + Assert.True(handler.Requests > 0, "the caller-supplied handler never saw a request"); + } +} diff --git a/csharp/test/Native/SnowflakeDriverTests.cs b/csharp/test/Native/SnowflakeDriverTests.cs new file mode 100644 index 0000000..637a93f --- /dev/null +++ b/csharp/test/Native/SnowflakeDriverTests.cs @@ -0,0 +1,142 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native; +using FluentAssertions; +using Xunit; + +using Apache.Arrow; +using Apache.Arrow.Adbc; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +[Trait("Category", "Unit")] +public class SnowflakeDriverTests : IDisposable +{ + private readonly SnowflakeDriver _driver; + + public SnowflakeDriverTests() + { + _driver = new SnowflakeDriver(); + } + + public void Dispose() + { + _driver?.Dispose(); + } + + [Fact] + public async Task OpenAsync_WithInvalidParameters_ShouldThrowArgumentException() + { + // Given invalid connection parameters + var parameters = new Dictionary + { + ["invalid"] = "parameter" + }; + + // When the database is opened and a connection attempted + using SnowflakeDatabase database = (SnowflakeDatabase)_driver.Open(parameters); + + // Then ConnectAsync throws, complaining about the missing account + var ex = await Assert.ThrowsAsync(() => database.ConnectAsync(null!)); + ex.Message.Should().Contain("account"); + } + + [Fact] + public void Open_WithValidParameters_ShouldReturnDatabase() + { + // Given valid connection parameters + var parameters = new Dictionary + { + ["account"] = "testaccount", + ["user"] = "testuser", + ["password"] = "testpass" + }; + + // When the driver opens a database + using var database = _driver.Open(parameters); + + // Then a SnowflakeDatabase is returned + database.Should().NotBeNull(); + database.Should().BeOfType(); + } + + [Fact] + public void Open_WithNullParameters_ShouldThrowArgumentNullException() + { + // When / Then opening with null parameters throws ArgumentNullException + var exception = Assert.Throws(() => _driver.Open((IReadOnlyDictionary)null!)); + exception.ParamName.Should().Be("parameters"); + } + + [Fact] + public void Open_WithInvalidParameters_ShouldThrowArgumentException() + { + // Given invalid connection parameters + var parameters = new Dictionary + { + ["invalid"] = "parameter" + }; + + // When the database is opened and a connection attempted + var database = _driver.Open(parameters); + + // Then Connect throws, complaining about the missing account + var exception = Assert.Throws(() => database.Connect(null)); + exception.Message.Should().Contain("account"); + } + + [Fact] + public void Open_WithMissingRequiredParameters_ShouldThrowArgumentException() + { + // Given parameters missing the required account + var parameters = new Dictionary + { + ["user"] = "testuser", + ["password"] = "testpass" + }; + + // When the database is opened and a connection attempted + var database = _driver.Open(parameters); + + // Then Connect throws, complaining about the missing account + var exception = Assert.Throws(() => database.Connect(null)); + exception.Message.Should().Contain("account"); + } + + [Fact] + public void Dispose_ShouldNotThrow() + { + // When / Then disposing once does not throw + var ex = Record.Exception(() => _driver.Dispose()); + Assert.Null(ex); + } + + [Fact] + public void Dispose_CalledMultipleTimes_ShouldNotThrow() + { + // When / Then disposing twice does not throw + var ex = Record.Exception(() => + { + _driver.Dispose(); + _driver.Dispose(); + }); + Assert.Null(ex); + } +} diff --git a/csharp/test/Native/SnowflakeResultArrowStreamTests.cs b/csharp/test/Native/SnowflakeResultArrowStreamTests.cs new file mode 100644 index 0000000..c130c5a --- /dev/null +++ b/csharp/test/Native/SnowflakeResultArrowStreamTests.cs @@ -0,0 +1,176 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System.Collections.Generic; +using System.Threading.Tasks; +using AdbcDrivers.Snowflake.Native.Services.Query; +using Apache.Arrow; +using Apache.Arrow.Types; +using Xunit; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Offline tests for — the wire→stable-type fixups +/// (FIXED sizing, TIME/TIMESTAMP rescaling), driven by field logicalType metadata over an +/// in-memory inner stream. Complements the live TypeDecodingTests (real wire shapes) with +/// deterministic coverage of value math and null handling, which the live literals barely hit. +/// +[Trait("Category", "Unit")] +public class SnowflakeResultArrowStreamTests +{ + private static Dictionary FixedMeta(int precision, int scale) => new() + { + ["logicalType"] = "FIXED", + ["precision"] = precision.ToString(), + ["scale"] = scale.ToString(), + }; + + private static async Task TransformAsync(Field field, IArrowArray column) + { + var schema = new Schema([field], null); + var stream = new SnowflakeResultArrowStream(new InMemoryArrowStream(schema, [column])); + RecordBatch? batch = await stream.ReadNextRecordBatchAsync(); + Assert.NotNull(batch); + return batch!; + } + + [Fact] + public async Task Fixed_Int8WithNulls_WidensToInt32_PreservingNulls() + { + // Given a FIXED(5,0) column arriving as Int8 with a null in the middle + var column = new Int8Array.Builder().Append(7).AppendNull().Append(-3).Build(); + var field = new Field("c", Int8Type.Default, true, FixedMeta(5, 0)); + + using RecordBatch batch = await TransformAsync(field, column); + + // Then it becomes Int32 with values and the null preserved + var result = Assert.IsType(batch.Column(0)); + Assert.Equal(7, result.GetValue(0)); + Assert.True(result.IsNull(1)); + Assert.Equal(-3, result.GetValue(2)); + } + + [Fact] + public async Task Fixed_Int16NoNulls_WidensToInt32() + { + // No-null columns take the fast path that skips validity building entirely + var column = new Int16Array.Builder().Append(1000).Append(-1000).Build(); + var field = new Field("c", Int16Type.Default, false, FixedMeta(9, 0)); + + using RecordBatch batch = await TransformAsync(field, column); + + var result = Assert.IsType(batch.Column(0)); + Assert.Equal(0, result.NullCount); + Assert.Equal(1000, result.GetValue(0)); + Assert.Equal(-1000, result.GetValue(1)); + } + + [Fact] + public async Task Fixed_ScaledInt64WithNulls_RescalesToDecimal() + { + // Given NUMBER(10,2): 9.99 arrives as 999, -0.05 as -5 + var column = new Int64Array.Builder().Append(999).AppendNull().Append(-5).Build(); + var field = new Field("c", Int64Type.Default, true, FixedMeta(10, 2)); + + using RecordBatch batch = await TransformAsync(field, column); + + var result = Assert.IsType(batch.Column(0)); + Assert.Equal(9.99m, result.GetValue(0)); + Assert.True(result.IsNull(1)); + Assert.Equal(-0.05m, result.GetValue(2)); + } + + [Fact] + public async Task Time_Int32Scale3_RescalesToNanoseconds() + { + // Given TIME(3): 12:34:56.789 arrives as 45_296_789 (ms of day) + var column = new Int32Array.Builder().Append(45_296_789).AppendNull().Build(); + var field = new Field("c", Int32Type.Default, true, new Dictionary + { + ["logicalType"] = "TIME", + ["scale"] = "3", + }); + + using RecordBatch batch = await TransformAsync(field, column); + + var result = Assert.IsType(batch.Column(0)); + Assert.Equal(45_296_789_000_000L, result.GetValue(0)); + Assert.True(result.IsNull(1)); + } + + [Fact] + public async Task TimestampNtz_EpochFractionStructWithNull_CombinesToNanoseconds() + { + // Given TIMESTAMP_NTZ(9) in its two-field struct shape: epoch seconds + nanosecond + // fraction, with the middle row null (children hold garbage there) + var epoch = new Int64Array.Builder().Append(1).Append(0).Append(2).Build(); + var fraction = new Int32Array.Builder().Append(500).Append(0).Append(750).Build(); + var structType = new StructType( + [ + new Field("epoch", Int64Type.Default, false), + new Field("fraction", Int32Type.Default, false), + ]); + var bitmap = new ArrowBuffer.BitmapBuilder(); + bitmap.Append(true); bitmap.Append(false); bitmap.Append(true); + var column = new StructArray(structType, 3, [epoch, fraction], bitmap.Build(), nullCount: 1); + var field = new Field("c", structType, true, new Dictionary + { + ["logicalType"] = "TIMESTAMP_NTZ", + ["scale"] = "9", + }); + + using RecordBatch batch = await TransformAsync(field, column); + + // Then epoch*1e9 + fraction, null preserved, type Timestamp[ns] without a zone + var result = Assert.IsType(batch.Column(0)); + Assert.Null(((TimestampType)result.Data.DataType).Timezone); + Assert.Equal(1_000_000_500L, result.Values[0]); + Assert.True(result.IsNull(1)); + Assert.Equal(2_000_000_750L, result.Values[2]); + } + + [Fact] + public async Task TimestampLtz_SingleInt64Scale3_RescalesToUtcNanoseconds() + { + // Given TIMESTAMP_LTZ(3) in its single-integer shape (ms since epoch) + var column = new Int64Array.Builder().Append(1_577_836_800_123L).Build(); + var field = new Field("c", Int64Type.Default, true, new Dictionary + { + ["logicalType"] = "TIMESTAMP_LTZ", + ["scale"] = "3", + }); + + using RecordBatch batch = await TransformAsync(field, column); + + var result = Assert.IsType(batch.Column(0)); + Assert.Equal("UTC", ((TimestampType)result.Data.DataType).Timezone); + Assert.Equal(1_577_836_800_123_000_000L, result.Values[0]); + } + + [Fact] + public async Task UntaggedColumns_PassThroughUntouched() + { + // Given a plain string column with no Snowflake logicalType metadata + var column = new StringArray.Builder().Append("hi").Build(); + var field = new Field("c", StringType.Default, true); + + using RecordBatch batch = await TransformAsync(field, column); + + var result = Assert.IsType(batch.Column(0)); + Assert.Equal("hi", result.GetString(0)); + } +} diff --git a/csharp/test/Native/TypeConverterTests.cs b/csharp/test/Native/TypeConverterTests.cs new file mode 100644 index 0000000..c6646dd --- /dev/null +++ b/csharp/test/Native/TypeConverterTests.cs @@ -0,0 +1,279 @@ +/* +* Copyright (c) 2025 ADBC Drivers Contributors +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +using System; +using AdbcDrivers.Snowflake.Native.Services.TypeConversion; +using Xunit; + +using Apache.Arrow; +using Apache.Arrow.Types; + +namespace AdbcDrivers.Snowflake.Native.Tests; + +/// +/// Offline unit tests for — the describe/metadata mapping +/// (layer 1 of 3, see below). It translates a Snowflake type name to an Arrow type and +/// back, maps an Arrow batch to bind parameters, and builds an Arrow batch from a JSON row set. +/// This is the path behind GetTableSchema/describe; being a pure function over type names, it +/// needs no connection. +/// +/// The driver has three type-fidelity layers, each tested in its own place: +/// +/// this — Snowflake type name ⇄ Arrow (describe path, offline); +/// — Snowflake result wire → Arrow, +/// over a live query (the source of truth for what a SELECT actually returns); +/// Integration.ClientTests.Reader_ConvertsColumnTypesToClr — Arrow → CLR via the +/// ADO.NET client. +/// +/// Layers 1 and 2 are distinct code paths and can differ — e.g. NUMBER(38,0) maps to Int64 here +/// but decodes to Decimal128 off the wire. +/// +[Trait("Category", "Unit")] +public class TypeConverterTests +{ + private readonly TypeConverter _converter = new(); + + // ---- ConvertSnowflakeTypeToArrow ---- + + [Theory] + [InlineData("BOOLEAN", typeof(BooleanType))] + // INTEGER/INT/BIGINT are NUMBER(38,0) in Snowflake → sized like NUMBER(38,0) (Decimal128). + [InlineData("INTEGER", typeof(Decimal128Type))] + [InlineData("INT", typeof(Decimal128Type))] + [InlineData("BIGINT", typeof(Decimal128Type))] + [InlineData("FLOAT", typeof(FloatType))] + [InlineData("DOUBLE", typeof(DoubleType))] + [InlineData("REAL", typeof(DoubleType))] + [InlineData("VARCHAR", typeof(StringType))] + [InlineData("STRING", typeof(StringType))] + [InlineData("TEXT", typeof(StringType))] + [InlineData("BINARY", typeof(BinaryType))] + [InlineData("DATE", typeof(Date32Type))] + [InlineData("TIME", typeof(Time64Type))] + [InlineData("VARIANT", typeof(StringType))] + [InlineData("OBJECT", typeof(StringType))] + [InlineData("ARRAY", typeof(ListType))] + [InlineData("GEOGRAPHY", typeof(StringType))] + [InlineData("GEOMETRY", typeof(StringType))] + public void ConvertSnowflakeTypeToArrow_MapsScalarTypes(string typeName, Type expectedArrowType) + { + IArrowType result = _converter.ConvertSnowflakeTypeToArrow(new SnowflakeDataType { TypeName = typeName }); + Assert.IsType(expectedArrowType, result); + } + + [Theory] + [InlineData(9, typeof(Int32Type))] + [InlineData(18, typeof(Int64Type))] + [InlineData(38, typeof(Decimal128Type))] + public void ConvertSnowflakeTypeToArrow_ScaleZeroNumber_SizedByPrecision(int precision, Type expectedArrowType) + { + // Matches the result decoder: a scale-0 NUMBER is sized by its declared precision so the + // described schema agrees with what a query returns. + IArrowType result = _converter.ConvertSnowflakeTypeToArrow( + new SnowflakeDataType { TypeName = "NUMBER", Precision = precision, Scale = 0 }); + Assert.IsType(expectedArrowType, result); + } + + [Fact] + public void ConvertSnowflakeTypeToArrow_NumberWithScale_IsDecimal128() + { + IArrowType result = _converter.ConvertSnowflakeTypeToArrow( + new SnowflakeDataType { TypeName = "NUMBER", Precision = 18, Scale = 2 }); + var decimalType = Assert.IsType(result); + Assert.Equal(18, decimalType.Precision); + Assert.Equal(2, decimalType.Scale); + } + + [Fact] + public void ConvertSnowflakeTypeToArrow_Array_IsListOfString() + { + IArrowType result = _converter.ConvertSnowflakeTypeToArrow(new SnowflakeDataType { TypeName = "ARRAY" }); + var listType = Assert.IsType(result); + Assert.IsType(listType.ValueDataType); + } + + [Theory] + [InlineData("TIMESTAMP_NTZ", null)] + [InlineData("TIMESTAMP", null)] + [InlineData("DATETIME", null)] + [InlineData("TIMESTAMP_LTZ", "UTC")] + public void ConvertSnowflakeTypeToArrow_Timestamp_HasExpectedTimezone(string typeName, string? expectedTimezone) + { + var result = Assert.IsType( + _converter.ConvertSnowflakeTypeToArrow(new SnowflakeDataType { TypeName = typeName })); + Assert.Equal(TimeUnit.Nanosecond, result.Unit); + Assert.Equal(expectedTimezone, result.Timezone); + } + + [Fact] + public void ConvertSnowflakeTypeToArrow_TimestampTz_IsTaggedUtc() + { + // TIMESTAMP_TZ decodes to its UTC instant (a single Arrow column cannot carry a per-row + // offset), so the described type matches the result: Timestamp[ns] tagged "UTC", whatever + // the column's own timezone. + var result = Assert.IsType(_converter.ConvertSnowflakeTypeToArrow( + new SnowflakeDataType { TypeName = "TIMESTAMP_TZ", Timezone = "America/New_York" })); + Assert.Equal("UTC", result.Timezone); + } + + [Fact] + public void ConvertSnowflakeTypeToArrow_UnknownType_Throws() + { + Assert.Throws( + () => _converter.ConvertSnowflakeTypeToArrow(new SnowflakeDataType { TypeName = "NONSENSE" })); + } + + [Fact] + public void ConvertSnowflakeTypeToArrow_Null_Throws() + { + Assert.Throws(() => _converter.ConvertSnowflakeTypeToArrow(null!)); + } + + // ---- ConvertArrowBatchToParameters ---- + + [Fact] + public void ConvertArrowBatchToParameters_KeysBindingsPositionallyWithTypes() + { + // Given a two-column Arrow batch (an Int64 and a string) + var schema = new Schema( + [new Field("A", Int64Type.Default, true), new Field("B", StringType.Default, true)], + null); + IArrowArray idArray = new Int64Array.Builder().Append(42).Build(); + IArrowArray nameArray = new StringArray.Builder().Append("hello").Build(); + using var batch = new RecordBatch(schema, [idArray, nameArray], 1); + + // When it is converted to bind parameters + var result = _converter.ConvertArrowBatchToParameters(batch); + + // Then bindings are keyed by 1-based placeholder position (matching '?'), not by column + // name, with the Snowflake bind type mapped from the Arrow type. + Assert.Equal(2, result.Parameters.Count); + Assert.Equal("FIXED", result.Parameters["1"].Type); + Assert.Equal("42", result.Parameters["1"].Value); + Assert.Equal("TEXT", result.Parameters["2"].Type); + Assert.Equal("hello", result.Parameters["2"].Value); + } + + [Fact] + public void ConvertArrowBatchToParameters_EmptyBatch_ReturnsNoParameters() + { + var schema = new Schema([new Field("A", Int64Type.Default, true)], null); + IArrowArray empty = new Int64Array.Builder().Build(); + using var batch = new RecordBatch(schema, [empty], 0); + + ParameterSet result = _converter.ConvertArrowBatchToParameters(batch); + + Assert.Empty(result.Parameters); + } + + [Fact] + public void ConvertArrowBatchToParameters_Null_Throws() + { + Assert.Throws(() => _converter.ConvertArrowBatchToParameters(null!)); + } + + [Theory] + [MemberData(nameof(BindCases.Names), MemberType = typeof(BindCases))] + public void ConvertArrowBatchToParameters_FormatsEachTypePerSnowflakeBindProtocol(string caseName) + { + // One row per bindable Arrow type, defined once in BindCases; this asserts the wire format. + var bindCase = BindCases.Get(caseName); + AssertBind(bindCase.BuildArray(), bindCase.ExpectedBindType, bindCase.ExpectedValue); + } + + [Fact] + public void ConvertArrowBatchToParameters_NullValue_KeepsTheColumnBindType() + { + // A null in a DATE column still binds as a typed DATE null, not an untyped TEXT null. + AssertBind(new Date32Array.Builder().AppendNull().Build(), "DATE", null); + } + + [Fact] + public void ConvertArrowBatchToParameters_MultiRowBatch_BindsValueArrays() + { + // Array binding (executemany): each column becomes one array bind with a wire value + // per row — same per-value format as a scalar bind, nulls preserved. + var schema = new Schema( + [ + new Field("id", Int64Type.Default, true), + new Field("name", StringType.Default, true), + ], null); + var ids = new Int64Array.Builder().Append(1).AppendNull().Append(3).Build(); + var names = new StringArray.Builder().Append("a").Append("b").AppendNull().Build(); + using var batch = new RecordBatch(schema, [ids, names], 3); + + var parameters = _converter.ConvertArrowBatchToParameters(batch).Parameters; + + Assert.Equal("FIXED", parameters["1"].Type); + Assert.Null(parameters["1"].Value); + Assert.Equal(["1", null, "3"], parameters["1"].Values); + Assert.Equal("TEXT", parameters["2"].Type); + Assert.Equal(["a", "b", null], parameters["2"].Values); + } + + [Fact] + public void ConvertArrowBatchToParameters_SingleRowBatch_StaysScalar() + { + // Wire compatibility: a single-row batch keeps the scalar "value" shape. + var schema = new Schema([new Field("p", Int64Type.Default, true)], null); + var values = new Int64Array.Builder().Append(42).Build(); + using var batch = new RecordBatch(schema, [values], 1); + + var parameters = _converter.ConvertArrowBatchToParameters(batch).Parameters; + + Assert.Equal("42", parameters["1"].Value); + Assert.Null(parameters["1"].Values); + } + + [Fact] + public void SnowflakeBinding_SerializesScalarAndArrayValueShapes() + { + // The bind protocol carries both shapes under the same "value" key: a string for a + // scalar bind, an array of strings/nulls for an array bind. + string scalar = System.Text.Json.JsonSerializer.Serialize( + new Services.Transport.SnowflakeBinding("TEXT", "x")); + Assert.Equal("""{"type":"TEXT","value":"x"}""", scalar); + + string scalarNull = System.Text.Json.JsonSerializer.Serialize( + new Services.Transport.SnowflakeBinding("TEXT", (string?)null)); + Assert.Equal("""{"type":"TEXT","value":null}""", scalarNull); + + string array = System.Text.Json.JsonSerializer.Serialize( + new Services.Transport.SnowflakeBinding("FIXED", ["1", null, "3"])); + Assert.Equal("""{"type":"FIXED","value":["1",null,"3"]}""", array); + } + + [Fact] + public void ConvertArrowBatchToParameters_UnsupportedArrowType_Throws() + { + // An untyped null column can't be bound to a Snowflake type — throw rather than guess. + var schema = new Schema([new Field("p", NullType.Default, true)], null); + using var batch = new RecordBatch(schema, [new NullArray(1)], 1); + + Assert.Throws(() => _converter.ConvertArrowBatchToParameters(batch)); + } + + private void AssertBind(IArrowArray array, string expectedType, string? expectedValue) + { + var schema = new Schema([new Field("p", array.Data.DataType, true)], null); + using var batch = new RecordBatch(schema, [array], array.Length); + + var binding = _converter.ConvertArrowBatchToParameters(batch).Parameters["1"]; + + Assert.Equal(expectedType, binding.Type); + Assert.Equal(expectedValue, binding.Value); + } +} diff --git a/csharp/test/Native/readme.md b/csharp/test/Native/readme.md new file mode 100644 index 0000000..ab7768c --- /dev/null +++ b/csharp/test/Native/readme.md @@ -0,0 +1,230 @@ + + +# Native Snowflake ADBC Driver — Tests + +Tests for the native C# Snowflake ADBC driver (`AdbcDrivers.Snowflake.Native`). The +suite splits into two tiers, separated both by **folder** and by an xUnit **trait**: + +- **Unit tests** — no network, no credentials; run everywhere (CI included). They live in + the project root (plus `Configuration/`) and are tagged `[Trait("Category", "Unit")]`. +- **Integration tests** — exercise a *live* Snowflake account; they live under + `Integration/` and are tagged `[Trait("Category", "Integration")]`. They are also written + as `[SkippableFact]`/`[SkippableTheory]`, so they **skip automatically** when no + configuration is present instead of failing. + +``` +dotnet test --filter "Category=Unit" # offline only — no account needed +dotnet test --filter "Category=Integration" # live — needs SNOWFLAKE_TEST_CONFIG_FILE +dotnet test # everything (integration skips if unconfigured) +``` + +**Adding a test:** put offline tests in the root and tag them `Category=Unit`; put tests +that need a live account under `Integration/` and tag them `Category=Integration`. + +--- + +## What is tested + +### Offline unit tests (no Snowflake connection) + +| File | Tests | What it covers | +|------|-------|----------------| +| `TypeConverterTests.cs` | ~41 | Snowflake ⇄ Arrow type mapping (describe path): NUMBER sized by precision (scale 0 → Int32 ≤9 / Int64 ≤18 / else Decimal128; scale>0 → Decimal128) to match the result decoder, BOOLEAN/VARCHAR/BINARY/DATE/TIME, TIMESTAMP NTZ/LTZ/TZ (TZ tagged UTC), and unsupported-type → `NotSupportedException`. | +| `RequestBuilderTests.cs` | ~8 | REST request-body construction — `BuildQueryRequest` (sqlText, ARROW result format, session parameters, bindings, multi-statement, `describeOnly`), `BuildCancelRequest`, and argument validation. | +| `SnowflakeAccountUrlTests.cs` | ~8 | Account → base-URL building: plain account vs. full hostname (no double-append, case-insensitive), and `NetworkConfig` host/port/protocol overrides. | +| `QueryExecutorTests.cs` | ~5 | DML affected-row detection (`TryGetDmlAffectedRows`): INSERT count, MERGE summing across count columns, reads the row-count summary (not the payload `Returned`), non-DML / empty → false. | +| `Configuration/ConnectionStringParserTests.cs` | ~12 | Connection-string / parameter parsing and required-parameter / invalid-authenticator validation. | +| `SnowflakeDriverTests.cs` | ~7 | `Open` parameter validation (missing/invalid/null → `ArgumentException`/`ArgumentNullException`) and idempotent `Dispose` — offline; the *live* connect path is `ConnectionTests`. | + +These assert pure logic against in-memory inputs, so they're the fast feedback loop and +the regression net for the protocol/encoding code. + +### Integration tests (require a live account) + +Each integration file owns **one concern** of one of the driver's two surfaces — the Arrow-native +ADBC API (`Driver`/`Database`/`Connection`/`Statement` returning Arrow) or the ADO.NET client layer. + +| File | Surface | What it covers | +|------|---------|----------------| +| `ConnectionTests.cs` | Arrow-native | **Live connectivity / lifecycle smoke**: the `Driver → Database → Connection` open path works against a real account. The fast first-line diagnostic that isolates "cannot connect" from "a query failed"; every other suite relies on this path as setup but does not assert it directly. | +| `StatementTests.cs` | Arrow-native | The `AdbcStatement` surface: execute query, `ExecuteUpdate` (SELECT → -1; DML → affected-row count), `Prepare` then execute, parameter binding (`CanBindParameter` theory drives every type in `BindCases.cs` by position), `Cancel` of a running query (`SYSTEM$WAIT` aborted via `/queries/v1/abort-request`), and `GetParameterSchema` → `NotImplementedException`. | +| `QueryAndMetadataTests.cs` | Arrow-native | **Content-asserting** query execution + the metadata methods, against the shared read-only `SNOWFLAKE_SAMPLE_DATA` (TPC-H SF1). Because that data has fixed cardinalities/contents in every account, these verify *real values*: REGION's 5 names, NATION = 25 rows/4 cols, CUSTOMER = full 150,000-row chunk streaming, invalid-SQL → `AdbcException`, deterministic `GetTableSchema` types, `GetTableTypes` = TABLE/VIEW, `GetInfo` vendor = "Snowflake", and `GetObjects` navigated through the nested Arrow down to NATION's columns. | +| `TypeDecodingTests.cs` | Arrow-native | The over-the-wire **result type-decode matrix**: for each Snowflake type, `SELECT ` and assert the Arrow type the result stream produces — plus exact values for NUMBER precision sizing (Int32/Int64/Decimal128), TIME, and TIMESTAMP UTC instants. | +| `ClientTests.cs` | ADO.NET client | The driver used as a `System.Data.Common` provider (`Apache.Arrow.Adbc.Client`): `DbDataReader` row iteration, column metadata, connection-string parsing, `ExecuteNonQuery` DML, and the end-to-end Arrow→CLR type contract. | +| `BenchmarkTests.cs` | Arrow-native | **Performance**: result-fetch throughput across row-count sizes against the sample data. | + +> **Note on result-set types.** Query result-set Arrow types come from Snowflake's +> native Arrow IPC (encoding-dependent), so the integration tests assert on **content, +> row counts, and column names** there. Deterministic *type* assertions are done via +> `GetTableSchema` (the describe path, which maps through `TypeConverter`). + +--- + +## Requirements + +### Offline tests +- .NET 8 SDK. Nothing else — `dotnet test` with the filters below runs them with no + account. + +### Integration tests +- A reachable **Snowflake account** and login (username/password, key-pair/JWT, or + OAuth — see config below). +- A **running warehouse**. +- The **`SNOWFLAKE_SAMPLE_DATA`** share — mounted by default, **no setup required** — for the + suites that read it (the query/metadata/benchmark tests). +- A **writable database + schema** for the write-path tests (anything that creates a + `TEMPORARY` table — e.g. `StatementTests.ExecuteUpdateOnDmlReturnsAffectedRowCount` and the + `ClientTests` write tests). + This is the only manual prerequisite: create a database your role can write to (e.g. + `CREATE DATABASE ADBC_TEST;` — a `PUBLIC` schema is created automatically) and point + `metadata.catalog` / `metadata.schema` at it. The tests create their own **temporary** + tables, so nothing needs to be pre-created or seeded; if no writable schema is configured, + those tests `Skip`. + +--- + +## Configuring integration tests + +Configuration is loaded from a **JSON file** pointed to by the +`SNOWFLAKE_TEST_CONFIG_FILE` environment variable. (The per-variable +`SNOWFLAKE_*` env-var loader exists in `IntegrationTestingUtils` but is currently +disabled because it does not populate the `metadata.*` block the metadata tests need.) + +**1. Create a config file** (keep it outside the repo — it holds secrets): + +```json +{ + "account": "your-account", + "user": "your-username", + "password": "your-password", + "warehouse": "your-warehouse", + "database": "your-database", + "schema": "your-schema", + "query": "SELECT * FROM your-database.your-schema.your-table", + "expectedResults": 2, + "authentication": { + "auth_snowflake": { + "user": "your-username", + "password": "your-password" + }, + "auth_jwt": { + "user": "your-service-user", + "private_key_file": "C:\\path\\to\\rsa_key.p8" + }, + "auth_pat": { + "user": "your-service-user", + "token": "your-programmatic-access-token" + } + }, + "metadata": { + "catalog": "your-database", + "schema": "your-schema", + "table": "your-table", + "expectedColumnCount": 30 + } +} +``` + +The native tests need only valid **credentials + a warehouse**, plus a **writable** +`metadata.catalog`/`metadata.schema` for the write-path tests (`ClientTests`, +`StatementTests.ExecuteUpdateOnDmlReturnsAffectedRowCount`). `QueryAndMetadataTests` and +`TypeDecodingTests` target `SNOWFLAKE_SAMPLE_DATA` / SQL literals directly and ignore +`metadata.*`. The `query` / `expectedResults` / `expectedColumnCount` fields are **not used** by +the native tests — they remain only for Interop-config compatibility. + +When multiple `authentication` blocks are present, the main suite uses `auth_snowflake` +first; the `auth_jwt` and `auth_pat` blocks are exercised only by their dedicated +connection tests (`ConnectionTests.OpenAndConnect_WithKeyPair_Succeeds` / +`OpenAndConnect_WithPat_Succeeds`), which skip when the block is absent. + +PAT setup — the details matter (the server reports every misconfiguration as the same +"Programmatic access token is invalid" error): +- The `auth_pat` block's `user` must be the user the token was **created for** (PATs are + user-bound). +- The user must be subject to a **network policy** (`CREATE NETWORK POLICY …` + + `ALTER USER … SET NETWORK_POLICY = …`). Snowsight's "bypass requirement for network + policy" option exists for **human users only** and is temporary. +- For `TYPE = SERVICE` users, `ROLE_RESTRICTION` is **mandatory**: + `ALTER USER ADD PROGRAMMATIC ACCESS TOKEN + ROLE_RESTRICTION = '' DAYS_TO_EXPIRY = 30;` — the printed `token_secret` + (shown once) is what goes in the config, not the token's name. Key-pair setup — generate a PKCS#8 key pair, then register the public key on a +dedicated service user (its `DEFAULT_ROLE` only needs warehouse usage; the top-level +`role` in the config is not applied to this test): + +```bash +openssl genrsa 2048 | openssl pkcs8 -topk8 -inform PEM -nocrypt -out rsa_key.p8 +openssl rsa -in rsa_key.p8 -pubout -out rsa_key.pub +``` + +```sql +CREATE ROLE IF NOT EXISTS ADBC_TEST_ROLE; +GRANT USAGE ON WAREHOUSE TO ROLE ADBC_TEST_ROLE; +CREATE USER TYPE = SERVICE + DEFAULT_ROLE = ADBC_TEST_ROLE DEFAULT_WAREHOUSE = ; +GRANT ROLE ADBC_TEST_ROLE TO USER ; +-- public key with the BEGIN/END lines and newlines stripped +ALTER USER SET RSA_PUBLIC_KEY='MIIB...'; +-- verify: RSA_PUBLIC_KEY_FP must equal SHA256: +DESC USER ; +``` + +**2. Point the environment variable at it:** + +```powershell +# PowerShell +$env:SNOWFLAKE_TEST_CONFIG_FILE = "C:\path\to\snowflakeconfig.local.json" +``` +```bash +# Linux/macOS +export SNOWFLAKE_TEST_CONFIG_FILE=/path/to/snowflakeconfig.local.json +``` + +The JSON format matches the Interop Snowflake tests, so the same config file works for +both drivers (the native driver just ignores `driverPath`/`driverEntryPoint`). + +--- + +## Running + +```bash +# Unit tests only (no account needed) +dotnet test --filter "Category=Unit" + +# All integration tests (set SNOWFLAKE_TEST_CONFIG_FILE first) +dotnet test --filter "Category=Integration" + +# A single integration suite +dotnet test --filter "Category=Integration & FullyQualifiedName~QueryAndMetadataTests" + +# Everything — integration tests skip automatically if SNOWFLAKE_TEST_CONFIG_FILE is unset +dotnet test + +# Deliberately slow tests (Category=Slow, e.g. the ~50s long-running-query polling test) +# skip by default; enable them explicitly: +$env:SNOWFLAKE_RUN_SLOW_TESTS = "1" # PowerShell (bash: export SNOWFLAKE_RUN_SLOW_TESTS=1) +dotnet test --filter "Category=Slow" +``` + +--- + +## Security + +- **Never commit credentials.** Keep the config file outside the repository. +- Prefer key-pair (JWT) or OAuth over passwords where possible. +- Rotate credentials regularly.