Skip to content

Generate SQL Server DDL and bulk rows from the dataset schema - #158

Merged
victorchutw merged 3 commits into
mainfrom
issue-134-sqlserver-ddl-and-bulk-rows
Sep 3, 2026
Merged

Generate SQL Server DDL and bulk rows from the dataset schema#158
victorchutw merged 3 commits into
mainfrom
issue-134-sqlserver-ddl-and-bulk-rows

Conversation

@victorchutw

@victorchutw victorchutw commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes #134

Implements the mechanics of ADR-0062 as a pure, offline module (src/sqlserver.rs): the created shape a dataset schema takes as CREATE TABLE DDL, and the encoding of one chunk's records into tiberius bulk rows in destination table column order. Nothing opens a connection; every test is an ungated unit test (ADR-0066).

What lands

  • create_table_ddl(dataset, schema, table)CREATE TABLE [schema].[table] (...) with the exact-fit mapping: int64BIGINT, float64FLOAT(53), booleanBIT, utf8NVARCHAR(MAX), timestamp/timestamptzDATETIME2(6), decimal(p,s)DECIMAL(p,s) verbatim. Nullability mirrors the field (NULL/NOT NULL), identifiers are bracket-quoted with ]] escaping, and no COLLATE clause is emitted. A schema with no fields, or an Arrow type outside the dataset vocabulary, is a destination_write_failed naming the field — never an approximate mapping.
  • BulkRowPlan::new(dataset, table_columns) / rows(&RecordBatch) — one TokenRow per record, columns in the table order the caller states (one-to-one with the dataset fields by exact name; unknown, duplicate, or missing columns fail the plan). NULLs are None inside the typed variant, an empty string is a present value, instants ride as the UTC value ADR-0043 already stored, DATETIME2 values are built directly at scale 6 (day count from 0001-01-01 + microseconds into the day, so tiberius writes them as-is with no rescale), and decimals become Numeric::new_with_scale(value, declared_scale) — the scale the created shape carries and the Accept Family (ADR-0065) requires of an existing column, since tiberius encodes a value only at the column's own scale.
  • Glossary: CONTEXT.md gains Created Shape, a term ADR-0062/0064/0065 and Validate existing SQL Server tables against the Accept Family #135 already use without a definition.
  • lib.rs registers the module under #[cfg_attr(not(test), allow(dead_code))] until Land the sqlserver connector: session and full-refresh loads #136 consumes it, mirroring the precedent on SqlServerConfig.

One judgement call to review: the DATETIME2 range

The issue says the two representability slivers (over-long NVARCHAR, timestamps outside 0001–9999) get no client-side preflight in this module. That holds for NVARCHAR: values reach the wire and tiberius returns Err(BulkInput(...)) at send(), a clean chunk write failure.

It cannot hold for timestamps. Tiberius's Date::new asserts (days >> 24 == 0, fork src/tds/time.rs:168) instead of returning an error, so a pre-0001 value — reachable through the parse menu (0000-… wall-clock) or instant normalization (0001-01-01T00:00:00+01:000000-12-31T23:00:00Z) — would panic inside row encoding, and a panic is not the write failure ADR-0062 assigns that sliver. The encoder therefore refuses a timestamp outside 0001-01-01..=9999-12-31 with a destination_write_failed naming the record, field, value, and range (rows_fail_instead_of_panicking_for_timestamps_outside_the_datetime2_range).

Scope, stated honestly: only the lower bound is forced by the driver. Years 10000–45941 would pass tiberius's asserts and reach the server as a genuine write failure; the upper bound is the same rule applied symmetrically, so the refused range is exactly ADR-0062's enumerated sliver with one message, rather than a driver-shaped subset. It is the conversion reporting what it cannot produce, not a separate validation pass, and the live slice maps it to a chunk write failure like any other.

Ruling (maintainer, 2026-09-03): keep the symmetric range check as implemented, and record it on ADR-0062 as a dated "Range check activated" note in the ADR-0069 pattern, in this PR. The note is the third commit; the code is unchanged.

Acceptance criteria

  1. DDLddl_renders_every_type_and_nullability_combination_exactly (9 type cases × 2 nullabilities), ddl_doubles_closing_brackets_inside_every_identifier (] in column, schema, and table names), ddl_never_carries_a_collate_clause, field order kept, empty schema and unmapped types fail.
  2. Rowsrows_emit_every_type_in_table_column_order_not_dataset_order (seven columns, table order ≠ field order, with hand-derived day counts), rows_keep_an_empty_string_distinct_from_null, rows_carry_the_numeric_boundaries_exactly (i64::MAX/i64::MIN, f64::MAX/f64::MIN/f64::MIN_POSITIVE, ±(10³⁸−1)), rows_carry_negative_decimals_with_the_declared_scale, rows_carry_the_datetime2_range_edges_and_microseconds_exactly (0001-01-01T00:00:00 and 9999-12-31T23:59:59.999999, .000001 and .999999), plus rows_agree_with_the_tiberius_chrono_conversion — the direct construction cross-checked against tiberius's own chrono path (the one the Spike: validate Tiberius against a containerized SQL Server #115 spike round-tripped through a real server) at ten calendar edges, and the day constants checked against chrono's proleptic Gregorian calendar.
  3. Regressionrows_convert_decimal_38_38_scaled_minus_one_to_a_scale_38_numeric: Arrow Decimal128(38,38) scaled -1Numeric value -1, scale 38, precision() == 38, encoded twice with equal results.
  4. Offline — 22 new unit tests, none opening a connection, none #[ignore].
  5. No CHANGELOG.md entry: nothing is user-observable until Land the sqlserver connector: session and full-refresh loads #136 goes live.

