Skip to content

Commit fecc13a

Browse files
dmealingclaude
andcommitted
fix(java): an unreadable decimal became 0, and a decimal array vanished
Two defects found by asserting VALUES rather than compilation, both in the shared JVM metadata module, so Kotlin inherits the fixes. DataConverter.toBigDecimal swallowed NumberFormatException and returned BigDecimal.ZERO. That is the one wrong answer available: 0 is a plausible amount, so unreadable input reached a money field as a real-looking value indistinguishable from a genuine zero. Every sibling converter — toInt, toLong, toDouble, toFloat — throws for the same input; decimal was the lone exception, and the quiet one. It now throws too. Nothing pinned the ZERO behaviour except the hazard note in this branch's own round-trip test, and there is exactly one caller (MetaField:1215), so the change is contained. In the lenient extract tier assemble's never-throws guard turns the throw into a lost component, which yields null — the honest answer, and what that test asserted before it had to be relaxed. DataTypes had no DECIMAL_ARRAY. arrayTypeFor(DECIMAL) mapped decimal to ITSELF, with a comment explaining that arrays-of-decimal "aren't in the metamodel" so the declaration would "degrade gracefully". It did not degrade gracefully: the loader accepts field.decimal @isarray, every port generates code for it, the conversion then failed, and the component came back null with no error. C# populates the same shape correctly — now pinned BY VALUE, not just by compilation, since a reader that returned an empty list or rounded through a double would have compiled fine — so this was a JVM port gap wearing a design position's clothes. Added DECIMAL_ARRAY plus toBigDecimalArray, element-converting through toBigDecimal rather than a double: routing a decimal array through doubles would defeat the entire reason field.decimal exists. The new member takes id 22 rather than a tidy slot beside DECIMAL=10 because those ids are PERSISTED identifiers — renumbering the existing members to keep the enum ordered would silently repoint stored data. Scope deliberately stops at the JVM. That conversion table has no C#, TypeScript or Python analogue (verified, not assumed): TS binds field.decimal to `string` and has no DECIMAL coercion kind, Python binds it to Decimal, and neither carries a per-type array conversion enum. One change, one module, two ports. Verified: metadata 1622 + render/om 292 pass; ci-local.sh --only java green on conformance and Testcontainers integration for BOTH java and kotlin. The lane's single red is the pre-existing SpringPayloadGeneratorTest camelCase failure, which fails identically on a clean tree (macOS case-insensitive filesystem). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8125ac5 commit fecc13a

5 files changed

Lines changed: 136 additions & 29 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,28 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
119119
for one date should cost you that date, not the twenty fields beside it that parsed. The
120120
component is now left unset, which is what "lost" already means everywhere else in that pass.
121121

122+
- **Java: an unparseable decimal became `0` instead of null.** `DataConverter.toBigDecimal`
123+
caught the `NumberFormatException` and returned `BigDecimal.ZERO` — the one wrong answer
124+
available, because `0` is a plausible amount, so unreadable input reached a money field as a
125+
real-looking value with nothing to distinguish it from a genuine zero. Every sibling
126+
converter (`toInt` / `toLong` / `toDouble` / `toFloat`) throws for the same input; decimal
127+
was the lone exception. It now throws too, and the lenient extract tier's never-throws guard
128+
turns that into a lost component — so the answer there is `null`, which is the honest one.
129+
130+
- **Java/Kotlin: a declared `field.decimal @isArray` was silently dropped.** `DataTypes` had no
131+
`DECIMAL_ARRAY`, and `arrayTypeFor(DECIMAL)` mapped decimal to ITSELF with a comment saying
132+
arrays-of-decimal "aren't in the metamodel" so the declaration would "degrade gracefully". It
133+
did not degrade gracefully: the loader accepts the declaration, all five ports generate code
134+
for it, and C# populates it correctly (now pinned by value in
135+
`Fr010DelegatingMirrorLockStepTests`) — so this was a JVM gap presented as a design position,
136+
not a metamodel rule. Added `DECIMAL_ARRAY` plus a `toBigDecimalArray` that element-converts
137+
through `toBigDecimal` rather than a double, so precision survives the round trip. The new
138+
member takes id 22 rather than a slot beside `DECIMAL=10`: those ids are persisted
139+
identifiers, and renumbering the existing members to keep the enum tidy would silently
140+
repoint stored data. Kotlin inherits the fix — it consumes the same `metadata` module — and
141+
C#, TypeScript and Python needed no change (that conversion table is JVM-only; TS binds
142+
`field.decimal` to `string` and Python to `Decimal`).
143+
122144
- **Java: `DataConverter.toDate` could not parse an ISO-8601 date.** The `String` arm was a bare
123145
`Long.parseLong`, so it accepted epoch milliseconds and nothing else — a plain `"2026-03-04"`
124146
threw `NumberFormatException`. Since that is the form `normalization.md` puts on the wire, no

