Skip to content

Commit 4259b8d

Browse files
author
Philip Z
committed
fix: align graph mutation and checker lifecycle
Closes #85. Signed-off-by: Philip Z <hello@teaql.io>
1 parent 4be6d45 commit 4259b8d

12 files changed

Lines changed: 300 additions & 41 deletions

File tree

‎pom.xml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>io.teaql</groupId>
88
<artifactId>teaql-java-parent</artifactId>
9-
<version>1.541-RELEASE</version>
9+
<version>1.542-RELEASE</version>
1010
<packaging>pom</packaging>
1111

1212
<name>teaql-java-parent</name>

‎teaql-core/src/main/java/io/teaql/core/BaseEntity.java‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,14 @@ public void __internalSet(String property, Object value) {
123123
}
124124
}
125125

126+
@FrameworkInternal("Mutation transaction rollback only")
127+
public void __internalRestorePersistenceState(
128+
Long restoredVersion, EntityStatus restoredStatus, boolean versionWasLoaded) {
129+
this.version = restoredVersion;
130+
this.$status = restoredStatus;
131+
restorePropertyLoaded(VERSION_PROPERTY, versionWasLoaded);
132+
}
133+
126134
@FrameworkInternal("Business code should use typed getXxx() methods")
127135
public Object __internalGet(String property) {
128136
// First try to get from entityMutationLedger if available
@@ -393,6 +401,17 @@ public void __internalHydrate(String propertyName, Object value, int loadedPrope
393401
hydratingProperty = false;
394402
}
395403
markPropertyLoaded(loadedPropertyIndex, propertyName);
404+
captureHydratedOriginalVersion();
405+
}
406+
407+
/**
408+
* Hydration may assign {@code id} and {@code version} in either column order.
409+
* Once both are available, retain the authoritative database version in the
410+
* entity-owned Mutation Ledger without creating a business mutation.
411+
*/
412+
private void captureHydratedOriginalVersion() {
413+
if (entityMutationLedger == null || id == null || version == null) return;
414+
entityMutationLedger.setOriginalVersion(new EntityKey(typeName(), id), version);
396415
}
397416

