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.
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