Skip to content

Commit 282876b

Browse files
suryaiyer95claude
andcommitted
fix: follow-up PR bot review findings (cubic P1/P2 + coderabbit MAJOR/MINOR)
Addresses 5 substantive issues raised by the latest round of bot reviews. ### P1 / MAJOR - **MySQL/MariaDB week-partition values no longer corrupted** (cubic P1, data-diff.ts:610) — the prior ISO `yyyy-mm-dd` normalization applied to every dialect silently rewrote MySQL `DATE_FORMAT(%Y-%u)` outputs like `"2024-42"` into invalid dates, producing WHERE clauses that never match. Scope the normalization to T-SQL / Fabric only — those use `CONVERT(DATE, …, 23)` which is the only code path that requires ISO. Postgres, MySQL, ClickHouse, BigQuery, Oracle all get the raw value verbatim, matching their own `DATE_TRUNC`/`toStartOf*` output. - **Partitioned diff no longer drops extra_columns** (coderabbit MAJOR, data-diff.ts:824) — the partition fix wraps each side as a SELECT subquery before recursing. `discoverExtraColumns` skips SQL queries (only inspects plain table names), so the recursive `runDataDiff` fell through to key-only comparison, silently losing value-level diffs. Now `runPartitionedDiff` runs discovery ONCE on the plain source table up-front and passes the resolved `extra_columns` explicitly to each recursive call. Audit-column exclusion metadata is also propagated to the aggregated result for user reporting. ### P2 / MINOR - **`azure_resource_url` trailing slash normalized** (cubic P2, sqlserver.ts:50) — an explicit `"https://custom-host"` (no slash) would produce an invalid OAuth scope `"https://custom-host.default"`. Enforce a trailing slash in `resolveAzureResourceUrl`. - **`az account get-access-token` uses `execFile`** (coderabbit, sqlserver.ts:200) — replaces `exec(<shell command string>)` with `execFile("az", [args])` so user-supplied `azure_resource_url` can't introduce shell metacharacters into the command string. Also updates the test harness to stub both `exec` and `execFile`. ### Test isolation / coverage - **Added same-dialect cross-warehouse joindiff test** (cubic, data-diff-cross-dialect.test.ts:97) — two MSSQL servers with different hosts must still be gated by the joindiff guard; previous tests only exercised mixed dialects. - **Added MySQL week-partition regression tests** — prevent future revivals of the dialect-unaware ISO rewrite. - **Added trailing-slash `azure_resource_url` test.** Test results: - 44/44 sqlserver driver tests pass - 2824/2824 altimate tests pass, 0 fail - Remaining full-suite failures (`mcp/`, `tool/project-scan`, `plan-approval-phrase`) are pre-existing on `main`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 64dd815 commit 282876b

4 files changed

Lines changed: 177 additions & 53 deletions

File tree

