Skip to content

Commit bbf51f2

Browse files
dmealingclaude
andcommitted
feat(#208): Java @sql/@Unmanaged accessors + loader validation
Task 7 — the Java port of the DDL-ownership escape-valve validation, mirroring the TS reference (validate-source-escapes.ts). - MetaSource.java: resolving getSqlBody() (String, empty→null) + isUnmanaged() (boolean) accessors (ADR-0039 resolving, matching getEffectiveKind()/getRole(); read RdbSource.ATTR_SQL/ATTR_UNMANAGED registered in Task 1). - ValidationPhase.java: validateSourceEscapes implementing the six fail-closed rules R1–R6, wired right after validateOnePrimarySource (the source-roles pass). Same codes as TS: ERR_SQL_BODY_WITH_UNMANAGED, ERR_SQL_BODY_ON_WRITABLE_KIND, ERR_BAD_ATTR_VALUE (R3), ERR_ORIGIN_UNDER_SQL_BODY (R4+R5), WARN_ORIGIN_UNDER_UNMANAGED (R6, envelope warning). R4 (error) beats R6 (warn); R3 reads the raw attr. Fail-fast throw wrapped by pass() — the Java loader idiom. - ErrorCode.java + ErrorMessageConstants.java: the three new codes + the WARN code. - Issue208SourceEscapeValidationTest.java: 8 TDD tests (5 errors + R6 warn + a positive @SQL view projection), confirmed red before implementing. Kotlin rides Java's JVM registry (no separate change). Registration was Task 1; migrate/verify lowering is TS-only (ADR-0015). Scoped test 8/8; full `mvn -pl metadata test` 1183/1183. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 828b6b8 commit bbf51f2

5 files changed

Lines changed: 506 additions & 0 deletions

File tree

server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,32 @@ public enum ErrorCode {
382382
*/
383383
ERR_COMPUTED_TYPE_MISMATCH,
384384

385+
/**
386+
* #208 (DDL-ownership escape valves, design doc §5 R1): a {@code source.rdb}
387+
* declares both {@code @sql} and {@code @unmanaged} — the two mutually
388+
* exclusive non-default states of one DDL-ownership axis (an author-supplied
389+
* body contradicts "someone else owns this DDL").
390+
*/
391+
ERR_SQL_BODY_WITH_UNMANAGED,
392+
393+
/**
394+
* #208 (design doc §5 R2): a {@code source.rdb} declares {@code @sql} with a
395+
* writable {@code @kind} ("table", the default) — {@code @sql} is legal only
396+
* on a read-only kind (view/materializedView/storedProc/tableFunction); a
397+
* writable table is either fully modeled or marked {@code @unmanaged}, never
398+
* opaque-bodied.
399+
*/
400+
ERR_SQL_BODY_ON_WRITABLE_KIND,
401+
402+
/**
403+
* #208 (design doc §5 R4/R5): a field carrying an {@code origin.*} (derived)
404+
* child — or, for an {@code object.projection}, a row-scope {@code @filter}
405+
* (#207) — lives under a host object that declares an {@code @sql} read
406+
* source. The synthesized derivation/filter and the author's verbatim SQL
407+
* body are two sources of truth for the same data.
408+
*/
409+
ERR_ORIGIN_UNDER_SQL_BODY,
410+
385411
/** An internal loader error with no stable error code. */
386412
ERR_UNKNOWN,
387413
}

server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ public static void run(MetaRoot root, MetaDataLoader loader) {
179179
// FR-015 — source.rdb @parameterRef typed-input rules.
180180
pass(collected, () -> validateSourceParameterRef(root));
181181
pass(collected, () -> validateOnePrimarySource(root));
182+
// #208 — DDL-ownership escape valves (@sql/@unmanaged on source.rdb).
183+
// Sibling of the source-role pass above; must run after it.
184+
pass(collected, () -> validateSourceEscapes(root, loader));
182185
pass(collected, () -> validateRelationshipReferentialActions(root));
183186
pass(collected, () -> validateRelationshipsM2M(root));
184187
// ADR-0042 — the cross-package ambiguity pass (ERR_AMBIGUOUS_REF) is RETIRED. A bare
@@ -1063,6 +1066,142 @@ private static void validateObjectPrimarySource(MetaObject obj) {
10631066
}
10641067
}
10651068

1069+
// =========================================================================
1070+
// #208 — DDL-ownership escape valves (@sql / @unmanaged on source.rdb).
1071+
//
1072+
// source.rdb has two mutually exclusive, non-default DDL-ownership markers:
1073+
// @sql (a hand-written body the tool registers/fingerprints/drift-checks but
1074+
// never authors or parses) and @unmanaged (this object's DDL is owned
1075+
// elsewhere — the tool never touches it). Six fail-closed rules (design doc §5):
1076+
//
1077+
// R1 @sql AND @unmanaged on the SAME source → ERR_SQL_BODY_WITH_UNMANAGED
1078+
// R2 @sql on a writable @kind ("table", the default) → ERR_SQL_BODY_ON_WRITABLE_KIND
1079+
// R3 @sql present but empty / whitespace-only → ERR_BAD_ATTR_VALUE
1080+
// R4 origin.*-bearing own field under an @sql host → ERR_ORIGIN_UNDER_SQL_BODY
1081+
// R5 object.projection @filter (#207) + @sql host → ERR_ORIGIN_UNDER_SQL_BODY
1082+
// R6 origin.*-bearing own field under an @unmanaged host → WARN_ORIGIN_UNDER_UNMANAGED
1083+
//
1084+
// R1–R3 are per-source (declaration-layer, own-only). R4–R6 are per-host-object:
1085+
// a host with ANY own source.rdb carrying @sql/@unmanaged is judged against its
1086+
// own fields (and, for R5, its own @filter). @sql (R4, hard error) takes priority
1087+
// over @unmanaged (R6, warn) when a host declares both markers across different
1088+
// sources. Mirrors TS validate-source-escapes.ts.
1089+
// =========================================================================
1090+
1091+
static void validateSourceEscapes(MetaRoot root, MetaDataLoader loader) {
1092+
for (MetaData rootChild : root.getChildren(MetaData.class, false)) {
1093+
if (!(rootChild instanceof MetaObject)) continue;
1094+
validateObjectSourceEscapes((MetaObject) rootChild, loader);
1095+
}
1096+
}
1097+
1098+
private static void validateObjectSourceEscapes(MetaObject obj, MetaDataLoader loader) {
1099+
// ADR-0039: own — declaration-layer source iteration (mirrors
1100+
// validateObjectSourcePhysicalNames / validateObjectPrimarySource): R1–R3
1101+
// judge markers DECLARED on this object's own sources.
1102+
List<RdbSource> sources = obj.getChildren(RdbSource.class, false);
1103+
1104+
boolean hasSqlHost = false;
1105+
boolean hasUnmanagedHost = false;
1106+
1107+
for (RdbSource source : sources) {
1108+
// ADR-0039: resolving — @sql/@unmanaged are inheritable (follow the
1109+
// @role/effectiveKind precedent — sources are inheritable, NOT the
1110+
// @dbColumnType own-only exception).
1111+
boolean sqlSet = source.getSqlBody() != null;
1112+
boolean unmanagedSet = source.isUnmanaged();
1113+
1114+
// R1 — contradictory DDL owners on the same source.
1115+
if (sqlSet && unmanagedSet) {
1116+
throw new MetaDataException(
1117+
ErrorMessageConstants.ERR_SQL_BODY_WITH_UNMANAGED
1118+
+ ": source.rdb on object \"" + obj.getName()
1119+
+ "\" declares both @sql and @unmanaged — these are the mutually "
1120+
+ "exclusive non-default states of one DDL-ownership axis (an "
1121+
+ "author-supplied body contradicts \"someone else owns this DDL\")",
1122+
ErrorCode.ERR_SQL_BODY_WITH_UNMANAGED, source.getSource());
1123+
}
1124+
1125+
// R2 — @sql on a writable kind would bypass the column-diff machinery;
1126+
// tables are fully modeled or @unmanaged, never opaque-bodied.
1127+
if (sqlSet && source.isWritable()) {
1128+
throw new MetaDataException(
1129+
ErrorMessageConstants.ERR_SQL_BODY_ON_WRITABLE_KIND
1130+
+ ": source.rdb on object \"" + obj.getName()
1131+
+ "\" declares @sql with a writable @kind (\"" + source.getEffectiveKind()
1132+
+ "\") — @sql is legal only on a read-only kind "
1133+
+ "(view/materializedView/storedProc/tableFunction); a writable table is "
1134+
+ "either fully modeled or marked @unmanaged, never opaque-bodied",
1135+
ErrorCode.ERR_SQL_BODY_ON_WRITABLE_KIND, source.getSource());
1136+
}
1137+
1138+
// R3 — @sql present but empty/whitespace. MUST read the raw attr, not
1139+
// getSqlBody(): getSqlBody() already narrows an empty string to null,
1140+
// which would make a present-but-empty @sql indistinguishable from an
1141+
// absent one.
1142+
if (source.hasMetaAttr(RdbSource.ATTR_SQL)) {
1143+
String rawSql = source.getMetaAttr(RdbSource.ATTR_SQL).getValueAsString();
1144+
if (rawSql == null || rawSql.trim().isEmpty()) {
1145+
throw new MetaDataException(
1146+
ErrorMessageConstants.ERR_BAD_ATTR_VALUE
1147+
+ ": source.rdb on object \"" + obj.getName()
1148+
+ "\" sets @sql to an empty/whitespace-only value; @sql requires a "
1149+
+ "non-empty SQL body",
1150+
ErrorCode.ERR_BAD_ATTR_VALUE, source.getSource());
1151+
}
1152+
}
1153+
1154+
if (sqlSet) hasSqlHost = true;
1155+
if (unmanagedSet) hasUnmanagedHost = true;
1156+
}
1157+
1158+
if (!hasSqlHost && !hasUnmanagedHost) return;
1159+
1160+
// R4 / R6 — origin.*-bearing (derived) own fields under an @sql / @unmanaged
1161+
// host. ADR-0039: own — origin.* never inherits (ADR-0029), so isDerived()
1162+
// is own-only by policy (mirrors validateDerivedFieldProvidability). @sql
1163+
// (hard error) takes priority over @unmanaged (warn) when a host happens to
1164+
// declare both markers across different sources.
1165+
for (MetaField<?> field : obj.getChildren(MetaField.class, false)) {
1166+
if (!field.isDerived()) continue;
1167+
if (hasSqlHost) {
1168+
throw new MetaDataException(
1169+
ErrorMessageConstants.ERR_ORIGIN_UNDER_SQL_BODY
1170+
+ ": field \"" + obj.getName() + "." + field.getName()
1171+
+ "\" carries an origin.* (derived) child, but \"" + obj.getName()
1172+
+ "\" has a read source carrying @sql — the synthesized derivation and "
1173+
+ "the author's verbatim SQL are two sources of truth for the same body",
1174+
ErrorCode.ERR_ORIGIN_UNDER_SQL_BODY, field.getSource());
1175+
} else if (loader != null) {
1176+
loader.addEnvelopeWarning(new LoaderWarning(
1177+
ErrorMessageConstants.WARN_ORIGIN_UNDER_UNMANAGED,
1178+
"field \"" + obj.getName() + "." + field.getName()
1179+
+ "\" carries an origin.* (derived) child, but \"" + obj.getName()
1180+
+ "\" has a source marked @unmanaged — the tool never touches this "
1181+
+ "object's DDL, so the derivation is documented lineage only (not "
1182+
+ "acted on); this is informational, not an error",
1183+
field.getSource()));
1184+
}
1185+
}
1186+
1187+
// R5 — a projection's row-scope @filter (#207) lowers to the outer WHERE of
1188+
// a TOOL-SYNTHESIZED body; with @sql the author owns the body (and its
1189+
// WHERE), so wrapping it is deferred cleverness (design doc D5) — reject.
1190+
if (hasSqlHost && MetaObject.SUBTYPE_PROJECTION.equals(obj.getSubType())) {
1191+
// ADR-0039: own — the @filter is declared locally on this projection
1192+
// (mirrors validateProjectionFilter).
1193+
if (obj.hasMetaAttr(MetaObject.ATTR_FILTER, false)) {
1194+
throw new MetaDataException(
1195+
ErrorMessageConstants.ERR_ORIGIN_UNDER_SQL_BODY
1196+
+ ": projection \"" + obj.getName()
1197+
+ "\" declares both @filter and an @sql read source — a view-level "
1198+
+ "@filter lowers to the outer WHERE of a synthesized body; with @sql "
1199+
+ "the author owns the body (and its WHERE), so the two cannot be combined",
1200+
ErrorCode.ERR_ORIGIN_UNDER_SQL_BODY, obj.getSource());
1201+
}
1202+
}
1203+
}
1204+
10661205
// =========================================================================
10671206
// Relationship @onDelete / @onUpdate enum validation
10681207
//

server/java/metadata/src/main/java/com/metaobjects/source/MetaSource.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,37 @@ public String getSchema() {
231231
: null;
232232
}
233233

234+
/**
235+
* FR-024/#208 — the hand-written SQL body for this source's DB object, when
236+
* declared via {@code @sql}, narrowed to a non-empty string (empty string →
237+
* {@code null}, treated as absent; a whitespace-only value is still "set" —
238+
* loader validation independently rejects it via {@code ERR_BAD_ATTR_VALUE}).
239+
* The tool registers + fingerprints + drift-checks this body but never
240+
* authors or parses it. {@code @sql} is registered on the concrete
241+
* {@code source.rdb} subtype ({@link RdbSource#ATTR_SQL}).
242+
*/
243+
public String getSqlBody() {
244+
// ADR-0039: resolving — an inherited source's @sql lives on the super node
245+
// (matches getEffectiveKind()/getRole()/getTableName() above).
246+
if (!hasMetaAttr(RdbSource.ATTR_SQL)) return null;
247+
String v = getMetaAttr(RdbSource.ATTR_SQL).getValueAsString();
248+
return (v != null && !v.isEmpty()) ? v : null;
249+
}
250+
251+
/**
252+
* FR-024/#208 — {@code true} when this source's DB object is managed
253+
* elsewhere (Flyway / a hand-migration owns its DDL); {@code meta migrate}
254+
* does not create/drop/drift-check it. {@code @unmanaged} is registered on
255+
* the concrete {@code source.rdb} subtype ({@link RdbSource#ATTR_UNMANAGED}).
256+
*/
257+
public boolean isUnmanaged() {
258+
// ADR-0039: resolving — an inherited source's @unmanaged lives on the
259+
// super node (matches getEffectiveKind()/getRole()/getTableName() above).
260+
if (!hasMetaAttr(RdbSource.ATTR_UNMANAGED)) return false;
261+
Object v = getMetaAttr(RdbSource.ATTR_UNMANAGED).getValue();
262+
return Boolean.TRUE.equals(v);
263+
}
264+
234265
/**
235266
* FR-016 / ADR-0018: resolved physical SQL name for this source, following
236267
* the four-step rule:

server/java/metadata/src/main/java/com/metaobjects/util/ErrorMessageConstants.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,38 @@ private ErrorMessageConstants() {
223223
*/
224224
public static final String ERR_INVALID_INDEX = "ERR_INVALID_INDEX";
225225

226+
/**
227+
* #208 (DDL-ownership escape valves, design doc §5 R1): a {@code source.rdb}
228+
* declares both {@code @sql} and {@code @unmanaged} on the same source.
229+
* Cross-language contract: {@code ERR_SQL_BODY_WITH_UNMANAGED}.
230+
*/
231+
public static final String ERR_SQL_BODY_WITH_UNMANAGED = "ERR_SQL_BODY_WITH_UNMANAGED";
232+
233+
/**
234+
* #208 (design doc §5 R2): a {@code source.rdb} declares {@code @sql} with a
235+
* writable {@code @kind} ("table", the default) — {@code @sql} is legal only
236+
* on a read-only kind.
237+
* Cross-language contract: {@code ERR_SQL_BODY_ON_WRITABLE_KIND}.
238+
*/
239+
public static final String ERR_SQL_BODY_ON_WRITABLE_KIND = "ERR_SQL_BODY_ON_WRITABLE_KIND";
240+
241+
/**
242+
* #208 (design doc §5 R4/R5): an {@code origin.*}-bearing (derived) own field,
243+
* or an {@code object.projection}'s row-scope {@code @filter} (#207), lives
244+
* under a host object that declares an {@code @sql} read source.
245+
* Cross-language contract: {@code ERR_ORIGIN_UNDER_SQL_BODY}.
246+
*/
247+
public static final String ERR_ORIGIN_UNDER_SQL_BODY = "ERR_ORIGIN_UNDER_SQL_BODY";
248+
249+
/**
250+
* #208 (design doc §5 R6) warning: an {@code origin.*}-bearing (derived) own
251+
* field lives under a host object that declares an {@code @unmanaged} source.
252+
* {@code @unmanaged} acts on nothing, so documented-but-unacted-on lineage is
253+
* benign — informational only, never an error.
254+
* Cross-language contract: {@code WARN_ORIGIN_UNDER_UNMANAGED}.
255+
*/
256+
public static final String WARN_ORIGIN_UNDER_UNMANAGED = "WARN_ORIGIN_UNDER_UNMANAGED";
257+
226258
// === ERROR MESSAGE FORMATS ===
227259

228260
/** Format template for not found errors */

0 commit comments

Comments
 (0)