‎server/csharp/MetaObjects.Codegen.Tests/Fr010DelegatingMirrorLockStepTests.cs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,38 @@ public void Generated_delegating_extract_populates_nested_decimals()
162162
Assert.Equal(7m, many[1].GetType().GetProperty("v")!.GetValue(many[1]));
163163
}
164164

165+
[Fact]
166+
public void Generated_delegating_extract_populates_a_nested_decimal_ARRAY()
167+
{
168+
// The compile gates above prove a decimal ARRAY emits code that builds. They do not
169+
// prove the VALUES arrive: DlgList(..., DlgDecimal) could return an empty list, or
170+
// round every element through a double, and still compile. Java cannot do this at all
171+
// (its object model has no DECIMAL_ARRAY conversion, so the component is dropped), so
172+
// whether C# genuinely supports the shape is a fact worth pinning rather than assuming.
173+
var root = Load(NestedScalarModel(FIELD_SUBTYPE_DECIMAL, true));
174+
var asm = Compile(root);
175+
176+
var parserType = asm.GetType("Acme.Generated.ProbeParser")!;
177+
var extract = parserType.GetMethod("ExtractLenient",
178+
new[] { typeof(MetaObject), typeof(string), typeof(MetaObjects.Render.Extract.ExtractOptions) })!;
179+
180+
const string dirty = "```json\n{ \"id\": \"A-1\", \"single\": { \"v\": [1.25, 2.5, 7] } }\n```";
181+
182+
var result = extract.Invoke(null, new object?[] { root.FindObject("Probe")!, dirty, null })!;
183+
var data = result.GetType().GetProperty("Data")!.GetValue(result)!;
184+
185+
var single = data.GetType().GetProperty("single")!.GetValue(data);
186+
Assert.NotNull(single);
187+
var v = (IEnumerable)single!.GetType().GetProperty("v")!.GetValue(single)!;
188+
var elems = v.Cast<object?>().ToList();
189+
190+
Assert.Equal(3, elems.Count);
191+
// decimal?, element-typed — NOT string, and precision preserved.
192+
Assert.Equal(1.25m, elems[0]);
193+
Assert.Equal(2.5m, elems[1]);
194+
Assert.Equal(7m, elems[2]);
195+
}
196+
165197
// ---- harness ----
166198

167199
private static void AssertGeneratedCompiles(string subType, bool isArray)

‎server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedScalarExtractRoundTripTest.java‎

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -150,31 +150,31 @@ public void coercedScalarsCarryTheirValuesAndMalformedOnesBecomeNull() throws Ex
150150
List<?> counts = (List<?>) get(p, "counts");
151151
assertEquals("int array is element-typed, not List<String>", List.of(3, 4), counts);
152152

153-
// A decimal ARRAY is deliberately NOT a metamodel shape: DataTypes has no
154-
// DECIMAL_ARRAY ("arrays-of-decimal aren't in the metamodel" — the same position
155-
// C# records as SP-A "decimal is single-only"), so DataTypes.arrayTypeFor maps
156-
// field.decimal @isArray to DECIMAL and the assembled value cannot be populated.
157-
// What matters is the FAILURE MODE: the component is lost, and the rest of the
158-
// payload still parses. Before the never-throws fix in MetaObjectExtractor.assemble
159-
// this threw out of the whole extract. It still COMPILES (see
160-
// GeneratedScalarExtractLockStepTest) — a declared shape must never emit broken code
161-
// even when the runtime cannot fill it.
162-
assertNull("decimal array is unsupported by the object model — lost, not fatal",
163-
get(p, "rates"));
153+
// A decimal ARRAY now populates, element-typed and precision-exact. It used to be
154+
// dropped silently: DataTypes had no DECIMAL_ARRAY, so arrayTypeFor(DECIMAL)
155+
// returned DECIMAL, the conversion failed, and the component came back null with
156+
// no error — while the loader accepted the declaration and every port generated
157+
// code for it. C# populated the same shape correctly, so this was a JVM port
158+
// divergence rather than a metamodel position.
159+
List<?> rates = (List<?>) get(p, "rates");
160+
assertNotNull("decimal array must populate", rates);
161+
assertEquals("rates size", 2, rates.size());
162+
assertTrue("rates element is BigDecimal, not String", rates.get(0) instanceof BigDecimal);
163+
assertEquals("rates[0] precision", 0, ((BigDecimal) rates.get(0)).compareTo(new BigDecimal("1.25")));
164+
assertEquals("rates[1] precision", 0, ((BigDecimal) rates.get(1)).compareTo(new BigDecimal("2.5")));
164165

