Skip to content

Commit 4e4a725

Browse files
dmealingclaude
andcommitted
feat(omdb): R6 — field.float emits REAL (Real4); field.double stays DOUBLE
Adds a distinct SqlType.Real4 record (float4, single precision) to the sealed SqlType interface so field.float maps to SQL REAL, separate from field.double (float8) -> DOUBLE/DOUBLE PRECISION. Mirrors the committed TS/C#/Python R6 ports. - SqlType: new Real4 record in the permits clause (exhaustive-switch enforced). - JdbcSqlTypes.fromJdbc: Types.FLOAT/REAL -> Real4, Types.DOUBLE -> Real. This is the single shared mapping point, so the expected-schema and DB-introspection paths agree (no phantom ALTER COLUMN round-trip). - Postgres pgType / Derby derbyType: new Real4 -> "REAL" arms. - codegen-spring SpringTypeMapper: FloatField -> Float, DecimalField -> BigDecimal DTO arms (previously fell through to the unmapped-type throw). - integration-tests Normalization: canonicalFloat stringifies REAL/DOUBLE on the wire and fails loudly on out-of-band (exponential) values; mirrors normalization.ts. - Tests: SqlType Real4 equality/widening, fromJdbc split, per-dialect REAL vs DOUBLE rendering, and the canonicalFloat in-band/strip/throw cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 42f4096 commit 4e4a725

10 files changed

Lines changed: 122 additions & 4 deletions

File tree

