Skip to content

Commit 8a4dc28

Browse files
dmealingclaude
andcommitted
test(omdb): cover UUID no-clobber, Time/Float/Decimal codecs, MSSQL/Oracle SELECT assembly
- UuidPrimaryKeyTest: pre-set the UUID PK before create and assert the persisted + reloaded id equals it exactly (the mint guard must not overwrite a caller-supplied UUID). - JdbcCodecRoundTripTest: add Float + Decimal full DB round-trips. For Time, a full OMDB round-trip is blocked by the DataConverter CUSTOM-type limitation (TimeField is DataTypes.CUSTOM; DataConverter.toType(CUSTOM, ...) throws), so instead drive TimeCodec.write/readInto directly against a real Derby TIME column, asserting LocalTime -> java.sql.Time -> LocalTime is symmetric. - SelectAssemblyRangeTest: exercise the full getSelectStatementWhere assembly for MSSQL and Oracle with an unordered ranged query, asserting the fallback ORDER BY ((SELECT NULL) / NULL) is immediately followed by the OFFSET/FETCH range clause. SQL is captured at prepareStatement() via a stub Connection, so no live DB is needed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f7e59c5 commit 8a4dc28

3 files changed

Lines changed: 300 additions & 0 deletions

File tree

server/java/omdb/src/test/java/com/metaobjects/manager/db/UuidPrimaryKeyTest.java

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,40 @@ public void uuidPrimaryKeyIsMintedAppSideBeforeInsert() throws Exception {
116116
omdb.releaseConnection(oc);
117117
}
118118
}
119+
120+
/**
121+
* Task 1.7 (remediation) — the mint guard ({@code if (f.getString(o) == null)})
122+
* must NOT clobber a caller-supplied UUID. When the PK is pre-set before
123+
* create, the persisted + reloaded id must equal that exact value.
124+
*/
125+
@Test
126+
public void callerSuppliedUuidIsNotClobbered() throws Exception {
127+
MetaObject mo = registry.findMetaObjectByName("uuidtest::Widget");
128+
assertNotNull(mo);
129+
130+
String presetId = UUID.randomUUID().toString();
131+
132+
ObjectConnection oc = omdb.getConnection();
133+
try {
134+
ValueObject vo = (ValueObject) mo.newInstance();
135+
vo.setString("id", presetId); // caller supplies the UUID
136+
vo.setString("name", "preset");
137+
138+
omdb.createObject(oc, vo);
139+
140+
// The mint guard must have left the caller's value intact.
141+
assertEquals("OMDB must not overwrite a caller-supplied UUID PK",
142+
presetId, vo.getString("id"));
143+
144+
// Reload by the preset id and confirm it persisted under that exact value.
145+
Collection<?> rows = omdb.getObjects(oc, mo,
146+
new QueryOptions(new Expression("id", presetId, Expression.EQUAL)));
147+
assertEquals("exactly one row persisted under the caller-supplied UUID", 1, rows.size());
148+
ValueObject read = (ValueObject) rows.iterator().next();
149+
assertEquals("reloaded id equals the caller-supplied UUID", presetId, read.getString("id"));
150+
assertEquals("preset", read.getString("name"));
151+
} finally {
152+
omdb.releaseConnection(oc);
153+
}
154+
}
119155
}

