mssql-extension
CLAUDE.md137 starsChanged 3 months agoMITView on GitHub
DuckDB extension for SQL Server via a custom TDS protocol implementation (no FreeTDS/ODBC).
- Reads credentials
In this repository11 other files it gives its agents.
- agents.mdAGENTS.md
- speckit-analyzeSkill
- speckit-checklistSkill
- speckit-clarifySkill
- speckit-constitutionSkill
- speckit-convergeSkill
- speckit-implementSkill
- speckit-planSkill
- speckit-specifySkill
- speckit-tasksSkill
- speckit-taskstoissuesSkill
The file
We split this CLAUDE.md into the rules it gives an agent
7 rules. About 0 are common, 1 is distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 66
When releasing a new version: update MSSQL_EXTENSION_VERSION in CMakeLists.txt and version in vcpkg.json
- Very specific: line 104
Use clang-format (version 14): find src -name '*.cpp' -o -name '*.hpp' | xargs clang-format -i
- Very specific: line 111
Test files: test_<component>.cpp (C++), <feature>_<scenario>.test (SQL)
- Distinctive: line 126
PascalCase: always. Prefix per namespace rule above.
- Very specific: line 154
Test tables: PascalCase (TestSimplePK, TxTestOrders) or snake_case for simple ones (tx_test)
- Very specific: line 159
Keep architecture/internals diagrams in DATAMODEL.md.
- Very specific: line 160
Use GitHub-rendered `
mermaid` fenced blocks for diagrams.
≈ “Common” is an estimate: at least 1 in 5 of the 26,980 CLAUDE.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
mssql-extension Development Guidelines
DuckDB extension for SQL Server via a custom TDS protocol implementation (no FreeTDS/ODBC).
Technology
- Language: C++17 (DuckDB extension standard)
- DuckDB:
v2.0-cyanoptera, DuckDB's 2.0 branch (2.0 API track, spec 069). Tracked instead ofmainsince the branch was cut on 2026-09-03; since v2.0.0 (2026-10-20) it carries the backports of the 2.0.x line, and the pin follows its head between releases (2026-11-08:4569c0a1eeb, 880 commits past v2.0.0). A head that is not a release tag builds as-devand loads into no released DuckDB, so a release waits for the 2.0.x tag (see Versioning).main(2.1-dev) is not tracked: measured 2026-11-08, it is mid-refactor (ClientContextgiving way toContext/ExecutionServices,registered_stategone) and table functions default tostability = CONSISTENT_WITHIN_QUERY, which a side-effecting vehicle (mssql_dml) would have to opt out of. The 1.5.x world lives on branchduckdb-v1.5.5for 0.2.x maintenance releases; main compiles against the pinned duckdb submodule SHA only (no dual-API shim since spec 069 retiredmssql_compat.hpp) - TLS: OpenSSL via vcpkg (statically linked, symbol visibility controlled)
- Platforms: Linux (GCC), macOS (Clang), Windows (MSVC, MinGW/Rtools 4.2)
Project Structure
src/
azure/ # Azure AD authentication infrastructure
catalog/ # DuckDB catalog integration + transaction manager
codec/ # Per-type-family codec layer (spec 045): boolean/integer/
# float/decimal/money/string/binary/datetime/uuid. Each
# <family>_codec.cpp owns EncodeToBcp / DecodeFromTds /
# FormatSqlLiteral / FormatDdlTypeName for its types.
# literal_format.cpp + type_family.cpp are the dispatchers.
# code_page/ (spec 091): server-generated code-page tables
# (tools/code_pages/), the code-page -> UTF-8 kernels, the
# UTF-16 stage 1 (DecodeToUtf16) and the collation lookups
# (by CollationId, by name, by wire key -> code page).
connection/ # Connection pooling, provider, settings
dml/ # DML operations
insert/ # INSERT (batched VALUES, OUTPUT INSERTED for RETURNING)
update/ # UPDATE (rowid-based, VALUES JOIN pattern)
delete/ # DELETE (rowid-based, VALUES JOIN pattern)
include/ # Headers (mirrors src/ layout)
query/ # Query execution and result streaming
table_scan/ # Table scan with filter/projection pushdown
tds/ # TDS protocol implementation
auth/ # Authentication strategies (SQL auth, FEDAUTH)
encoding/ # Type encoding (datetime, decimal, GUID, UTF-16)
tls/ # TLS via OpenSSL with custom BIO callbacks
test/
sql/ # SQLLogicTest files (require SQL Server for most)
attach/ # ATTACH/DETACH tests
azure/ # Azure AD authentication tests (no SQL Server required)
catalog/ # Catalog, DDL, filter pushdown, statistics
copy/ # COPY TO MSSQL (BulkLoadBCP) tests
ctas/ # CREATE TABLE AS SELECT tests
dml/ # UPDATE and DELETE tests
insert/ # INSERT tests
integration/ # Core integration (pool, TLS, large data)
query/ # Query-level tests
rowid/ # Rowid pseudo-column tests
tds_connection/ # TDS protocol tests
transaction/ # Transaction management tests
cpp/ # C++ unit tests (no SQL Server required)
docs/ # Architecture documentation (see docs/architecture.md)
docker/ # SQL Server container and Linux CI build
tools/
code_pages/ # Spec 091: generate.py asks a SQL Server how it reads every
# code-page byte (data/, committed); emit_tables.py checks the
# answers and writes src/codec/code_page/code_page_tables.cppVersioning
- The extension version is defined in
CMakeLists.txtasMSSQL_EXTENSION_VERSION(e.g.,set(MSSQL_EXTENSION_VERSION "0.1.10")) - This is passed to C++ code via the
MSSQL_VERSIONcompile definition and returned bymssql_version() - When releasing a new version: update
MSSQL_EXTENSION_VERSIONinCMakeLists.txtandversioninvcpkg.json - DuckDB's own label: DuckDB 2.0's CMake calls every build
v<X.Y>.0-dev<N>unlessOVERRIDE_GIT_DESCRIBEis set, even on a release tag's commit. A-devbuild looks for extensions under its source id, where DuckDB publishes nothing for a release, and its loadable loads into no released DuckDB. So when the duckdb pin IS a release tag (scripts/ci/duckdb_release_tag.sh), the build is labelled with it:checkout_duckdb.shexports it in CI and release, and the Makefile sets it locally from the submodule's local tags (git -C duckdb fetch --tagsafter a bump). The release notes then say that the binaries load into a stock DuckDB of that version.
Commands
# Build
make # Release build (with TLS via vcpkg)
make debug # Debug build
make clean # Remove build artifacts
# Test
make test # Unit tests (no SQL Server required)
make docker-up # Start SQL Server container (no .env needed; `cp .env.example .env` to override port/password)
make integration-test # Integration tests (requires SQL Server)
make test-all # All tests
make test-debug # Tests with debug build
make test-code-page # Code-page kernels vs the committed server data (spec 091; no
# SQL Server, no DuckDB); test-code-page-portable = SWAR form
make bench-code-page # Code-page kernel microbenchmark (manual, not in CI)
make code-pages-check-wire # Every collation's wire key against a live server (spec 092 D1)
make bench-code-page-column # One 2048-row column, staging to vector: DecodeColumn vs today's
# NVARCHAR path (needs a release build; CELL=thai filters cells)
# Docker
make docker-up # Start SQL Server test container
make docker-down # Stop container
make docker-status # Check container health
# Load extension in DuckDB CLI
./build/release/duckdb
# Or dynamically:
duckdb --unsigned -c "INSTALL mssql FROM local_build_debug; LOAD mssql;"Code Style
- C++17, follow DuckDB extension conventions
- Use clang-format (version 14):
find src -name '*.cpp' -o -name '*.hpp' | xargs clang-format -i
Naming Conventions
Files
- Source files:
mssql_<component>.cppfor extension code,tds_<component>.cppfor TDS protocol - Headers mirror source layout in
src/include/ - Test files:
test_<component>.cpp(C++),<feature>_<scenario>.test(SQL)
Namespace Prefix Rule
Critical: The MSSQL or Tds prefix depends on namespace placement:
| Namespace | Prefix | Rationale | Examples |
duckdb (common) | Required (MSSQL, Tds) | Avoid name collisions in shared namespace | MSSQLCatalog, MSSQLTransaction, TdsConnection, TdsSocket |
duckdb::mssql | No prefix | Already scoped by namespace | ConnectionProvider, PoolStatistics, InsertConfig |
duckdb::tds | No prefix | Already scoped by namespace | Connection, Socket, PacketType |
duckdb::tds::tls | No prefix | Already scoped by namespace | TlsContext, TlsBio |
duckdb::encoding | No prefix | Already scoped by namespace | TypeConverter, DatetimeEncoding |
Classes and Structs
- PascalCase always. Prefix per namespace rule above.
- Info/metadata structs:
PrimaryKeyInfo,ColumnInfo,TableMetadata,PoolStatistics - Config structs:
InsertConfig,DMLConfig,PoolConfig
Methods
- PascalCase (DuckDB convention):
GetConnection(),SetPinnedConnection(),ExecuteBatch() - Getters:
Get<Property>()— Setters:Set<Property>() - Boolean queries:
Is<State>(),Has<Property>()(e.g.,IsAlive(),HasPinnedConnection())
Variables
- Member variables:
snake_casewith trailing underscore:connection_pool_,schema_mutex_,transaction_descriptor_[8] - Local variables:
snake_casewithout underscore:row_count,schema_name - Constants:
constexprwith UPPER_SNAKE_CASE:TDS_VERSION_7_4,TDS_HEADER_SIZE,DEFAULT_IDLE_TIMEOUT
Enums
enum classwith PascalCase values:ConnectionState::Idle,PacketType::SQL_BATCH,MSSQLCacheState::LOADED- All-caps for protocol constants:
DONE_FINAL,ENCRYPT_ON
Namespaces
duckdb— main extension entry points and DuckDB API overrides (prefixed)duckdb::tds— TDS protocol layer (no prefix)duckdb::tds::tls— TLS implementation (no prefix)duckdb::mssql— MSSQL-specific utilities (no prefix)duckdb::encoding— Type encoding/decoding (no prefix)
Test Naming
- SQL test files:
# group: [sql],[mssql],[integration],[transaction],[dml] - SQL test context names: unique per file, prefixed by operation (e.g.,
txtest,mssql_upd_scalar) - Test tables: PascalCase (
TestSimplePK,TxTestOrders) or snake_case for simple ones (tx_test)
Documentation
DATAMODEL.mdis the layered-architecture reference (TDS → connection pool → catalog → cache → codec) with Mermaid diagrams. On every PR, consider whetherDATAMODEL.mdneeds updating — if the change adds/removes a layer component, alters an invariant (lifetime, cache invalidation, threading, ownership), or changes an end-to-end flow, update the relevant layer section (and its diagram) in the same PR.- Keep architecture/internals diagrams in
DATAMODEL.md; keepREADME.mduser-facing (link intoDATAMODEL.mdrather than duplicating internals). - Use GitHub-rendered
mermaidfenced blocks for diagrams.
Key Architecture Concepts
- Custom TDS implementation: No external TDS/ODBC library. TDS v7.4 protocol with PRELOGIN, LOGIN7, SQL_BATCH, RPC, ATTENTION packet types. A request is a
tds::Request(tds/tds_request.hpp): batch text, or an RPC body (tds::RpcRequestBuilder,tds/tds_rpc.hpp) with the batch text beside it for logs and errors;TdsConnection::ExecuteRequestsends either through one path (descriptor, RESET_CONNECTION, fragmentation). - Connection pool: Thread-safe pool, owned per
MSSQLCatalog(spec 047; since the issue #178 review ashared_ptrwhose SOLE strong reference is the catalog — result streams holdweak_ptrhandles for safe destructor-time release) — pool lifetime is bounded by catalog lifetime; no singleton, no cross-instance sharing. Two ATTACHes against the same DSN under different aliases get independent pools; DETACH tears down the pool deterministically via RAII. Idle timeout, background cleanup, configurable limits as before. - Transaction support: Connection pinning maps DuckDB transactions to SQL Server transactions via 8-byte ENVCHANGE descriptors -- explicit transactions, and since issue #451 an autocommit statement that writes to the catalog (UPDATE / DELETE / MERGE; INSERT / CTAS when their
use_bcpsetting is off), which runs as a transaction of one statement. Seedocs/transactions.md. - Catalog integration: DuckDB Catalog/Schema/Table APIs with incremental metadata cache (lazy loading, TTL-based expiration, point invalidation; the shared cache holds committed state only — inside an explicit transaction a miss loads on the pinned connection into the transaction's own
MSSQLTransactionMetadata, the names the transaction changed are forgotten in the shared cache at COMMIT/ROLLBACK, andmssql_refresh_cache/mssql_preload_catalogare refused once the transaction has used any MSSQL catalog; issue #380; at COMMIT/ROLLBACK what the transaction loaded and did not change is published into the shared cache from memory, with no round trip (PublishTransactionMetadata; withheld aftermssql_execDDL, under READ UNCOMMITTED, for filter-hidden names, and entirely when the shared cache was invalidated while the transaction ran --GetInvalidationEpoch, so any DDL through the catalog withholds it); inside a transaction the shared metadata cache is read into the transaction's own layer, and a miss there is never "does not exist"; issue #383, review of #386), rowid key discovery for rowid support — the primary key if it is usable, else a usable unique index (spec 077 W1: not filtered, not disabled, key columns NOT NULL and matchable by a literal; aSQL_VARIANT,time(7)ordatetimeoffset(7)key is not, #354/#358 -- aDATETIMEkey is since spec 080 W3, compared in its own type --, and such a table falls through to another unique index or refuses by name). - Read path (spec 075):
mssql_scandescribes at Bind and executes at InitGlobal; in a transaction every scan of a catalog materialises at init under the pinned connection's materialize lock (MSSQLCatalog::MaterializeMutexFor: the transaction's own, so other DuckDB connections' transactions run in parallel -- issue #409; the catalog's in autocommit on a pool of one), and the planner also materialises the catalog scans of any catalog the plan sinks into (COPY's BulkLoadBCP, INSERT, CTAS, and since spec 080 PR 1 UPDATE / DELETE / MERGE) — the sink and the scan feeding it share the ONE pinned connection. The per-table metadata queries (columns, PK, row count, table list) send the names assp_executesqlparameters, so one server plan serves every table (#334); since spec 076 the primary key rides in the same batch as the table's metadata, so a fresh table costs one round trip before its first row. Since spec 084 every metadata load computes a table's row count once per OBJECT (it used to sit beside the columns, evaluated per column row): a first touch is one batch of three result sets (object row, columns, keys, routed by ordinal); the bulk loads (preload, listing,mssql_refresh_cache; one function,LoadObjectsAndColumnsLocked) are objects / columns / keys, grouped onobject_id, with the whole-catalog row counts from onesys.dm_db_partition_statspass into@rcwhen unfiltered and permitted (VIEW DATABASE STATE; not on Fabric/Synapse, not in a transaction). Every load that publishes columns carries the rowid key, so a table entry is born with it (columns LOADED =>pk_loaded, asserted) and nothing discovers it lazily. - DML: an UPDATE / DELETE / INSERT … SELECT the remote-pushdown rewriter can write in T-SQL goes to the server whole as
SELECT * FROM mssql_dml(…)(spec 080 PR 3c / 3d,mssql_dml_pushdown); the rest takes the paths below. The row-by-row INSERT / UPDATE / DELETE batches, INSERT … OUTPUT and the pushed DML read their response through ONE function,mssql::ExecuteDmlBatch(dml/mssql_dml_execute.hpp, spec 080 acceptance 7: five copies of the token loop before it; the staged path's #stage / JOIN statements, IDENTITY_INSERT and BEGIN / COMMIT still useMSSQLSimpleQuery):mssql_query_timeoutper statement (it was a fixed 30 s; a pushed DML has none), 1 s slices that askClientContext::IsInterrupted(Ctrl-C cancels any of them; a read cut short by a signal is retried, not a timeout), the parse lever, the first ERROR as the cause, INFO tokens to the log, the socket drained to EOM after a server error, a desync, a timeout or an interrupt; a failed statement is a status, not an exception -- the caller words it and fails its statement. INSERT loads throughINSERT BULKabovemssql_insert_bcp_thresholdrows (spec 062; staged in aMSSQLStagedRowsbuffer until the threshold decides, then the operator'sBulkLoadSessionon the pinned or a pool connection, parallel writers where locks allow) and as batched VALUES statements otherwise; UPDATE/DELETE use the rowid key (primary key, else a usable unique index) in VALUES JOIN statements up tomssql_dml_stage_thresholdrows (held inMSSQLStageSwitchuntil the threshold decides), and past it -- and always for a table with no usable key, keyed by every column (spec 080 D3 rung 3: hidden virtual key columns atMSSQL_KEYLESS_KEY_START,catalog/mssql_keyless_key.hpp; refused by name unless the plan under the DML is projections over the table's own scan with every filter on the server and nothing volatile, and for a column that cannot be a key --IsRoundTripExactForKey, shared with rung 2) -- through a session#stage_<uuid>filled by INSERT BULK on the statement's connection and ONEUPDATE/DELETE … JOIN #stage(MSSQLStagedDml, never batched; string key columns also matched by bytes; nullable ones byIS NOT DISTINCT FROMon 2022+ / Azure SQL, elseEXISTS (… INTERSECT …)). ADATETIMEkey is compared in its own type (#358). The platform's DML forms areDmlCapabilities(spec 080 D0: host test, EngineEdition 6 = Synapse; Synapse refuses UPDATE/DELETE, its keys are NOT ENFORCED; Fabric, with noUPDATE/DELETE … FROM … JOIN, always stages and matches throughWHERE EXISTS/ a correlatedTOP (1)subquery --join_by_subquery-- not MERGE, whose 8672 would need a collation-folding DISTINCT). A MERGE whose source is in the catalog goes to the server whole since spec 080 PR 4a (NodeWriter::WriteMerge,mssql_dmlkind 'merge'; T-SQL's shape only -- two WHEN MATCHED (UPDATE + DELETE, the first conditional), one NOT MATCHED INSERT, two NOT MATCHED BY SOURCE (UPDATE + DELETE, the first conditional), a trailing DO NOTHING dropped; a NOT MATCHED BY TARGET action is bound against the source alone and a BY SOURCE one against the target alone, as DuckDB binds them (merge_forbidden_first_/last_: an unqualified name skips the other side, a qualified one naming it is vetoed; USING (c) maps c to the side the rows have); no subquery in a MERGE expression, no join source, no identity column in the INSERT); every other MERGE is staged (PR 4b,dml/merge/): MSSQLOptimizer's pre-optimize hook (PreOptimizeRoot) replaces the binder'sLogicalMergeIntowithMSSQLLogicalStagedMerge(mssql::RewriteMerge) before DuckDB's optimizers take the join apart -- the join and the target's scan go, ON = the join's conditions + the filters the binder pushed onto the target's side, each action expression inlined through the binder's projection and split: a largest source-only part (any DuckDB function) becomes a#srccolumn DuckDB computes under a projection over the untouched source, a part over no column is folded, what touches the target is written in T-SQL by FilterEncoder with aColumnResolverfor[t].[col]/[s].[cN]andwidening_casts(an exact numeric widening cast asCAST(… AS …)), and the binder's assignment cast (integer / string) is dropped where the value reads the target; a source value compared directly with a target column is a like-target#srccolumn (the target's type and collation, strings / binaries widened to MAX, a code-page char / varchar as nvarchar(max) so it compares in Unicode, rowversion as binary(8); a value between a temporal column's steps is rounded onto them, as the server compares), any other CTAS-typed (strings nvarchar(max) / varchar(max) COLLATE DATABASE_DEFAULT); a target column ON compares with the source must passIsRoundTripExactForKey; the shapes areCheckMergeShape's (shared with 4a).MSSQLStagedMergecreates#srcbySELECT … INTO … FROM (SELECT 1) d LEFT JOIN t ON 1 = 0 WHERE 1 = 0 UNION ALL …(measured: through the join alone an identity column came out NOT NULL IDENTITY), fills it throughMSSQLSessionStage(D3's stage, shared withMSSQLStagedDml), runs the MERGE throughExecuteDmlBatchwith no deadline, drops#src, commits; an empty source sends nothing unless a BY SOURCE clause exists. Refused by name, nothing sent: ERROR, RETURNING, an identity column in the INSERT, a subquery, a per-row value in BY SOURCE, Synapse, and (MSSQLCatalog::PlanMergeInto) any MERGE with DuckDB's optimizer off -- there is no native MERGE for a catalog table. A table with no usable key binds (GetRowIdColumnsreturns none; UPDATE / DELETE are refused by name at planning,PlanKeylessDml), so a MERGE into it runs. The VALUES batches are sent as the rows arrive in a transaction too: UPDATE / DELETE / a staged MERGE count as sinks, so the scan feeding them is materialised, and each batch on the pinned connection holds the transaction's materialize lock (MSSQLStatementConnection::LockPinned);RequireIdlerefuses a batch by name when the connection is still busy; the optimizer that materialises the scan is skipped underSET enable_optimizer = false, soPlanUpdate/PlanDeletelook for an unflagged catalog scan of the same catalog under the DML when the connection is shared (FeedsFromStreamingScan) and then hold the rows until Finalize --defer_to_finalizeon the VALUES path,hold_until_finalizeon the staged one (review of #423) (spec 080 PR 1; until then they deferred every batch to Finalize). UPDATE / DELETE … RETURNING go through the staged operator whatever the row count (spec 080 PR 2b): the JOIN statement carriesOUTPUT inserted.* / deleted.* INTO #out_<uuid>(created like the stage), read back withMSSQLColumnInfo::BuildReadExpressionthrough aMSSQLResultStreaminto the logical operator's types -- a DELETE's RETURNING chunk appends the table's virtual columns inGetVirtualColumns()order (the rowid, scalar or STRUCT, or rung 3's hidden key columns); refused by name withoutoutput_into_table/ a stage (Fabric, Synapse) and for DuckDB'scapture_old_rows. An autocommit UPDATE / DELETE / MERGE commits and rolls back with DuckDB (issue #451): it runs on its catalog as a transaction of one statement, as postgres_scanner / mysql_scanner run every DuckDB transaction -- the sinks'GetGlobalSinkState(andmssql_dml's InitGlobal) callConnectionProvider::JoinStatementTransaction, and so does every scan of that catalog at the top of its InitGlobal (joins_statement, set by MSSQLOptimizer -- DuckDB may initialise a join's build side or a MERGE's source before the sink, so whichever comes first pins; a pinned connection is recognised by identity,IsPinnedConnection, so an unmarked scan's pool connection still goes back),MSSQLTransaction::IsStatementJoinedmakesIsInTransaction/GetConnection/MaterializeMutexForanswer as in a transaction (one pinned connection, scans of that catalog materialised -- MSSQLOptimizer'sjoiningcatalogs), andMSSQLTransactionManager::CommitTransaction/RollbackTransactionend the server transaction (COMMIT / ROLLBACK read, not drained; no I/O undertransaction_lock_; a server-refused ROLLBACK keeps the connection only when@@TRANCOUNTis 0). So an error after the write, Ctrl-C, or a result DuckDB aborts (QueryResult::Close before the query ended, DuckDB ef5b57f94ad) undoes it. An INSERT joins undermssql_insert_use_bcp = false, a CTAS undermssql_ctas_use_bcp = false(decided at planning:MSSQLInsertConfig::join_statement_transaction,CTASConfig::use_bcp); a COPY never. Error wording followsMSSQLStatementConnection::InOpenTransaction(an explicit transaction), notIsPinned. Otherwise every DML statement is atomic in autocommit (spec 062 W1c/W2, #344): one connection per statement (MSSQLStatementConnection), its batches bracketed in a server transaction bymssql::LoadTransaction, which captures the ENVCHANGE transaction descriptor a batch-sentBEGIN TRANSACTIONanswers with (every later request must carry it — error 3989 otherwise) and clears it at COMMIT/ROLLBACK. An INSERT that names the identity column (spec 077 W2) is bracketed on that same connection withSET IDENTITY_INSERT … ONbefore its first batch andOFFon every way out — COMMIT, Fail, the destructor — and a connection on which OFF was not confirmed is discarded rather than pooled; it stays on the statement path (tens of rows — loading many rows with their identity values is COPY's job, which keeps a source column named like the identity column). A NULL in a named identity column is refused before anything is sent: DuckDB handsDEFAULTandNULLto the extension identically. INSERT … RETURNING sends a bareOUTPUT INSERTED(MSSQLReturningParserkeeps only the batch's LAST result set, so a trigger's own result set is not taken for RETURNING rows); refused with 334 beside an enabled trigger (a compile error: nothing ran, the statement's transaction intact), the batch is rebuilt from its kept literals (MSSQLInsertBatch::row_literals) asSELECT … INTO [#mssql_returning_<uuid>]/INSERT … OUTPUT … INTOit /SELECT */DROPin one request, andMSSQLBatchBuilder::UseOutputIntobuilds the statement's later batches that way -- only whereDmlCapabilities::output_into_table(SQL Server; issue #471). The table is typed byMSSQLColumnInfo::BuildReturningExpression, shared with the staged UPDATE / DELETE … RETURNING: the read expression, but rowversion as binary(8) (an OUTPUT cannot write a timestamp column) and char(n) as a trimmed NVARCHAR (the read's NCHAR(n) doubles a fixed width past the 8060-byte row). Not the default: a session table per batch measured 1.2 s for 10000 rows against 0.09 s bare. - DDL:
CREATE TABLE IF NOT EXISTSsilently succeeds when table exists;CREATE OR REPLACE TABLEdrops and recreates. Auto-TABLOCK enabled for new table creation (CTAS/COPY TO). Catalog DDL and CTAS run on a pool connection and autocommit, outside an explicit transaction (spec 057) — except on a pool of ONE connection (issue #380), where a CTAS in a transaction runs whole on the pinned connection (checks, CREATE, rows as INSERT statements) and rolls back with it, and so does any catalog DDL there (MSSQLCatalog::ExecuteDDL, issue #419: it used to wait outmssql_acquire_timeoutfor a second connection); on a pool of one in autocommit the optimizer materialises the scans of a catalog the plan sinks into (CTAS counts as a sink, like COPY and INSERT) as it does in a transaction, a rawmssql_scan/mssql_scan_paramsof it materialises at init the same way, and a COPY's / CTAS's bulk load keeps BCP but takes its connection on its FIRST chunk (BulkLoadSession::DeferAdoption/AdoptDeferred), after the source has given the connection back (review of #382: CTAS used to fall back to INSERT statements, and COPY and raw scans waited out the acquire timeout). - ORDER BY / TOP pushdown (
MSSQLOptimizer, opt-inmssql_order_pushdown): removes DuckDB's sort, so a key is pushed only whenMSSQLColumnInfo::OrdersLikeDuckDB()holds — an allow-list (numeric, bit, date/time types, notdatetime2(7), which the codec reads as TIMESTAMP_NS and NULLs past 2262; no string, no nvarchar, binary, uniqueidentifier, json, sql_variant; #362) — or, under a LIMIT only,OrdersLikeDuckDBAsBytes(): a bounded UTF-8varchar(notchar(n), stored padded and read trimmed; not MAX) ordered asCAST(col AS varbinary(n))(as text the server pads with spaces:ab+TAB beforeab; as bytes the only residual is a trailing NUL tie; not sargable, hence LIMIT only). A string-valued function key, and a date part of adatetimeoffset, are never pushed. A partial key list pushes nothing (the DuckDB sort stays anyway). Filter pushdown maps no string function (lower/upper/trim/lengthrun client-side: the server's case mapping is not DuckDB's, and a pushedupper(x) = 'ẞ'lost rows) and no date part of a TIMESTAMP WITH TIME ZONE. A nullable key whose NULL placement differs from the server's (NULL lowest) is pushed only under a LIMIT, with a leadingCASE WHEN k IS NULL …key; a plain ORDER BY keeps DuckDB's sort. TOP N is not pushed while any non-optional filter stays client-side (the server would take N rows before it). Spec 079's remote-pushdown writer is to use the same predicate (D4). Every identifier the extension sends goes throughmssql::QuoteIdentifier(query/mssql_identifier.hpp, self-contained); a name inside a string literal throughmssql::NVarcharLiteral(query/mssql_sql_params.hpp). - Filter pushdown: DuckDB filter expressions translated to T-SQL WHERE clauses. A DATE / TIMESTAMP / TIME constant SQL Server cannot hold (
'infinity', a year past 9999,TIME '24:00:00';codec::datetime::ServerHoldsTemporal) is never pushed,rowid =included -- the filter runs in DuckDB -- and the BCP / RPC encoders refuse such a value by name (BCPRowEncoder::RequireServerDay/RequireFiniteTimestamp/RequireServerTime, and a 9999-12-31 value that rounds into the next day at the column's scale), as does the statement path's literal (FormatSqlLiteral); the error names the column (ScatterChunk*/EncodeToBcpadd it, and since #460 so do the statement path's literals --MSSQLValueSerializer::Serialize(…, column)for INSERT VALUES and the UPDATE / DELETE VALUES join -- and the parameters,(parameter @d), and the#stagenames the target's column throughBCPColumnMetadata::reported_name, not its ownn0; one helper,codec::ThrowNamingColumn). The three-byte day count used to wrap it:'infinity'to 1969-12-31, TIMESTAMP_NS±infinityto 2262-04-11 (#447). Function mapping for common string/date/arithmetic operations. Since spec 076 the constants are@pNparameters ofsp_executesql, declared from the column's SQL Server type (FilterEncoder::DeclarationForColumn), and the statement text is one fixed string per filter shape; since spec 083 the call is a TDS RPC request with the values as typed parameters (SqlParamSet::ExecuteSqlRequest, encoded from the declared type through the BCP value encoders), not aDECLARE …; EXEC sp_executesqlbatch, whose outer text the server compiled on every call (0.26-0.29 vs 0.06-0.07 ms server CPU per scan). A declaration with no RPC encoding (CLR, alias,sql_variant), or a value its declared type cannot take, keeps the batch form for the whole call.mssql_scan_parameterize_filters = falserestores literals. A constant compared with adatetimecolumn is the 1/300 s tick that decides the comparison, declareddatetime(=/<>/ IN the tick read as it,>/<=the tick at or below,>=/<at or above;DateTimeEncoding::DatetimeTickBound, the inverse of the read's truncation), so a value read back finds its row (#426: 200 of 603 did); where no tick decides it, a datetime2 value on the right side of every datetime at any compatibility level (below 130 the server converts a datetime to datetime2 at the millisecond). Adatetimecolumn against anything but constants and otherdatetimecolumns -- adatetime2column, a CASE over it, a constant with it in its IN list -- is not pushed (LossyOperandsPushable).time(7)/datetimeoffset(7)are read with the 100 ns digit truncated (not one-to-one), so a comparison with a constant is rewritten over the range of stored values read as it (TruncatedReadComparison:=is[c, c + 1 us),>is>= c + 1 us,<=is< c + 1 us; at the last microsecondc + 1 usis never sent --k <> k/k = k), and against any other column they are not pushed.TIME_NSwould keep the digit but on the 2.0 pin it neither compares withTIMEnor takes INTERVAL / strftime / date_trunc, sotime(7)staysTIME. - DuckDB API (2.0 track, spec 069): main targets duckdb main only —
mssql_compat.hpp(spec 051) is retired; 1.5.x compatibility lives on branchduckdb-v1.5.5. The 2.0 contracts that bit during migration and MUST be honored by new code: producers filling vectors callDataChunk::SetChildCardinality(vectors carry their own size; the deprecatedSetCardinalitysets only the chunk count and every consumer keyed onVector::size()sees an empty vector); scalar functions that throw at runtime declareSetFallible(); writes go throughFlatVector::GetDataMutable/ValidityMutable(checked in release — hoist out of row loops; the per-valueDecodeFromTdsbodies use the*Unsafeforms, justified inspecs/069-duckdb-v2-migration/bench-results.md); every TableFilter handed to afilter_pushdownscan must be applied by the scan (DuckDB does not re-check — refused filters run client-side via the net intable_scan.cpp);Identifierstays at the DuckDB API boundary, internal metadata/TDS/SQL text remainstd::string.
Windows Build Support
- ssize_t:
src/include/tds/tds_platform.hppprovides Windows-compatible typedef - MSVC:
x64-windows-static-releasevcpkg triplet - MinGW:
x64-mingw-statictriplet (Rtools 4.2, not 4.3 due to linker bugs) - CI: Trigger Windows builds via Actions -> CI -> Run workflow -> Check "Run Windows build jobs"
Debug Environment Variables
| Variable | Description |
MSSQL_DEBUG=1..3 | TDS protocol debug level (1=basic, 3=trace) |
MSSQL_DML_DEBUG=1 | DML operation debugging (generated SQL, batch sizes, rowid values) |
MSSQL_COUNTERS=1 | Which way each UPDATE / DELETE went (dml: pushed / staged / values_join, spec 080), and per-stream and per-COPY performance counters without the logging that distorts them. Use this, not MSSQL_DEBUG, whenever a number is going to be quoted. MSSQL_DEBUG>=1 still enables them for existing scripts, and says so next to the output — but its own logging runs inside the phases being timed: on read, level 2 fprintfs every token from inside the parse timer (parse measured 22 → 1133 ns/row and the socket wait collapsed, i.e. the numbers inverted); on write, the sink used to log per chunk and the logger flushes, inflating the client CPU it reported about 4×. make counters-test runs the SQL suite with them on, which is the only way the counter code path is exercised at all (issue #233 was a crash that lived there). |
Extension Settings (SET in DuckDB)
| Setting | Default | Description |
mssql_connection_limit | 64 | Max connections per context, logins in flight included: a login holds its slot from before it starts and gives it back if it fails, so concurrent Acquires cannot overshoot it (issue #461: they did, 21 on a limit of 8). At 1 the scans and the sink of one statement have to take turns on the single connection, so the extension materialises rather than streams: the optimizer flags catalog scans of a catalog the plan sinks into (in autocommit as well as in a transaction), a raw mssql_scan materialises at InitGlobal whenever the limit is 1, and a CTAS loads with INSERT statements instead of a bulk load. The cost is that a raw scan at limit 1 buffers its whole result and loses early exit — SELECT * FROM mssql_scan(db, 'SELECT * FROM huge') LIMIT 5 reads every row — where above 1 it streams (issue #380, reviews of #382). |
mssql_connection_timeout | 30 | Connection timeout (seconds): the TCP dial and every login-phase read that follows (PRELOGIN, TLS, LOGIN7 / FEDAUTH responses), for ATTACH validation and for every pool refill. Until issue #302 it governed ATTACH validation only — the pool factories passed no timeout to Connect and the login reads had DEFAULT_CONNECTION_TIMEOUT spelled out, so a server that accepts the dial and hangs up (Azure SQL on an expired token) cost the compiled-in 30 s per attempt whatever this said. 0 (or less) means the 30 s default, not "no timeout" as for the metadata/query timeouts next to it: a dial or a login read that never completes must not hang a pool refill forever, and now that the setting reaches those reads a literal 0 would be poll(0), an instant failure on every pooled connection (spec 073 review). |
mssql_idle_timeout | 300 | Idle connection timeout (seconds) |
mssql_min_connections | 0 | Connections opened at ATTACH and kept while idle (issue #324). Until #324 it only kept idle connections from being closed and never opened one. ConnectionPool::Prewarm opens them in MSSQLCatalog::Initialize, before the collation query, with one thread per login. ATTACH's eager validation IS the pool's first login (MSSQLCatalog::ValidateThroughPool), so the connection that proved the credentials stays in the pool and a plain ATTACH costs one login, not two. Measured locally: a plain ATTACH takes 0.20 s (0.40 s before) and min_connections 4 takes 0.39 s. The slots are reserved first, so a concurrent Acquire cannot overshoot the limit. A failure does not fail the ATTACH, which the eager validation has already vouched for; a shortfall is logged as a WARNING. Skipped under lazy_validation, and the ATTACH option is refused beside it. Also an ATTACH option (min_connections), because DuckLake's METADATA_PARAMETERS forwards ATTACH options, not settings. |
mssql_acquire_timeout | 30 | How long Acquire waits for a pooled connection to be released when the pool is at its limit. It is not how long a failed creation takes to report: since issue #302 a factory failure (expired Azure AD token, wrong password, refused dial) with nothing active fails at once and names the reason — could not create a connection: Login failed for user 'sa'. — and with others active the pool keeps waiting for a release but retries creation on a backoff (250 ms doubling to 4 s), not on every wakeup; with nothing active there is no backoff, every statement dials once and fails at once with its own reason. The reason is per call (Acquire(timeout, &why)), and a successful creation clears the pool's recorded last_create_error, so an exhaustion timeout on a healthy pool is reported as a timeout, never as a creation failure another thread hit hours earlier (spec 073 review). Before, a failed creation was treated as a full pool and reported as (timeout) after this many seconds, with the reason discarded. |
mssql_connection_cache | true | Enable connection pooling |
mssql_reset_connection | true | Reset a pooled connection's session before the next statement uses it (the TDS RESET_CONNECTION bit — what sp_reset_connection does). This is why a ##global temp table does not survive between statements: it lives as long as its creating session, and the reset ends that session on the same physical connection (@@SPID unchanged). There is no selective form — one bit, two variants, and RESET_CONNECTION_SKIP_TRAN drops ##g and #loc alike (issue #189). false is not "keep my temp tables" but "I own this session's state": SET options, session variables, CONTEXT_INFO, cursors — and an open transaction that then keeps its locks until that connection is used again. Honoured by every release path (autocommit release — which is what mssql_exec uses —, result-stream close, COMMIT, ROLLBACK); for the transaction paths it is captured at BEGIN, because TransactionManager::RollbackTransaction gets no ClientContext. Also honoured by ReleaseBcpConnectionOnError, the mid-bulk-load failure path: it has no ClientContext by design (issue #178, runs from destructors on worker threads), so the answer is carried to it — and its reset_on_release parameter has no default, so a new caller cannot silently skip the question. Prerequisite for it: the write path's target policy already refuses a second bulk-load writer against a session-scoped #temp target (spec 063 D1) — without that, turning the reset off lets a parallel writer find a stale same-named temp table on another pooled session and land rows there silently. |
mssql_metadata_timeout | 300 | Metadata query timeout in seconds (0 = no timeout). It also bounds how long a load keeps reading again while another session's uncommitted DDL holds a row it needs: a load does not queue for a lock (LOCK_TIMEOUT 0, 1222, read again; MetadataRequest, not on Fabric Warehouse / Synapse), so it cannot be in a deadlock cycle and cannot be the victim that rolls back the user's transaction. Measured over 3 interleaved runs of the suite on 12 workers: 63 deadlocks queueing, 0 this way. The price: two transactions with uncommitted DDL, each listing the catalog, both wait out this timeout and both fail, where a deadlock rolled one back in seconds and the other went on (measured); at 0 they wait until interrupted -- the retry's pauses ask IsInterrupted, every load is passed its ClientContext -- and the retries hold the cache's mutex_ throughout. A CTAS, DDL or a table's first read needs no listing and waits for neither (concurrent_ctas_ddl_transactions.test). |
mssql_attach_validation_timeout | 0 | ATTACH-time eager credential validation timeout in seconds (0 = inherit mssql_connection_timeout). Spec 047 FR-011. |
mssql_catalog_cache_ttl | 0 | Metadata cache TTL (0 = manual via mssql_refresh_cache()) |
mssql_exec_invalidate_cache | false | Auto-invalidate the catalog cache after DDL run via mssql_exec() (CREATE/DROP/ALTER/TRUNCATE/RENAME/EXEC). Default false (like Postgres postgres_execute): invalidate manually with mssql_invalidate_cache(). Set true to auto-invalidate. Issue #151. The DDL test is mssql::SqlMayChangeSchema (query/mssql_ddl_detect.hpp): CREATE / DROP / ALTER / TRUNCATE / EXEC / EXECUTE / sp_rename as whole words outside literals, delimited identifiers and comments (a substring match until the review of #382). Inside an explicit transaction, false still stops the transaction's own lookups from trusting the shared cache after such DDL (MarkChangedLocally, review of #382). Otherwise what the transaction loads on its pinned connection -- columns, keys -- from objects it later rolls back would be published into the shared cache. |
mssql_insert_batch_size | 1000 | Rows per INSERT statement. Under it a cap of 1000 constants per statement always applies (spec 062 W1b: min(batch_size, 1000 / inserted columns) rows), because SQL Server auto-parameterises a multi-row VALUES INSERT only up to 1000 constants — below the line N distinct inserts of one shape share one Prepared plan at 14 µs/row, above it every statement compiles its own Adhoc plan at 70 µs/row and leaves it in the cache (the 74 s of a 1M-row INSERT). A clustered-index target parameterises only up to ~250 rows a statement (the plan must stay trivial); past that it compiles per statement at the cheap end (5.3 vs 4.7 ms for 333 rows), so the cap is about the cliff, not the cache. An sp_executesql form with a DECLARE block was measured 16× slower and is not the answer. |
mssql_insert_max_rows_per_statement | 1000 | Hard cap per INSERT |
mssql_insert_max_sql_bytes | 8MB (8388608) | INSERT SQL size limit |
mssql_insert_use_returning_output | true | Deprecated, no effect (review of #471: read by nothing -- MSSQLInsertConfig::use_returning_output was consulted by no code and is gone; an INSERT … RETURNING always uses OUTPUT). Registered for one more release so a SET in a .duckdbrc does not throw, then removed |
mssql_insert_use_bcp | true | Issue #451: false also makes an autocommit INSERT join the statement's DuckDB transaction (JoinStatementTransaction; committed or rolled back with it, one writer); with it true an INSERT commits on its own at Finalize, keeping the parallel writers. Spec 062. An INSERT with more rows than mssql_insert_bcp_threshold, no RETURNING and no explicitly named identity column loads through INSERT BULK — the wire COPY uses, with CHECK_CONSTRAINTS, FIRE_TRIGGERS, KEEP_NULLS so it still behaves like an INSERT statement (a bulk load ignores all three by default; measured, a CHECK violation in row 950 of 1000 loaded every row). Batch size, TABLOCK and writer count come from the mssql_copy_* settings. Parallel writers only on a target with NO nonclustered index — a bare heap under TABLOCK, or a bare clustered columnstore without it. Measured: a heap under TABLOCK takes a compatible BU lock while the same heap carrying a PRIMARY KEY NONCLUSTERED takes Sch-M (compatible with nothing) and each extra writer stalls 30 s; a clustered columnstore carrying a nonclustered index is worse still — the extra writer times out mid-stream and the whole INSERT fails and rolls back (30.8 s / 0 rows against 0.99 s / 400k on one writer): each writer holds its own server transaction until Finalize commits them -- one after another, so "together" means only that a failure before then rolls all back: a reader that does not wait on locks (READ_COMMITTED_SNAPSHOT, SNAPSHOT, NOLOCK) can see part of one INSERT between two commits (measured by duckdb-acl's door e2e under RCSI; mssql_copy_parallel_writers = 1, or the INSERT inside a transaction, makes it one commit) -- and conflicting locks deadlock CLIENT-SIDE (measured: a hang past ten minutes on a heap on row locks). false is the escape hatch. |
mssql_insert_bcp_threshold | 1000 | Spec 062. Rows up to which an INSERT is sent as statements — counted as they arrive in a staging buffer, never estimated. Measured (spec § 6.2): the paths cross near 300 rows on a local server (bulk's fixed cost is two round trips, INSERT BULK + DONE; statements cost ~12 µs/row plus a round trip per 333 rows) and near 1000 at a 20 ms RTT, so 1000 is where an INSERT is at most three statements and the bulk path never loses by more than a round trip or two. |
mssql_dml_batch_size | 500 | Rows per UPDATE/DELETE batch |
mssql_dml_max_parameters | 2000 | Max parameters per UPDATE/DELETE statement |
mssql_dml_pushdown | true | Spec 080 D5 / PR 3c-3d / PR 4a. Send an UPDATE / DELETE / INSERT … SELECT / MERGE the remote-pushdown rewriter can write in T-SQL to the server as ONE statement through mssql_dml (INSERT: NodeWriter::WriteInsert, the inner SELECT with raw_columns_ -- columns written as themselves, no read cast; each value the column's type or a widened integer for numerics / bit / date, else a column declared alike; IDENTITY_INSERT via the vehicle's identity_insert := true; VALUES / ON CONFLICT / BY NAME / RETURNING / a string constant vetoed -- a set-operation source goes on the rewriter's second ask, wrapped as a derived table -- and a refused INSERT keeps the rewriter's own fallback, its SELECT pushed alone; a same-declaration copy is exact on the server, more faithful than the row path for datetimeoffset / time(7) / datetime2(7) past 2262 / geometry SRID / sql_variant) (MSSQLCatalog::WriteDmlPushdown, pushdown/mssql_node_writer_dml.cpp). GLOBAL scope, read per rewrite in the dry run (through the context that resolved the tables: the hooks get none), unlike mssql_remote_pushdown (ATTACH); not re-read by RemoteExecute, which cannot hand a DML back. Vetoes keep the shipped path: RETURNING, a WITH on the DML, a view target, a SET value not already of the column's DuckDB type or an integer widened into it (or a constant typed as the binder types it), and beyond numerics / bit / date anything but a constant or a column declared alike (strings, the datetime family, binary: the server converts between declarations its own way), an aggregate / window in SET or WHERE, a DATE constant the server's date cannot hold ('infinity': the RPC encoder wraps it), DEFAULT, UPDATE … FROM without the source's unique key equated with target columns, a non-table source, read-only, Synapse; Fabric (dml_from_join false) the single-table form only, no FROM / USING / subquery. A SELECT / DML node holding a DML CTE body is never pushed (HoldsDmlCte): without it a DML CTE the query never reads was dropped (measured). Off: the shipped path for every UPDATE / DELETE / INSERT … SELECT, and every MERGE staged through #src (PR 4b). |
mssql_dml_stage_threshold | 1000 | Spec 080 D3. Rows (counted as they arrive) up to which an UPDATE/DELETE on a keyed table goes as VALUES JOIN statements; past it the held rows and the rest go into #stage by INSERT BULK and one JOIN statement writes them (MSSQLStageSwitch). 0 stages every statement. A table with no usable key always stages. Not on Fabric / Synapse (no stage). |
mssql_dml_use_prepared | true | Deprecated, no effect (read by nothing; spec 080). Registered for one more release so a SET in a .duckdbrc does not throw, then removed |
mssql_scan_parameterize_filters | true | Spec 076. The constants of a pushed filter travel as sp_executesql parameters (an RPC call since spec 083) instead of literals in the query text, so the server keeps one plan per filter shape rather than one ad-hoc plan per distinct value set (measured: 40 scans with 40 distinct two-predicate filters = 40 cached plans / 2.3 MB before, 1 / 57 KB after; wall time unchanged on a local server). Each parameter is declared from the column it is compared with — varchar stays varchar so an index on it is still seekable, the width is never narrower than the constant so nothing is truncated into a false match, a non-ASCII constant goes as varchar when every character of it is representable in both the column's and the database's code page (#361 — the seek survives on SQL_ collations, where an nvarchar parameter puts a CONVERT_IMPLICIT on the column; the pages are the server's, COLLATIONPROPERTY(…, 'CodePage'), read with the column metadata and the database collation; representability answered from tables for 874 and 1250–1258, mssql::CodePageCanEncode) and as nvarchar otherwise (a varchar variable takes the database's code page, not the column's — #321); IN lists and IS NULL stay literal. false is the escape hatch for parameter sniffing (a plan compiled for one value reused for a worse one) and what a reviewer flips to compare plans. |
mssql_enable_statistics | true | Report each MSSQL scan's row count to the DuckDB optimizer (TableFunction::cardinality). Until the roadmap's "step 0" callback landed this setting was read by nothing, and every MSSQL scan planned as ~1 row — a 200000-row table and a 50-row table looked identical, so join order and build-side choice around them were arbitrary. The estimate is free at plan time: statistics cache, then the count the catalog already loaded; it never opens a connection to plan a query, and reports nothing rather than 0 when the count is unknown (a VIEW has no sys.partitions rows and reports 0 while returning millions). Set false to restore the estimate-less behaviour. |
mssql_statistics_cache_ttl_seconds | 300 | How long a cached row count stays usable — read at the point of use, from the SESSION that asks, never written into the shared per-catalog provider (one session's SET must not govern another's plans). It governs the PLANNER's cardinality lookup. It does not age out a count the CATALOG loaded (PreloadRowCount) on the table-LISTING path: SHOW ALL TABLES / duckdb_tables() exempt those deliberately, because ageing them would cost a connection plus a row-count round trip per table on a catalog that had just loaded every count in one query. Such entries are refreshed by invalidation instead — mssql_invalidate_cache(), DDL, COPY/CTAS — not by time. A DMV-sourced count ages normally on every path. |
mssql_copy_flush_rows | 102400 | Rows per bulk-load batch — the boundary the server sees between DONE tokens, not a client buffer size. 102400 is SQL Server's own threshold for writing a batch straight into a compressed columnstore rowgroup; below it every row goes to the delta store and stays there (a delta rowgroup only closes on its own at 1048576 rows), so a smaller load never compresses at all. Measured on 1M rows — one OPEN rowgroup and 53 MB at the old 100000 default, nine COMPRESSED rowgroups and 7 MB at 102400 (spec 057). Raised from 100000, which silently defeated table_kind = 'columnstore'. One value for every target: batch size measured nearly flat on a heap, so the extra 2400 rows cost nothing there. |
mssql_copy_parallel_writers | 0 | Concurrent bulk-load connections a single COPY or CTAS may open (spec 057 step 7). 0 derives it from DuckDB's thread count, capped at 8; 1 disables parallel loading. Each writer is an independent INSERT BULK on its own pooled connection — the bound on this path is SQL Server's ingest rate (send() blocks because the receive window stays full while the server lays rows down), and the server parallelises across sessions, so more sessions is the only lever that moves it. Measured on 44 columns x 1M rows: 10.55 s at 1 writer, 3.24 s at 4, 3.51 s at 8 — it plateaus past 4. Ignored inside an explicit transaction, where the connection is pinned: a second connection would sit outside the transaction and its rows would not roll back with the rest. A ceiling, not a floor, in two more ways: one DuckDB thread driving the sink yields one writer whatever this says (which is why the parallel tests pin SET threads), and against a clustered columnstore target the extra writers are held back until the load has sunk one full 102400-row rowgroup on the shared writer (spec 070 W2) — so a small columnstore load legitimately finishes on one writer rather than splitting into batches too small to compress. Heap targets fan out immediately. The warm-up threshold is SQL Server's own rowgroup constant, not mssql_copy_flush_rows. And never above mssql_connection_limit (issue #380, COPY, CTAS and INSERT via BCP alike). An extra writer takes its connection with ConnectionPool::TryAcquire, which never waits and does not count toward acquire_timeout_count: it gets an idle connection, or a new one while the pool is below its limit, or none. In mssql_pool_stats a probe that GETS a connection counts in acquire_count and acquire_wait_total_ms like any other checkout; one that comes back empty counts in neither, so a writer asking once per chunk does not inflate the figures or skew the average wait (review of #382). With none, the claim answers Busy: the thread shares the global writer and asks again on a later chunk. Unavailable, which ends the asking, is left for a reached slot cap or a refused bulk load. It used to wait mssql_acquire_timeout for a connection the statement itself was holding: a 300k-row CTAS in a transaction on a pool of two took 30 s, and now takes 0.84 s. The first cut of #380 capped the writers at the limit minus one instead, and the review of #382 caught that this wastes a connection whenever the statement holds none. |
mssql_copy_tablock | auto | auto | true | false. auto decides from the target's shape: heap on, anything clustered off (rowstore and columnstore) — concurrent bulk loaders on a heap take mutually compatible BU locks so the hint lets them run together, while against a clustered rowstore index the same hint serialises them. Replaces the "enable for newly created tables" rule of issue #45, which never actually fired: the flag it tested was set from TryGetCurrentSetting succeeding, which it always does (spec 057). The columnstore half of this policy was wrong until spec 057 step 7 made the loads concurrent and exposed it: TABLOCK serialises them. Measured on 2M rows, clustered columnstore — hint ON 8.92 s with SQL Server pinned at ~99% CPU (exactly one core), hint OFF 5.23 s peaking at 305% (three cores), and identical compression either way (17 COMPRESSED rowgroups, 1740800 rows, both). What decides whether rows land compressed is mssql_copy_flush_rows crossing 102400, not the lock. |
mssql_utf8_collation | Latin1_General_100_BIN2_UTF8 | Collation given to VARCHAR columns created by CTAS when mssql_ctas_text_type is VARCHAR and the server granted UTF8SUPPORT at login (issue #225). Without a UTF-8 collation such a column takes the database's code page and SQL Server replaces everything outside it with ? on insert, silently. BIN2 matches what Fabric Warehouse uses as its own default: binary comparison, no linguistic rules — and therefore case- and accent-SENSITIVE, so WHERE name = 'abc' stops matching 'ABC'. Per-column override is the annotation x::MSSQL_VARCHAR(n, 'collation'), where n may be 'MAX' (or 0/-1) for the unbounded form — issue #321; a bare MAX keyword cannot work, DuckDB's parser rejects a non-constant type modifier before the extension sees it. This setting moves it globally. The collation is stored in the schema and governs every later comparison against the column. Empty adds no COLLATE clause, so the column inherits the database default: correct when that default is already UTF-8 (Fabric), and the way back to the pre-#225 behaviour otherwise. |
mssql_remote_pushdown | true | Spec 079. Answer DuckDB's remote-pushdown rewriter, which then sends a whole SELECT to SQL Server as one statement through mssql_scan_params (EXPLAIN shows the T-SQL). Read once per catalog, at ATTACH (D6), and fixes the catalog's answer to IS_REMOTE and EXECUTE_QUERY_NODE for its life; SetScope::GLOBAL. The writer (pushdown/mssql_sql_writer.cpp) takes a base table (PR B/C; joins in PR D): columns and computed columns (named as DuckDB names them; a result column has DuckDB's type or is not pushed -- a decimal beside a constant is vetoed as a result, a decimal product fitting 18 digits is cast back to DuckDB's DECIMAL(w1+w2, s1+s2); float is compared and divided, never + - * (an overflow is an error there, inf here: FunctionFor refuses it for both paths); a comparison of an integer x ± c / x * c / -x with a constant is vetoed, DuckDB moving the constant across), WHERE, ORDER BY on columns or an orderable aggregate (OrdersLikeDuckDB, NULL placement emulated under a LIMIT, the same mssql::OrderTerm as MSSQLOptimizer) and LIMIT / OFFSET; anything else is a veto. Expressions render through mssql::ExpressionVocabulary (pushdown/mssql_expression_vocabulary.*, spec 079 PR C), the atoms FilterEncoder uses too: arithmetic over same-type integer/decimal operands (no width-38 decimal sums, no products past 38 digits), division CAST(a AS float) / NULLIF(CAST(b AS float), 0) in a value only (inf/NaN vs NULL at zero: recorded for values, vetoed in conditions and under ieee_floating_point_ops = false), widening casts, CASE / COALESCE of one type (strings: nchar/nvarchar or UTF-8 columns of one collation, a constant branch only if it fits the column's declaration; no division under COALESCE), IN / BETWEEN / LIKE ([ escaped; no ILIKE / GLOB / ESCAPE), value against value of one type (strings of one collation, else 468). A constant is typed from its peer as the binder would (exact round-trip; a decimal literal beside an integer vetoed in a value); no second binding. PR D adds joins of one catalog's tables (INNER / LEFT / RIGHT / FULL / CROSS, USING, SEMI / ANTI as EXISTS / NOT EXISTS; relations aliased [r1]… and every column qualified), aggregates (COUNT_BIG; an integer sum as decimal(38,0) cast back to HUGEINT in a projection over the vehicle; exact avg as CAST(SUM(x) AS float) / COUNT_BIG(x); min / max only of a column that orders like DuckDB's, never a string), GROUP BY on columns (GROUP BY () sent without the clause: the server returns no row over no rows), HAVING, DISTINCT; a floating-point aggregate differs in its last bits (recorded, vetoed in a condition / ORDER BY / DISTINCT). A node is taken only when it sends more than the catalog scan would (SQLWriter::PushesMoreThanScan: ORDER BY, LIMIT, DISTINCT, GROUP BY / HAVING, an aggregate, a join with no CROSS link): the rewriter pushes a set operation's children (when it also reads another catalog or a local table) and an INSERT's / CTAS's query on their own, and a pushed bare SELECT * FROM t there would read the table the scan path reads through the enclosing WHERE. The pushed call passes column_types so the result has the catalog's types (datetime2(7) as TIMESTAMP_NS, a code-page varchar as its declared MSSQL_VARCHAR(n), geometry). An unqualified name is pushed only when every search-path entry of the catalog is its default schema (USE db.sales would otherwise read dbo.t: the rewriter looks names up in main; checked in the dry run AND at RemoteExecute, by the name the query wrote); $n parameters are not pushed. PR E1: the dry run answers renderability (the rewriter asks about every nested node, a no poisons the statement) and RemoteExecute decides the gain; a node that does not go whole is handed back as a subquery (names restored as written) with every nested part that gains pushed as its own vehicle. The writer renders derived tables, subquery expressions (IN / EXISTS / scalar, correlated through the enclosing writers; a correlated node asked alone answers yes via refers_outside), set operations when NESTED (at a statement's top DuckDB combines the parts -- owner's call), and CTEs inlined per reference (a body that picks rows -- a LIMIT anywhere -- not inlined twice or under a subquery expression; a CTE vs a table of one name decided by the written name; past 1 MB of T-SQL vetoed). A scalar subquery is pushed only when it provably returns one row (aggregate without GROUP BY, LIMIT 1, or its single table's unique key equated with constants / outer columns): the server evaluates it lazily. A set operation ordered by an expression keeps its children's select lists (DuckDB binds the key against them). A name without a schema (t and db.t) is pushed only when the search path allows. Join gain check: mssql_pushdown_join_rows_threshold, applied through derived tables / CTEs (a derived table's GROUP BY columns are its key). mssql_catalog_scan is registered by name so a plan copy (an inlined CTE) finds it. Side effects while on: schema main of the catalog answers as its default schema for every lookup (db.main.t binds, CREATE TABLE db.main.x lands in dbo); IS_REMOTE makes DuckDB skip its catalog-or-schema ambiguity check for the catalog; and with any such catalog attached the rewriter runs on every statement of the instance (constant folding included). PR E2: FILTER as agg(CASE WHEN c THEN x END); string_agg as STRING_AGG(CAST(x AS nvarchar(max)), sep) [WITHIN GROUP (ORDER BY …)] (a varchar / nvarchar column, one order per node: 8711); window functions (ROW_NUMBER / RANK / DENSE_RANK / NTILE / LAG / LEAD / FIRST_VALUE / LAST_VALUE / aggregates OVER, PARTITION BY a groupable column, the window ORDER BY with a UTF-8 varchar by its bytes and NULL placement emulated without a LIMIT, DuckDB's default frame sent as none, ROWS with literal offsets; a tie-dependent window picks rows, so a CTE holding one is not inlined twice); QUALIFY through a derived-table wrapper (alias-only condition the wrapper's WHERE, else a hidden bit column mssql_qualify). A pushed statement whose every result column the writer types goes through mssql_scan_params_unsafe / mssql_scan_unsafe with those types as columns and is not described at all (spec 081; a cast-back or server-typed column keeps the describe); otherwise the describe of a statement -- pushed or a user's own mssql_scan / mssql_scan_params (issue #410) -- is cached per catalog by its text (query/mssql_describe_cache, stored by the describing bind; the metadata invalidation epoch, preload and mssql_catalog_cache_ttl end an entry; in a transaction only while it has changed no schema, never for a prepared scan; a stream that differs at init -- datetime vs datetime2 included -- fails once, saying to run it again, and clears it: DDL by any other client, not only mssql_exec). Default true since PR E2: D4 for sets -- GROUP BY / DISTINCT / join keys / PARTITION BY / set-operation dedupe follow the column collation with the setting on (documented loudly). |
mssql_pushdown_join_rows_threshold | 1000000 | Spec 079 PR E1. The remote-pushdown gain check for a join, decided with no round trip. A join whose gain is certain goes: a link that equates a unique key of one side (the loaded primary / rowid key, never lazily loaded), SEMI / ANTI, a LIMIT over it, an aggregate or DISTINCT that does not keep a key of every table, a single table. One of uncertain gain (many-to-many, CROSS without a key equality in WHERE) goes while every table it joins has fewer rows than this -- the count the planner gets (statistics cache, else the one loaded with the entry; both can be stale) -- and a view (no count of its own) never counts as small; else RemoteExecute hands it back to the scan path. Not checked inside an explicit transaction or on a pool of one connection (trusted). Compared with the LARGEST input, not an estimate of the output: two 900k-row tables joined many-to-many go. 0 disables the check. Plan-based estimates (SHOWPLAN on a separate connection) are a later step. |
mssql_pushdown_min_rows | 0 | Spec 079 PR E2. A statement whose tables hold fewer rows than this together (cached, approximate counts; a table read twice -- a self-join -- counts twice; summed over joined / EXISTS-read tables, derived tables, set-operation children and subquery expressions: WrittenQuery::total_input_rows) is handed back to the catalog scans. 0 = no floor, the owner's call: measured on a local server, joins and TOP N gain from 5k rows, an aggregate / QUALIFY over 5k-100k rows is a few ms faster unpushed, and a network moves that line down. Not applied in a transaction, on a pool of one, or when a view is read. A floor makes D4-for-sets answers depend on the data size, as the join threshold already does. |
mssql_catalog_native_types | true | Report MSSQL_VARCHAR(n) / MSSQL_NVARCHAR(n) for the string columns of attached tables instead of a bare VARCHAR (spec 060). Since spec 079 PR A also for mssql_scan / mssql_scan_params, through the same NativeDuckDBType, so CREATE TABLE … AS SELECT * FROM mssql_scan(…) keeps the source's lengths and collations. There a char/varchar is annotated under a UTF-8 collation, and under a code page its catalog transcodes (spec 092 D7: its values are UTF-8); under client_transcode false a raw scan hands a code-page varchar's bytes over untranscoded (#224), so it stays plain VARCHAR. The bind's length is the widest of a statement's branches (the describe's answer for IF arms), so the shape check takes a stream no longer than it. prepared := true reports the same types: sp_prepare's COLMETADATA carries a collation id rather than a name, so when a column's declared type is ambiguous from COLMETADATA (a UTF-8 char/varchar, a code-page one the catalog transcodes, or an nvarchar under a UTF-8 collation on a connection NOT granted UTF8SUPPORT; an nvarchar on a granted connection is annotated directly) the bind also asks sp_describe_first_result_set (one more round trip), takes its type per column while the names agree (stopping at the first disagreement), and leaves an unconfirmed UTF-8 column plain VARCHAR (review of #387). A statement run at bind is typed from COLMETADATA the same way (nvarchar annotated, varchar plain). The execution-time shape check compares kind and length, with one tolerated mismatch: a declared UTF-8 varchar that the wire carries as nvarchar (transcoded without UTF8SUPPORT; nvarchar(max) past 4000 characters), recognised by the column's UTF-8 collation bit on a stream whose own connection was not granted UTF8SUPPORT (the connection's answer, not the catalog's cached one). A statement the server cannot describe, which therefore runs at bind, stays plain VARCHAR. This is what lets a target created from an MSSQL source inherit its declared lengths and collations with no cast written by anyone. Costs a changed DESCRIBE / duckdb_columns() type name, which is what turning it off restores. Read when a table entry is built, so mssql_invalidate_cache() applies a change to already-loaded tables; the scan's types and an INSERT's column types are the entry's own (columns_), never the setting read again (issue #415: after a SET they said VARCHAR beside an entry still MSSQL_NVARCHAR(n), the #369 class of disagreement). |
mssql_default_string_length | 0 | Length given to an unannotated VARCHAR column created by CTAS or COPY; 0 = MAX, which is what a plain VARCHAR has always meant. Set it and the extension's own overflow guard enforces it before the batch is sent. Above SQL Server's inline limit (4000 nvarchar / 8000 varchar) the column stays MAX. Per-column control is a cast to MSSQL_NVARCHAR(n) / MSSQL_VARCHAR(n). |
mssql_default_table_kind | HEAP | Shape of a table created by CTAS/COPY: HEAP or COLUMNSTORE. COLUMNSTORE runs CREATE CLUSTERED COLUMNSTORE INDEX right after the CREATE and before the load, so the load writes compressed rowgroups directly instead of needing a rebuild. Per-statement: the COPY table_kind option, or CREATE TABLE ... WITH (table_kind = '...'). |
mssql_ctas_use_bcp | true | Use BCP protocol for CTAS data transfer (2-10x faster than INSERT); the CTAS commits as it loads, DDL and rows, inside an explicit transaction too (spec 057). false (issue #451): the CTAS runs whole inside the DuckDB transaction -- in autocommit the statement's own, which it joins -- checks, CREATE and rows as INSERT statements on the pinned connection (CTASExecutionState::single_connection, DDL through ExecuteDDL(…, on_transaction_connection)), committed or rolled back with it |
mssql_client_transcode | true | Spec 092. Read code-page text as bytes and transcode it on the client through spec 091's module. Read once per catalog at ATTACH, like mssql_remote_pushdown (the ATTACH option client_transcode wins): one catalog must not mix the two forms, and two ATTACHes of one DSN measure both in a session. MSSQLColumnInfo::ClientTranscodedPage is the one predicate: a char / varchar on a single-byte page goes bare in every read expression (BuildReadExpression, BuildReturningExpression, the 079 select list; the flag is a parameter with no default); a double-byte page and text keep the server's cast. A raw scan transcodes every page and reports MSSQL_VARCHAR(n, collation). Every stream decodes by the wire's page (ColumnMetadata::code_page, set once per column); a catalog scan also compares it with the name's and logs a WARNING on a stale catalog (NamedCodePageMismatch, owner's call: not an error). make code-pages-check-wire verifies the keys against a server. false restores the casts, the raw scan's verbatim bytes and types, and the warning. Gate (spec 092 D10): 1M rows, 0.43-0.74x wall, half the wire, 26-57% less server CPU (server under Rosetta). A code-page column counts twice against the chunk's payload budget, since memory_limit accounts the output heap's whole 3x reservation (issue #536). |
mssql_convert_varchar_max | true | Convert VARCHAR(MAX) to NVARCHAR(MAX) in catalog queries where the server converts: a double-byte page (false drops the cast, and the client transcodes by the wire's page) or client_transcode off. No effect on a single-byte page, read bare either way (spec 092 D9) |
mssql_named_instance_resolution | true | Resolve Server=host\instance to the instance's dynamic TCP port via the SQL Server Browser (UDP 1434) at ATTACH time (spec 045). Set false in environments that strip outbound UDP 1434 — a named instance then errors instead of silently using port 1433, and you connect with an explicit Server=host,port. |
mssql_browser_timeout_seconds | 3 | SQL Server Browser UDP query timeout in seconds for named-instance resolution. On the ATTACH critical path, so kept short; the resolver retries once. |
mssql_tds_packet_size | 16384 | TDS frame size requested in LOGIN7, clamped to [512, 32767]. The server answers with min(requested, its own maximum) and never raises it, so this is the ceiling for every packet in both directions — it bounds recv() count on reads and send() count on BCP writes. Raised from the long-standing 4096 in spec 055: measured −28% client CPU / −43% wall on read and −27% CPU on write (test/bench/bench_results_live_server.md). Costs server memory per session (16 KB vs 4 KB per pooled connection); set to 4096 to restore the old behaviour. |
mssql_warn_non_utf8_collation | true | Log a WARNING into duckdb_logs when a CHAR/VARCHAR/TEXT column's code-page bytes reach a DuckDB VARCHAR verbatim, once per column over COLMETADATA, never per row. Since spec 092 D9 that is two cases: a catalog with client_transcode off, and a collation newer than the tables on a raw scan or a bare read. #224 stays documented rather than enforced there, because validating would cost the scan's hot path to make a hand-written mssql_scan() fail loudly. The off switch exists because under client_transcode false SQL_Latin1_General_CP1_CI_AS, the installation default, logs one line per varchar column per query, including for pure ASCII. Server INFO tokens (PRINT, RAISERROR ≤ 10) are unaffected by this setting and log at INFO, not WARNING, so they cannot dilute it. |
mssql_utf8_support | true | Advertise the TDS UTF8SUPPORT feature extension (0x0A) in LOGIN7 (issue #225). It also decides how a UTF-8 char(n) is read: as itself (a BIGCHAR the codec trims) only when the catalog's logins were granted it (MSSQLCatalog::UTF8SupportObserved, never borrowing a connection), else as CAST(… AS NCHAR(n)) like a code-page char(n) the client does not transcode (BuildReadExpression, issue #405: on a variable-length wire type the padding reached DuckDB and a char(4)/char(6) join found 0 rows of 2); ConnectionProvider::GetConnection records a login's answer (NoteUtf8Support) so a lazily validated catalog learns it. The 079 writer vetoes a char(n) / nchar(n) in a computed value (Unify: COALESCE / CASE / LAG … are varchar / nvarchar on the server and keep the blanks). A server that acknowledges it sends columns whose collation is a UTF-8 one as UTF-8 (0xA7) instead of transcoding them to UTF-16 (0xE7): measured 85.8 MB -> 43.9 MB on the wire and 0.44 s -> 0.25 s wall for 1M ~41-char values, because the client copies the bytes into the vector instead of running the UTF-16 batch decode. Requesting it is safe on any server — one that lacks the feature omits the acknowledgement and nothing changes — so this setting exists to turn the request off. |
mssql_test_fail_metadata_after_rows | 0 | Test-only (issue #317). Make a metadata query throw once it has parsed this many rows; 0 = off. Exists because every cause of a mid-query metadata failure in the wild — timeout, reset connection, killed session — arrives from outside and cannot be induced from SQL, so the invariant "a metadata load that fails leaves the cache exactly as it was" was untestable and got broken twice (#178 in Refresh(), #317 in LoadAllSchemasMetadata). Off is free: ExecuteMetadataQuery passes the row callback through unwrapped, so the row loop carries no test. |
mssql_test_fail_parse_after_tokens | 0 | Test-only (issues #323, #344). Put the TDS token parser of a DML response (INSERT batch, INSERT … RETURNING, UPDATE, DELETE, and since spec 080 acceptance 7 the pushed DML -- all read by mssql::ExecuteDmlBatch) into Error after this many tokens, as a desync would; 0 = off. A desync cannot be induced from SQL since spec 072, so without it the four response loops' handling of one — drain without buffering, report it naming what the server executed, never hang — was untestable, and stayed broken in those four copies after #323 fixed the fifth. Off costs one comparison per token in that one loop only; the result stream is untouched. |
mssql_test_dml_platform | '' | Test-only (spec 080 D0). Read at ATTACH: 'fabric' / 'synapse' / 'sqlserver' make the catalog's GetDmlCapabilities answer for that platform whatever its host, so Fabric's DML forms (always staged, matched through WHERE EXISTS / a correlated TOP (1) subquery, no RETURNING) run against SQL Server while no warehouse is at hand (fabric_emulated_dml.test). |
mssql_test_force_intersect_join_form | false | Test-only (spec 080 D3). Match the nullable key columns of a staged UPDATE/DELETE through EXISTS (… INTERSECT …) even where the server has IS NOT DISTINCT FROM -- the integration lane runs a server that has it, so without this the form every server takes would never run in CI. |
mssql_login7_max_packet | 0 | Test-only (issue #138). Max LOGIN7 TDS packet size (bytes) for integrated auth; lowers the fragmentation boundary so the multi-packet send path can be exercised without an AD-sized Kerberos PAC. 0 = production default (4096); effective values clamped to [256, 32767]. |
ATTACH Options & Secret Parameters (Catalog Filters)
| Parameter | Type | Description |
schema_filter | VARCHAR | Regex pattern to filter visible schemas (case-insensitive, partial match via regex_search) |
table_filter | VARCHAR | Regex pattern to filter visible tables/views (case-insensitive, partial match via regex_search) |
transaction_isolation (ADO.NET TransactionIsolation) | VARCHAR | Issue #331. Level of the server transaction a DuckDB transaction opens: default (unset: send nothing, as before), read_uncommitted / read_committed / repeatable_read / serializable / snapshot (sent as its own SET TRANSACTION ISOLATION LEVEL before BEGIN on the pinned connection), or auto (SNAPSHOT when sys.databases.snapshot_isolation_state = 1, probed at ATTACH in the collation query -- only for snapshot/auto off Fabric/Synapse, so everyone else's ATTACH query is unchanged; else nothing). Explicit snapshot on a database with it OFF is refused at ATTACH; Fabric Warehouse gets nothing (enforces snapshot); Synapse gets nothing and refuses explicit levels but read_uncommitted. The reset does not clear the isolation level (measured: SQL Server keeps it across RESET_CONNECTION), so a transaction that set one sends SET ... READ COMMITTED as its own statement after COMMIT / ROLLBACK -- appended to the ROLLBACK batch it is lost when the server already aborted the transaction (3960 update conflict, stale descriptor) -- and a connection whose level could not be put back is closed. SNAPSHOT makes a concurrently changed row an update conflict (3960) instead of a re-read; its readers are not blocked by a concurrent TABLOCK heap bulk load, READ COMMITTED readers are. |
default_schema (ADO.NET DefaultSchema) | VARCHAR | Issue #322. The schema unqualified names resolve against (db.table, USE db, CTAS into db.table, COPY into db.table or mssql://db/table); empty = dbo, as before. The exact name as the server spells it (the catalog's schema lookup is case-sensitive). Not checked against the server at ATTACH -- a missing schema is named at its first unqualified use -- but an explicit default that schema_filter hides is refused at ATTACH (an unset dbo hidden by the filter is not: existing ATTACHes pairing such a filter with qualified names keep working). Trimmed; empty -- including an explicit '' that clears a secret's value -- means dbo. Deliberately not SCHEMA_NAME(): SQL Server resolves unqualified names default-then-dbo, DuckDB has one default schema, so following the login's default would break unqualified references to dbo objects. |
preload | BOOLEAN | Issue #324. mssql_preload_catalog(name) run by the ATTACH itself (MSSQLCatalog::Preload, shared with the function), which is where DuckLake's METADATA_PARAMETERS can reach it. A failure fails the ATTACH, because it was asked for. It is refused beside lazy_validation true and catalog false, with each contradiction named. |
remote_pushdown | BOOLEAN | Spec 079 PR E2. The catalog's own answer to the remote-pushdown rewriter, over mssql_remote_pushdown (which it wins over for this catalog, read at ATTACH like the setting). The way DuckLake's METADATA_PARAMETERS -- ATTACH options, not settings -- turns pushdown off for a metadata catalog while the user's catalogs keep it. |
client_transcode | BOOLEAN | mssql_client_transcode for this catalog only, over the setting, fixed at ATTACH (spec 092 D4). An ATTACH option only. |
native_types | BOOLEAN | mssql_catalog_native_types for this catalog only, over the setting (which is GLOBAL), fixed at ATTACH; unset = the setting, read where it always was. An ATTACH option only (not a secret, connection-string or URI key). MSSQLReportsNativeTypes(catalog) asks the catalog's option first, so table entries, the scan's types, INSERT column types, remote-pushdown vehicle types (from the entry), mssql_scan / mssql_scan_params and the describe-cache key all follow it. For DuckLake's metadata catalog, whose catalog-load rows DuckLake reads with Value::ToString: an MSSQL_VARCHAR(n) value costs a cast lookup each (measured by mssql-ducklake: 400 CREATE TABLEs 45-56 s of DuckDB CPU with native types, 39 s without, postgres's level). |
min_connections | BIGINT | Issue #324. The ATTACH form of mssql_min_connections, and it wins over the setting. A string ('4') is accepted as well. |
lazy_validation (or LazyValidation) | BOOLEAN | Skip the eager ATTACH-time TCP+LOGIN7 credential check. Default false (eager — wrong creds / unreachable host surface as ATTACH errors). Set true for container/orchestrator startup where the SQL Server may not yet be reachable; first query then pays the connection-establishment cost as in the pre-spec-047 behaviour. Bounded by mssql_attach_validation_timeout. Spec 047 FR-011. |
TrustServerCertificate / URI trustservercertificate / secret trust_server_certificate | BOOLEAN | Spec 074. Default false: the server's certificate chain must validate against the platform trust store (Windows ROOT+CA, macOS keychain plus OpenSSL paths, Linux OpenSSL paths; SSL_CERT_FILE/SSL_CERT_DIR override) and its subject must match the host dialled, else the handshake fails with OpenSSL's reason and both ways out named. true accepts any certificate. Independent of Encrypt (the pre-074 alias and its "Conflicting values" error are gone); ignored under Encrypt=false. Every test DSN in the Makefile and in scripts/ci/integration_test.sh (the CI lane's own copy of those defaults) carries TrustServerCertificate=yes, and the CI smoke secret in scripts/sql/smoke_test.sql says trust_server_certificate true, because the docker server runs on its self-generated certificate. Connection string / URI / secret, not an ATTACH option. |
HostNameInCertificate (ODBC HostnameInCertificate) / URI hostnameincertificate / secret host_name_in_certificate | VARCHAR | Spec 074. The name the certificate must carry when it differs from the address dialled (IP literal, SSH tunnel, alias). Empty = the host dialled; after a routing hop the routed host, unless set, in which case it applies to every hop (go-mssqldb). An IP literal is matched against iPAddress SANs. Ignored under TrustServerCertificate=true. Carried by tds::TlsOptions through TdsConnection::SetTlsOptions -- a setter, not a parameter on the three Authenticate* signatures. |
azure_tenant_id (or azure_tenant) | VARCHAR | Tenant to acquire the Azure AD token against, overriding the tenant carried by the azure_secret. Registered as an MSSQL-secret parameter since spec 032 and read by nothing until 2026-08-14 (PR #264 review): with no override, credential_chain + interactive falls back to AZURE_DEFAULT_TENANT = common inside azure_device_code.cpp, so every interactive ATTACH in a single-tenant org authenticated against /common/ with no way to say otherwise. TENANT_ID on the azure secret is not the way to do this — duckdb-azure rejects it on provider='credential_chain'. The value is part of the TokenCache key, so two tenants do not share a token. |
Application Name / ApplicationName / App Name / application_name | VARCHAR | LOGIN7 program_name propagated to SQL Server (visible via APP_NAME() / sys.dm_exec_sessions.program_name). URI form uses spaceless applicationname query parameter; secret form uses application_name (canonical) or applicationname. Empty falls back to "DuckDB MSSQL Extension"; values longer than 128 chars are clamped client-side. Closes issue #82 (spec 047 FR-014). |
Available in: ATTACH options, ADO.NET connection strings (SchemaFilter/TableFilter), URI query parameters, and MSSQL secrets. ATTACH options override secret/connection string values. A boolean ATTACH option (lazy_validation, catalog, order_pushdown, remote_pushdown, native_types, client_transcode, preload) also takes a string — 'true'/'false', 'yes'/'no', '1'/'0', DuckDB's own boolean cast — because DuckLake's METADATA_PARAMETERS is a MAP(VARCHAR, VARCHAR) and can send nothing else (issue #325).
Extension Functions
| Function | Type | Description | ||
mssql_scan(context, query [, prepared := false]) | Table | Execute raw T-SQL, stream results. Since spec 075 Bind asks sp_describe_first_result_set for the shape and the query runs at InitGlobal — a DESCRIBE/EXPLAIN no longer executes it, and inside a transaction the rows are materialised at init under the transaction's materialize lock so the pinned connection is Idle for the next scan or sink. A batch the server cannot describe (one that reads a #temp table it creates) is still run at Bind, as before. prepared := true compiles once via sp_prepare (shape from its answer, execution by handle on the session that holds it; for a column whose collation is UTF-8 the describe is asked too, for the collation name the annotation needs) and degrades to the describe when the server prepares without a shape. | ||
mssql_scan(…, column_types := [...]) / mssql_scan_params(…, column_types := [...]) | Table | Spec 079: the type each result column is READ as ('' = the described one), checked against the describe (ColumnTypeFits: a datetime2 into any TIMESTAMP variant, varbinary into GEOMETRY, a string into another string label). The remote-pushdown rewriter's lever for giving a pushed SELECT the catalog's types; the init-time shape check still compares the stream to the DESCRIBED types (described_types). | ||
mssql_scan_unsafe(context, query, columns := {...}) / mssql_scan_params_unsafe(context, statement, STRUCT [, declarations], columns := {...}) | Table | Spec 081: the shape is GIVEN (columns, a STRUCT of type names, read_csv's form), not described -- the bind touches no connection, so it binds in a transaction, on a pool of one, and for an undescribable #temp batch like any statement. Init runs the batch and checks the stream (StreamFitsTrustedShape: count + ColumnTypeFits(stream, declared, stream datetime2): strict on kind, no conversion; a string's length/collation and a datetime2's TIMESTAMP variant are not checked); a mismatch names the statement and clears the describe cache. A type no stream produces is refused at bind (StreamCanProduce). MSSQLOptimizer counts both as raw scans (IsRawScanName). The remote-pushdown VehicleFor uses them when WrittenQuery::column_types has no INVALID entry. | ||
mssql_scan_params(context, statement, STRUCT [, declarations] [, prepared := false]) | Table | mssql_scan with parameters: {'p': 1} becomes @p, declared from the DuckDB type (spec 075 W5 table: VARCHAR → nvarchar(4000)/nvarchar(max), MSSQL_VARCHAR(n) exact, TIMESTAMP → datetime2(6), …) or by the caller's own list ('@ts datetime, @c varchar(8)'), and sent through sp_executesql as an RPC call (spec 083; sp_execute by handle with prepared := true) — one server plan per statement text, shared by every session. A bare NULL, a nested type or a name that is not a T-SQL identifier is refused with the fix named. | ||
mssql_dml(context, statement [, params [, declarations]], kind :=, target_schema :=, target_table := [, identity_insert :=]) | Table | Spec 080 PR 3b, internal: the vehicle a pushed UPDATE / DELETE / INSERT … SELECT / MERGE runs through (kind 'update' / 'delete' / 'insert' / 'merge' -- a MERGE registers INSERT | UPDATE | DELETE, its actions unknown at bind; identity_insert := true wraps the statement in SET IDENTITY_INSERT ON / OFF on its connection, PR 3d) (the rewriter's target since PR 3c, MSSQLCatalog::DmlVehicleFor). Binds with no connection; the bind registers the write (RegisterDBModify) and CheckWriteAccess -- with CHANGED_ROWS that makes its handle read like a native UPDATE's (DuckDB's StreamsByDefault answers no; since DuckDB #26880 there is no ResultEagerness and no FORCED: a submitted write, native or this, runs and waits at its count until its consumer settles it -- test/cpp/test_dml_vehicle.cpp, make test-dml-vehicle, in the concurrency job), and refuses a statement SqlMayChangeSchema flags; registered with call_return_type = CHANGED_ROWS and verify_serialization = false (never folded by the common-subplan optimizer); bind data SupportStatementCache() = false (every EXECUTE rebinds); a run-once latch, a re-initialised source (recursive CTE) refused by name, not an InternalException. Runs at InitGlobal on a MSSQLStatementConnection (atomic in autocommit, the pinned connection in a transaction) with NO deadline (mssql::ExecuteDmlBatch with interruptible: waits in 1 s slices asking ClientContext::IsInterrupted, ATTENTION + InterruptException on Ctrl-C; mssql_query_timeout is not applied -- a pushed copy answers nothing until its DONE; a dead peer ends the wait through the TCP keepalive every socket sets since PR 3d: 30 s idle / 10 s / 6 probes (over TLS too since #456: a TLS read error or a closed peer marks the socket dead, where it used to read as a timeout and the wait spun)); Count is the last DONE carrying a count (an AFTER trigger's arrive first, measured); an autocommit DELETE moves the planner's estimate (NoteRowsDeleted). |
mssql_exec(context, sql) | Scalar | Execute T-SQL, return affected row count | ||
mssql_exec_params(context, statement, STRUCT [, declarations]) | Scalar | mssql_exec with the same parameter contract as mssql_scan_params; one round trip per row, the plan reused across rows and sessions. | ||
mssql_pool_stats([context]) | Table | View connection pool statistics | ||
mssql_refresh_cache(context) | Scalar | Refresh metadata cache (eager full reload). Refused inside a transaction that has used any MSSQL catalog (issue #380; narrowed from "any open transaction" in the review of #382, so a transaction wrapping unrelated DuckDB work is allowed — but not narrowed to one catalog, because two ATTACHes of the same DSN are independent catalogs and alias A's uncommitted DDL would hang a load on alias B) | ||
mssql_invalidate_cache(context [, schema [, table]]) | Scalar | Lazy point invalidation — whole catalog / one schema / one table (keeps other tables' cached columns) | ||
mssql_preload_catalog(context [, schema]) | Scalar | Bulk-load all metadata in one round trip. Refused inside a transaction that has used any MSSQL catalog (issue #380, review of #382 — same reasoning as mssql_refresh_cache) | ||
mssql_azure_auth_test(secret, tenant?) | Scalar | Test Azure AD token acquisition | ||
mssql_kerberos_auth_test(host [, port]) | Scalar | Test POSIX Kerberos auth path (spec 042); returns OK + SPN / principal / token size, or verbatim GSSAPI error | ||
mssql_kerberos_auth_test_secret(secret_name) | Scalar | Same but reads keytab / SPN-override / etc. from an MSSQL secret | ||
mssql_winsspi_auth_test(host [, port]) | Scalar | Windows SSPI peer of mssql_kerberos_auth_test (spec 042 Phase 4); returns OK + SPN / UPN / token size, or verbatim SSPI error | ||
mssql_winsspi_auth_test_spn(spn) | Scalar | Same but takes an explicit SPN (overrides default MSSQLSvc/<host>:<port> derivation) |
Every scalar and table function is registered through mssql::RegisterDocumentedFunction (src/mssql_function_docs.cpp, issue #371), which gives it the description, examples and category duckdb_functions() reports, and names the scalar parameters in the signature. Those names are API: the binder matches named arguments against them (mssql_exec(context := 'db', sql := '...')), so renaming one breaks callers. test/sql/mssql_function_docs.test fails a function registered without documentation. A table function's positional parameters stay col0, col1, … in duckdb_functions(): DuckDB names them by position whatever the registration says.
Active Technologies
- C++17 (DuckDB extension standard) + DuckDB (main branch), OpenSSL (vcpkg), Winsock2 (Windows system library) (019-fix-winsock-init)
- C++17 (DuckDB extension standard) + DuckDB (main branch), existing TDS layer (specs 001-019) (020-multi-statement-scan)
- In-memory (result streaming, connection pool state) (020-multi-statement-scan)
- C++17 (DuckDB extension standard) + DuckDB (main branch), OpenSSL (vcpkg), existing TDS protocol layer (022-mssql-ctas)
- SQL Server 2019+ (remote), in-memory (result streaming, connection pool state) (022-mssql-ctas)
- C++17 (DuckDB extension standard) + DuckDB (main branch), OpenSSL (via vcpkg for TLS) (023-pool-stats-validation)
- In-memory (connection pool state, metadata cache) (023-pool-stats-validation)
- C++17 (DuckDB extension standard) + DuckDB (main branch), TDS BulkLoadBCP protocol (0x07), OpenSSL (via vcpkg for TLS) (024-mssql-copy-bcp)
- SQL Server 2019+ (remote target), in-memory (batch buffering, connection pool state) (024-mssql-copy-bcp)
- SQL Server 2019+ (remote target), in-memory (connection pool state) (025-bcp-improvements)
- C++17 (DuckDB extension standard) + DuckDB (main branch), existing TDS protocol layer, OpenSSL (vcpkg) (026-varchar-nvarchar-conversion)
- SQL Server 2019+ (remote target), in-memory (batch buffering) (027-ctas-bcp-integration)
- C++17 (DuckDB extension standard) + DuckDB (main branch), OpenSSL (vcpkg for TLS), libcurl (vcpkg for OAuth2 HTTP), DuckDB Azure extension (runtime, for Azure secret management) (001-azure-token-infrastructure)
- In-memory (token cache, no persistence required) (001-azure-token-infrastructure)
- C++17 (DuckDB extension standard) + DuckDB (main branch), OpenSSL (vcpkg), libcurl (vcpkg for Azure OAuth2) (031-connection-fedauth-refactor)
- In-memory (connection pool state, token cache) (031-connection-fedauth-refactor)
- C++17 (DuckDB extension standard, but C++11 compatible for ODR) + libcurl (OAuth2 HTTP), OpenSSL (TLS), DuckDB Azure extension (secret management) (032-fedauth-token-provider)
- In-memory token cache (TokenCache singleton) (032-fedauth-token-provider)
- C++17 (C++11-compatible for ODR with DuckDB) + DuckDB (main branch), OpenSSL (vcpkg), TDS protocol layer (033-fix-catalog-scan)
- In-memory metadata cache (
MSSQLMetadataCache) (033-fix-catalog-scan) - C++17 (DuckDB extension standard, C++11-compatible for ODR on Linux) + DuckDB v1.5-variegata (6,275 commits ahead of v1.4.4), OpenSSL (vcpkg), libcurl (vcpkg) (034-duckdb-v15-upgrade)
- N/A (remote SQL Server via TDS protocol) (034-duckdb-v15-upgrade)
- C++17 (C++11-compatible for ODR on Linux) + DuckDB v1.5-variegata (extension API) (035-ddl-schema-support)
- C++17 (C++11-compatible for ODR on Linux) + DuckDB v1.5-variegata + DuckDB extension API, OpenSSL (vcpkg), libcurl (vcpkg) (036-azure-token-docs)
- C++17 (C++11-compatible for ODR on Linux) + DuckDB (main branch), OpenSSL (vcpkg), cpp-httplib (bundled in DuckDB third_party) (037-replace-libcurl-httplib)
- N/A (in-memory token cache, no change) (037-replace-libcurl-httplib)
- C++17 (C++11-compatible for ODR on Linux) + DuckDB (main branch), OpenSSL (vcpkg), existing TDS protocol layer (039-order-pushdown)
- C++17 (C++11-compatible for ODR on Linux) + DuckDB (main branch), OpenSSL (vcpkg), custom TDS protocol layer (040-fix-datetimeoffset-nbc)
- 042-integrated-authentication: Added integrated authentication (Kerberos on POSIX, SSPI on Windows)
- POSIX: system GSSAPI (libgssapi_krb5 on Linux, GSS.framework on macOS via
-framework GSS) - Windows: secur32.dll (Phase 4 — not yet implemented)
- Connection-string keys (verbatim from
microsoft/go-mssqldb):authenticator,krb5-configfile,krb5-keytabfile,krb5-credcachefile,krb5-realm,service_principal_name - Aliases:
Trusted_Connection=yes,Integrated Security=SSPI/true
- POSIX: system GSSAPI (libgssapi_krb5 on Linux, GSS.framework on macOS via
Azure AD Authentication
The extension supports Azure AD authentication for Azure SQL Database and Microsoft Fabric. Authentication is implemented using DuckDB's bundled cpp-httplib (with OpenSSL) for OAuth2 token acquisition (no Azure SDK or libcurl dependency).
Supported methods:
- Service Principal: Client credentials flow with tenant_id, client_id, client_secret
- Azure CLI: Uses
az account get-access-tokenfor developers withaz login - Device Code Flow: Interactive authentication for MFA-enabled accounts
Implementation files:
src/azure/azure_http.cpp- HTTP client wrapper (single httplib compilation unit)src/azure/azure_token.cpp- OAuth2 token acquisitionsrc/azure/azure_device_code.cpp- RFC 8628 device code flowsrc/azure/azure_secret_reader.cpp- Reads Azure secrets from DuckDB Azure extensionsrc/azure/azure_test_function.cpp-mssql_azure_auth_test()function
See AZURE.md for user documentation.
Integrated Authentication (Kerberos / SSPI)
POSIX Kerberos and Windows SSPI integrated authentication, shipped via spec 042.
Supported credential modes (POSIX):
- CredCache (default): uses
kinitticket fromKRB5CCNAME//tmp/krb5cc_<uid>. Works on Linux and macOS. - Keytab:
krb5-keytabfile=/path/to.keytab+User Id=svc@REALM. Linux only (MIT Kerberos extensions required). - Raw: secret-only — cleartext passwords are never accepted from a connection string. Linux only.
Implementation files:
src/include/tds/auth/iauthenticator.hpp— three-method interface (InitialBytes/NextBytes/Free), modeled onmicrosoft/go-mssqldb'sintegratedauth.IntegratedAuthenticatorsrc/tds/auth/krb5_authenticator.{hpp,cpp}— GSSAPI implementation (POSIX, compiled whenMSSQL_ENABLE_KRB5is defined). Allgss_*/krb5_*calls go through the runtime shim (spec 053).src/tds/auth/gssapi_runtime.{hpp,cpp}— spec 053 (#161): lazydlopen/dlsymloader for GSSAPI/krb5. On Linux the extension carries NO link-time (DT_NEEDED) dependency onlibgssapi_krb5.so.2/libkrb5.so.3; the library is loaded on firstauthenticator=krb5use (thread-safe viastd::call_once). ThrowsKrb5RuntimeUnavailable(names the missing.so+ install package) when absent. macOS fills the table with direct&gss_*addresses (system framework, always present). CMake keeps the GSSAPI/krb5 include dirs but dropstarget_link_librarieson Linux.src/tds/auth/winsspi_authenticator.{hpp,cpp}— Windows SSPI implementation viasecur32.dllNegotiate package (compiled whenMSSQL_ENABLE_SSPIis defined; CMake auto-enables on_WIN32)src/include/tds/auth/integrated_auth_strategy.hpp— adapter wrappingIAuthenticatorin the existingAuthenticationStrategyinterfacesrc/tds/auth/auth_strategy_factory.cpp—AuthStrategyFactory::CreatedispatchesKRB5/WINSSPIbased oninfo.auth_methodsrc/tds/tds_connection.cppAuthenticateIntegrated()— SPNEGO continuation loop on0xEDSSPI tokenssrc/tds/tds_protocol.cppBuildLogin7WithSSPI+BuildSSPIMessage— LOGIN7 withfIntSecuritybit (0x80) + SSPI Message packet type 0x11src/connection/mssql_pool_manager.cppGetOrCreatePoolWithIntegratedAuth— pool factory builds a fresh authenticator per connection so kinit-refreshed tickets are picked upsrc/tds/auth/krb5_test_function.cpp— registersmssql_kerberos_auth_test(host[, port])andmssql_kerberos_auth_test_secret(secret_name)scalar functions. Mirrorsmssql_azure_auth_testfor Azure; exercisesKrb5Authenticator::InitialBytes()without connecting to SQL Server. Compiled with a no-op fallback so the functions are always registered (returns "compiled without Kerberos support" whenMSSQL_ENABLE_KRB5is undefined).
Test infrastructure: test/kerberos/ — self-contained docker-compose stack (KDC + SQL Server + test-client). No real Active Directory required:
cd test/kerberos
docker compose up -d --build
docker compose exec test-client /run-tests.sh
docker compose down -vThe test KDC's realm is EXAMPLE.COM, principal is testuser@EXAMPLE.COM (password testpass), SPN is MSSQLSvc/sql.example.com:1433. The test-client uses a multi-stage Dockerfile that builds the extension inside Linux, so the stack works on macOS hosts too.
Platform matrix:
| Platform | CredCache | Keytab | Raw | Status |
| Linux x86_64 / ARM64 | yes | yes | yes (secret only) | Phase 3 shipped |
| macOS ARM64 | yes | rejected at construction | rejected at construction | Phase 3 shipped |
| Windows x64 | yes (logon session) | n/a | n/a | Phase 4 shipped |
Connection-string surface (verbatim from microsoft/go-mssqldb):
| Key | Purpose |
authenticator=krb5 / authenticator=winsspi | Explicit form |
Trusted_Connection=yes | pyodbc alias — resolves to krb5 on POSIX, winsspi on Windows |
Integrated Security=SSPI / Integrated Security=true | ADO.NET alias — same resolution |
krb5-configfile=/path/to/krb5.conf | Per-connection krb5.conf override (Linux only, via cred_store config element) |
krb5-keytabfile=/path/to/file.keytab | Selects keytab mode |
krb5-credcachefile=FILE:/path | ccache override (Linux only, via cred_store ccache element) |
krb5-realm=REALM.COM | Required for keytab when User Id lacks @REALM |
service_principal_name=MSSQLSvc/host:port | Override default SPN derivation |
All also accepted on CREATE SECRET (with underscore naming: krb5_keytabfile, etc.).
Key design decisions (do NOT re-litigate — these were settled during ultrareview):
- Raw mode is SECRET-ONLY. The validator unconditionally rejects
Passwordin any connection string when integrated auth is selected. Defends against cleartext passwords in connection-string logs. User Idrequires either a keytab or a Password (in a secret). CredCache mode rejects bareUser Id— was silently authenticating as the ambient ccache holder before fix.- Default SPN form is
MSSQLSvc/<fqdn>:<port>(canonical Kerberos principal-name form, matches AD default registration).Krb5Authenticatorpicks thegss_import_namename type based on whether the SPN contains/. - No
setenv()for per-connection overrides — usesgss_acquire_cred_fromwith cred_store elements (ccache,config). Thread-safe vs concurrentgetenvon worker threads. - macOS uses
GSS.framework(Heimdal-derived subset) which lacks MIT extensions for keytab/raw modes;Krb5Authenticatorconstructor rejects those modes on macOS with a clear error pointing at the Linux container path. - GSSAPI OIDs (SPNEGO, Kerberos, hostbased-service, krb5-principal-name) are constructed inline as
gss_OID_descliterals — macOS'sGSS.frameworkdeclaresGSS_C_NT_HOSTBASED_SERVICEetc. asextern gss_OIDin the header but does NOT export the symbols. Inline DER bytes avoid the link dependency on every platform.
See Kerberos.md for end-user documentation.
Build Troubleshooting
ODR (One Definition Rule) Errors on Linux
Symptom: Linux builds fail with "multiple definition of duckdb::LogicalType::BIGINT" and similar errors for constexpr static members.
Root Cause: DuckDB defaults to C++11. If the extension uses target_compile_features(... cxx_std_17), it gets compiled with C++17 while DuckDB remains C++11. The different handling of constexpr static members (external linkage in C++11 vs inline in C++17) causes ODR violations when linking.
What NOT to do in CMakeLists.txt:
# DO NOT USE - causes ODR errors on Linux when DuckDB is C++11:
set(CMAKE_CXX_STANDARD 17 CACHE STRING "..." FORCE)
target_compile_features(${EXTENSION_NAME} PRIVATE cxx_std_17)Correct Solution: Don't force C++17 for the extension. Use DuckDB's default C++ standard (C++11) and avoid C++17-only features like structured bindings. The extension code should be compatible with C++11.
Note: This issue only manifests on GCC/Linux, not on Clang/macOS, because Clang is more lenient with ODR for constexpr static members.
Recent Changes
- 049-fix-partitioned-table-catalog: Partitioned tables were unreadable (#85). The three catalog metadata queries joined
sys.partitionsdirectly, and that view holds one row per partition — so on an N-partition table every object and every column came back N times, aborting catalog load withColumn with name <x> already exists!. A plain clustered index reads fine; only partitioning triggers it. The same join silently tookapprox_rowsfrom whichever partition surfaced first — typically an empty one — so the planner saw a partitioned table as nearly empty while the statistics provider (which already summed) disagreed. Both fixed by joining a pre-aggregated subquery. The subquery now also reports the object's physical shape —index_kind(MSSQLIndexKind: heap / clustered / clustered columnstore, fromsys.indexes.type) andpartition_countonMSSQLTableMetadata— at no extra round trip, which the write path needs: TABLOCK helps a heap and serialises a clustered rowstore index, and a clustered index that is not a primary key is invisible to PK discovery. Typed rather than a bool because a clustered columnstore also reportsindex_id = 1and wants the opposite TABLOCK decision. - 047-process-state-cleanup: Process-wide singleton cleanup + ATTACH credential validation + Azure TokenCache namespacing + custom Application Name (closes #96, #82; spawns #119 for future spec 049). Three singletons removed:
MssqlPoolManager→ per-MSSQLCatalogunique_ptr<ConnectionPool>;MSSQLContextManager(spec 045 band-aid) → directCatalog::GetCatalog()lookup;MSSQLResultStreamRegistry→ per-catalogRegisterStream/RetrieveStreammethods onMSSQLCatalog. One singleton kept + deprecated:MSSQLConnectionHandleManagerbacksmssql_open/mssql_close/mssql_ping(no catalog discriminator on those APIs); marked[DEPRECATED]group with companionmssql_close_all()shutdown helper, scheduled for removal with the functions in a future major release. Security hardening: AzureTokenCachekeyed by(uintptr_t(DatabaseInstance*), cache_key)so two instances sharing a secret name no longer alias (FR-012); ATTACH eagerly validates credentials by default withlazy_validation trueopt-out (FR-011); ATTACH error path audited to never echo password (T028a);mssql_pool_statsredaction grep gate (SC-005); explicitnoexcepton the teardown chain (~MSSQLCatalog/~ConnectionPool/~TdsConnection/~TdsSocket/~TlsTdsContext/~TlsImpl) + debug-onlyD_ASSERT(active_connections_.empty())invariant. Custom Application Name:Application Name=.../applicationname=.../ secretapplication_namepropagated to LOGIN7 program_name; visible asAPP_NAME()/sys.dm_exec_sessions.program_name; 128-char client-side clamp matches SQL Server's own limit. Seespecs/047-process-state-cleanup/state_inventory.mdfor the post-spec process-wide-static classification. - 042-integrated-authentication Phase 4: Added Windows SSPI authentication via
secur32.dll's Negotiate package.WinSspiAuthenticatorpeer ofKrb5Authenticator. SameIAuthenticatorinterface; shared SPNEGO continuation loop inTdsConnection::AuthenticateIntegrated. - 045-type-codec-consolidation: Per-type encoding/decoding/literal/DDL logic consolidated into 9 family modules under
src/codec/(boolean/integer/float/decimal/money/string/binary/datetime/uuid). 5 LogicalType-side dispatch sites collapsed to family-dispatch (FamilyFromLogicalTypeswitch orcodec::FormatSqlLiteralone-liner). 762 LOC removed across dispatch sites (3243→2481, −23.5%). Bonus: TIMESTAMP_MS/NS/S/TZ now round-trip losslessly through SQL Server DATETIME2(3/7/0/7) with full type-transparency (catalog reports the variant, encode/decode preserves native precision). Bonus: stale-ATTACH ContextManager fix (sqllogictest--force-reloadpointer-reuse). Closes issue #91 (BCP nvarchar character-vs-byte length); closes issue #89 (VIEW catalog-vs-runtime type divergence). No new vcpkg deps. Per-row bench (1M rows): 0.988–1.015× ratio vs spec-044 baseline (well within 5% gate). - 044-codec-consolidation: Finishes the simdutf migration started in 043 — every legacy
Utf16LE*call site moves to the simdutf-backed wrapper, the wrapper is renamed back toUtf16LE*(legacy file path resurrected with new implementation), and the legacy hand-rolled converter survives only as a private invalid-input fallback. Includes codec microbenchmark (make bench-utf16) and an end-to-end before/after benchmark (test/bench/bench_codec_e2e.sh, 100M rows) recorded intobench_results.md. No new vcpkg deps. - 043-refactoring-foundation: Added C++ (C++11-compatible ABI) + DuckDB (main branch), simdutf (vcpkg, statically linked, MIT) for LOGIN7 non-ASCII fix; OpenSSL unchanged
- 042-integrated-authentication: Added Kerberos (POSIX) integrated authentication via system GSSAPI. SPNEGO + LOGIN7
fIntSecuritybit + 0xED SSPI continuation tokens. Self-contained test stack attest/kerberos/. - 041-xml-type-support: Added C++17 (C++11-compatible for ODR on Linux) + DuckDB (main branch), OpenSSL (vcpkg), existing TDS protocol layer
- 040-fix-datetimeoffset-nbc: Added C++17 (C++11-compatible for ODR on Linux) + DuckDB (main branch), OpenSSL (vcpkg), custom TDS protocol layer
- 039-order-pushdown: Added C++17 (C++11-compatible for ODR on Linux) + DuckDB (main branch), OpenSSL (vcpkg), existing TDS protocol layer
<!-- SPECKIT START -->Active spec: 068-login-routing-unification. See implementation plan at
specs/068-login-routing-unification/plan.md for technical context,
research findings, data model, contracts, and quickstart.
<!-- SPECKIT END -->Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

