Skip to content

Commit fc9bae5

Browse files
bpamiriclaude
andauthored
test(database): build the timestamp round-trip diagnostic without casts (#3716)
On BoxLang + Oracle, TimestampRoundTripSpec errored with "Can't cast oracle.sql.TIMESTAMP to a string". Its failure message is evaluated on every run, pass or fail, and it concatenated the raw driver value. The normalizer itself is sound: a new spec feeds it a real oracle.sql.TIMESTAMP and it passes on BoxLang. The fix is a $describeDbValue() helper that prints any value (simple, struct, component, Java object) without casting, used by the spec's message. Fixes #3714 Signed-off-by: Peter Amiri <peter@alurium.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c8a39bb commit fc9bae5

3 files changed

Lines changed: 74 additions & 5 deletions

File tree

‎vendor/wheels/global/util.cfm‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,46 @@
342342
}
343343
344344
345+
/**
346+
* Internal function.
347+
* A printable description of any value read back from a database, for
348+
* diagnostics. Never casts a driver object to a string: BoxLang throws
349+
* "Can't cast oracle.sql.TIMESTAMP to a string" on concatenation, which
350+
* turned a failure message into the failure (#3714).
351+
*/
352+
public string function $describeDbValue(required any value) {
353+
if (IsSimpleValue(arguments.value)) {
354+
try {
355+
return "" & arguments.value;
356+
} catch (any e) {
357+
// Reported as simple but not castable; describe it by type below.
358+
}
359+
}
360+
if (IsStruct(arguments.value) && !IsObject(arguments.value)) {
361+
return "[struct]";
362+
}
363+
if (IsArray(arguments.value)) {
364+
return "[array]";
365+
}
366+
if (IsQuery(arguments.value)) {
367+
return "[query]";
368+
}
369+
try {
370+
local.meta = GetMetadata(arguments.value);
371+
if (IsStruct(local.meta) && StructKeyExists(local.meta, "fullname")) {
372+
return "[component " & local.meta.fullname & "]";
373+
}
374+
} catch (any e) {
375+
// Not a component.
376+
}
377+
try {
378+
return "[object " & arguments.value.getClass().getName() & "]";
379+
} catch (any e) {
380+
return "[unprintable value]";
381+
}
382+
}
383+
384+
345385
/**
346386
* Internal function for `$normalizeDbTimestamp()`. Converts a
347387
* `java.util.Date` into a CFML date through `java.util.Calendar`, so the

‎vendor/wheels/tests/specs/database/TimestampRoundTripSpec.cfc‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,16 +62,17 @@ component extends="wheels.WheelsTest" {
6262
// contract (#3649).
6363
var normalized = application.wo.$normalizeDbTimestamp(readBack);
6464
var shape = IsDate(normalized) ? "date" : "neither";
65-
var valueClass = IsObject(readBack) ? readBack.getClass().getName() : "not-an-object";
66-
6765
// Report the value and its Java class, not just the verdict.
6866
// "Expected [NO] to be true" is what the downstream failures say,
69-
// and it names nothing at all.
67+
// and it names nothing at all. The message is built on every run,
68+
// pass or fail, so it must never cast the raw driver object: on
69+
// BoxLang, concatenating an oracle.sql.TIMESTAMP throws (#3714).
70+
var described = application.wo.$describeDbValue(readBack);
7071
expect(shape).notToBe(
7172
"neither",
7273
"A cf_sql_timestamp round-tripped as something $secondsSince() cannot "
7374
& "read: wrote [" & DateTimeFormat(written, "yyyy-mm-dd HH:nn:ss")
74-
& "], read back [" & readBack & "] of class [" & valueClass & "]. Every "
75+
& "], read back " & described & ". Every "
7576
& "framework path that stores a timestamp and later measures elapsed time "
7677
& "against it — RateLimiter's token bucket, the migrator's applied_at — is "
7778
& "unreliable here."
@@ -87,7 +88,7 @@ component extends="wheels.WheelsTest" {
8788
120,
8889
"The stored timestamp came back as a #shape# that does not resolve to "
8990
& "the time written: wrote [" & DateTimeFormat(written, "yyyy-mm-dd HH:nn:ss")
90-
& "], read back [" & readBack & "] of class [" & valueClass & "]. "
91+
& "], read back " & described & ". "
9192
& "$secondsSince() would report " & elapsed & "s elapsed where "
9293
& expected & "s is correct."
9394
);

‎vendor/wheels/tests/specs/global/DbTimestampNormalizationSpec.cfc‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,34 @@ component extends="wheels.WheelsTest" {
5959
expect(DateDiff("s", normalized, g.$normalizeDbTimestamp(millis))).toBe(0);
6060
});
6161

62+
it("normalizes a real oracle.sql.TIMESTAMP when the Oracle driver is loaded (##3714)", () => {
63+
// The stub above pins the timestampValue() bridge; this pins the real
64+
// driver class, which BoxLang hands back for a cf_sql_timestamp column.
65+
var state = {value = ""};
66+
try {
67+
state.value = CreateObject("java", "oracle.sql.TIMESTAMP").init(
68+
CreateObject("java", "java.sql.Timestamp").init(JavaCast("long", millis))
69+
);
70+
} catch (any e) {
71+
state.value = "";
72+
}
73+
if (IsSimpleValue(state.value)) {
74+
skip("oracle.sql.TIMESTAMP is not on this engine's classpath (runs on the Oracle legs).");
75+
return;
76+
}
77+
var normalized = g.$normalizeDbTimestamp(state.value);
78+
expect(IsDate(normalized)).toBeTrue();
79+
expect(DateDiff("s", normalized, g.$normalizeDbTimestamp(millis))).toBe(0);
80+
// Concatenating the raw object throws on BoxLang; the describer must not.
81+
expect(g.$describeDbValue(state.value)).toInclude("oracle.sql.TIMESTAMP");
82+
});
83+
84+
it("describes any value for a diagnostic without casting it (##3714)", () => {
85+
expect(g.$describeDbValue("2026-07-25 10:20:00")).toBe("2026-07-25 10:20:00");
86+
expect(g.$describeDbValue(new wheels.tests._assets.db.OracleTimestampStub(millis))).toInclude("OracleTimestampStub");
87+
expect(g.$describeDbValue({a = 1})).toBe("[struct]");
88+
});
89+
6290
it("accepts fractional-second datetime strings", () => {
6391
var normalized = g.$normalizeDbTimestamp("2026-07-25 10:20:00.205");
6492
expect(IsDate(normalized)).toBeTrue();

0 commit comments

Comments
 (0)