From 6791dc2e1966fc24aface0bf8bfd59db828ec7ef Mon Sep 17 00:00:00 2001 From: Curt Hagenlocher Date: Wed, 20 May 2026 19:30:03 -0700 Subject: [PATCH] fix(csharp): support spec-correct driver manifests Closes #4329 The driver manager was parsing every TOML file as a connection profile, so real driver manifests (with `manifest_version = 1` and a string `version`) were rejected with "The 'profile_version' field has an invalid value '1.5.2'. It must be an integer." Add a proper DriverManifest parser per docs/source/format/driver_manifests.rst, with [Driver.shared] as either a single string or a platform-tuple table. Managed (.NET) driver selection moves from the C#-specific `driver_type` field on connection profiles to a scheme-prefixed entrypoint on the manifest: `dotnet:Type` for modern .NET, `netfx:Type` for .NET Framework. The host rejects a manifest whose scheme doesn't match its runtime, so mismatches fail with a clear error instead of an assembly-loader mystery. Profile-driven managed loading uses the same scheme via an `entrypoint` option in `[Options]`, which the driver manager consumes and does not forward to the driver. Also aligns env_var placeholder support with the spec syntax `{{ env_var(NAME) }}` per docs/source/format/connection_profiles.rst: placeholders may be embedded anywhere in a value, repeated, missing vars expand to "" (matching the C/C++ driver manager), and unknown functions are rejected. End-to-end coverage against DuckDB lives in DriverManifestTests.FindLoadDriver_WithRealDriverManifest_LoadsDuckDbDriver. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../DriverManager/AdbcDriverManager.cs | 244 ++++++++----- .../DriverManager/ConnectionProfile.cs | 164 ++++++--- .../DriverManager/DriverManifest.cs | 331 ++++++++++++++++++ .../FilesystemProfileProvider.cs | 8 +- csharp/src/Apache.Arrow.Adbc/readme.md | 71 +++- .../DriverManager/ColocatedManifestTests.cs | 90 +++-- .../DriverManager/DriverManifestTests.cs | 320 +++++++++++++++++ .../DriverManager/EntrypointSchemeTests.cs | 123 +++++++ .../TomlConnectionProfileTests.cs | 263 ++++++++++---- 9 files changed, 1357 insertions(+), 257 deletions(-) create mode 100644 csharp/src/Apache.Arrow.Adbc/DriverManager/DriverManifest.cs create mode 100644 csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/DriverManifestTests.cs create mode 100644 csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/EntrypointSchemeTests.cs diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs index 88adeddfef..3badc3e457 100644 --- a/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/AdbcDriverManager.cs @@ -64,10 +64,11 @@ public static class AdbcDriverManager /// /// Managed (.NET) drivers always require a manifest. This method loads the /// driver as a native shared library unless a co-located TOML manifest is present - /// that specifies a managed driver_type. To load a managed .NET driver, - /// either point at a directory containing a - /// co-located .toml manifest, call directly, - /// or use to load a .NET assembly without a manifest. + /// whose [Driver].entrypoint begins with a managed-runtime scheme prefix + /// (e.g. dotnet:My.Driver.Type). To load a managed .NET driver, either + /// point at a directory containing such a co-located + /// manifest or use to load a .NET assembly + /// without a manifest. /// /// /// Security: must be an absolute, fully @@ -112,12 +113,19 @@ public static AdbcDriver LoadDriver(string driverPath, string? entrypoint = null } /// - /// Loads a native driver assembly from a verified absolute path, with no - /// further manifest probing. All terminal native-load call sites funnel - /// through this helper so security policy is applied uniformly. + /// Loads a driver assembly from a verified absolute path, with no further + /// manifest probing. If is scheme-prefixed + /// (dotnet:, netfx:) the managed loader is used; otherwise + /// the path is loaded as a native shared library. All terminal load call + /// sites funnel through this helper so security policy and audit logging + /// are applied uniformly. /// private static AdbcDriver LoadNativeDriver(string driverPath, string? entrypoint, string loadMethod) { + if (entrypoint != null && HasManagedEntrypointScheme(entrypoint)) + { + return LoadByEntrypointScheme(driverPath, entrypoint, manifestPath: null, loadMethod); + } string resolvedEntrypoint = entrypoint ?? DeriveEntrypoint(driverPath); return LoadWithSecurity( driverPath, @@ -424,25 +432,30 @@ private static AdbcDriver LoadManagedDriverCore(string assemblyPath, string type // OpenDatabaseFromProfile – load driver + open database in one step // ----------------------------------------------------------------------- + /// The option key the connection profile uses to override the driver entrypoint. + internal const string EntrypointOptionKey = "entrypoint"; + /// /// Loads the driver specified by and opens a database, /// applying all options from the profile as connection parameters. /// /// /// - /// If the profile has a non-null , the - /// driver is loaded as a managed .NET assembly via and - /// is used as the assembly path. - /// - /// - /// Otherwise the driver is loaded as a native shared library via - /// . + /// The driver is located by name via . If a driver + /// manifest is found, its [Driver].entrypoint determines whether the + /// driver loads natively or via the managed (.NET) host; a scheme-prefixed + /// entrypoint such as dotnet:My.Driver.Type selects the managed loader. + /// The profile's [Options] table may carry an entrypoint value + /// that overrides anything in the manifest -- useful when driver is a + /// bare shared-library path with no companion manifest. /// /// /// All options (string, integer, and double) are merged into a single /// string → string dictionary. Integer and double values are formatted /// using . The merged dictionary is /// passed to . + /// The entrypoint option (if any) is consumed by the driver manager + /// and is not forwarded to the driver. /// /// /// Call on the profile before @@ -475,24 +488,24 @@ public static AdbcDatabase OpenDatabaseFromProfile( /// options, the explicit value takes precedence. /// /// - /// If the profile has a non-null , the - /// driver is loaded as a managed .NET assembly via and - /// is used as the assembly path. + /// The driver is located by name via ; the + /// entrypoint option (if present in either the profile's [Options] + /// or ) is consumed by the driver manager + /// and overrides any entrypoint specified by a discovered driver manifest. + /// Scheme-prefixed values such as dotnet:My.Driver.Type route the load + /// through the managed (.NET) host. /// /// - /// Otherwise the driver is loaded as a native shared library via - /// . - /// - /// - /// All options are merged into a single - /// following order (later values override earlier ones for the same key): + /// All options are merged into a single dictionary in the following order (later + /// values override earlier ones for the same key): /// /// Profile integer options (formatted as strings) /// Profile double options (formatted as strings) /// Profile string options /// Explicit options from /// - /// The merged dictionary is passed to . + /// The merged dictionary, minus any entrypoint entry, is passed to + /// . /// /// /// The connection profile specifying the driver and options. @@ -513,25 +526,24 @@ public static AdbcDatabase OpenDatabaseFromProfile( { if (profile == null) throw new ArgumentNullException(nameof(profile)); - AdbcDriver driver; - - if (!string.IsNullOrEmpty(profile.DriverTypeName)) + Dictionary mergedOptions = new Dictionary(StringComparer.Ordinal); + foreach (KeyValuePair kv in BuildStringOptions(profile, explicitOptions)) { - // Managed .NET driver path - if (string.IsNullOrEmpty(profile.DriverName)) - throw new AdbcException( - "The connection profile specifies a driver_type but no driver assembly path (driver field).", - AdbcStatusCode.InvalidArgument); - - driver = LoadManagedDriver(profile.DriverName!, profile.DriverTypeName!); + mergedOptions[kv.Key] = kv.Value; } - else + + // entrypoint is a driver-manager option, not a driver option: pull it out + // of the bag before opening the database. When set, it overrides any + // entrypoint declared by a driver manifest found via FindLoadDriver. + string? entrypoint = null; + if (mergedOptions.TryGetValue(EntrypointOptionKey, out string? entrypointValue)) { - // Native shared-library path - driver = LoadDriverFromProfile(profile, null, loadOptions, additionalSearchPathList); + entrypoint = entrypointValue; + mergedOptions.Remove(EntrypointOptionKey); } - return driver.Open(BuildStringOptions(profile, explicitOptions)); + AdbcDriver driver = LoadDriverFromProfile(profile, entrypoint, loadOptions, additionalSearchPathList); + return driver.Open(mergedOptions); } /// @@ -658,15 +670,9 @@ private static AdbcDriver LoadFromAbsolutePath(string path, string? entrypoint) /// /// /// Co-located manifests allow drivers to ship with metadata about how they should - /// be loaded (e.g., specifying they're managed .NET drivers via driver_type, - /// or redirecting to the actual driver location via the driver field). - /// - /// - /// Important: Options specified in co-located manifests are NOT automatically - /// applied to database connections. The manifest is used solely for driver loading. - /// To use manifest options, explicitly load the profile with - /// and use - /// . + /// be loaded -- the symbol name to invoke (or, for managed .NET drivers, the type + /// to instantiate via a dotnet: / netfx: scheme on entrypoint), + /// and platform-specific shared library paths under [Driver.shared]. /// /// /// The path to the driver file. @@ -694,6 +700,12 @@ private static AdbcDriver LoadFromAbsolutePath(string path, string? entrypoint) } } + /// The entrypoint scheme prefix for managed .NET (Core / 5+) drivers. + internal const string DotnetEntrypointScheme = "dotnet:"; + + /// The entrypoint scheme prefix for managed .NET Framework 4.x drivers. + internal const string NetFxEntrypointScheme = "netfx:"; + private static AdbcDriver LoadFromManifest(string manifestPath, string? entrypoint) { if (!File.Exists(manifestPath)) @@ -703,68 +715,114 @@ private static AdbcDriver LoadFromManifest(string manifestPath, string? entrypoi AdbcStatusCode.NotFound); } - ConnectionProfile manifest = FilesystemProfileProvider.LoadFromFile(manifestPath); + DriverManifest manifest = DriverManifest.LoadFromFile(manifestPath); - if (string.IsNullOrEmpty(manifest.DriverName)) - { - throw new AdbcException( - $"Driver manifest does not specify a 'driver' field.", - AdbcStatusCode.InvalidArgument); - } + // Caller-supplied entrypoint wins over the manifest's. Falls back to + // a derived native symbol name only when neither is provided. + string resolvedEntrypoint = entrypoint + ?? manifest.Entrypoint + ?? DeriveEntrypoint(manifest.LibraryPath); string? manifestDir = Path.GetDirectoryName(Path.GetFullPath(manifestPath)); + string resolvedPath = ResolveManifestPath(manifest.LibraryPath, manifestDir); - // Check if this is a managed driver - if (!string.IsNullOrEmpty(manifest.DriverTypeName)) + return LoadByEntrypointScheme(resolvedPath, resolvedEntrypoint, manifestPath, nameof(LoadFromManifest)); + } + + /// Returns true if uses a managed-runtime scheme prefix. + private static bool HasManagedEntrypointScheme(string entrypoint) => + entrypoint.StartsWith(DotnetEntrypointScheme, StringComparison.Ordinal) || + entrypoint.StartsWith(NetFxEntrypointScheme, StringComparison.Ordinal); + + /// + /// Resolves a path read out of a manifest: absolute paths are validated + /// against the security policy as-is; relative paths are anchored to the + /// manifest's directory and validated to ensure they don't escape it. + /// + private static string ResolveManifestPath(string libraryPath, string? manifestDir) + { + if (IsAbsolutePath(libraryPath)) { - // Managed .NET driver - resolve path - string driverPath = manifest.DriverName!; - if (IsAbsolutePath(driverPath)) - { - // Absolute path - validate it doesn't contain path traversal - DriverManagerSecurity.ValidatePathSecurity(driverPath, "manifest driver path"); - } - else - { - // Relative path - validate it doesn't escape the manifest directory - if (!string.IsNullOrEmpty(manifestDir)) - { - driverPath = DriverManagerSecurity.ValidateAndResolveManifestPath(manifestDir, driverPath); - } - } + DriverManagerSecurity.ValidatePathSecurity(libraryPath, "manifest driver path"); + return libraryPath; + } + if (!string.IsNullOrEmpty(manifestDir)) + { + return DriverManagerSecurity.ValidateAndResolveManifestPath(manifestDir!, libraryPath); + } + return libraryPath; + } + + /// + /// Dispatches a driver load by the scheme prefix on the entrypoint value. + /// Plain symbol names load as native drivers; dotnet: / netfx: + /// route to the managed loader and are rejected when the host process is + /// running on the wrong runtime. + /// + private static AdbcDriver LoadByEntrypointScheme( + string driverPath, + string entrypoint, + string? manifestPath, + string loadMethod) + { + if (entrypoint.StartsWith(DotnetEntrypointScheme, StringComparison.Ordinal)) + { + string typeName = entrypoint.Substring(DotnetEntrypointScheme.Length); + EnsureRuntime(isNetFramework: false, scheme: DotnetEntrypointScheme); return LoadWithSecurity( driverPath, - manifest.DriverTypeName!, + typeName, manifestPath, - nameof(LoadFromManifest), - () => LoadManagedDriverCore(driverPath, manifest.DriverTypeName!)); + loadMethod, + () => LoadManagedDriverCore(driverPath, typeName)); } - // Native driver - resolve entrypoint and path - string resolvedEntrypoint = entrypoint ?? DeriveEntrypoint(manifest.DriverName!); - - // Resolve driver path - string resolvedDriverPath = manifest.DriverName!; - if (IsAbsolutePath(resolvedDriverPath)) - { - // Absolute path - validate it doesn't contain path traversal - DriverManagerSecurity.ValidatePathSecurity(resolvedDriverPath, "manifest driver path"); - } - else + if (entrypoint.StartsWith(NetFxEntrypointScheme, StringComparison.Ordinal)) { - // Relative path - validate it doesn't escape the manifest directory - if (!string.IsNullOrEmpty(manifestDir)) - { - resolvedDriverPath = DriverManagerSecurity.ValidateAndResolveManifestPath(manifestDir, resolvedDriverPath); - } + string typeName = entrypoint.Substring(NetFxEntrypointScheme.Length); + EnsureRuntime(isNetFramework: true, scheme: NetFxEntrypointScheme); + return LoadWithSecurity( + driverPath, + typeName, + manifestPath, + loadMethod, + () => LoadManagedDriverCore(driverPath, typeName)); } return LoadWithSecurity( - resolvedDriverPath, + driverPath, typeName: null, manifestPath: manifestPath, - loadMethod: nameof(LoadFromManifest), - () => CAdbcDriverImporter.Load(resolvedDriverPath, resolvedEntrypoint)); + loadMethod: loadMethod, + () => CAdbcDriverImporter.Load(driverPath, entrypoint)); + } + + /// + /// Throws when the running .NET runtime does + /// not match the host implied by the entrypoint scheme. The check uses + /// (a runtime + /// property) rather than a compile-time symbol: this library can be + /// targeted at netstandard2.0 and consumed from either runtime. + /// + private static void EnsureRuntime(bool isNetFramework, string scheme) + { + bool hostIsNetFramework = RuntimeInformation.FrameworkDescription + .StartsWith(".NET Framework", StringComparison.OrdinalIgnoreCase); + + if (isNetFramework && !hostIsNetFramework) + { + throw new AdbcException( + $"Driver entrypoint scheme '{scheme}' requires .NET Framework, but the host process is " + + RuntimeInformation.FrameworkDescription + ".", + AdbcStatusCode.NotImplemented); + } + if (!isNetFramework && hostIsNetFramework) + { + throw new AdbcException( + $"Driver entrypoint scheme '{scheme}' requires .NET 5 or later, but the host process is " + + RuntimeInformation.FrameworkDescription + ".", + AdbcStatusCode.NotImplemented); + } } private static AdbcDriver? TryLoadFromDirectory(string dir, string driverName, string? entrypoint) diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/ConnectionProfile.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/ConnectionProfile.cs index 371ef67c34..f84a3e1b02 100644 --- a/csharp/src/Apache.Arrow.Adbc/DriverManager/ConnectionProfile.cs +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/ConnectionProfile.cs @@ -17,6 +17,8 @@ using System; using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; namespace Apache.Arrow.Adbc.DriverManager { @@ -33,12 +35,20 @@ namespace Apache.Arrow.Adbc.DriverManager /// /// /// Options come in three typed flavors: string, 64-bit integer, and double. - /// String option values of the form env_var(ENV_VAR_NAME) are expanded - /// from the named environment variable by . + /// String option values may contain {{ env_var(NAME) }} placeholders that + /// expands using process environment variables. /// /// public sealed class ConnectionProfile { + // Per docs/source/format/connection_profiles.rst, dynamic substitutions + // are written as `{{ }}` and may appear anywhere inside + // a string value. The character set inside the placeholder excludes + // braces so adjacent placeholders don't accidentally merge. + private static readonly Regex PlaceholderRegex = new Regex( + @"\{\{\s*([^{}]*?)\s*\}\}", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + private const string EnvVarPrefix = "env_var("; private readonly Dictionary _stringOptions; @@ -49,25 +59,24 @@ public sealed class ConnectionProfile /// Initializes a new . /// /// - /// The driver name. For native drivers this is the path to a shared library or - /// a bare driver name; for managed drivers this is the path to the .NET assembly. - /// - /// - /// The fully-qualified .NET type name of the subclass - /// to instantiate for managed (pure .NET) drivers, or null for native drivers. + /// The driver reference: a bare driver name (resolved against the manifest + /// search path), an absolute or relative path to a shared library, or an + /// absolute or relative path to a driver manifest .toml file. For + /// managed (.NET) drivers, the manifest at this location selects the + /// runtime via [Driver].entrypoint; alternatively, a profile that + /// points directly at a managed assembly can supply the type name through + /// an entrypoint option. /// /// String options, or null for none. /// Integer options, or null for none. /// Double options, or null for none. public ConnectionProfile( string? driverName = null, - string? driverTypeName = null, IReadOnlyDictionary? stringOptions = null, IReadOnlyDictionary? intOptions = null, IReadOnlyDictionary? doubleOptions = null) { DriverName = driverName; - DriverTypeName = driverTypeName; _stringOptions = new Dictionary(StringComparer.Ordinal); if (stringOptions != null) { @@ -86,25 +95,16 @@ public ConnectionProfile( } /// - /// Gets the name of the driver specified by this profile, or null if - /// the profile does not specify a driver. + /// Gets the driver reference specified by this profile, or null if + /// the profile does not specify one. May be a bare driver name, a shared + /// library path, or a driver manifest path. /// public string? DriverName { get; } /// - /// Gets the fully-qualified .NET type name of the - /// subclass to instantiate for managed (pure .NET) drivers, or null - /// for native drivers. - /// - /// - /// Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver - /// - public string? DriverTypeName { get; } - - /// - /// Gets the string options specified by this profile. Values of the form - /// env_var(ENV_VAR_NAME) will be expanded from the named environment - /// variable when is called. + /// Gets the string options specified by this profile. Values may contain + /// {{ env_var(NAME) }} placeholders that + /// expands using process environment variables. /// public IReadOnlyDictionary StringOptions => _stringOptions; @@ -119,38 +119,106 @@ public ConnectionProfile( public IReadOnlyDictionary DoubleOptions => _doubleOptions; /// - /// Returns a new profile with any env_var(NAME) values in - /// replaced by the value of the corresponding - /// environment variable. + /// Returns a new profile with any {{ env_var(NAME) }} placeholders + /// in expanded using process environment + /// variables. /// + /// + /// + /// Placeholder syntax matches the ADBC spec (see + /// docs/source/format/connection_profiles.rst): + /// + /// + /// + /// + /// Placeholders use {{ }} as the escape delimiters and may + /// appear anywhere inside a value. Whitespace inside the braces is + /// optional. Multiple placeholders may appear in one value + /// (e.g. "jdbc://{{ env_var(HOST) }}:{{ env_var(PORT) }}/db"). + /// + /// + /// + /// + /// A missing environment variable expands to an empty string and + /// processing continues; this matches the C/C++ driver manager. + /// + /// + /// + /// + /// The only supported function inside a placeholder is + /// env_var(NAME). Any other content -- including a literal + /// {{ in a value -- is rejected with + /// . + /// + /// + /// + /// /// - /// Thrown when a referenced environment variable is not set. + /// Thrown when a placeholder uses an unrecognized function or is malformed + /// (e.g. missing the closing parenthesis or environment variable name). /// public ConnectionProfile ResolveEnvVars() { Dictionary resolved = new Dictionary(StringComparer.Ordinal); foreach (KeyValuePair kv in _stringOptions) { - string value = kv.Value; - if (value.StartsWith(EnvVarPrefix, StringComparison.Ordinal) && - value.EndsWith(")", StringComparison.Ordinal)) - { - string varName = value.Substring(EnvVarPrefix.Length, value.Length - EnvVarPrefix.Length - 1); - string? envValue = Environment.GetEnvironmentVariable(varName); - if (envValue == null) - { - throw new AdbcException( - $"Environment variable '{varName}' required by profile option '{kv.Key}' is not set.", - AdbcStatusCode.InvalidState); - } - resolved[kv.Key] = envValue; - } - else - { - resolved[kv.Key] = value; - } + resolved[kv.Key] = ExpandPlaceholders(kv.Key, kv.Value); + } + return new ConnectionProfile(DriverName, resolved, _intOptions, _doubleOptions); + } + + /// + /// Substitutes every {{ ... }} placeholder in + /// with its expansion. The only recognized function is env_var(NAME); + /// anything else is an error. + /// + private static string ExpandPlaceholders(string key, string value) + { + if (string.IsNullOrEmpty(value) || value.IndexOf("{{", StringComparison.Ordinal) < 0) + { + return value; + } + + StringBuilder sb = new StringBuilder(value.Length); + int lastIndex = 0; + foreach (Match match in PlaceholderRegex.Matches(value)) + { + sb.Append(value, lastIndex, match.Index - lastIndex); + sb.Append(ExpandFunction(key, match.Groups[1].Value)); + lastIndex = match.Index + match.Length; + } + sb.Append(value, lastIndex, value.Length - lastIndex); + return sb.ToString(); + } + + private static string ExpandFunction(string key, string content) + { + if (!content.StartsWith(EnvVarPrefix, StringComparison.Ordinal)) + { + throw new AdbcException( + $"Profile option '{key}' uses an unsupported substitution '{content}'. " + + "Only env_var(NAME) is recognized.", + AdbcStatusCode.InvalidArgument); } - return new ConnectionProfile(DriverName, DriverTypeName, resolved, _intOptions, _doubleOptions); + if (content.Length == 0 || content[content.Length - 1] != ')') + { + throw new AdbcException( + $"Profile option '{key}' has a malformed env_var() placeholder: missing closing parenthesis.", + AdbcStatusCode.InvalidArgument); + } + + string varName = content.Substring(EnvVarPrefix.Length, content.Length - EnvVarPrefix.Length - 1); + if (varName.Length == 0) + { + throw new AdbcException( + $"Profile option '{key}' has a malformed env_var() placeholder: missing environment variable name.", + AdbcStatusCode.InvalidArgument); + } + + // Missing environment variables expand to empty per the spec, matching + // the C/C++ driver manager. Callers that want to require an env var + // should validate after ResolveEnvVars returns. + return Environment.GetEnvironmentVariable(varName) ?? string.Empty; } } } diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/DriverManifest.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/DriverManifest.cs new file mode 100644 index 0000000000..9ad35036b6 --- /dev/null +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/DriverManifest.cs @@ -0,0 +1,331 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.Runtime.InteropServices; +using System.Text; + +namespace Apache.Arrow.Adbc.DriverManager +{ + /// + /// A parsed ADBC driver manifest. A driver manifest is a TOML file that + /// describes where a driver shared library lives and how to load it. + /// It is distinct from a , which describes + /// how to open a database with a driver and carries option key/value + /// pairs. + /// + /// + /// + /// The manifest format is defined in + /// docs/source/format/driver_manifests.rst: + /// + /// + /// manifest_version = 1 + /// + /// name = "Driver Display Name" + /// version = "1.2.3" # the driver's own version, a string + /// publisher = "..." + /// license = "Apache-2.0" + /// source = "..." + /// + /// [Driver] + /// entrypoint = "AdbcDriverInit" # optional; defaults are derived from the file name + /// + /// # Either a single path: + /// [Driver] + /// shared = "/path/to/libadbc_driver.so" + /// + /// # Or platform-tuple-keyed paths: + /// [Driver.shared] + /// linux_amd64 = "/path/to/libadbc_driver.so" + /// macos_arm64 = "/path/to/libadbc_driver.dylib" + /// windows_amd64 = "C:\\path\\to\\adbc_driver.dll" + /// + /// + /// The value may be a plain native symbol name + /// (e.g. AdbcDriverDuckdbInit) or a scheme-prefixed value such as + /// dotnet:Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver / + /// netfx:My.Driver.Class. The scheme tells the driver manager which + /// managed-runtime host to start; values without a scheme are loaded as + /// native C entrypoints. + /// + /// + internal sealed class DriverManifest + { + private const string ManifestVersionField = "manifest_version"; + + private DriverManifest( + long manifestVersion, + string? name, + string? version, + string? publisher, + string? license, + string? source, + string? entrypoint, + string libraryPath) + { + ManifestVersion = manifestVersion; + Name = name; + Version = version; + Publisher = publisher; + License = license; + Source = source; + Entrypoint = entrypoint; + LibraryPath = libraryPath; + } + + /// The manifest format version. Always 1 today. + public long ManifestVersion { get; } + + /// Display name of the driver. + public string? Name { get; } + + /// The driver's own version (a free-form string per the spec). + public string? Version { get; } + + /// Publisher of the driver. + public string? Publisher { get; } + + /// License identifier of the driver. + public string? License { get; } + + /// Where this driver came from (e.g. a package name). + public string? Source { get; } + + /// + /// The entrypoint value from [Driver].entrypoint. May be a plain + /// symbol name, a dotnet:-prefixed type name, or any other + /// scheme-prefixed value. May be null if the manifest does not + /// specify one (in which case callers derive a default). + /// + public string? Entrypoint { get; } + + /// + /// The driver library path for the current platform, resolved from + /// [Driver.shared]. May be an absolute path or a path relative + /// to the manifest's directory; callers are responsible for resolution. + /// + public string LibraryPath { get; } + + /// + /// Returns true if the given parsed TOML content looks like a + /// driver manifest rather than a connection profile. + /// + /// + /// The discriminator is the presence of manifest_version at the + /// root level, or any [Driver] / [Driver.shared] section. + /// + internal static bool LooksLikeManifest(Dictionary> sections) + { + if (sections == null) return false; + + if (sections.TryGetValue("", out Dictionary? root) && + root.ContainsKey(ManifestVersionField)) + { + return true; + } + + return sections.ContainsKey("Driver") || sections.ContainsKey("Driver.shared"); + } + + /// + /// Parses a driver manifest from TOML content. + /// + /// The raw TOML text to parse. + /// The parsed . + /// If is null. + /// + /// Thrown when the TOML is malformed, the manifest version is unsupported, + /// or no library path can be resolved for the current platform. + /// + public static DriverManifest LoadFromContent(string tomlContent) + { + if (tomlContent == null) throw new ArgumentNullException(nameof(tomlContent)); + + Dictionary> sections; + try + { + sections = TomlParser.Parse(tomlContent); + } + catch (FormatException ex) + { + throw new AdbcException( + "Invalid TOML driver manifest: " + ex.Message, + AdbcStatusCode.InvalidArgument, + ex); + } + + Dictionary root = sections.TryGetValue("", out Dictionary? r) + ? r + : new Dictionary(); + + long manifestVersion = ReadManifestVersion(root); + if (manifestVersion != 1) + { + throw new AdbcException( + $"Driver manifest version '{manifestVersion}' is not supported by this driver manager.", + AdbcStatusCode.NotImplemented); + } + + string? name = ReadOptionalString(root, "name"); + string? version = ReadOptionalString(root, "version"); + string? publisher = ReadOptionalString(root, "publisher"); + string? license = ReadOptionalString(root, "license"); + string? source = ReadOptionalString(root, "source"); + + string? entrypoint = null; + if (sections.TryGetValue("Driver", out Dictionary? driverSection)) + { + entrypoint = ReadOptionalString(driverSection, "entrypoint"); + } + + string libraryPath = ResolveLibraryPath(sections, driverSection); + + return new DriverManifest( + manifestVersion, + name, + version, + publisher, + license, + source, + entrypoint, + libraryPath); + } + + /// + /// Parses a driver manifest from a file path. + /// + public static DriverManifest LoadFromFile(string filePath) + { + if (filePath == null) throw new ArgumentNullException(nameof(filePath)); + string content = File.ReadAllText(filePath, Encoding.UTF8); + return LoadFromContent(content); + } + + private static long ReadManifestVersion(Dictionary root) + { + // Per the spec, manifest_version defaults to 1 when absent. + if (!root.TryGetValue(ManifestVersionField, out object? versionObj)) + { + return 1; + } + + if (versionObj is long lv) + { + return lv; + } + + throw new AdbcException( + $"The 'manifest_version' field has an invalid value '{versionObj}'. It must be an integer.", + AdbcStatusCode.InvalidArgument); + } + + private static string? ReadOptionalString(Dictionary section, string key) + { + if (section.TryGetValue(key, out object? obj) && obj is string s) + { + return s; + } + return null; + } + + /// + /// Resolves the library path from the manifest. Supports the two spec forms: + /// [Driver].shared = "..." (single string) and + /// [Driver.shared] table keyed by platform tuple. + /// + private static string ResolveLibraryPath( + Dictionary> sections, + Dictionary? driverSection) + { + // Form 1: [Driver].shared = "/path/to/lib" + if (driverSection != null && + driverSection.TryGetValue("shared", out object? sharedObj) && + sharedObj is string sharedPath) + { + if (string.IsNullOrEmpty(sharedPath)) + { + throw new AdbcException( + "Driver manifest has an empty 'Driver.shared' path.", + AdbcStatusCode.InvalidArgument); + } + return sharedPath; + } + + // Form 2: [Driver.shared] table keyed by platform tuple + if (sections.TryGetValue("Driver.shared", out Dictionary? platformTable)) + { + string current = GetCurrentPlatformTuple(); + if (platformTable.TryGetValue(current, out object? platObj) && platObj is string platPath) + { + if (string.IsNullOrEmpty(platPath)) + { + throw new AdbcException( + $"Driver manifest has an empty path for current platform '{current}'.", + AdbcStatusCode.InvalidArgument); + } + return platPath; + } + + List tuples = new List(platformTable.Count); + foreach (KeyValuePair kv in platformTable) tuples.Add(kv.Key); + tuples.Sort(StringComparer.Ordinal); + throw new AdbcException( + $"Driver manifest has no entry for current platform '{current}'. " + + $"Available platforms: {string.Join(", ", tuples)}.", + AdbcStatusCode.NotFound); + } + + throw new AdbcException( + "Driver manifest does not specify a library path. " + + "Provide a 'Driver.shared' value, either as a single string or a " + + "platform-tuple-keyed table.", + AdbcStatusCode.InvalidArgument); + } + + /// + /// Returns the platform tuple identifying the current OS and architecture, + /// matching the format used by the ADBC driver-manifest spec + /// (e.g. windows_amd64, linux_arm64, macos_amd64). + /// + internal static string GetCurrentPlatformTuple() + { + string os; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) os = "windows"; + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) os = "macos"; + else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) os = "linux"; +#if NET6_0_OR_GREATER + else if (RuntimeInformation.IsOSPlatform(OSPlatform.FreeBSD)) os = "freebsd"; +#endif + else os = "unknown"; + + string arch; + switch (RuntimeInformation.ProcessArchitecture) + { + case Architecture.X64: arch = "amd64"; break; + case Architecture.Arm64: arch = "arm64"; break; + case Architecture.X86: arch = "x86"; break; + case Architecture.Arm: arch = "arm"; break; + default: arch = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(); break; + } + + return os + "_" + arch; + } + } +} diff --git a/csharp/src/Apache.Arrow.Adbc/DriverManager/FilesystemProfileProvider.cs b/csharp/src/Apache.Arrow.Adbc/DriverManager/FilesystemProfileProvider.cs index 47e51c74e8..870bbb8aeb 100644 --- a/csharp/src/Apache.Arrow.Adbc/DriverManager/FilesystemProfileProvider.cs +++ b/csharp/src/Apache.Arrow.Adbc/DriverManager/FilesystemProfileProvider.cs @@ -131,12 +131,6 @@ public static ConnectionProfile LoadFromContent(string tomlContent) driverName = driverStr; } - string? driverTypeName = null; - if (root.TryGetValue("driver_type", out object? driverTypeObj) && driverTypeObj is string driverTypeStr) - { - driverTypeName = driverTypeStr; - } - Dictionary stringOpts = new Dictionary(StringComparer.Ordinal); Dictionary intOpts = new Dictionary(StringComparer.Ordinal); Dictionary doubleOpts = new Dictionary(StringComparer.Ordinal); @@ -173,7 +167,7 @@ public static ConnectionProfile LoadFromContent(string tomlContent) } } - return new ConnectionProfile(driverName, driverTypeName, stringOpts, intOpts, doubleOpts); + return new ConnectionProfile(driverName, stringOpts, intOpts, doubleOpts); } /// diff --git a/csharp/src/Apache.Arrow.Adbc/readme.md b/csharp/src/Apache.Arrow.Adbc/readme.md index 75f956ccb0..3b4b14a2fa 100644 --- a/csharp/src/Apache.Arrow.Adbc/readme.md +++ b/csharp/src/Apache.Arrow.Adbc/readme.md @@ -27,42 +27,79 @@ The `Apache.Arrow.Adbc.DriverManager` namespace provides a .NET implementation o ### Features - **Driver discovery**: search for ADBC drivers by name across configurable directories (environment variable, user-level, system-level). -- **TOML manifest loading**: locate drivers via `.toml` manifest files that specify the shared library path. +- **TOML driver manifests**: locate drivers via `.toml` manifest files that specify the shared library path per platform. - **Connection profiles**: load reusable connection configurations (driver + options) from `.toml` profile files. +- **Managed (.NET) drivers**: load .NET drivers via a scheme-prefixed `entrypoint` (`dotnet:` for .NET 5+, `netfx:` for .NET Framework 4.x). - **Custom profile providers**: plug in your own `IConnectionProfileProvider` implementation. -### TOML Manifest / Profile Format +### Driver Manifest Format -#### Connection Profile Example (Snowflake) +A *driver manifest* is a TOML file describing where a driver lives and how to load it. The format is shared across all ADBC driver-manager implementations and documented in `docs/source/format/driver_manifests.rst`. -For unmanaged drivers loaded from native shared libraries: +#### Native Driver Manifest Example (Snowflake) ```toml -profile_version = 1 -driver = "libadbc_driver_snowflake" +manifest_version = 1 + +name = "Snowflake" +version = "1.5.2" +publisher = "snowflake.com" + +[Driver] entrypoint = "AdbcDriverSnowflakeInit" +[Driver.shared] +windows_amd64 = "C:\\path\\to\\adbc_driver_snowflake.dll" +linux_amd64 = "/usr/local/lib/libadbc_driver_snowflake.so" +macos_arm64 = "/opt/homebrew/lib/libadbc_driver_snowflake.dylib" +``` + +#### Managed Driver Manifest Example (BigQuery) + +Managed .NET drivers use a scheme-prefixed `entrypoint`: + +- `dotnet:` for modern .NET (.NET 5 and later, including .NET 8 / .NET 10) +- `netfx:` for .NET Framework 4.x + +The host process rejects a manifest whose scheme doesn't match its runtime, so a `dotnet:` manifest on a .NET Framework process (or vice versa) fails with a clear error rather than mysteriously failing inside the assembly loader. + +```toml +manifest_version = 1 + +name = "BigQuery" +version = "1.2.0" + +[Driver] +entrypoint = "dotnet:Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver" +shared = "Apache.Arrow.Adbc.Drivers.BigQuery.dll" +``` + +`shared` is relative to the manifest's directory. Managed .NET assemblies are platform-neutral, so the single-string form of `shared` is usually appropriate; the platform-tuple table is also accepted. + +### Connection Profile Format + +A *connection profile* points at a driver and supplies options to apply when opening a database. Profiles can name a driver by manifest name (resolved against the standard search paths), by direct path to a shared library, or by direct path to a manifest. + +```toml +profile_version = 1 +driver = "snowflake" + [Options] adbc.snowflake.sql.account = "myaccount" adbc.snowflake.sql.warehouse = "mywarehouse" -adbc.snowflake.sql.auth_type = "auth_snowflake" -username = "myuser" -password = "env_var(SNOWFLAKE_PASSWORD)" +password = "{{ env_var(SNOWFLAKE_PASSWORD) }}" ``` -#### Managed Driver Profile Example (BigQuery) - -For managed .NET drivers: +If the profile points directly at a shared library that uses a non-default entrypoint (or at a managed assembly that needs a `dotnet:` / `netfx:` selector), supply it through the `entrypoint` option. The driver manager consumes that option and does not forward it to the driver: ```toml profile_version = 1 driver = "C:\\path\\to\\Apache.Arrow.Adbc.Drivers.BigQuery.dll" -driver_type = "Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver" [Options] +entrypoint = "dotnet:Apache.Arrow.Adbc.Drivers.BigQuery.BigQueryDriver" adbc.bigquery.project_id = "my-project" -adbc.bigquery.auth_type = "service" -adbc.bigquery.json_credential = "env_var(BIGQUERY_JSON_CREDENTIAL)" +adbc.bigquery.json_credential = "{{ env_var(BIGQUERY_JSON_CREDENTIAL) }}" ``` #### Format Notes @@ -70,9 +107,7 @@ adbc.bigquery.json_credential = "env_var(BIGQUERY_JSON_CREDENTIAL)" - Use `profile_version = 1` for the version field (legacy `version` is also supported for backward compatibility) - Use `[Options]` for the options section (legacy `[options]` is also supported for backward compatibility) - Boolean option values are converted to the string equivalents `"true"` or `"false"`. -- Values of the form `env_var(ENV_VAR_NAME)` are expanded from the named environment variable at connection time. -- For unmanaged drivers, use `driver` for the library path and `entrypoint` for the initialization function. -- For managed drivers, use `driver` for the assembly path and `driver_type` for the fully-qualified type name. +- String values may contain `{{ env_var(NAME) }}` placeholders, which are expanded from process environment variables when `ResolveEnvVars()` is called. The `{{` and `}}` delimiters serve as escapes: any text outside placeholders is treated literally. Placeholders may appear anywhere inside a value and may be repeated. A missing environment variable expands to an empty string. Only `env_var(NAME)` is recognized; other content inside a placeholder is an error. ### Managed Driver Loading (.NET Core / .NET 8) diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/ColocatedManifestTests.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/ColocatedManifestTests.cs index 4c72c40cbf..19c4ef1a5e 100644 --- a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/ColocatedManifestTests.cs +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/ColocatedManifestTests.cs @@ -75,6 +75,17 @@ public class ColocatedManifestTests : IDisposable return (dllPath, tomlPath); } + /// + /// Scheme prefix this test process must use when selecting managed drivers + /// (matches the runtime hosting the test). + /// + private static string ManagedScheme => +#if NETFRAMEWORK + "netfx:"; +#else + "dotnet:"; +#endif + /// /// Creates test files where the manifest uses a relative path to a real assembly. /// @@ -98,12 +109,12 @@ public class ColocatedManifestTests : IDisposable File.WriteAllText(placeholderDllPath, "placeholder"); _tempFiles.Add(placeholderDllPath); - // Create manifest that uses relative path to the real assembly - string toml = "version = 1\n" - + "driver = \"" + realAssemblyName + "\"\n" - + "driver_type = \"" + typeName + "\"\n" - + "\n[options]\n" - + "from_manifest = \"true\"\n"; + // Driver manifest: scheme-prefixed entrypoint selects the managed runtime, + // [Driver].shared = "..." carries the relative assembly path. + string toml = "manifest_version = 1\n" + + "\n[Driver]\n" + + "entrypoint = \"" + ManagedScheme + typeName + "\"\n" + + "shared = \"" + realAssemblyName + "\"\n"; string tomlPath = Path.Combine(tempDir, baseName + ".toml"); File.WriteAllText(tomlPath, toml); @@ -133,8 +144,8 @@ public void LoadDriver_WithColocatedManifest_LoadsFromManifest() CreateTestFilesWithRelativeDriver("test_driver", typeName); // LoadDriver should auto-detect the co-located manifest and use it to determine: - // - The actual driver location (from the 'driver' field - relative path) - // - Whether it's a managed driver (from 'driver_type') + // - The actual driver location (from [Driver].shared, a relative path here) + // - The managed runtime to host the driver (from the scheme prefix on entrypoint) AdbcDriver driver = AdbcDriverManager.LoadDriver(dllPath); Assert.NotNull(driver); // Check type name instead of IsType to avoid assembly identity issues @@ -195,20 +206,57 @@ public void LoadDriver_ManifestCanOverrideDriverPath() } [Fact] - public void LoadDriver_ExplicitEntrypointStillWorks() + public void LoadDriver_ExplicitEntrypointOverridesManifest() { + // Build a manifest whose [Driver].entrypoint is something the caller will + // override. The caller passes a scheme-prefixed entrypoint pointing at + // the real managed driver type; that override must win over the manifest + // value and select the managed loader. string typeName = typeof(FakeAdbcDriver).FullName!; (string dllPath, string tomlPath, string realAssemblyPath) = - CreateTestFilesWithRelativeDriver("entrypoint_test", typeName); + CreateTestFilesWithManifestEntrypoint("entrypoint_test", "AdbcDriverInit"); - // Even with a manifest, explicit entrypoint parameter should work - // (though for managed drivers, entrypoint doesn't apply - it's ignored) - AdbcDriver driver = AdbcDriverManager.LoadDriver(dllPath, "CustomEntrypoint"); + AdbcDriver driver = AdbcDriverManager.LoadDriver(dllPath, ManagedScheme + typeName); Assert.NotNull(driver); Assert.Equal(typeName, driver.GetType().FullName); } + /// + /// Like but lets the caller + /// control the manifest's [Driver].entrypoint value -- useful for + /// tests that verify caller-supplied entrypoint overrides win. + /// + private (string placeholderDllPath, string tomlPath, string realAssemblyPath) CreateTestFilesWithManifestEntrypoint( + string baseName, + string manifestEntrypoint) + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + _tempDirs.Add(tempDir); + + string realAssemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string realAssemblyName = Path.GetFileName(realAssemblyPath); + string copiedAssemblyPath = Path.Combine(tempDir, realAssemblyName); + File.Copy(realAssemblyPath, copiedAssemblyPath, overwrite: true); + _tempFiles.Add(copiedAssemblyPath); + + string placeholderDllPath = Path.Combine(tempDir, baseName + ".dll"); + File.WriteAllText(placeholderDllPath, "placeholder"); + _tempFiles.Add(placeholderDllPath); + + string toml = "manifest_version = 1\n" + + "\n[Driver]\n" + + "entrypoint = \"" + manifestEntrypoint + "\"\n" + + "shared = \"" + realAssemblyName + "\"\n"; + + string tomlPath = Path.Combine(tempDir, baseName + ".toml"); + File.WriteAllText(tomlPath, toml); + _tempFiles.Add(tomlPath); + + return (placeholderDllPath, tomlPath, copiedAssemblyPath); + } + [Fact] public void LoadDriver_RelativePathInManifest_ResolvedCorrectly() { @@ -226,9 +274,10 @@ public void LoadDriver_RelativePathInManifest_ResolvedCorrectly() _tempFiles.Add(localAssemblyPath); // Manifest uses relative path to the driver - string toml = "version = 1\n" - + "driver = \"" + assemblyFileName + "\"\n" - + "driver_type = \"" + typeName + "\"\n"; + string toml = "manifest_version = 1\n" + + "\n[Driver]\n" + + "entrypoint = \"" + ManagedScheme + typeName + "\"\n" + + "shared = \"" + assemblyFileName + "\"\n"; string dllPath = Path.Combine(tempDir, "wrapper.dll"); string tomlPath = Path.Combine(tempDir, "wrapper.toml"); @@ -269,9 +318,10 @@ public void LoadDriver_DifferentExtensions_AllDetectManifest() _tempFiles.Add(copiedAssemblyPath); // Create manifest with relative path - string toml = "version = 1\n" - + "driver = \"" + assemblyFileName + "\"\n" - + "driver_type = \"" + typeName + "\"\n"; + string toml = "manifest_version = 1\n" + + "\n[Driver]\n" + + "entrypoint = \"" + ManagedScheme + typeName + "\"\n" + + "shared = \"" + assemblyFileName + "\"\n"; string soPath = Path.Combine(tempDir, "test.driver.so"); string soToml = Path.Combine(tempDir, "test.driver.toml"); @@ -306,7 +356,7 @@ public void LoadManagedDriver_WithColocatedManifest_LoadsDirectly() // Note: LoadManagedDriver does not currently detect co-located manifests. // It loads directly from the specified assembly path. // To use manifest redirection, use LoadDriver with a co-located manifest - // that specifies driver_type. + // whose [Driver].entrypoint carries a dotnet:/netfx: scheme prefix. string typeName = typeof(FakeAdbcDriver).FullName!; string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/DriverManifestTests.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/DriverManifestTests.cs new file mode 100644 index 0000000000..db1c51938f --- /dev/null +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/DriverManifestTests.cs @@ -0,0 +1,320 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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.Runtime.InteropServices; +using Apache.Arrow.Adbc.DriverManager; +using Xunit; + +namespace Apache.Arrow.Adbc.Tests.DriverManager +{ + /// + /// Tests for . These cover the format documented + /// in docs/source/format/driver_manifests.rst: the + /// manifest_version, the optional metadata fields, and the two + /// shapes of [Driver.shared] (single string vs. platform table). + /// Also includes a real-driver round-trip against DuckDB that originally + /// reproduced https://github.com/apache/arrow-adbc/issues/4329. + /// + [Collection(DriverManagerSecurityCollection.Name)] + public class DriverManifestTests : IDisposable + { + private readonly List _tempDirs = new List(); + + public void Dispose() + { + foreach (string d in _tempDirs) + { + try { if (Directory.Exists(d)) Directory.Delete(d, true); } catch { } + } + } + + // ----------------------------------------------------------------------- + // [Driver.shared] in single-string form + // ----------------------------------------------------------------------- + + [Fact] + public void LoadFromContent_DriverSharedAsString_ReturnsThatPath() + { + const string toml = @" +manifest_version = 1 +name = ""Example"" + +[Driver] +shared = ""/usr/local/lib/libadbc_driver_example.so"" +"; + DriverManifest manifest = DriverManifest.LoadFromContent(toml); + Assert.Equal("/usr/local/lib/libadbc_driver_example.so", manifest.LibraryPath); + Assert.Equal("Example", manifest.Name); + Assert.Equal(1, manifest.ManifestVersion); + Assert.Null(manifest.Entrypoint); + } + + // ----------------------------------------------------------------------- + // [Driver.shared] as a platform-tuple table + // ----------------------------------------------------------------------- + + [Fact] + public void LoadFromContent_DriverSharedAsPlatformTable_PicksCurrentPlatform() + { + string current = DriverManifest.GetCurrentPlatformTuple(); + string toml = + "manifest_version = 1\n" + + "\n[Driver.shared]\n" + + current + " = \"/path/for/this/platform\"\n" + + "irrelevant_platform = \"/should/not/match\"\n"; + + DriverManifest manifest = DriverManifest.LoadFromContent(toml); + Assert.Equal("/path/for/this/platform", manifest.LibraryPath); + } + + [Fact] + public void LoadFromContent_NoMatchingPlatform_ThrowsNotFound() + { + // Build a table that intentionally excludes the current platform. + const string toml = @" +manifest_version = 1 + +[Driver.shared] +made_up_os_made_up_arch = ""/nowhere"" +"; + AdbcException ex = Assert.Throws( + () => DriverManifest.LoadFromContent(toml)); + Assert.Equal(AdbcStatusCode.NotFound, ex.Status); + // Error message should mention the current platform tuple + Assert.Contains(DriverManifest.GetCurrentPlatformTuple(), ex.Message); + } + + // ----------------------------------------------------------------------- + // Metadata fields + // ----------------------------------------------------------------------- + + [Fact] + public void LoadFromContent_ReadsAllMetadataFields() + { + // Mixes single- and double-quoted strings to exercise both forms. + const string toml = @" +manifest_version = 1 + +name = ""Display Name"" +version = '1.5.2' +publisher = 'ExampleCo' +license = 'Apache-2.0' +source = 'pkg-manager' + +[Driver] +entrypoint = ""AdbcDriverExampleInit"" +shared = ""/path/lib.so"" +"; + DriverManifest manifest = DriverManifest.LoadFromContent(toml); + Assert.Equal("Display Name", manifest.Name); + Assert.Equal("1.5.2", manifest.Version); + Assert.Equal("ExampleCo", manifest.Publisher); + Assert.Equal("Apache-2.0", manifest.License); + Assert.Equal("pkg-manager", manifest.Source); + Assert.Equal("AdbcDriverExampleInit", manifest.Entrypoint); + } + + // ----------------------------------------------------------------------- + // manifest_version defaulting + validation + // ----------------------------------------------------------------------- + + [Fact] + public void LoadFromContent_NoManifestVersion_DefaultsTo1() + { + const string toml = @" +[Driver] +shared = ""/path/lib.so"" +"; + DriverManifest manifest = DriverManifest.LoadFromContent(toml); + Assert.Equal(1, manifest.ManifestVersion); + } + + [Fact] + public void LoadFromContent_ManifestVersion2_ThrowsNotImplemented() + { + const string toml = @" +manifest_version = 2 + +[Driver] +shared = ""/path/lib.so"" +"; + AdbcException ex = Assert.Throws( + () => DriverManifest.LoadFromContent(toml)); + Assert.Equal(AdbcStatusCode.NotImplemented, ex.Status); + } + + [Fact] + public void LoadFromContent_ManifestVersionAsString_ThrowsInvalidArgument() + { + // Despite the docs example showing 'version = "1.5.2"' (which is the + // driver's own version), manifest_version itself must be an integer. + const string toml = @" +manifest_version = ""1"" + +[Driver] +shared = ""/path/lib.so"" +"; + AdbcException ex = Assert.Throws( + () => DriverManifest.LoadFromContent(toml)); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + // ----------------------------------------------------------------------- + // Missing Driver.shared + // ----------------------------------------------------------------------- + + [Fact] + public void LoadFromContent_NoLibraryPath_ThrowsInvalidArgument() + { + const string toml = @" +manifest_version = 1 +name = ""Example"" + +[Driver] +entrypoint = ""AdbcDriverInit"" +"; + AdbcException ex = Assert.Throws( + () => DriverManifest.LoadFromContent(toml)); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + [Fact] + public void LoadFromContent_EmptyDriverSharedString_ThrowsInvalidArgument() + { + const string toml = @" +manifest_version = 1 + +[Driver] +shared = """" +"; + AdbcException ex = Assert.Throws( + () => DriverManifest.LoadFromContent(toml)); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + // ----------------------------------------------------------------------- + // Malformed TOML wraps to AdbcException + // ----------------------------------------------------------------------- + + [Fact] + public void LoadFromContent_MalformedToml_ThrowsAdbcException() + { + // Unterminated string -> FormatException out of TomlParser, wrapped. + const string toml = "manifest_version = 1\n[Driver]\nshared = \"unterminated\n"; + AdbcException ex = Assert.Throws( + () => DriverManifest.LoadFromContent(toml)); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + // ----------------------------------------------------------------------- + // Round-trip with the literal-string form used in the docs example + // ----------------------------------------------------------------------- + + [Fact] + public void LoadFromContent_DocsExampleStyle_Parses() + { + // Mirrors docs/source/cpp/recipe_driver/driver_example.toml.in + // (which uses single-quoted strings throughout). + string current = DriverManifest.GetCurrentPlatformTuple(); + string toml = + "manifest_version = 1\n" + + "\n" + + "name = 'Driver Example'\n" + + "publisher = 'arrow-adbc-docs'\n" + + "license = 'Apache-2.0'\n" + + "version = '1.0.0'\n" + + "source = 'recipe'\n" + + "\n" + + "[ADBC]\n" + + "version = 'v1.1.0'\n" + + "\n" + + "[Driver]\n" + + "[Driver.shared]\n" + + current + " = '/opt/adbc/libadbc_driver_example.so'\n"; + + DriverManifest manifest = DriverManifest.LoadFromContent(toml); + Assert.Equal("Driver Example", manifest.Name); + Assert.Equal("1.0.0", manifest.Version); + Assert.Equal("/opt/adbc/libadbc_driver_example.so", manifest.LibraryPath); + } + + // ----------------------------------------------------------------------- + // End-to-end against a real driver (DuckDB). Originally written as the + // regression test for https://github.com/apache/arrow-adbc/issues/4329: + // FindLoadDriver was routing real driver manifests through the + // connection-profile loader, which rejected the spec-correct + // `version = "1.5.2"` field as a non-integer. + // ----------------------------------------------------------------------- + + [Fact] + public void FindLoadDriver_WithRealDriverManifest_LoadsDuckDbDriver() + { + // Locate the DuckDB native library copied next to the test assembly + // by the CopyDuckDb MSBuild target (see Apache.Arrow.Adbc.Testing.csproj). + string root = Directory.GetCurrentDirectory(); + string duckdbFile; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + duckdbFile = Path.Combine(root, "duckdb.dll"); + else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + duckdbFile = Path.Combine(root, "libduckdb.dylib"); + else + duckdbFile = Path.Combine(root, "libduckdb.so"); + + Assert.True(File.Exists(duckdbFile), $"DuckDB library missing at {duckdbFile}"); + + string current = DriverManifest.GetCurrentPlatformTuple(); + // TOML basic strings interpret \ as an escape, so backslashes in + // Windows paths must be doubled. + string tomlEscapedPath = duckdbFile.Replace("\\", "\\\\"); + + string manifest = + "manifest_version = 1\n" + + "\n" + + "name = \"DuckDB\"\n" + + "version = \"1.5.2\" # driver version - a string per the spec\n" + + "publisher = \"duckdb.org\"\n" + + "license = \"MIT\"\n" + + "\n" + + "[ADBC]\n" + + "version = \"1.1.0\"\n" + + "\n" + + "[Driver]\n" + + "entrypoint = \"duckdb_adbc_init\"\n" + + "\n" + + "[Driver.shared]\n" + + current + " = \"" + tomlEscapedPath + "\"\n"; + + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + _tempDirs.Add(tempDir); + + string manifestPath = Path.Combine(tempDir, "duckdb.toml"); + File.WriteAllText(manifestPath, manifest); + + using AdbcDriver driver = AdbcDriverManager.FindLoadDriver( + "duckdb", + entrypoint: "duckdb_adbc_init", + loadOptions: AdbcLoadFlags.Default, + additionalSearchPathList: tempDir); + + Assert.NotNull(driver); + } + } +} diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/EntrypointSchemeTests.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/EntrypointSchemeTests.cs new file mode 100644 index 0000000000..aafd99e6bb --- /dev/null +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/EntrypointSchemeTests.cs @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 Apache.Arrow.Adbc.DriverManager; +using Xunit; + +namespace Apache.Arrow.Adbc.Tests.DriverManager +{ + /// + /// Tests for the entrypoint-scheme dispatch in : + /// dotnet: and netfx: prefixes select the managed loader and + /// fail closed when the host process runs on the wrong .NET runtime. + /// + [Collection(DriverManagerSecurityCollection.Name)] + public class EntrypointSchemeTests : IDisposable + { + private readonly List _tempDirs = new List(); + + public void Dispose() + { + foreach (string d in _tempDirs) + { + try { if (Directory.Exists(d)) Directory.Delete(d, true); } catch { } + } + } + + /// The "other" managed scheme for this test process (the one the host can't load). + private static string ForeignScheme => +#if NETFRAMEWORK + "dotnet:"; +#else + "netfx:"; +#endif + + /// The matching managed scheme for this test process. + private static string NativeScheme => +#if NETFRAMEWORK + "netfx:"; +#else + "dotnet:"; +#endif + + private string CreateManifest(string entrypoint, string assemblyFileName) + { + string tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(tempDir); + _tempDirs.Add(tempDir); + + string realAssemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + File.Copy(realAssemblyPath, Path.Combine(tempDir, assemblyFileName), overwrite: true); + + string toml = "manifest_version = 1\n" + + "\n[Driver]\n" + + "entrypoint = \"" + entrypoint + "\"\n" + + "shared = \"" + assemblyFileName + "\"\n"; + + string tomlPath = Path.Combine(tempDir, "scheme_test.toml"); + File.WriteAllText(tomlPath, toml); + return tomlPath; + } + + [Fact] + public void Manifest_ForeignScheme_FailsClosedWithClearError() + { + // A dotnet: manifest under .NET Framework (or netfx: under modern .NET) + // should fail before any reflection happens, with a message that names + // the requested scheme. + string assemblyFileName = Path.GetFileName(typeof(FakeAdbcDriver).Assembly.Location); + string typeName = typeof(FakeAdbcDriver).FullName!; + string entrypoint = ForeignScheme + typeName; + string tomlPath = CreateManifest(entrypoint, assemblyFileName); + + AdbcException ex = Assert.Throws( + () => AdbcDriverManager.FindLoadDriver(tomlPath)); + Assert.Equal(AdbcStatusCode.NotImplemented, ex.Status); + Assert.Contains(ForeignScheme.TrimEnd(':'), ex.Message); + } + + [Fact] + public void Manifest_NativeScheme_LoadsManagedDriver() + { + // Sanity check: the matching scheme on the same code path does load. + string assemblyFileName = Path.GetFileName(typeof(FakeAdbcDriver).Assembly.Location); + string typeName = typeof(FakeAdbcDriver).FullName!; + string entrypoint = NativeScheme + typeName; + string tomlPath = CreateManifest(entrypoint, assemblyFileName); + + AdbcDriver driver = AdbcDriverManager.FindLoadDriver(tomlPath); + Assert.NotNull(driver); + Assert.Equal(typeName, driver.GetType().FullName); + } + + [Fact] + public void LoadDriver_ExplicitForeignSchemeEntrypoint_FailsClosed() + { + // The dispatch fires even without a manifest: an explicit caller-supplied + // entrypoint with a foreign scheme prefix is rejected on this runtime. + string realAssemblyPath = typeof(FakeAdbcDriver).Assembly.Location; + string typeName = typeof(FakeAdbcDriver).FullName!; + + AdbcException ex = Assert.Throws( + () => AdbcDriverManager.LoadDriver(realAssemblyPath, ForeignScheme + typeName)); + Assert.Equal(AdbcStatusCode.NotImplemented, ex.Status); + } + } +} diff --git a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs index a6a0f35dcd..c811b47ec1 100644 --- a/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs +++ b/csharp/test/Apache.Arrow.Adbc.Tests/DriverManager/TomlConnectionProfileTests.cs @@ -292,7 +292,7 @@ public void ResolveEnvVars_ExpandsEnvVarValues() driver = ""d"" [options] -password = ""env_var(ADBC_TEST_PASSWORD_TOML)"" +password = ""{{ env_var(ADBC_TEST_PASSWORD_TOML) }}"" plain = ""notanenvvar"" "; ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml).ResolveEnvVars(); @@ -307,7 +307,7 @@ public void ResolveEnvVars_ExpandsEnvVarValues() } [Fact] - public void ResolveEnvVars_NoEnvVarValues_ReturnsSameProfile() + public void ResolveEnvVars_NoPlaceholders_ReturnsSameValues() { const string toml = @" version = 1 @@ -320,6 +320,101 @@ public void ResolveEnvVars_NoEnvVarValues_ReturnsSameProfile() Assert.Equal("value", profile.StringOptions["key"]); } + [Fact] + public void ResolveEnvVars_PlaceholderEmbeddedInString_ExpandedInPlace() + { + // Per spec: placeholders may appear anywhere inside a value. + const string varName = "ADBC_TEST_EMBEDDED_HOST"; + Environment.SetEnvironmentVariable(varName, "prod.example.com"); + try + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +uri = ""postgres://user@{{ env_var(ADBC_TEST_EMBEDDED_HOST) }}:5432/db"" +"; + ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml).ResolveEnvVars(); + Assert.Equal("postgres://user@prod.example.com:5432/db", profile.StringOptions["uri"]); + } + finally + { + Environment.SetEnvironmentVariable(varName, null); + } + } + + [Fact] + public void ResolveEnvVars_MultiplePlaceholdersInOneValue_AllExpanded() + { + const string hostVar = "ADBC_TEST_MULTI_HOST"; + const string portVar = "ADBC_TEST_MULTI_PORT"; + Environment.SetEnvironmentVariable(hostVar, "db.local"); + Environment.SetEnvironmentVariable(portVar, "5433"); + try + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +uri = ""postgres://{{ env_var(ADBC_TEST_MULTI_HOST) }}:{{ env_var(ADBC_TEST_MULTI_PORT) }}/db"" +"; + ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml).ResolveEnvVars(); + Assert.Equal("postgres://db.local:5433/db", profile.StringOptions["uri"]); + } + finally + { + Environment.SetEnvironmentVariable(hostVar, null); + Environment.SetEnvironmentVariable(portVar, null); + } + } + + [Fact] + public void ResolveEnvVars_WhitespaceVariationsInsidePlaceholder_AllAccepted() + { + const string varName = "ADBC_TEST_WS_VAR"; + Environment.SetEnvironmentVariable(varName, "X"); + try + { + // No whitespace, lots of whitespace, asymmetric whitespace -- all valid. + const string toml = @" +version = 1 +driver = ""d"" + +[options] +tight = ""{{env_var(ADBC_TEST_WS_VAR)}}"" +loose = ""{{ env_var(ADBC_TEST_WS_VAR) }}"" +asymmetric = ""{{ env_var(ADBC_TEST_WS_VAR)}}"" +"; + ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml).ResolveEnvVars(); + Assert.Equal("X", profile.StringOptions["tight"]); + Assert.Equal("X", profile.StringOptions["loose"]); + Assert.Equal("X", profile.StringOptions["asymmetric"]); + } + finally + { + Environment.SetEnvironmentVariable(varName, null); + } + } + + [Fact] + public void ResolveEnvVars_BareEnvVarSyntax_NotInterpretedAsPlaceholder() + { + // The old (pre-spec) C# implementation treated a whole-value 'env_var(NAME)' + // as a placeholder. The spec requires '{{ }}' delimiters, so the bare form + // is now a literal string. + const string toml = @" +version = 1 +driver = ""d"" + +[options] +literal = ""env_var(NOT_A_PLACEHOLDER)"" +"; + ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml).ResolveEnvVars(); + Assert.Equal("env_var(NOT_A_PLACEHOLDER)", profile.StringOptions["literal"]); + } + // ----------------------------------------------------------------------- // Positive: AdbcDriverManager DeriveEntrypoint // ----------------------------------------------------------------------- @@ -438,12 +533,15 @@ public void ParseProfile_FileNotFound_ThrowsIOException() } // ----------------------------------------------------------------------- - // Negative: env_var expansion – variable not set + // env_var expansion – variable not set // ----------------------------------------------------------------------- [Fact] - public void ResolveEnvVars_MissingEnvVar_ThrowsAdbcException() + public void ResolveEnvVars_MissingEnvVar_ExpandsToEmptyString() { + // Per spec (and matching the C/C++ driver manager): a missing env var + // expands to "" and processing of the rest of the value continues. + // Example from the spec: "foo{{ env_var(MISSING) }}bar" -> "foobar". const string varName = "ADBC_TEST_DEFINITELY_NOT_SET_XYZ"; Environment.SetEnvironmentVariable(varName, null); @@ -452,12 +550,61 @@ public void ResolveEnvVars_MissingEnvVar_ThrowsAdbcException() driver = ""d"" [options] -password = ""env_var(ADBC_TEST_DEFINITELY_NOT_SET_XYZ)"" +password = ""{{ env_var(ADBC_TEST_DEFINITELY_NOT_SET_XYZ) }}"" +greeting = ""foo{{ env_var(ADBC_TEST_DEFINITELY_NOT_SET_XYZ) }}bar"" +"; + ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml).ResolveEnvVars(); + Assert.Equal("", profile.StringOptions["password"]); + Assert.Equal("foobar", profile.StringOptions["greeting"]); + } + + // ----------------------------------------------------------------------- + // Negative: malformed / unsupported placeholders + // ----------------------------------------------------------------------- + + [Fact] + public void ResolveEnvVars_UnsupportedFunction_ThrowsInvalidArgument() + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +weird = ""{{ unknown_func(FOO) }}"" +"; + ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml); + AdbcException ex = Assert.Throws(() => profile.ResolveEnvVars()); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + [Fact] + public void ResolveEnvVars_MissingClosingParen_ThrowsInvalidArgument() + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +oops = ""{{ env_var(FOO }}"" "; ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml); AdbcException ex = Assert.Throws(() => profile.ResolveEnvVars()); - Assert.Equal(AdbcStatusCode.InvalidState, ex.Status); - Assert.Contains(varName, ex.Message); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); + } + + [Fact] + public void ResolveEnvVars_EmptyVarName_ThrowsInvalidArgument() + { + const string toml = @" +version = 1 +driver = ""d"" + +[options] +oops = ""{{ env_var() }}"" +"; + ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml); + AdbcException ex = Assert.Throws(() => profile.ResolveEnvVars()); + Assert.Equal(AdbcStatusCode.InvalidArgument, ex.Status); } // ----------------------------------------------------------------------- @@ -606,34 +753,6 @@ public void FilesystemProfileProvider_AbsoluteTomlPath_LoadsDirectly() Assert.Equal("abs_driver", profile!.DriverName); } - // ----------------------------------------------------------------------- - // Positive: driver_type field in TOML profile - // ----------------------------------------------------------------------- - - [Fact] - public void ParseProfile_WithDriverType_ParsedCorrectly() - { - const string toml = @" -version = 1 -driver = ""Apache.Arrow.Adbc.Tests.dll"" -driver_type = ""Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDriver"" -"; - ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml); - Assert.Equal("Apache.Arrow.Adbc.Tests.dll", profile.DriverName); - Assert.Equal("Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDriver", profile.DriverTypeName); - } - - [Fact] - public void ParseProfile_WithoutDriverType_DriverTypeNameIsNull() - { - const string toml = @" -version = 1 -driver = ""mydriver"" -"; - ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml); - Assert.Null(profile.DriverTypeName); - } - // ----------------------------------------------------------------------- // Positive: LoadManagedDriver loads a managed .NET driver by reflection // ----------------------------------------------------------------------- @@ -692,35 +811,50 @@ public void BuildStringOptions_NoOptions_ReturnsEmptyDictionary() // ----------------------------------------------------------------------- // Positive: OpenDatabaseFromProfile end-to-end with managed driver + // + // Managed drivers are selected by a scheme-prefixed 'entrypoint' option: + // dotnet:Type for modern .NET, netfx:Type for .NET Framework. The driver + // manager consumes the entrypoint option before opening the database. // ----------------------------------------------------------------------- + /// + /// Scheme prefix this test process must use when selecting managed drivers + /// -- a dotnet: entrypoint on .NET Framework (or vice versa) is a runtime + /// mismatch that the driver manager intentionally rejects. + /// + private static string ManagedScheme => +#if NETFRAMEWORK + "netfx:"; +#else + "dotnet:"; +#endif + [Fact] public void OpenDatabaseFromProfile_ManagedDriver_OpensDatabase() { string assemblyPath = typeof(FakeAdbcDriver).Assembly.Location; string typeName = typeof(FakeAdbcDriver).FullName!; - // Build TOML content; escape any backslashes in the Windows assembly path. string escapedPath = assemblyPath.Replace("\\", "\\\\"); - string toml = "version = 1\n" + string toml = "profile_version = 1\n" + "driver = \"" + escapedPath + "\"\n" - + "driver_type = \"" + typeName + "\"\n" - + "\n[options]\n" + + "\n[Options]\n" + + "entrypoint = \"" + ManagedScheme + typeName + "\"\n" + "project_id = \"my-project\"\n" + "region = \"us-east1\"\n"; ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml); AdbcDatabase db = AdbcDriverManager.OpenDatabaseFromProfile(profile); - // Use type name comparison to avoid assembly identity issues when loaded via Assembly.LoadFrom Assert.Equal("Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDatabase", db.GetType().FullName); - // Access parameters via reflection since the type identity differs System.Reflection.PropertyInfo? paramsProp = db.GetType().GetProperty("Parameters"); Assert.NotNull(paramsProp); IReadOnlyDictionary parameters = (IReadOnlyDictionary)paramsProp!.GetValue(db)!; Assert.Equal("my-project", parameters["project_id"]); Assert.Equal("us-east1", parameters["region"]); + // entrypoint is consumed by the driver manager, not forwarded to the driver + Assert.False(parameters.ContainsKey("entrypoint")); } // ----------------------------------------------------------------------- @@ -776,14 +910,13 @@ public void OpenDatabaseFromProfile_NullProfile_ThrowsArgumentNullException() } [Fact] - public void OpenDatabaseFromProfile_ManagedDriverMissingAssemblyPath_ThrowsAdbcException() + public void OpenDatabaseFromProfile_NoDriver_ThrowsAdbcException() { - // driver_type is set but the driver (assembly path) field is omitted. + // A profile with no 'driver' field cannot be opened on its own. const string toml = @" -version = 1 -driver_type = ""Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDriver"" +profile_version = 1 -[options] +[Options] key = ""value"" "; ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml); @@ -876,11 +1009,11 @@ public void ParseProfile_DuplicateOptionKey_LastValueWins() } // ----------------------------------------------------------------------- - // ResolveEnvVars preserves DriverTypeName + // ResolveEnvVars preserves the driver reference and other non-env values // ----------------------------------------------------------------------- [Fact] - public void ResolveEnvVars_DriverTypeNameIsPreserved() + public void ResolveEnvVars_DriverNameIsPreserved() { const string varName = "ADBC_TEST_RESOLVE_ENVVAR_HOST"; Environment.SetEnvironmentVariable(varName, "myhost"); @@ -889,13 +1022,12 @@ public void ResolveEnvVars_DriverTypeNameIsPreserved() const string toml = @" version = 1 driver = ""MyDriver.dll"" -driver_type = ""My.Namespace.MyDriver"" [options] -host = ""env_var(ADBC_TEST_RESOLVE_ENVVAR_HOST)"" +host = ""{{ env_var(ADBC_TEST_RESOLVE_ENVVAR_HOST) }}"" "; ConnectionProfile resolved = FilesystemProfileProvider.LoadFromContent(toml).ResolveEnvVars(); - Assert.Equal("My.Namespace.MyDriver", resolved.DriverTypeName); + Assert.Equal("MyDriver.dll", resolved.DriverName); Assert.Equal("myhost", resolved.StringOptions["host"]); } finally @@ -915,20 +1047,18 @@ public void OpenDatabaseFromProfile_UnknownOptionsPassThroughToDriver() string typeName = typeof(FakeAdbcDriver).FullName!; string escapedPath = assemblyPath.Replace("\\", "\\\\"); - string toml = "version = 1\n" + string toml = "profile_version = 1\n" + "driver = \"" + escapedPath + "\"\n" - + "driver_type = \"" + typeName + "\"\n" - + "\n[options]\n" + + "\n[Options]\n" + + "entrypoint = \"" + ManagedScheme + typeName + "\"\n" + "known_key = \"hello\"\n" + "unknown_widget = \"ignored_by_driver\"\n"; ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml); AdbcDatabase db = AdbcDriverManager.OpenDatabaseFromProfile(profile); - // Use type name comparison to avoid assembly identity issues Assert.Equal("Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDatabase", db.GetType().FullName); - // Access parameters via reflection since the type identity differs System.Reflection.PropertyInfo? paramsProp = db.GetType().GetProperty("Parameters"); Assert.NotNull(paramsProp); IReadOnlyDictionary parameters = (IReadOnlyDictionary)paramsProp!.GetValue(db)!; @@ -1051,10 +1181,10 @@ public void OpenDatabaseFromProfile_WithExplicitOptions_MergesCorrectly() string typeName = typeof(FakeAdbcDriver).FullName!; string escapedPath = assemblyPath.Replace("\\", "\\\\"); - string toml = "version = 1\n" + string toml = "profile_version = 1\n" + "driver = \"" + escapedPath + "\"\n" - + "driver_type = \"" + typeName + "\"\n" - + "\n[options]\n" + + "\n[Options]\n" + + "entrypoint = \"" + ManagedScheme + typeName + "\"\n" + "profile_option = \"from_profile\"\n" + "shared_option = \"profile_value\"\n"; @@ -1068,21 +1198,14 @@ public void OpenDatabaseFromProfile_WithExplicitOptions_MergesCorrectly() AdbcDatabase db = AdbcDriverManager.OpenDatabaseFromProfile(profile, explicitOptions); - // Use type name comparison to avoid assembly identity issues Assert.Equal("Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDatabase", db.GetType().FullName); - // Access parameters via reflection since the type identity differs System.Reflection.PropertyInfo? paramsProp = db.GetType().GetProperty("Parameters"); Assert.NotNull(paramsProp); IReadOnlyDictionary parameters = (IReadOnlyDictionary)paramsProp!.GetValue(db)!; - // Profile-only option should be present Assert.Equal("from_profile", parameters["profile_option"]); - - // Explicit-only option should be present Assert.Equal("from_explicit", parameters["explicit_option"]); - - // Shared option: explicit should override profile Assert.Equal("explicit_value", parameters["shared_option"]); } @@ -1093,19 +1216,17 @@ public void OpenDatabaseFromProfile_NullExplicitOptions_UsesOnlyProfile() string typeName = typeof(FakeAdbcDriver).FullName!; string escapedPath = assemblyPath.Replace("\\", "\\\\"); - string toml = "version = 1\n" + string toml = "profile_version = 1\n" + "driver = \"" + escapedPath + "\"\n" - + "driver_type = \"" + typeName + "\"\n" - + "\n[options]\n" + + "\n[Options]\n" + + "entrypoint = \"" + ManagedScheme + typeName + "\"\n" + "key = \"value\"\n"; ConnectionProfile profile = FilesystemProfileProvider.LoadFromContent(toml); AdbcDatabase db = AdbcDriverManager.OpenDatabaseFromProfile(profile, null); - // Use type name comparison to avoid assembly identity issues Assert.Equal("Apache.Arrow.Adbc.Tests.DriverManager.FakeAdbcDatabase", db.GetType().FullName); - // Access parameters via reflection since the type identity differs System.Reflection.PropertyInfo? paramsProp = db.GetType().GetProperty("Parameters"); Assert.NotNull(paramsProp); IReadOnlyDictionary parameters = (IReadOnlyDictionary)paramsProp!.GetValue(db)!;