packages/drivers/src/sqlserver.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,26 @@ function parseTokenExpiry(token: string): number | undefined {
4444
* 1. Explicit `config.azure_resource_url`.
4545
* 2. Inferred from host suffix (Azure Gov / China).
4646
* 3. Default Azure commercial cloud.
47+
*
48+
* The returned URL is guaranteed to end with `/` — callers append `.default`
49+
* to build the OAuth scope (e.g. `https://database.windows.net/.default`).
50+
* Without the trailing slash, an explicit user value like
51+
* `"https://custom-host"` would produce an invalid scope
52+
* `"https://custom-host.default"` and `credential.getToken` would fail.
4753
*/
4854
function resolveAzureResourceUrl(config: ConnectionConfig): string {
4955
const explicit = config.azure_resource_url as string | undefined
50-
if (explicit) return explicit
51-
const host = (config.host as string | undefined) ?? ""
52-
if (host.includes(".usgovcloudapi.net") || host.includes(".datawarehouse.fabric.microsoft.us")) {
53-
return "https://database.usgovcloudapi.net/"
54-
}
55-
if (host.includes(".chinacloudapi.cn")) {
56-
return "https://database.chinacloudapi.cn/"
57-
}
58-
return "https://database.windows.net/"
56+
const raw = explicit ?? (() => {
57+
const host = (config.host as string | undefined) ?? ""
58+
if (host.includes(".usgovcloudapi.net") || host.includes(".datawarehouse.fabric.microsoft.us")) {
59+
return "https://database.usgovcloudapi.net/"
60+
}
61+
if (host.includes(".chinacloudapi.cn")) {
62+
return "https://database.chinacloudapi.cn/"
63+
}
64+
return "https://database.windows.net/"
65+
})()
66+
return raw.endsWith("/") ? raw : `${raw}/`
5967
}
6068

6169
/** Visible for testing: reset the module-scoped token cache. */
@@ -176,18 +184,26 @@ export async function connect(config: ConnectionConfig): Promise<Connector> {
176184

177185
if (!token) {
178186
try {
179-
// Use async `exec` (not `execSync`) so the connection path stays
180-
// non-blocking — `az account get-access-token` can take several
181-
// seconds to round-trip and execSync would block the entire
182-
// event loop for that duration.
187+
// Use async `execFile` (NOT `exec`/`execSync`):
188+
// - `execFile` skips the shell entirely and passes args as an
189+
// array, preventing shell interpolation when `resourceUrl`
190+
// comes from user config (e.g. `"https://x/;other-cmd"`).
191+
// - async keeps the connect path non-blocking during the
192+
// multi-second `az` round trip.
183193
const childProcess = await import("node:child_process")
184194
const { promisify } = await import("node:util")
185-
const execAsync = promisify(childProcess.exec)
186-
const { stdout } = await execAsync(
187-
`az account get-access-token --resource ${resourceUrl} --query accessToken -o tsv`,
195+
const execFileAsync = promisify(childProcess.execFile)
196+
const { stdout } = await execFileAsync(
197+
"az",
198+
[
199+
"account", "get-access-token",
200+
"--resource", resourceUrl,
201+
"--query", "accessToken",
202+
"-o", "tsv",
203+
],
188204
{ encoding: "utf-8", timeout: 15000 },
189205
)
190-
const out = stdout.trim()
206+
const out = String(stdout).trim()
191207
if (out) {
192208
token = out
193209
expiresAt = parseTokenExpiry(out)

packages/drivers/test/sqlserver-unit.test.ts

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -92,30 +92,50 @@ const cliState = {
9292
}
9393
const realChildProcess = await import("node:child_process")
9494
const realUtil = await import("node:util")
95-
// Stub `exec` with a custom `util.promisify.custom` so `promisify(exec)`
96-
// yields { stdout, stderr } exactly as the real implementation does. Also
97-
// keep the legacy callback form of `execSync` for tests that still use it.
98-
const execStub: any = (cmd: string, optsOrCb: any, maybeCb?: any) => {
99-
cliState.lastCmd = cmd
100-
const cb = typeof optsOrCb === "function" ? optsOrCb : maybeCb
101-
if (cliState.throwError) {
102-
const e: any = new Error(cliState.throwError.message ?? "az failed")
103-
e.stderr = cliState.throwError.stderr
104-
if (cb) cb(e, "", cliState.throwError.stderr ?? "")
95+
96+
// Helper: build a mock with callback + util.promisify.custom support so
97+
// `promisify(child_process.exec)` or `promisify(child_process.execFile)`
98+
// yields { stdout, stderr } exactly like the real implementation.
99+
function makeChildProcessMock(captureCmd: (args: string) => void) {
100+
const stub: any = (arg0: any, arg1: any, arg2: any, arg3: any) => {
101+
// Accept both exec(cmd, opts?, cb?) and execFile(file, args?, opts?, cb?)
102+
const cb = [arg0, arg1, arg2, arg3].find((x) => typeof x === "function")
103+
// Pick the best "command" representation for test assertions:
104+
// - exec: first arg is the full command string
105+
// - execFile: first arg is the program, second arg is the args array
106+
if (Array.isArray(arg1)) {
107+
captureCmd(`${arg0} ${arg1.join(" ")}`)
108+
} else {
109+
captureCmd(String(arg0))
110+
}
111+
if (cliState.throwError) {
112+
const e: any = new Error(cliState.throwError.message ?? "az failed")
113+
e.stderr = cliState.throwError.stderr
114+
if (cb) cb(e, "", cliState.throwError.stderr ?? "")
115+
return { on() {}, stdout: null, stderr: null }
116+
}
117+
if (cb) cb(null, cliState.output, "")
105118
return { on() {}, stdout: null, stderr: null }
106119
}
107-
if (cb) cb(null, cliState.output, "")
108-
return { on() {}, stdout: null, stderr: null }
109-
}
110-
execStub[realUtil.promisify.custom] = (cmd: string, _opts?: any) => {
111-
cliState.lastCmd = cmd
112-
if (cliState.throwError) {
113-
const e: any = new Error(cliState.throwError.message ?? "az failed")
114-
e.stderr = cliState.throwError.stderr
115-
return Promise.reject(e)
120+
stub[realUtil.promisify.custom] = (arg0: any, arg1: any) => {
121+
if (Array.isArray(arg1)) {
122+
captureCmd(`${arg0} ${arg1.join(" ")}`)
123+
} else {
124+
captureCmd(String(arg0))
125+
}
126+
if (cliState.throwError) {
127+
const e: any = new Error(cliState.throwError.message ?? "az failed")
128+
e.stderr = cliState.throwError.stderr
129+
return Promise.reject(e)
130+
}
131+
return Promise.resolve({ stdout: cliState.output, stderr: "" })
116132
}
117-
return Promise.resolve({ stdout: cliState.output, stderr: "" })
133+
return stub
118134
}
135+
136+
const execStub = makeChildProcessMock((c) => { cliState.lastCmd = c })
137+
const execFileStub = makeChildProcessMock((c) => { cliState.lastCmd = c })
138+
119139
mock.module("node:child_process", () => ({
120140
...realChildProcess,
121141
execSync: (cmd: string) => {
@@ -128,6 +148,7 @@ mock.module("node:child_process", () => ({
128148
return cliState.output
129149
},
130150
exec: execStub,
151+
execFile: execFileStub,
131152
}))
132153

133154
// Import after mocking
@@ -726,6 +747,20 @@ describe("SQL Server driver unit tests", () => {
726747
expect(azureIdentityState.lastScope).toBe("https://custom.sovereign.example/.default")
727748
})
728749

750+
test("azure_resource_url without trailing slash is normalized", async () => {
751+
// Regression: without the slash, `${resourceUrl}.default` produced an
752+
// invalid scope like "https://custom-host.default", and `getToken`
753+
// would reject it.
754+
resetMocks()
755+
const c = await connect({
756+
host: "x.database.windows.net", database: "d",
757+
authentication: "azure-active-directory-default",
758+
azure_resource_url: "https://custom-host",
759+
})
760+
await c.connect()
761+
expect(azureIdentityState.lastScope).toBe("https://custom-host/.default")
762+
})
763+
729764
test("az CLI fallback uses the same resource URL", async () => {
730765
// Disable @azure/identity so we hit the az CLI fallback
731766
azureIdentityState.throwOnGetToken = true

packages/opencode/src/altimate/native/connections/data-diff.ts

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -595,21 +595,29 @@ export function buildPartitionWhereClause(
595595

596596
// date mode
597597
const expr = dateTruncExpr(granularity!, quotedCol, dialect)
598-
// Normalize the partition value to ISO yyyy-mm-dd. The mssql driver returns
599-
// date columns as JS Date objects which get `String()`-coerced upstream,
600-
// producing output like "Mon Jan 01 2024 00:00:00 GMT+0000 (UTC)" —
601-
// T-SQL `CONVERT(DATE, …, 23)` and Postgres date literals both reject that
602-
// format. Parsing once here keeps the downstream SQL dialect-safe.
603-
const isoDate = (() => {
604-
const trimmed = partitionValue.trim()
605-
// Already looks like yyyy-mm-dd — preserve as-is so pre-formatted values
606-
// (e.g. from Postgres, BigQuery, DATE_FORMAT MySQL output) flow through
607-
// without surprising timezone shifts.
608-
if (/^\d{4}-\d{2}-\d{2}(\s|T|$)/.test(trimmed)) return trimmed.slice(0, 10)
609-
const d = new Date(trimmed)
610-
return Number.isNaN(d.getTime()) ? trimmed : d.toISOString().slice(0, 10)
611-
})()
612-
const escaped = isoDate.replace(/'/g, "''")
598+
// Normalize to ISO `yyyy-mm-dd` ONLY for T-SQL / Fabric, which use
599+
// `CONVERT(DATE, '…', 23)` (strict ISO-8601 parser). The mssql driver
600+
// returns date columns as JS Date objects that coerce to strings like
601+
// "Mon Jan 01 2024 00:00:00 GMT+0000 (UTC)" — that format must be parsed
602+
// to ISO before CONVERT will accept it.
603+
//
604+
// For other dialects, pass the value through unchanged. MySQL/MariaDB
605+
// produce non-ISO `DATE_FORMAT` outputs (e.g. `YYYY-%u` for ISO week,
606+
// which is `YYYY-42` not `YYYY-MM-DD`), and forcing ISO conversion would
607+
// corrupt them — the WHERE would never match. Postgres / BigQuery /
608+
// ClickHouse accept whatever their own `DATE_TRUNC`/`toStartOf*`
609+
// emits verbatim on the round trip.
610+
const needsIso = dialect === "tsql" || dialect === "fabric" ||
611+
dialect === "sqlserver" || dialect === "mssql"
612+
const normalized = needsIso
613+
? (() => {
614+
const trimmed = partitionValue.trim()
615+
if (/^\d{4}-\d{2}-\d{2}(\s|T|$)/.test(trimmed)) return trimmed.slice(0, 10)
616+
const d = new Date(trimmed)
617+
return Number.isNaN(d.getTime()) ? trimmed : d.toISOString().slice(0, 10)
618+
})()
619+
: partitionValue
620+
const escaped = normalized.replace(/'/g, "''")
613621

614622
// Cast the literal appropriately per dialect
615623
switch (dialect) {
@@ -775,6 +783,26 @@ async function runPartitionedDiff(params: DataDiffParams): Promise<DataDiffResul
775783
}
776784
}
777785

786+
// Auto-discover extra_columns ONCE here on the plain source table — we wrap
787+
// each partition's source/target as SELECT subqueries below, which
788+
// `discoverExtraColumns` skips (it only works on plain table names). If we
789+
// let the recursive `runDataDiff` try, it'd always see a wrapped query and
790+
// regress to key-only comparison (value-level diffs silently lost).
791+
let resolvedExtraColumns = params.extra_columns
792+
let partitionExcludedAudit: string[] = []
793+
if (!resolvedExtraColumns || resolvedExtraColumns.length === 0) {
794+
const discovered = await discoverExtraColumns(
795+
params.source,
796+
params.key_columns,
797+
sourceDialect,
798+
params.source_warehouse,
799+
)
800+
if (discovered) {
801+
resolvedExtraColumns = discovered.columns
802+
partitionExcludedAudit = discovered.excludedAudit
803+
}
804+
}
805+
778806
// Diff each partition
779807
const partitionResults: PartitionDiffResult[] = []
780808
let aggregatedOutcome: unknown = null
@@ -820,6 +848,10 @@ async function runPartitionedDiff(params: DataDiffParams): Promise<DataDiffResul
820848
target: targetSql,
821849
// Preserve the user's shared where_clause — it's dialect-neutral.
822850
where_clause: params.where_clause,
851+
// Pass auto-discovered extras explicitly — `runDataDiff`'s own
852+
// discovery path would skip these wrapped SELECT subqueries and
853+
// regress to key-only comparison.
854+
extra_columns: resolvedExtraColumns,
823855
partition_column: undefined, // prevent recursion
824856
})
825857

@@ -840,6 +872,7 @@ async function runPartitionedDiff(params: DataDiffParams): Promise<DataDiffResul
840872
steps: totalSteps,
841873
outcome: aggregatedOutcome ?? { mode: "diff", stats: { rows_table1: 0, rows_table2: 0, exclusive_table1: 0, exclusive_table2: 0, updated: 0, unchanged: 0 }, diff_rows: [] },
842874
partition_results: partitionResults,
875+
...(partitionExcludedAudit.length > 0 ? { excluded_audit_columns: partitionExcludedAudit } : {}),
843876
}
844877
}
845878

packages/opencode/test/altimate/data-diff-cross-dialect.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,23 @@ describe("buildPartitionWhereClause — cross-dialect correctness (the CRITICAL
130130
const sql = buildPartitionWhereClause(col, "Mon Apr 01 2024 00:00:00 GMT+0000", "month", undefined, "tsql")
131131
expect(sql).toContain("CONVERT(DATE, '2024-04-01', 23)")
132132
})
133+
134+
test("mysql week-format values pass through unchanged (regression: no ISO rewrite)", () => {
135+
// Regression guard: MySQL `DATE_FORMAT(%Y-%u)` emits e.g. "2024-42" for
136+
// week 42 — that's not a parseable JS Date. An earlier revision tried
137+
// to normalize it to ISO `yyyy-mm-dd`, which either produced NaN or a
138+
// wildly wrong date (Dec 2024 in 0042 AD). Must be passed through.
139+
const sql = buildPartitionWhereClause("ts", "2024-42", "week", undefined, "mysql")
140+
expect(sql).toContain("= '2024-42'")
141+
expect(sql).not.toContain("0042")
142+
expect(sql).not.toContain("NaN")
143+
})
144+
145+
test("mysql DATE_FORMAT month output flows through verbatim", () => {
146+
const sql = buildPartitionWhereClause("ts", "2024-04-01", "month", undefined, "mariadb")
147+
expect(sql).toContain("DATE_FORMAT(`ts`, '%Y-%m-01')")
148+
expect(sql).toContain("= '2024-04-01'")
149+
})
133150
})
134151

135152
// The joindiff guard runs BEFORE `runDataDiff`'s NAPI import, so we can drive
@@ -147,7 +164,7 @@ describe("joindiff + cross-warehouse guard", () => {
147164
Registry.reset()
148165
})
149166

150-
test("explicit joindiff with different warehouses returns early error", async () => {
167+
test("explicit joindiff with different warehouses (mixed dialect) returns early error", async () => {
151168
Registry.setConfigs({
152169
msrc: { type: "sqlserver", host: "mssql-host", database: "src" },
153170
ptgt: { type: "postgres", host: "pg-host", database: "tgt" },
@@ -166,6 +183,29 @@ describe("joindiff + cross-warehouse guard", () => {
166183
expect(result.steps).toBe(0)
167184
})
168185

186+
test("explicit joindiff with different warehouses (SAME dialect) still errors", async () => {
187+
// Regression guard: if `crossWarehouse` were computed from dialect
188+
// equality (as an earlier revision did) instead of resolved warehouse
189+
// identity, this case would slip through and route a JOIN query to a
190+
// warehouse that doesn't have the other side's tables. Two MSSQL
191+
// servers share a dialect but are independent physical databases.
192+
Registry.setConfigs({
193+
mssql_a: { type: "sqlserver", host: "server-a", database: "src" },
194+
mssql_b: { type: "sqlserver", host: "server-b", database: "tgt" },
195+
})
196+
const result = await runDataDiff({
197+
source: "dbo.orders",
198+
target: "dbo.orders",
199+
key_columns: ["id"],
200+
source_warehouse: "mssql_a",
201+
target_warehouse: "mssql_b",
202+
algorithm: "joindiff",
203+
})
204+
expect(result.success).toBe(false)
205+
expect(result.error).toMatch(/joindiff requires both tables in the same warehouse/i)
206+
expect(result.steps).toBe(0)
207+
})
208+
169209
test("same-name warehouse on both sides does NOT trigger the guard", async () => {
170210
// Guard compares resolved warehouse identity, not dialect — same name →
171211
// guard stays quiet. We can't drive the whole diff without NAPI, but we

0 commit comments

Comments
 (0)