server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringTypeMapper.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import com.metaobjects.field.BooleanField;
44
import com.metaobjects.field.CurrencyField;
55
import com.metaobjects.field.DateField;
6+
import com.metaobjects.field.DecimalField;
67
import com.metaobjects.field.DoubleField;
78
import com.metaobjects.field.EnumField;
9+
import com.metaobjects.field.FloatField;
810
import com.metaobjects.field.IntegerField;
911
import com.metaobjects.field.LongField;
1012
import com.metaobjects.field.MetaField;
@@ -65,6 +67,8 @@ public static String javaTypeName(MetaField<?> field) {
6567
if (field instanceof IntegerField) return "Integer";
6668
if (field instanceof LongField) return "Long";
6769
if (field instanceof DoubleField) return "Double";
70+
if (field instanceof FloatField) return "Float";
71+
if (field instanceof DecimalField) return "java.math.BigDecimal";
6872
if (field instanceof BooleanField) return "Boolean";
6973
if (field instanceof DateField) return "java.time.LocalDate";
7074
if (field instanceof TimestampField) return "java.time.Instant";

server/java/integration-tests/src/test/java/com/metaobjects/integration/Normalization.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,8 @@ public static Object normalizeValue(Object v) {
4848
if (v instanceof Integer i)return i;
4949
if (v instanceof Short s) return (int) s;
5050
if (v instanceof Byte b) return (int) b;
51-
if (v instanceof Float f) return (double) f;
52-
if (v instanceof Double d) return d;
51+
if (v instanceof Float f) return canonicalFloat(f);
52+
if (v instanceof Double d) return canonicalFloat(d);
5353
if (v instanceof BigDecimal bd) return canonicalDecimal(bd);
5454
if (v instanceof UUID u) return u.toString().toLowerCase(java.util.Locale.ROOT);
5555
if (v instanceof byte[] bytes) return Base64.getEncoder().encodeToString(bytes);
@@ -74,6 +74,23 @@ public static Object normalizeValue(Object v) {
7474
return v.toString();
7575
}
7676

77+
/** REAL → canonical plain-decimal string, formatted from the single (no widening tail). */
78+
private static String canonicalFloat(float f) { return canonicalFloatStr(Float.toString(f), f); }
79+
/** DOUBLE → canonical plain-decimal string. */
80+
private static String canonicalFloat(double d) { return canonicalFloatStr(Double.toString(d), d); }
81+
private static String canonicalFloatStr(String s, Object v) {
82+
if (s.indexOf('E') >= 0 || s.indexOf('e') >= 0) {
83+
throw new IllegalArgumentException(
84+
"canonicalFloat: " + v + " is outside the plain-decimal band (exponential notation); "
85+
+ "REAL/DOUBLE fixture values must be in-band dyadic rationals — "
86+
+ "see fixtures/persistence-conformance/normalization.md");
87+
}
88+
if (!s.contains(".")) return s;
89+
s = s.replaceAll("0+$", "");
90+
if (s.endsWith(".")) s = s.substring(0, s.length() - 1);
91+
return s;
92+
}
93+
7794
/** NUMERIC/DECIMAL → canonical string: strip trailing zeros + the decimal point if integral. */
7895
private static String canonicalDecimal(BigDecimal d) {
7996
String s = d.stripTrailingZeros().toPlainString();
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.metaobjects.integration;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import static org.junit.jupiter.api.Assertions.assertEquals;
6+
import static org.junit.jupiter.api.Assertions.assertThrows;
7+
8+
/**
9+
* R6: REAL/DOUBLE wire normalization (mirror of the TS {@code normalization.test.ts} cases).
10+
* {@link Normalization#normalizeValue} routes Float/Double through the canonical-float
11+
* stringifier: in-band values become plain-decimal strings (trailing zeros + bare decimal
12+
* point stripped); out-of-band (exponential) values fail loudly so a bad fixture surfaces
13+
* immediately instead of silently corrupting the cross-port byte-equality compare.
14+
*/
15+
class NormalizationFloatTest {
16+
@Test void inBandFloat_isPlainDecimalString() {
17+
assertEquals("1.5", Normalization.normalizeValue(1.5f)); // REAL (float4)
18+
assertEquals("0.25", Normalization.normalizeValue(0.25d)); // DOUBLE (float8)
19+
}
20+
@Test void integerValuedDouble_stripsTrailingZeroAndPoint() {
21+
assertEquals("2", Normalization.normalizeValue(2.0d)); // "2.0" -> "2." -> "2"
22+
}
23+
@Test void outOfBandDouble_throws() {
24+
assertThrows(IllegalArgumentException.class,
25+
() -> Normalization.normalizeValue(1.0e-10d)); // Double.toString -> "1.0E-10"
26+
}
27+
@Test void outOfBandFloat_throws() {
28+
assertThrows(IllegalArgumentException.class,
29+
() -> Normalization.normalizeValue(1.0e-10f)); // Float.toString -> "1.0E-10"
30+
}
31+
}

server/java/omdb/src/main/java/com/metaobjects/manager/db/driver/DerbyDriver.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ String derbyType(SqlType t) {
357357
case SqlType.Bool b -> "BOOLEAN";
358358
case SqlType.Timestamp ts -> "TIMESTAMP";
359359
case SqlType.Real r -> "DOUBLE";
360+
case SqlType.Real4 r -> "REAL";
360361
case SqlType.Numeric n -> "NUMERIC";
361362
case SqlType.Json j -> "VARCHAR(32700)";
362363
case SqlType.Date d -> "DATE";

server/java/omdb/src/main/java/com/metaobjects/manager/db/driver/PostgresDriver.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ String pgType(SqlType t) {
370370
// gratuitously promoted to TIMESTAMPTZ (matches TS/C# behavior).
371371
case SqlType.Timestamp ts -> ts.withTimezone() ? "TIMESTAMP WITH TIME ZONE" : "TIMESTAMP";
372372
case SqlType.Real r -> "DOUBLE PRECISION";
373+
case SqlType.Real4 r -> "REAL";
373374
case SqlType.Numeric n -> "NUMERIC";
374375
case SqlType.Json j -> "JSONB";
375376
case SqlType.Date d -> "DATE";

server/java/omdb/src/main/java/com/metaobjects/manager/db/migrate/JdbcSqlTypes.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@ public static SqlType fromJdbc(int jdbcType, int length) {
2323
return new SqlType.Int(64);
2424
case Types.FLOAT:
2525
case Types.REAL:
26+
// float4 / single precision — field.float. Postgres reports a REAL column as
27+
// Types.REAL and SimpleMappingHandlerDB maps FloatField -> Types.FLOAT, so both
28+
// the expected-snapshot and introspection paths land on Real4 (stable round-trip).
29+
return new SqlType.Real4();
2630
case Types.DOUBLE:
31+
// float8 / double precision — field.double.
2732
return new SqlType.Real();
2833
case Types.NUMERIC:
2934
case Types.DECIMAL:

server/java/omdb/src/main/java/com/metaobjects/manager/db/migrate/SqlType.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* Per spec §5.2.
1010
*/
1111
public sealed interface SqlType
12-
permits SqlType.Text, SqlType.Int, SqlType.Real, SqlType.Numeric, SqlType.Bool,
12+
permits SqlType.Text, SqlType.Int, SqlType.Real, SqlType.Real4, SqlType.Numeric, SqlType.Bool,
1313
SqlType.Timestamp, SqlType.Date, SqlType.Json, SqlType.Blob, SqlType.Uuid {
1414

1515
/** maxLength == null means unbounded (TEXT). */
@@ -18,8 +18,12 @@ record Text(Integer maxLength) implements SqlType {}
1818
/** bits: 32 (INTEGER) or 64 (BIGINT). Named Int to avoid shadowing java.lang.Integer. */
1919
record Int(int bits) implements SqlType {}
2020

21+
/** DOUBLE PRECISION (float8, double precision) — field.double. */
2122
record Real() implements SqlType {}
2223

24+
/** REAL (float4, single precision) — field.float. */
25+
record Real4() implements SqlType {}
26+
2327
record Numeric(Integer precision, Integer scale) implements SqlType {}
2428

2529
record Bool() implements SqlType {}
@@ -65,7 +69,7 @@ static boolean isWidening(SqlType from, SqlType to) {
6569
return tp >= fp && ts == fs && (tp - ts) >= (fp - fs);
6670
}
6771

68-
// Real/Bool/Date/Json/Blob/Uuid/Timestamp: any same-kind difference is not widening
72+
// Real/Real4/Bool/Date/Json/Blob/Uuid/Timestamp: any same-kind difference is not widening
6973
return false;
7074
}
7175
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.metaobjects.manager.db.driver;
2+
3+
import com.metaobjects.manager.db.migrate.SqlType;
4+
import org.junit.Test;
5+
6+
import static org.junit.Assert.assertEquals;
7+
8+
/**
9+
* R6: REAL (float4, field.float) renders distinctly from DOUBLE (float8, field.double)
10+
* per dialect. Guards against the two arms collapsing back to a single floating type.
11+
*/
12+
public class DriverFloatTypeTest {
13+
@Test public void postgres_real4_is_REAL_and_real_is_DOUBLE_PRECISION() {
14+
PostgresDriver pg = new PostgresDriver();
15+
assertEquals("REAL", pg.pgType(new SqlType.Real4()));
16+
assertEquals("DOUBLE PRECISION", pg.pgType(new SqlType.Real()));
17+
}
18+
@Test public void derby_real4_is_REAL_and_real_is_DOUBLE() {
19+
DerbyDriver derby = new DerbyDriver();
20+
assertEquals("REAL", derby.derbyType(new SqlType.Real4()));
21+
assertEquals("DOUBLE", derby.derbyType(new SqlType.Real()));
22+
}
23+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.metaobjects.manager.db.migrate;
2+
3+
import org.junit.Test;
4+
5+
import java.sql.Types;
6+
7+
import static org.junit.Assert.assertEquals;
8+
9+
/**
10+
* R6: java.sql.Types FLOAT/REAL route to {@link SqlType.Real4} (float4, single precision);
11+
* DOUBLE routes to {@link SqlType.Real} (float8). This single mapping point is shared by both
12+
* the expected-snapshot builder and the DB introspector, so the split round-trips cleanly.
13+
*/
14+
public class JdbcSqlTypesTest {
15+
@Test public void floatAndReal_mapToReal4() {
16+
assertEquals(new SqlType.Real4(), JdbcSqlTypes.fromJdbc(Types.FLOAT, 0));
17+
assertEquals(new SqlType.Real4(), JdbcSqlTypes.fromJdbc(Types.REAL, 0));
18+
}
19+
@Test public void double_mapsToReal() {
20+
assertEquals(new SqlType.Real(), JdbcSqlTypes.fromJdbc(Types.DOUBLE, 0));
21+
}
22+
}

server/java/omdb/src/test/java/com/metaobjects/manager/db/migrate/SqlTypeTest.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,14 @@ public class SqlTypeTest {
2525
assertFalse(SqlType.isWidening(new Int(32), new Text(120))); // cross-kind: lossy
2626
assertFalse(SqlType.isWidening(new Text(120), new Text(120))); // identical
2727
}
28+
// R6: REAL (float4, single precision) is a distinct kind from DOUBLE (float8).
29+
@Test public void real4_equals_real4_and_differs_from_real() {
30+
assertEquals(new Real4(), new Real4());
31+
assertNotEquals(new Real(), new Real4()); // float4 != float8
32+
}
33+
@Test public void float_kinds_are_never_widening() {
34+
assertFalse(SqlType.isWidening(new Real4(), new Real())); // single -> double: not auto-widened
35+
assertFalse(SqlType.isWidening(new Real(), new Real4())); // double -> single: narrowing
36+
assertFalse(SqlType.isWidening(new Real4(), new Real4())); // identical
37+
}
2838
}

0 commit comments

Comments
 (0)