165166
// --- NEVER-THROWS: a malformed component does not cost the whole extract ---
166167
assertNull("malformed date degrades to null", get(p, "badDate"));
167168
assertEquals("a bad field must not cost the good ones", "W-1", get(p, "ref"));
168169

169-
// KNOWN HAZARD, asserted as-is so it is visible rather than silently relied on:
170-
// DataConverter.toBigDecimal returns BigDecimal.ZERO for an unparseable string
171-
// instead of null, so "not-a-number" reaches the record as 0 — a plausible-looking
172-
// money value standing in for "we could not read this". Null would be the honest
173-
// answer. NOT changed here: toBigDecimal is a core converter the OMDB persistence
174-
// path also uses, so flipping its contract is a decision with its own blast radius,
175-
// not a drive-by. When it is fixed, this assertion flips to assertNull.
176-
assertEquals("unparseable decimal currently becomes ZERO, not null",
177-
BigDecimal.ZERO, get(p, "bad"));
170+
// An unreadable decimal is NULL, not zero. DataConverter.toBigDecimal used to
171+
// swallow the NumberFormatException and return BigDecimal.ZERO, which is the one
172+
// wrong answer available: 0 is a plausible amount, so unreadable input became a
173+
// real-looking money value indistinguishable from a genuine zero. It now throws
174+
// like every sibling converter does for the same input, and assemble's
175+
// never-throws guard turns that into a lost component.
176+
assertNull("unparseable decimal must be null, never a plausible-looking 0",
177+
get(p, "bad"));
178178
}
179179
}
180180