server/java/omdb/src/test/java/com/metaobjects/manager/db/codec/JdbcCodecRoundTripTest.java

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import com.metaobjects.manager.db.ObjectManagerDB;
1515
import com.metaobjects.manager.db.driver.DerbyDriver;
1616
import com.metaobjects.manager.db.validator.MetaClassDBValidatorService;
17+
import com.metaobjects.field.TimeField;
1718
import com.metaobjects.manager.exp.Expression;
1819
import com.metaobjects.object.MetaObject;
1920
import com.metaobjects.object.value.ValueObject;
@@ -26,6 +27,7 @@
2627
import javax.sql.DataSource;
2728
import java.io.PrintWriter;
2829
import java.sql.*;
30+
import java.time.LocalTime;
2931
import java.util.Collection;
3032
import java.util.Date;
3133
import java.util.logging.Logger;
@@ -102,6 +104,8 @@ public void primitivesRoundTripThroughCodecs() throws Exception {
102104
vo.setLong("bignum", 9_000_000_000L); // > Integer.MAX_VALUE, proves LongCodec
103105
vo.setBoolean("active", true);
104106
vo.setDouble("ratio", 3.5d);
107+
vo.setFloat("rate", 2.5f); // FloatCodec
108+
vo.setObject("amount", new java.math.BigDecimal("123.45")); // DecimalCodec
105109
vo.setString("label", "hello-codec");
106110
Date created = new Date(1_700_000_000_000L);
107111
vo.setDate("createdAt", created);
@@ -117,6 +121,11 @@ public void primitivesRoundTripThroughCodecs() throws Exception {
117121
assertEquals("LongCodec round-trip", Long.valueOf(9_000_000_000L), read.getLong("bignum"));
118122
assertEquals("BooleanCodec round-trip", Boolean.TRUE, read.getBoolean("active"));
119123
assertEquals("DoubleCodec round-trip", Double.valueOf(3.5d), read.getDouble("ratio"));
124+
assertEquals("FloatCodec round-trip", Float.valueOf(2.5f), read.getFloat("rate"));
125+
// DecimalField is backed by DataTypes.DOUBLE, so the field surfaces the
126+
// value as a Double; the DecimalCodec write/read still goes through
127+
// PreparedStatement.setBigDecimal / ResultSet.getBigDecimal.
128+
assertEquals("DecimalCodec round-trip", Double.valueOf(123.45d), read.getDouble("amount"));
120129
assertEquals("StringCodec round-trip", "hello-codec", read.getString("label"));
121130
// DateCodec stores as a timestamp; compare epoch millis.
122131
assertNotNull("DateCodec round-trip non-null", read.getDate("createdAt"));
@@ -125,4 +134,63 @@ public void primitivesRoundTripThroughCodecs() throws Exception {
125134
omdb.releaseConnection(oc);
126135
}
127136
}
137+
138+
/**
139+
* Task 1.5 (remediation) — TimeCodec symmetry at the codec/JDBC boundary.
140+
*
141+
* <p>A full OMDB round-trip for {@code field.time} is blocked by a
142+
* {@code MetaField}/{@code DataConverter} limitation: {@link TimeField}'s
143+
* data type is {@code DataTypes.CUSTOM}, and {@code DataConverter.toType(CUSTOM, …)}
144+
* throws {@code IllegalStateException}. So {@code MetaField.setObject} (which
145+
* {@code TimeCodec.readInto} calls) cannot accept a {@code LocalTime} on a real
146+
* TimeField. That is unrelated to the codec's correctness.
147+
*
148+
* <p>This test therefore drives the codec's real JDBC IO directly against an
149+
* embedded-Derby {@code TIME} column: write a {@link LocalTime} via
150+
* {@link JdbcCodecs.TimeCodec#write} and read it back via
151+
* {@link JdbcCodecs.TimeCodec#readInto}, using a thin {@link TimeField} subclass
152+
* whose {@code setObject}/{@code getObject} store the value verbatim (bypassing
153+
* the unrelated CUSTOM-type {@code DataConverter} hop). This proves the codec's
154+
* {@code LocalTime → java.sql.Time → LocalTime} conversion is symmetric.
155+
*/
156+
@Test
157+
public void timeCodecIsSymmetricAtTheJdbcBoundary() throws Exception {
158+
// Verbatim-storing TimeField: bypasses DataConverter's CUSTOM-type hop so we
159+
// exercise ONLY the codec's JDBC conversion, not the documented MetaField gap.
160+
final java.util.Map<String, Object> store = new java.util.HashMap<>();
161+
TimeField timeField = new TimeField("startTime") {
162+
@Override public void setObject(Object obj, Object value) { store.put("v", value); }
163+
@Override public Object getObject(Object obj) { return store.get("v"); }
164+
};
165+
166+
JdbcCodecs.TimeCodec codec = new JdbcCodecs.TimeCodec();
167+
LocalTime original = LocalTime.of(13, 45, 30);
168+
169+
try (Connection conn = getConnection()) {
170+
try (Statement st = conn.createStatement()) {
171+
st.execute("CREATE TABLE codec_time_probe (t TIME)");
172+
}
173+
174+
// WRITE side: bind the LocalTime through TimeCodec.write.
175+
try (PreparedStatement ps = conn.prepareStatement("INSERT INTO codec_time_probe (t) VALUES (?)")) {
176+
codec.write(ps, timeField, 1, original);
177+
ps.executeUpdate();
178+
}
179+
180+
// READ side: pull it back through TimeCodec.readInto.
181+
try (Statement st = conn.createStatement();
182+
ResultSet rs = st.executeQuery("SELECT t FROM codec_time_probe")) {
183+
assertTrue("one row written", rs.next());
184+
Object target = new Object();
185+
codec.readInto(target, timeField, rs, 1);
186+
}
187+
188+
assertEquals("TimeCodec must round-trip a LocalTime symmetrically through JDBC",
189+
original, store.get("v"));
190+
191+
try (Statement st = conn.createStatement()) {
192+
st.execute("DROP TABLE codec_time_probe");
193+
}
194+
}
195+
}
128196
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
/*
2+
* Copyright 2003 Doug Mealing LLC dba Meta Objects. All Rights Reserved.
3+
*
4+
* This software is the proprietary information of Doug Mealing LLC dba Meta Objects.
5+
* Use is subject to license terms.
6+
*
7+
* Task 1.4 (remediation) — exercise the FULL getSelectStatementWhere assembly
8+
* path (not getRangeString in isolation) for OFFSET/FETCH dialects (MSSQL,
9+
* Oracle). An UNORDERED ranged query must emit the deterministic fallback
10+
* ORDER BY immediately followed by the OFFSET/FETCH range clause, in the correct
11+
* clause order. The assembled SQL is captured at prepareStatement() time via a
12+
* stub Connection, so no live database is required.
13+
*/
14+
package com.metaobjects.manager.db.driver;
15+
16+
import com.metaobjects.field.MetaField;
17+
import com.metaobjects.loader.MetaDataLoader;
18+
import com.metaobjects.manager.QueryOptions;
19+
import com.metaobjects.manager.db.ObjectManagerDB;
20+
import com.metaobjects.manager.db.ObjectMapping;
21+
import com.metaobjects.manager.db.ObjectMappingDB;
22+
import com.metaobjects.manager.db.validator.MetaClassDBValidatorService;
23+
import com.metaobjects.manager.exp.Range;
24+
import com.metaobjects.object.MetaObject;
25+
import com.metaobjects.registry.MetaDataLoaderRegistry;
26+
import com.metaobjects.registry.ServiceRegistryFactory;
27+
import org.junit.AfterClass;
28+
import org.junit.BeforeClass;
29+
import org.junit.Test;
30+
31+
import javax.sql.DataSource;
32+
import java.io.PrintWriter;
33+
import java.lang.reflect.InvocationHandler;
34+
import java.lang.reflect.Method;
35+
import java.lang.reflect.Proxy;
36+
import java.sql.Connection;
37+
import java.sql.DriverManager;
38+
import java.sql.PreparedStatement;
39+
import java.sql.SQLException;
40+
import java.sql.SQLNonTransientConnectionException;
41+
import java.util.Collection;
42+
import java.util.concurrent.atomic.AtomicReference;
43+
import java.util.logging.Logger;
44+
45+
import static org.junit.Assert.*;
46+
47+
public class SelectAssemblyRangeTest {
48+
49+
private static ObjectManagerDB omdb;
50+
private static String dbFile;
51+
private static MetaDataLoader loader;
52+
private static MetaDataLoaderRegistry registry;
53+
54+
/** Exposes the protected getReadMapping so the test can fetch the read mapping. */
55+
static class MappingExposingOMDB extends ObjectManagerDB {
56+
ObjectMapping readMappingFor(MetaObject mc) { return getReadMapping(mc); }
57+
}
58+
59+
@BeforeClass
60+
public static void setupDB() throws Exception {
61+
registry = new MetaDataLoaderRegistry(ServiceRegistryFactory.getDefault());
62+
// Reuse the codec fixture (codectest::Sample) — any concrete table-backed
63+
// entity works; we only need a valid read mapping to assemble a SELECT.
64+
loader = MetaDataLoader.fromResources("test-select-assembly", java.util.List.of("meta.codec.json"));
65+
registry.registerLoader(loader);
66+
67+
dbFile = "omb-select-assembly-" + System.currentTimeMillis();
68+
Class.forName("org.apache.derby.jdbc.EmbeddedDriver");
69+
derbyConnection().close();
70+
71+
DataSource ds = new DataSource() {
72+
@Override public Connection getConnection() throws SQLException { return derbyConnection(); }
73+
@Override public Connection getConnection(String u, String p) throws SQLException { return getConnection(); }
74+
@Override public PrintWriter getLogWriter() { return new PrintWriter(System.out); }
75+
@Override public void setLogWriter(PrintWriter out) {}
76+
@Override public void setLoginTimeout(int s) {}
77+
@Override public int getLoginTimeout() { return 100; }
78+
@Override public Logger getParentLogger() { throw new UnsupportedOperationException(); }
79+
@Override public <T> T unwrap(Class<T> iface) { throw new UnsupportedOperationException(); }
80+
@Override public boolean isWrapperFor(Class<?> iface) { return false; }
81+
};
82+
83+
MappingExposingOMDB m = new MappingExposingOMDB();
84+
// Derby driver just to bootstrap the schema / mapping. The actual assembly
85+
// under test uses MSSQL / Oracle drivers against a capturing stub Connection.
86+
m.setDatabaseDriver(new DerbyDriver());
87+
m.setDataSource(ds);
88+
m.init();
89+
omdb = m;
90+
91+
MetaClassDBValidatorService vs = new MetaClassDBValidatorService();
92+
vs.setObjectManager(omdb);
93+
vs.setAutoCreate(true);
94+
vs.setMetaDataLoaderRegistry(registry);
95+
vs.init();
96+
}
97+
98+
private static Connection derbyConnection() throws SQLException {
99+
return DriverManager.getConnection("jdbc:derby:memory:" + dbFile + ";create=true");
100+
}
101+
102+
@AfterClass
103+
public static void teardown() throws Exception {
104+
if (dbFile != null) {
105+
try { DriverManager.getConnection("jdbc:derby:memory:" + dbFile + ";drop=true"); }
106+
catch (SQLNonTransientConnectionException ignored) {}
107+
}
108+
if (loader != null) loader.destroy();
109+
}
110+
111+
/**
112+
* A Connection whose only job is to capture the SQL passed to
113+
* prepareStatement(String) and return a no-op PreparedStatement proxy. No
114+
* statement is ever executed, so dialect-specific SQL never needs a real DB.
115+
*/
116+
private static Connection capturingConnection(AtomicReference<String> sink) {
117+
InvocationHandler stmtHandler = (proxy, method, args) -> {
118+
switch (method.getName()) {
119+
case "close": return null;
120+
case "hashCode": return System.identityHashCode(proxy);
121+
case "equals": return proxy == args[0];
122+
case "toString": return "stub-PreparedStatement";
123+
default: return defaultReturn(method.getReturnType());
124+
}
125+
};
126+
PreparedStatement stubPs = (PreparedStatement) Proxy.newProxyInstance(
127+
SelectAssemblyRangeTest.class.getClassLoader(),
128+
new Class<?>[]{PreparedStatement.class}, stmtHandler);
129+
130+
InvocationHandler connHandler = (proxy, method, args) -> {
131+
if ("prepareStatement".equals(method.getName()) && args != null && args.length >= 1) {
132+
sink.set((String) args[0]);
133+
return stubPs;
134+
}
135+
switch (method.getName()) {
136+
case "close": return null;
137+
case "hashCode": return System.identityHashCode(proxy);
138+
case "equals": return proxy == args[0];
139+
case "toString": return "stub-Connection";
140+
default: return defaultReturn(method.getReturnType());
141+
}
142+
};
143+
return (Connection) Proxy.newProxyInstance(
144+
SelectAssemblyRangeTest.class.getClassLoader(),
145+
new Class<?>[]{Connection.class}, connHandler);
146+
}
147+
148+
private static Object defaultReturn(Class<?> rt) {
149+
if (rt == boolean.class) return false;
150+
if (rt == int.class) return 0;
151+
if (rt == long.class) return 0L;
152+
return null;
153+
}
154+
155+
/** Drives the full getSelectStatementWhere assembly for an unordered ranged query. */
156+
private String assembleUnorderedRangedSelect(GenericSQLDriver driver) throws Exception {
157+
driver.setManager(omdb);
158+
MetaObject mc = registry.findMetaObjectByName("codectest::Sample");
159+
ObjectMappingDB mapping = (ObjectMappingDB) ((MappingExposingOMDB) omdb).readMappingFor(mc);
160+
Collection<MetaField> fields = mapping.getMetaFields();
161+
162+
QueryOptions qo = new QueryOptions(); // NO sort order
163+
qo.setRange(11, 20); // offset 10, fetch 10
164+
165+
AtomicReference<String> sql = new AtomicReference<>();
166+
driver.getSelectStatementWhere(capturingConnection(sql), mc, mapping, fields, qo);
167+
return sql.get();
168+
}
169+
170+
@Test
171+
public void mssqlUnorderedRangeUsesFallbackOrderByThenOffsetFetch() throws Exception {
172+
String sql = assembleUnorderedRangedSelect(new MSSQLDriver());
173+
assertNotNull("SQL must have been assembled", sql);
174+
175+
String fallback = "ORDER BY (SELECT NULL)";
176+
String range = "OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY";
177+
assertTrue("must contain the MSSQL fallback ORDER BY: " + sql, sql.contains(fallback));
178+
assertTrue("must contain the OFFSET/FETCH range: " + sql, sql.contains(range));
179+
// The range clause must immediately follow the fallback ORDER BY.
180+
assertTrue("fallback ORDER BY must be immediately followed by OFFSET/FETCH: " + sql,
181+
sql.contains(fallback + " " + range));
182+
}
183+
184+
@Test
185+
public void oracleUnorderedRangeUsesFallbackOrderByThenOffsetFetch() throws Exception {
186+
String sql = assembleUnorderedRangedSelect(new OracleDriver());
187+
assertNotNull("SQL must have been assembled", sql);
188+
189+
String fallback = "ORDER BY NULL";
190+
String range = "OFFSET 10 ROWS FETCH NEXT 10 ROWS ONLY";
191+
assertTrue("must contain the Oracle fallback ORDER BY: " + sql, sql.contains(fallback));
192+
assertTrue("must contain the OFFSET/FETCH range: " + sql, sql.contains(range));
193+
assertTrue("fallback ORDER BY must be immediately followed by OFFSET/FETCH: " + sql,
194+
sql.contains(fallback + " " + range));
195+
}
196+
}

0 commit comments

Comments
 (0)