Review

Two-axis /code-review (Standards × Spec) over git diff main...HEAD, both reports valid. Standards: no documented-standard violation; the judgement calls acted on are a module-level write_failure helper (six literal constructions collapsed), out-of-range timestamps rendered in the dataset's own notation instead of chrono's Debug, a doc note that plan mismatches are invariant breaches behind the Accept Family rather than table validation, and the glossary avoid list no longer names "Generated DDL" because ADR-0062 uses that phrase. Left as-is: the four match ColumnType cascades (idiomatic), schema_name/table_name as two &str (a QualifiedTable type can be born when #135/#136 need it), and table_columns: &[String] (the seam #135 will type). Spec: AC1–AC5 each traced to a test; day constants, scale handling, null handling, and table order independently re-derived; the only deviation is the timestamp range above, judged justified, with the scope note now in the module docs. Hand-off for #136: the fork's bulk Numeric arm todo!()-panics on a row/column scale mismatch, so ADR-0065 validation must run before any bulk_insert.

Local checks

cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo test --locked, and cargo build --release --locked --bin data-spark all pass.

Seams left for the next slices

🤖 Generated with Claude Code

victorchu-gamasys and others added 2 commits September 3, 2026 08:28
Add the offline SQL Server type-mapping module of ADR-0062: the created
shape as CREATE TABLE DDL with exact-fit column types, mirrored
nullability, bracket-quoted identifiers, and no COLLATE clause; and a
BulkRowPlan that encodes each chunk's records into tiberius TokenRows in
destination table column order, preserving NULLs, keeping empty strings
present, carrying microsecond DATETIME2(6) values built directly at scale
6, and emitting decimals as Numeric at the declared scale (the
decimal(38,38) scaled -1 regression from #156 is pinned).

The encoder refuses a timestamp outside DATETIME2's 0001-9999 range as a
write failure rather than reaching tiberius, whose Date::new asserts on a
pre-0001 day count instead of returning an error; a panic is not the
write failure ADR-0062 assigns that sliver. Over-long NVARCHAR values are
not preflighted: tiberius reports them at send().

Define Created Shape in the glossary, a term ADR-0062/0064/0065 already
use. No CHANGELOG entry: nothing is user-observable until a mode goes
live (#136).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Promote the write-failure constructor to one module-level helper, spell
out-of-range timestamps in the dataset's own notation instead of chrono's
Debug output, state that the DATETIME2 upper bound is the lower bound's
rule applied symmetrically (only the pre-0001 side is forced by tiberius's
assert), note that plan mismatches are invariant breaches behind the
Accept Family rather than table validation, and drop "Generated DDL" from
the Created Shape avoid list because ADR-0062 uses that phrase itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@victorchutw
victorchutw marked this pull request as ready for review September 3, 2026 00:39
Copilot AI lite review requested due to automatic review settings September 3, 2026 00:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

變更為離線純模組且以單元測試完整覆蓋型別對映、識別子 escaping、欄位順序與邊界值,未發現需阻擋合併的問題。

Pull request overview

這個 PR 在 CLI/core 增加一個純離線(不開連線)的 SQL Server 映射模組,將 dataset schema 轉為「Created Shape」的 CREATE TABLE DDL,並把 RecordBatch 編碼成 tiberius bulk insert 所需的 TokenRow(依目的地 table 欄位順序),以供後續 #136 的 live connector 消費。

Changes:

  • 新增 src/sqlserver.rs:實作 create_table_ddl(精確型別對映與識別子 bracket quoting)與 BulkRowPlan/rows()(table 欄位順序的 bulk rows 編碼),並以單元測試覆蓋 DDL 與 row 編碼矩陣與邊界案例。
  • src/lib.rs 註冊 sqlserver 模組,並以 #[cfg_attr(not(test), allow(dead_code))] 避免在尚無 live consumer 前觸發 dead_code。
  • CONTEXT.md Glossary 新增「Created Shape」定義,與 ADR/後續 slice 的術語一致。
File summaries
File Description
src/sqlserver.rs 新增 SQL Server DDL 生成與 bulk row 編碼(含完整單元測試覆蓋)。
src/lib.rs 註冊 sqlserver 模組並暫時抑制非測試建置的 dead_code。
CONTEXT.md Glossary 補上「Created Shape」術語定義以對齊 ADR 用語。
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

The maintainer ruled on PR #158: keep the encoder's symmetric
0001-9999 range check, and record it on the ADR that owns the sliver
rather than leaving the decision in code alone. The note states what
forced the lower bound (tiberius asserts on a pre-0001 day count), why
the upper bound follows the same rule, and why this is a write failure
rather than a preflight or a Rejected Record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@victorchutw

Copy link
Copy Markdown
Owner Author

Merge authorization: the maintainer authorized the agent to merge this PR in the active agent session on 2026-09-03 (instruction: "merge"). Required checks rust and copilot-reviewed pass on head 59bdee0; no review conversations open. Merging by squash per repository convention.

@victorchutw
victorchutw merged commit b361d6d into main Sep 3, 2026
5 checks passed
@victorchutw
victorchutw deleted the issue-134-sqlserver-ddl-and-bulk-rows branch September 3, 2026 01:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generate SQL Server DDL and bulk rows from the dataset schema

3 participants