‎server/java/metadata/src/main/java/com/metaobjects/DataTypes.java‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@ public enum DataTypes {
1616
STRING(8), DATE(9),
1717

1818
// Exact decimal (BigDecimal-backed; field.decimal). Slot 10 (between DATE=9
19-
// and BOOLEAN_ARRAY=11). No DECIMAL_ARRAY — arrays-of-decimal are not a thing
20-
// in the metamodel; getArrayEquivalent maps DECIMAL to itself (see below).
19+
// and BOOLEAN_ARRAY=11).
2120
DECIMAL(10),
2221

2322
// Numeric Arrays
@@ -30,6 +29,18 @@ public enum DataTypes {
3029
// Object and Object Array
3130
OBJECT(20), OBJECT_ARRAY(21),
3231

32+
// Exact-decimal array (field.decimal @isArray). Added at 22 rather than beside
33+
// DECIMAL=10 because the ids are PERSISTED identifiers — renumbering the existing
34+
// members to keep the enum tidy would silently repoint stored data.
35+
//
36+
// This used to be absent, with getArrayEquivalent mapping DECIMAL to ITSELF so the
37+
// declaration "degraded gracefully". It did not degrade gracefully: the loader accepts
38+
// field.decimal @isArray, every port generates code for it, and the conversion then
39+
// failed and the component was dropped with no error. C# populates the shape correctly
40+
// (proven by value in Fr010DelegatingMirrorLockStepTests), so the JVM being unable to
41+
// hold it was a port divergence, not a metamodel position.
42+
DECIMAL_ARRAY(22),
43+
3344
// Custom
3445
CUSTOM(99);
3546

@@ -79,6 +90,7 @@ private DataTypes( int id ) {
7990
case 18: valueClass=List.class; isArray=true; itemClass=String.class; isStringArray=true; break;
8091
case 19: valueClass=List.class; isArray=true; itemClass=Date.class; isDateArray=true; break;
8192
case 21: valueClass=List.class; isArray=true; itemClass=Object.class; isObjectArray=true; break;
93+
case 22: valueClass=List.class; isArray=true; itemClass=java.math.BigDecimal.class; isNumericArray=true; break;
8294

8395
case 99: valueClass=Object.class; isCustom=true; break;
8496

@@ -182,10 +194,7 @@ public DataTypes getArrayEquivalent() {
182194
case OBJECT: return OBJECT_ARRAY;
183195
case CUSTOM: return OBJECT_ARRAY; // Custom objects become object arrays
184196

185-
// No DECIMAL_ARRAY exists (arrays-of-decimal aren't in the metamodel);
186-
// map to itself so @isArray on a decimal degrades gracefully rather
187-
// than throwing. Matches the "no array form" intent of slot 10.
188-
case DECIMAL: return DECIMAL;
197+
case DECIMAL: return DECIMAL_ARRAY;
189198

190199
// Already arrays - return as-is
191200
case BOOLEAN_ARRAY:
@@ -198,6 +207,7 @@ public DataTypes getArrayEquivalent() {
198207
case STRING_ARRAY:
199208
case DATE_ARRAY:
200209
case OBJECT_ARRAY:
210+
case DECIMAL_ARRAY:
201211
return this;
202212

203213
default:

‎server/java/metadata/src/main/java/com/metaobjects/util/DataConverter.java‎

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public static Object toType( DataTypes dataType, Object val ) {
5050
return unsupported(dataType,val);
5151

5252
case DATE_ARRAY: return toDateArray( val );
53+
case DECIMAL_ARRAY: return toBigDecimalArray( val );
5354
case STRING_ARRAY: return toStringArray( val );
5455
case OBJECT_ARRAY: return toObjectArray( val );
5556

@@ -337,7 +338,13 @@ public static java.math.BigDecimal toBigDecimal( Object val )
337338
if ( val instanceof String ) {
338339
String s = ((String) val).trim();
339340
if ( s.isEmpty() ) return null;
340-
try { return new java.math.BigDecimal( s ); } catch ( NumberFormatException ignored ) { return java.math.BigDecimal.ZERO; }
341+
// Let NumberFormatException propagate, exactly as toInt/toLong/toDouble/toFloat do
342+
// for the same input. This used to swallow it and return ZERO, which is the one
343+
// wrong answer available: 0 is a PLAUSIBLE amount, so unreadable input became a
344+
// real-looking money value with nothing to distinguish it from a genuine zero.
345+
// Callers that must not fail — the lenient extract tier — already treat a throw
346+
// here as "component lost" (MetaObjectExtractor.assemble), which yields null.
347+
return new java.math.BigDecimal( s );
341348
}
342349
if ( val instanceof Boolean ) return ((Boolean) val) ? java.math.BigDecimal.ONE : java.math.BigDecimal.ZERO;
343350
if ( val instanceof java.math.BigInteger ) return new java.math.BigDecimal( (java.math.BigInteger) val );
@@ -351,8 +358,9 @@ public static java.math.BigDecimal toBigDecimal( Object val )
351358
}
352359
if ( val instanceof Date ) return java.math.BigDecimal.valueOf( ((Date) val).getTime() );
353360

354-
try { return new java.math.BigDecimal( val.toString().trim() ); } catch ( NumberFormatException ignored ) {}
355-
return java.math.BigDecimal.ZERO;
361+
// Same contract as the String arm above and as every sibling converter's tail: parse,
362+
// and throw if it is not a number. No ZERO fallback.
363+
return new java.math.BigDecimal( val.toString().trim() );
356364
} // toBigDecimal
357365

358366
/**
@@ -833,6 +841,41 @@ public static List<Date> toDateArray(Object val) {
833841
/**
834842
* Convert value to Double array (List&lt;Double&gt;)
835843
*/
844+
/**
845+
* Convert the object value to a List of BigDecimal.
846+
*
847+
* <p>Element-converts through {@link #toBigDecimal(Object)} rather than a double, so a
848+
* decimal array stays precision-exact — the reason {@code field.decimal} exists at all.
849+
* Shape mirrors {@link #toDoubleArray(Object)} exactly, including the comma-split for a
850+
* single delimited String.</p>
851+
*
852+
* @param val Value
853+
* @return List of BigDecimal
854+
*/
855+
public static List<java.math.BigDecimal> toBigDecimalArray(Object val) {
856+
if (val == null) return null;
857+
858+
if (val instanceof List<?>) {
859+
List<?> list = (List<?>) val;
860+
return list.stream()
861+
.map(DataConverter::toBigDecimal)
862+
.collect(java.util.stream.Collectors.toList());
863+
} else if (val instanceof String) {
864+
String s = (String) val;
865+
if (s.trim().isEmpty()) return new java.util.ArrayList<>();
866+
867+
if (s.contains(",")) {
868+
return java.util.Arrays.stream(s.split(","))
869+
.map(item -> toBigDecimal(item.trim()))
870+
.collect(java.util.stream.Collectors.toList());
871+
} else {
872+
return java.util.Arrays.asList(toBigDecimal(s.trim()));
873+
}
874+
} else {
875+
return java.util.Arrays.asList(toBigDecimal(val));
876+
}
877+
}
878+
836879
public static List<Double> toDoubleArray(Object val) {
837880
if (val == null) return null;
838881

0 commit comments

Comments
 (0)