398417
private void markPropertyLoaded(int index, String propertyName) {
@@ -404,6 +423,28 @@ private void markPropertyLoaded(int index, String propertyName) {
404423
overflowLoadedProperties.add(propertyName);
405424
}
406425

426+
private void restorePropertyLoaded(String propertyName, boolean loaded) {
427+
Integer index = LOADED_PROPERTY_LAYOUTS.get(getClass()).find(propertyName);
428+
if (index == null) {
429+
if (loaded) {
430+
if (overflowLoadedProperties == null) overflowLoadedProperties = new java.util.HashSet<>();
431+
overflowLoadedProperties.add(propertyName);
432+
} else if (overflowLoadedProperties != null) {
433+
overflowLoadedProperties.remove(propertyName);
434+
}
435+
return;
436+
}
437+
if (index < Long.SIZE) {
438+
if (loaded) loadedPropertyBits |= 1L << index;
439+
else loadedPropertyBits &= ~(1L << index);
440+
} else if (loaded) {
441+
if (overflowLoadedProperties == null) overflowLoadedProperties = new java.util.HashSet<>();
442+
overflowLoadedProperties.add(propertyName);
443+
} else if (overflowLoadedProperties != null) {
444+
overflowLoadedProperties.remove(propertyName);
445+
}
446+
}
447+
407448
private static final class LoadedPropertyLayout {
408449
private final ConcurrentHashMap<String, Integer> indexes = new ConcurrentHashMap<>();
409450
private final java.util.concurrent.atomic.AtomicInteger nextIndex =

‎teaql-core/src/main/java/io/teaql/core/UserContext.java‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import java.util.List;
66
import io.teaql.data.dynamic.DynamicFieldsFacade;
77
import io.teaql.core.checker.CheckResult;
8+
import io.teaql.core.checker.FixEvidence;
89
import io.teaql.core.i18n.I18nCatalog;
910
import io.teaql.core.i18n.Locale;
1011

@@ -13,6 +14,35 @@ public interface UserContext extends OptNullBasicTypeFromObjectGetter<String> {
1314
String TEAQL_LOCALE = "teaql.locale";
1415
String TEAQL_I18N_CATALOG = "teaql.i18n.catalog";
1516
String TEAQL_ACTIVE_ROOT = "teaql.active.root";
17+
String TEAQL_FIX_EVIDENCE_CURRENT = "teaql.fix.evidence.current";
18+
String TEAQL_FIX_EVIDENCE_LAST = "teaql.fix.evidence.last";
19+
20+
default void beginFixEvidence() {
21+
putAttribute(TEAQL_FIX_EVIDENCE_CURRENT, new java.util.ArrayList<FixEvidence>());
22+
}
23+
24+
@SuppressWarnings("unchecked")
25+
default void recordFixEvidence(FixEvidence evidence) {
26+
List<FixEvidence> current = (List<FixEvidence>) getAttribute(TEAQL_FIX_EVIDENCE_CURRENT);
27+
if (current == null) {
28+
current = new java.util.ArrayList<>();
29+
putAttribute(TEAQL_FIX_EVIDENCE_CURRENT, current);
30+
}
31+
current.add(evidence);
32+
}
33+
34+
@SuppressWarnings("unchecked")
35+
default void finishFixEvidence() {
36+
List<FixEvidence> current = (List<FixEvidence>) getAttribute(TEAQL_FIX_EVIDENCE_CURRENT);
37+
putAttribute(TEAQL_FIX_EVIDENCE_LAST, current == null ? List.of() : List.copyOf(current));
38+
putAttribute(TEAQL_FIX_EVIDENCE_CURRENT, null);
39+
}
40+
41+
@SuppressWarnings("unchecked")
42+
default List<FixEvidence> lastFixEvidence() {
43+
List<FixEvidence> evidence = (List<FixEvidence>) getAttribute(TEAQL_FIX_EVIDENCE_LAST);
44+
return evidence == null ? List.of() : evidence;
45+
}
1646

1747
default void ensureSchema() {
1848
SchemaExecutor schema = capability(SchemaExecutor.class);

‎teaql-core/src/main/java/io/teaql/core/checker/CheckResult.java‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ public static CheckResult required(ObjectLocation location) {
2020
return checkResult;
2121
}
2222

23+
public static CheckResult invalidType(ObjectLocation location, String message) {
24+
CheckResult checkResult = new CheckResult();
25+
checkResult.setLocation(location);
26+
checkResult.setRuleId(RuleId.INVALID_TYPE);
27+
checkResult.setNaturalLanguageStatement(message);
28+
return checkResult;
29+
}
30+
2331
public static CheckResult min(ObjectLocation location, Number minNumber, Number current) {
2432
CheckResult checkResult = new CheckResult();
2533
checkResult.setLocation(location);
@@ -143,6 +151,7 @@ public enum RuleId {
143151
MAX_STR_LEN,
144152
MIN_DATE,
145153
MAX_DATE,
146-
REQUIRED
154+
REQUIRED,
155+
INVALID_TYPE
147156
}
148157
}

‎teaql-core/src/main/java/io/teaql/core/checker/Checker.java‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ default void requiredCheck(UserContext context, ObjectLocation location, Object
6565
}
6666
}
6767

68+
default void invalidTypeCheck(UserContext context, ObjectLocation location, String message) {
69+
CheckResult result = CheckResult.invalidType(location, message);
70+
appendResult(context, result);
71+
result.setNaturalLanguageStatement(message);
72+
}
73+
6874
default void minNumberCheck(
6975
UserContext context, ObjectLocation location, Number minNumber, Number current) {
7076
if (NumberUtil.isLess(NumberUtil.toBigDecimal(current), NumberUtil.toBigDecimal(minNumber))) {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.teaql.core.checker;
2+
3+
/** Safe, value-free provenance for one framework-managed Fix. */
4+
public record FixEvidence(
5+
String entityType,
6+
String modelPath,
7+
Source source,
8+
String sourceLabel) {
9+
10+
public enum Source { CLOCK, CONTEXT }
11+
12+
public FixEvidence {
13+
if (entityType == null || entityType.isBlank()) {
14+
throw new IllegalArgumentException("entityType must not be blank");
15+
}
16+
if (modelPath == null || modelPath.isBlank()) {
17+
throw new IllegalArgumentException("modelPath must not be blank");
18+
}
19+
if (source == null) {
20+
throw new IllegalArgumentException("source must not be null");
21+
}
22+
if (sourceLabel == null || sourceLabel.isBlank()) {
23+
throw new IllegalArgumentException("sourceLabel must not be blank");
24+
}
25+
String normalized = sourceLabel.toLowerCase(java.util.Locale.ROOT);
26+
if (normalized.contains("authorization") || normalized.contains("cookie")
27+
|| normalized.contains("token=")) {
28+
throw new IllegalArgumentException("sourceLabel must be a safe framework label");
29+
}
30+
}
31+
}

‎teaql-core/src/test/java/io/teaql/core/BaseEntityTest.java‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,27 @@ public void testCompiledHydrationMarksLoadedWithoutRecordingAnUpdate() {
225225
assertTrue(entity.isPropertyLoaded("name"));
226226
assertTrue(entity.getUpdatedProperties().isEmpty());
227227
}
228+
229+
@Test
230+
public void testCompiledHydrationCapturesOriginalVersionInEitherColumnOrder() {
231+
TestEntity idThenVersion = new TestEntity();
232+
idThenVersion.__internalHydrate(
233+
"id", 41L, BaseEntity.loadedPropertyIndex(TestEntity.class, "id"));
234+
idThenVersion.__internalHydrate(
235+
"version", 7L, BaseEntity.loadedPropertyIndex(TestEntity.class, "version"));
236+
237+
assertEquals(Long.valueOf(7L), idThenVersion.getOriginalVersion());
238+
assertTrue(idThenVersion.getUpdatedProperties().isEmpty());
239+
240+
TestEntity versionThenId = new TestEntity();
241+
versionThenId.__internalHydrate(
242+
"version", 9L, BaseEntity.loadedPropertyIndex(TestEntity.class, "version"));
243+
versionThenId.__internalHydrate(
244+
"id", 42L, BaseEntity.loadedPropertyIndex(TestEntity.class, "id"));
245+
246+
assertEquals(Long.valueOf(9L), versionThenId.getOriginalVersion());
247+
assertTrue(versionThenId.getUpdatedProperties().isEmpty());
248+
}
228249

229250
@Test
230251
public void testActionList() {

0 commit comments

Comments
 (0)