Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
75737dd
Add InstallExternalLibrary and UninstallExternalLibrary API to .NET e…
SicongLiu2000 Mar 11, 2026
10ce19a
InstallExternalLibrary/UninstallExternalLibrary: ZIP extraction, mani…
Mar 21, 2026
1a782e3
Add tests for manifest-based install/uninstall, conflict detection, A…
Apr 17, 2026
8060650
Add 6 production-quality tests for Install/Uninstall API
Apr 17, 2026
f55001d
Address review: alias naming, zip-slip DID, ALTER atomicity, libName …
Apr 17, 2026
0b3916b
Remove 'fallback' language from comments
Apr 17, 2026
af9f8d7
Add Microsoft.Data.SqlClient 5.2.2 reference to csproj
Apr 17, 2026
07e0615
Merge remote-tracking branch 'origin/main' into dev/stuartpa/dotnet-i…
Apr 17, 2026
a342b0c
Fix: do not append .dll when library name already ends in .dll
Apr 21, 2026
7bfdd73
Merge remote-tracking branch 'upstream/main' into dev/stuartpa/dotnet…
Apr 21, 2026
89a7bae
Add regression test for double-.dll bug
Apr 21, 2026
246067b
Address PR #85 review: DllUtils exact-name, Uninstall libName validat…
Apr 21, 2026
e7ec844
Address PR #85 second review pass: raw-DLL fail-if-exists+manifest, a…
Apr 22, 2026
38c553d
Address PR #85 yaelh review: concurrent-install lock, empty-dirs ZIP …
Apr 28, 2026
9e6d5fb
Fix gtest suite regressions discovered by local test run (38c553d -> …
Apr 28, 2026
1c40a61
Bump System.Text.Json to 10.0.4; fix stale native comment
JustinMDotNet Apr 28, 2026
a0d7aae
Revert System.Text.Json 10.0.4 pin (keep native comment fix)
JustinMDotNet Apr 28, 2026
55d15e3
Address PR #85 third review pass: A1 native buffer ownership, A2-A7 c…
Apr 30, 2026
8ef1573
Address PR #85 B8: explain why ExecuteInvalidLibraryNameScriptTest ca…
Apr 30, 2026
21976c5
PR #85 review pass 4: production fixes + reviewer items
May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,46 @@ SQLEXTENSION_INTERFACE SQLRETURN CleanupSession(SQLGUID sessionId, SQLUSMALLINT
//
SQLEXTENSION_INTERFACE SQLRETURN Cleanup();

// Installs an external library to the specified directory.
//
// Dispatch is by the registered library name (libraryName), not the
// contents of libraryFile:
// - libraryName ending in ".zip" -> ZIP archive install. If the archive
// contains a single inner zip, that inner zip is extracted to the
// install directory; otherwise the outer archive's files are copied
// directly. A "{libName}.manifest" listing every extracted file is
// written so UninstallExternalLibrary can clean up exactly what was
// installed.
// - libraryName ending in ".dll" -> raw DLL install. libraryFile is
// copied verbatim to "{installDir}\{libraryName}" and a one-entry
// manifest is written.
// - libraryName with neither extension -> falls back to libraryFile's
// extension (preserves the legacy contract for callers that register
// libraries by bare name and point libraryFile at a "*.zip" or
// "*.dll" fixture).
//
SQLEXTENSION_INTERFACE SQLRETURN InstallExternalLibrary(
SQLGUID setupSessionId,
SQLCHAR *libraryName,
SQLINTEGER libraryNameLength,
SQLCHAR *libraryFile,
SQLINTEGER libraryFileLength,
SQLCHAR *libraryInstallDirectory,
SQLINTEGER libraryInstallDirectoryLength,
SQLCHAR **libraryError,
SQLINTEGER *libraryErrorLength);

// Uninstalls an external library from the specified directory.
//
SQLEXTENSION_INTERFACE SQLRETURN UninstallExternalLibrary(
SQLGUID setupSessionId,
SQLCHAR *libraryName,
SQLINTEGER libraryNameLength,
SQLCHAR *libraryInstallDirectory,
SQLINTEGER libraryInstallDirectoryLength,
SQLCHAR **libraryError,
SQLINTEGER *libraryErrorLength);

// Dotnet environment pointer
//
static DotnetEnvironment* g_dotnet_runtime = nullptr;
1,209 changes: 1,209 additions & 0 deletions language-extensions/dotnet-core-CSharp/src/managed/CSharpExtension.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,31 @@ DataFrameColumn column
SetDataPtrs<byte>(columnNumber, GetStringArray(column));
break;
case SqlDataType.DotNetWChar:
case SqlDataType.DotNetNVarChar:
// Calculate column size from actual data.
// columnSize = max character count (UTF-16 byte length / 2).
// Minimum size is 1 character (nchar(0) is illegal in SQL).
// GetStrLenNullMap returns the per-row byte length
// (Encoding.Unicode.GetByteCount) and the SQL_C_WCHAR
// ODBC contract requires the column Size and the
// strLenOrNullMap entries to use the same unit. Report
// Size in BYTES; do NOT convert to a character count
// here -- a character-count Size combined with a
// byte-count length map causes SPEES to log
// "Reading one row failed for column N row M. The
// length information is incorrect." and reject the
// rowset.
//
// DotNetNVarChar is treated as an alias of DotNetWChar
// (both are SQL_C_WCHAR-shaped at the ODBC layer).
// Without this case, callers who set
// DataTypeMap[typeof(string)] = SqlDataType.DotNetNVarChar
// hit a KeyNotFoundException in DataTypeSize and the
// column never reaches the dispatch switch.
//
// Minimum size is 2 bytes (one UTF-16 code unit -- nchar(0)
// is illegal in SQL).
//
int maxUnicodeByteLen = colMap.Length > 0 ? colMap.Where(x => x > 0).DefaultIfEmpty(0).Max() : 0;
int maxCharCount = maxUnicodeByteLen / sizeof(char);
_columns[columnNumber].Size = (ulong)Math.Max(maxCharCount, MinUtf16CharSize);
_columns[columnNumber].Size = (ulong)Math.Max(maxUnicodeByteLen, MinUtf16CharSize);

SetDataPtrs<char>(columnNumber, GetUnicodeStringArray(column));
break;
Expand Down Expand Up @@ -429,8 +447,11 @@ private int[] GetStrLenNullMap(ushort columnNumber, DataFrameColumn column)
Logging.Trace($"GetStrLenNullMap: Row {rowNumber}, Value='{column[rowNumber]}', ByteLen={colMap[rowNumber]}");
break;
case SqlDataType.DotNetWChar:
case SqlDataType.DotNetNVarChar:
// Report the byte length of the UTF-16 encoded string (2 bytes per code unit).
// This must match the buffer size emitted by GetUnicodeStringArray().
// This must match the buffer size emitted by GetUnicodeStringArray()
// and the column Size set in ExtractColumn (also bytes for SQL_C_WCHAR).
// DotNetNVarChar is an alias of DotNetWChar at the ODBC layer.
//
colMap[rowNumber] = Encoding.Unicode.GetByteCount((string)column[rowNumber]);
Logging.Trace($"GetStrLenNullMap: Row {rowNumber}, Value='{column[rowNumber]}', ByteLen={colMap[rowNumber]}");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,12 @@ public static List<string> CreateDllList(
}
else
{
if (!string.IsNullOrEmpty(privatePath))
{
dllList.AddRange(Directory.GetFiles(privatePath, userLibName));
}

if (!string.IsNullOrEmpty(publicPath))
{
dllList.AddRange(Directory.GetFiles(publicPath, userLibName));
}
// Callers may pass either a bare library name ("regex") or an explicit
// filename ("Foo.dll"). Try the exact name first so filenames with
// extensions resolve correctly; fall back to the "{name}.*" wildcard
// for bare names.
AddMatches(privatePath, userLibName, dllList);
AddMatches(publicPath, userLibName, dllList);
}

if (dllList.Count == 0)
Expand All @@ -110,6 +107,63 @@ public static List<string> CreateDllList(
return dllList;
}

/// <summary>
/// Adds DLL matches for <paramref name="userLibName"/> under
/// <paramref name="searchPath"/>. Tries the exact name first (so callers
/// that pass "Foo.dll" resolve correctly) and falls back to all .dll
/// files whose stem equals <paramref name="userLibName"/> (so callers
/// that pass "Foo" still match "Foo.dll").
/// </summary>
/// <param name="searchPath">
/// Directory to search. Returns immediately if null/empty or absent;
/// missing public/private library paths are not an error -- the other
/// path may still yield matches.
/// </param>
/// <param name="userLibName">
/// Library name to match. May be a bare stem ("regex") or include a
/// .dll suffix ("Foo.dll"). Wildcards / path separators are not
/// expected -- the caller is responsible for validation.
/// </param>
/// <param name="dllList">
/// Output list to append discovered .dll paths to. Caller-owned;
/// AddMatches never clears or replaces.
/// </param>
/// <remarks>
/// We deliberately avoid <see cref="Directory.GetFiles(string, string)"/>'s
/// search-pattern argument and its Win32 <c>FindFirstFile</c>
/// wildcard semantics, which over-match in two well-known ways on
/// Windows: <c>"*.dll"</c> matches files like <c>"foo.dllx"</c>
/// (3-char-extension prefix-match quirk inherited from FAT), and
/// <c>"Foo.*"</c> can spuriously match short-name (8.3) aliases of
/// long-named files. Enumerating all entries and filtering with
/// <see cref="StringComparison.OrdinalIgnoreCase"/> equality on
/// both the extension and the stem gives exact, predictable
/// matches and avoids loading anything we did not intend.
/// </remarks>
private static void AddMatches(string searchPath, string userLibName, List<string> dllList)
Comment thread
yaelh marked this conversation as resolved.
{
if (string.IsNullOrEmpty(searchPath) || !Directory.Exists(searchPath))
{
return;
}

string exactPath = Path.Combine(searchPath, userLibName);
if (File.Exists(exactPath))
{
dllList.Add(exactPath);
return;
}

foreach (string f in Directory.EnumerateFiles(searchPath))
{
if (Path.GetExtension(f).Equals(".dll", StringComparison.OrdinalIgnoreCase) &&
Path.GetFileNameWithoutExtension(f).Equals(userLibName, StringComparison.OrdinalIgnoreCase))
{
dllList.Add(f);
}
}
}

/// <summary>
/// This method finds the corresponding loaded dll for user dll's dependencies.
/// It searches for the corresponding loaded dll that matches args.Name.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ public enum SqlDataType: short
{SqlDataType.DotNetBit, sizeof(bool)},
{SqlDataType.DotNetChar, MinUtf8CharSize},
{SqlDataType.DotNetWChar, MinUtf16CharSize},
{SqlDataType.DotNetNVarChar, MinUtf16CharSize},
{SqlDataType.DotNetNumeric, SqlNumericStructSize}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -340,4 +340,157 @@ SQLRETURN Cleanup()
LOG("nativecsharpextension::Cleanup");
delete g_dotnet_runtime;
return SQL_SUCCESS;
}

//--------------------------------------------------------------------------------------------------
// Name: SetLibraryError
//
// Description:
// Helper to populate the library error output parameters.
//
static void SetLibraryError(
const std::string &errorString,
SQLCHAR **libraryError,
SQLINTEGER *libraryErrorLength)
{
// Guard against null out-parameters. The managed SetLibraryError
// (CSharpExtension.cs) does the same null-check; without it, a caller
// passing null libraryError / libraryErrorLength would dereference null
// and crash the host process.
if (libraryError == nullptr || libraryErrorLength == nullptr)
{
return;
}

if (!errorString.empty())
Comment thread
stuartpa marked this conversation as resolved.
Comment thread
stuartpa marked this conversation as resolved.
{
// Length excludes null terminator -- ExtHost adds +1 when copying
// (see Utf8ToNullTerminatedUtf16Le / strcpy_s in the host).
*libraryErrorLength = static_cast<SQLINTEGER>(errorString.length());

// Ownership of the buffer transfers to ExtHost; it frees the pointer
// via the C runtime (free()) after consuming the message. Mirrors
// the managed SetLibraryError (CSharpExtension.cs), which uses
// Marshal.AllocHGlobal for the same reason. The previous
// implementation -- new std::string(errorString) + returning
// c_str() -- leaked the string AND handed back a pointer into a
// std::string's internal buffer, which is undefined behavior the
// moment ExtHost calls free() on it.
size_t bufLen = errorString.length() + 1;
char *buf = static_cast<char*>(malloc(bufLen));
if (buf == nullptr)
{
// Out of memory; surface a "no error" state rather than crash
// (the original failure will still be logged by the caller).
*libraryError = nullptr;
*libraryErrorLength = 0;
return;
}
memcpy(buf, errorString.c_str(), bufLen);
*libraryError = reinterpret_cast<SQLCHAR*>(buf);
}
else
{
// Explicitly clear the out-parameters so callers that don't
// pre-initialize them see a well-defined "no error" state.
*libraryError = nullptr;
*libraryErrorLength = 0;
}
}

//--------------------------------------------------------------------------------------------------
// Name: InstallExternalLibrary
//
// Description:
// Installs an external library to the specified directory.
// The library file may be a ZIP archive or a raw DLL.
// If a ZIP, and it contains an inner zip, that inner zip is extracted to the
// install directory. Otherwise, all files are copied directly.
//
// Returns:
// SQL_SUCCESS on success, else SQL_ERROR
//
SQLRETURN InstallExternalLibrary(
SQLGUID setupSessionId,
SQLCHAR *libraryName,
SQLINTEGER libraryNameLength,
SQLCHAR *libraryFile,
SQLINTEGER libraryFileLength,
SQLCHAR *libraryInstallDirectory,
SQLINTEGER libraryInstallDirectoryLength,
SQLCHAR **libraryError,
SQLINTEGER *libraryErrorLength)
{
LOG("nativecsharpextension::InstallExternalLibrary");

SQLRETURN result = SQL_ERROR;

if (g_dotnet_runtime == nullptr)
{
SetLibraryError(
"Extension not initialized. Call Init before InstallExternalLibrary.",
libraryError,
libraryErrorLength);
}
else
{
result = g_dotnet_runtime->call_managed_method<decltype(&InstallExternalLibrary)>(
nameof(InstallExternalLibrary),
setupSessionId,
libraryName,
libraryNameLength,
libraryFile,
libraryFileLength,
libraryInstallDirectory,
libraryInstallDirectoryLength,
libraryError,
libraryErrorLength);
}

return result;
}

//--------------------------------------------------------------------------------------------------
// Name: UninstallExternalLibrary
//
// Description:
// Uninstalls an external library from the specified directory.
//
// Returns:
// SQL_SUCCESS on success, else SQL_ERROR
//
SQLRETURN UninstallExternalLibrary(
SQLGUID setupSessionId,
SQLCHAR *libraryName,
SQLINTEGER libraryNameLength,
SQLCHAR *libraryInstallDirectory,
SQLINTEGER libraryInstallDirectoryLength,
SQLCHAR **libraryError,
SQLINTEGER *libraryErrorLength)
{
LOG("nativecsharpextension::UninstallExternalLibrary");

SQLRETURN result = SQL_ERROR;

if (g_dotnet_runtime == nullptr)
{
SetLibraryError(
"Extension not initialized. Call Init before UninstallExternalLibrary.",
libraryError,
libraryErrorLength);
}
else
{
result = g_dotnet_runtime->call_managed_method<decltype(&UninstallExternalLibrary)>(
nameof(UninstallExternalLibrary),
setupSessionId,
libraryName,
libraryNameLength,
libraryInstallDirectory,
libraryInstallDirectoryLength,
libraryError,
libraryErrorLength);
}

return result;
}
Loading