indexes = new ConcurrentHashMap<>();
- private final java.util.concurrent.atomic.AtomicInteger nextIndex =
- new java.util.concurrent.atomic.AtomicInteger();
-
- int index(String propertyName) {
- return indexes.computeIfAbsent(propertyName, ignored -> nextIndex.getAndIncrement());
- }
-
- Integer find(String propertyName) {
- return indexes.get(propertyName);
- }
+ loadState = loadState.withLoaded(propertyName, loaded);
}
@Override
@@ -544,9 +550,8 @@ public P getProperty(String propertyName) {
if (o != null) {
return (P) o;
}
- Object dynamicProperty = this.additionalInfo.get(dynamicPropertyNameOf(propertyName));
- if (dynamicProperty != null) {
- return (P) dynamicProperty;
+ if (propertyName != null && (propertyName.startsWith("_") || propertyName.startsWith("#"))) {
+ return (P) additionalInfo.get(propertyName);
}
return Entity.super.getProperty(propertyName);
}
@@ -559,12 +564,13 @@ public void handleUpdate(String propertyName, Object oldValue, Object newValue)
oldValue = propertyChange.getOldValue();
}
if (ObjectUtil.equals(oldValue, newValue)) {
- updatedProperties.remove(propertyName);
+ if (!updatedProperties.isEmpty()) updatedProperties.remove(propertyName);
return;
}
- updatedProperties.put(propertyName, new PropertyChange(propertyName, oldValue, newValue));
+ mutableUpdatedProperties().put(propertyName, new PropertyChange(propertyName, oldValue, newValue));
- if (entityMutationLedger != null && id != null) {
+ if (id != null) {
+ EntityMutationLedger entityMutationLedger = getEntityMutationLedger();
EntityKey key = new EntityKey(typeName(), id);
entityMutationLedger.set(key, propertyName, newValue);
if (!_traceChain.isEmpty()) {
@@ -573,13 +579,19 @@ public void handleUpdate(String propertyName, Object oldValue, Object newValue)
}
}
+ private Map mutableUpdatedProperties() {
+ if (updatedProperties == NO_UPDATES) updatedProperties = new ConcurrentHashMap<>();
+ return updatedProperties;
+ }
+
public void gotoNextStatus(EntityAction action) {
set$status(get$status().next(action));
}
public void cacheRelation(String relationName, Entity relation) {
markPropertyLoaded(relationName);
- this.relationCache.put(relationName, relation);
+ if (relationCache.isEmpty()) relationCache = new HashMap<>();
+ relationCache.put(relationName, relation);
Object initValue = getProperty(relationName);
handleUpdate(relationName, initValue, relation);
}
@@ -615,17 +627,85 @@ public int hashCode() {
}
public Map getAdditionalInfo() {
- return additionalInfo;
+ return mutableAdditionalInfo();
}
public void setAdditionalInfo(Map additionalInfo) {
- this.additionalInfo = additionalInfo;
+ this.additionalInfo = additionalInfo == null || additionalInfo.isEmpty()
+ ? Map.of() : new HashMap<>(additionalInfo);
+ }
+
+ private Map mutableAdditionalInfo() {
+ if (additionalInfo.isEmpty()) additionalInfo = new HashMap<>();
+ return additionalInfo;
+ }
+
+ @Override
+ public DynamicFieldValues dynamicFields() {
+ return dynamicFieldValues == null ? collectDynamicFieldValues() : dynamicFieldValues;
}
public DynamicFieldValues getDynamicFieldValues() {
return dynamicFieldValues;
}
+ public BaseEntity updateDynamicField(String code, Object value) {
+ if (dynamicFieldValues == null) throw new io.teaql.data.dynamic.DynamicFieldException("DYNAMIC_FIELD_DEFINITIONS_MISSING", "Load dynamic field definitions before editing");
+ var mutation = io.teaql.data.dynamic.DynamicFieldMutation.set(code, dynamicFieldValues.metadata().requireType(code), value);
+ stageDynamicMutation(mutation);
+ return this;
+ }
+
+ public BaseEntity deleteDynamicField(String code) {
+ if (dynamicFieldValues == null) throw new io.teaql.data.dynamic.DynamicFieldException("DYNAMIC_FIELD_DEFINITIONS_MISSING", "Load dynamic field definitions before editing");
+ stageDynamicMutation(io.teaql.data.dynamic.DynamicFieldMutation.delete(code, dynamicFieldValues.metadata().requireType(code)));
+ return this;
+ }
+
+ private void stageDynamicMutation(io.teaql.data.dynamic.DynamicFieldMutation mutation) {
+ EntityStatus nextStatus = get$status().next(EntityAction.UPDATE);
+ Map next = new HashMap<>(dynamicFieldValues.toMap());
+ if (mutation.kind() == io.teaql.data.dynamic.DynamicFieldMutation.Kind.SET) next.put(mutation.code(), mutation.asLoadedValue());
+ else next.remove(mutation.code());
+ DynamicFieldValues values = new DynamicFieldValues(dynamicFieldValues.metadata(), next);
+ LoadState state = loadState.withDynamicSelection(values.selectedCodes());
+ if (dynamicOriginalValues.isEmpty()) dynamicOriginalValues = new HashMap<>();
+ dynamicOriginalValues.putIfAbsent(mutation.code(), dynamicFieldValues.field(mutation.code()));
+ __internalHydrateDynamicFields(values, state);
+ if (dynamicMutations.isEmpty()) dynamicMutations = new HashMap<>();
+ dynamicMutations.put(mutation.code(), mutation);
+ set$status(nextStatus);
+ if (id != null) getEntityMutationLedger().setDynamic(new EntityKey(typeName(), id), mutation);
+ }
+
+ @FrameworkInternal("Graph mutation planning only")
+ public Map __internalDynamicMutations() {
+ return dynamicMutations.isEmpty() ? Map.of() : java.util.Collections.unmodifiableMap(dynamicMutations);
+ }
+
+ @FrameworkInternal("Invocation-local audit and log privacy provenance only")
+ public Map __internalDynamicOriginalValues() {
+ return dynamicOriginalValues.isEmpty() ? Map.of() : java.util.Collections.unmodifiableMap(dynamicOriginalValues);
+ }
+
+ @FrameworkInternal("Successful graph commit only")
+ public void __internalClearDynamicMutations() { dynamicMutations = Map.of(); dynamicOriginalValues = Map.of(); }
+
+ @FrameworkInternal("Runtime optimistic guard for an extension-only update")
+ public void __internalRequireVersionUpdate() {
+ if (version == null) throw new TeaQLRuntimeException("Dynamic update requires a loaded optimistic version");
+ mutableUpdatedProperties().put(VERSION_PROPERTY, new PropertyChange(VERSION_PROPERTY, version, version));
+ }
+
+ @FrameworkInternal("Replay validated ledger intent into a provider-owned mutation entity")
+ public void __internalApplyRecordedMutation(String property, Object value) {
+ Object before = __internalGet(property);
+ __internalSet(property, value);
+ // The blank provider entity is not the original loaded snapshot. Equal
+ // defaults here must not discard an explicit NULL/zero/false intent.
+ mutableUpdatedProperties().put(property, new PropertyChange(property, before, value));
+ }
+
public DynamicFieldValues collectDynamicFieldValues() {
List fields = new ArrayList<>();
for (Map.Entry entry : additionalInfo.entrySet()) {
@@ -655,21 +735,45 @@ public DynamicFieldValues collectDynamicFieldValues() {
}
public void setDynamicFieldValues(DynamicFieldValues values) {
+ // Hydration replaces this view's selection, not stored values or mutation intent.
+ if (!additionalInfo.isEmpty()) {
+ for (String key : new ArrayList<>(additionalInfo.keySet())) {
+ if (key.startsWith("#")) {
+ additionalInfo.remove(key);
+ loadState = loadState.withLoaded(key, false);
+ }
+ }
+ }
this.dynamicFieldValues = values;
if (values != null) {
for (Map.Entry entry : values.toMap().entrySet()) {
String key = "#" + entry.getKey();
- Object val = entry.getValue().value();
- additionalInfo.put(key, val);
+ if (!entry.getValue().isLoaded()) continue;
+ mutableAdditionalInfo().put(key, entry.getValue().value());
+ loadState = loadState.withLoaded(key, true);
}
}
}
+ /** Bulk hydration supplies a shared final selection; values and wrappers stay row-owned. */
+ @FrameworkInternal("Dynamic-field query hydration only")
+ public void __internalHydrateDynamicFields(DynamicFieldValues values, LoadState sharedState) {
+ __internalUseLoadState(sharedState);
+ if (!additionalInfo.isEmpty()) additionalInfo.keySet().removeIf(key -> key.startsWith("#"));
+ dynamicFieldValues = values;
+ for (Map.Entry entry : values.toMap().entrySet()) {
+ if (entry.getValue().isLoaded()) mutableAdditionalInfo().put("#" + entry.getKey(), entry.getValue().value());
+ }
+ }
+
/**
* Put additional property directly without prefix.
* Used by deserializers to populate entity fields.
*/
public void putAdditional(String propertyName, Object value) {
- additionalInfo.put(propertyName, value);
+ mutableAdditionalInfo().put(propertyName, value);
+ if (propertyName != null && propertyName.startsWith("#")) {
+ loadState = loadState.withLoaded(propertyName, true);
+ }
}
}
diff --git a/teaql-core/src/main/java/io/teaql/core/BaseRequest.java b/teaql-core/src/main/java/io/teaql/core/BaseRequest.java
index aebc5d16..98abe986 100644
--- a/teaql-core/src/main/java/io/teaql/core/BaseRequest.java
+++ b/teaql-core/src/main/java/io/teaql/core/BaseRequest.java
@@ -31,6 +31,10 @@ public abstract class BaseRequest implements SearchRequest
protected String comment;
protected String purpose;
+ protected DynamicPropertyMetadata dynamicPropertyMetadata;
+
+ @Override public DynamicPropertyMetadata getDynamicPropertyMetadata() { return dynamicPropertyMetadata; }
+ public void setDynamicPropertyMetadata(DynamicPropertyMetadata metadata) { dynamicPropertyMetadata = metadata; }
// select properties
protected List projections = new ArrayList<>();
@@ -925,6 +929,7 @@ public boolean equals(Object pO) {
if (!(pO instanceof BaseRequest> that)) return false;
return Objects.equals(getProjections(), that.getProjections())
&& Objects.equals(getSimpleDynamicProperties(), that.getSimpleDynamicProperties())
+ && Objects.equals(getDynamicPropertyMetadata(), that.getDynamicPropertyMetadata())
&& Objects.equals(getSearchCriteria(), that.getSearchCriteria())
&& Objects.equals(orderBys, that.orderBys)
&& Objects.equals(getSlice(), that.getSlice())
@@ -946,6 +951,7 @@ public int hashCode() {
return Objects.hash(
getProjections(),
getSimpleDynamicProperties(),
+ getDynamicPropertyMetadata(),
getSearchCriteria(),
orderBys,
getSlice(),
diff --git a/teaql-core/src/main/java/io/teaql/core/DynamicPropertyMetadata.java b/teaql-core/src/main/java/io/teaql/core/DynamicPropertyMetadata.java
new file mode 100644
index 00000000..73273896
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/DynamicPropertyMetadata.java
@@ -0,0 +1,41 @@
+package io.teaql.core;
+
+import java.util.Map;
+import java.util.Objects;
+
+/** Immutable readonly property names/types; no values, presence markers or fixed slots. */
+public final class DynamicPropertyMetadata {
+ private final Map> types;
+ private final int hash;
+
+ public DynamicPropertyMetadata(Map> types) {
+ for (Map.Entry> entry : types.entrySet()) {
+ String name = entry.getKey();
+ if (name == null || !name.startsWith("_") || name.substring(1).isBlank()) {
+ throw new IllegalArgumentException("Dynamic property metadata requires '_' names");
+ }
+ Class> type = Objects.requireNonNull(entry.getValue(), "dynamic property type");
+ if (type.isPrimitive()) {
+ throw new IllegalArgumentException("Dynamic property metadata requires boxed types");
+ }
+ }
+ this.types = Map.copyOf(types);
+ this.hash = this.types.hashCode();
+ }
+
+ public Map> types() { return types; }
+ public Class> type(String name) { return types.get(name); }
+
+ /** Validation never prints the value; absence is not validated as a NULL assignment. */
+ public void validate(String name, Object value) {
+ Class> type = type(name);
+ if (type != null && value != null && !type.isInstance(value)) {
+ throw new IllegalArgumentException("Invalid dynamic property type: " + name);
+ }
+ }
+
+ @Override public boolean equals(Object other) {
+ return this == other || other instanceof DynamicPropertyMetadata metadata && types.equals(metadata.types);
+ }
+ @Override public int hashCode() { return hash; }
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/Entity.java b/teaql-core/src/main/java/io/teaql/core/Entity.java
index 9aca8060..47659760 100644
--- a/teaql-core/src/main/java/io/teaql/core/Entity.java
+++ b/teaql-core/src/main/java/io/teaql/core/Entity.java
@@ -5,6 +5,9 @@
// the super interface in TEAQL repository
public interface Entity {
+ /** Optional readonly result-schema information, not an availability or persistence contract. */
+ default Class> getDynamicPropertyType(String name) { return null; }
+
Long getId();
Long getVersion();
diff --git a/teaql-core/src/main/java/io/teaql/core/EntityChangeSet.java b/teaql-core/src/main/java/io/teaql/core/EntityChangeSet.java
index 840f7d97..13d22dbd 100644
--- a/teaql-core/src/main/java/io/teaql/core/EntityChangeSet.java
+++ b/teaql-core/src/main/java/io/teaql/core/EntityChangeSet.java
@@ -8,9 +8,10 @@
*/
public class EntityChangeSet {
private final Map> changes = new TreeMap<>();
+ private Map> dynamicChanges;
public boolean isEmpty() {
- return changes.isEmpty();
+ return changes.isEmpty() && (dynamicChanges == null || dynamicChanges.isEmpty());
}
public void set(EntityKey key, String field, Object value) {
@@ -31,12 +32,31 @@ public Map> changes() {
return Collections.unmodifiableMap(changes);
}
+ public void setDynamic(EntityKey key, io.teaql.data.dynamic.DynamicFieldMutation mutation) {
+ if (dynamicChanges == null) dynamicChanges = new TreeMap<>();
+ dynamicChanges.computeIfAbsent(key, ignored -> new TreeMap<>()).put(mutation.code(), mutation);
+ }
+
+ public Map> dynamicChanges() {
+ if (dynamicChanges == null) return Collections.emptyMap();
+ Map> view = new TreeMap<>();
+ dynamicChanges.forEach((key, values) -> view.put(key, Collections.unmodifiableMap(values)));
+ return Collections.unmodifiableMap(view);
+ }
+
public void clearEntity(EntityKey key) {
changes.remove(key);
+ if (dynamicChanges != null) dynamicChanges.remove(key);
}
public Set fieldNames(EntityKey key) {
Map record = changes.get(key);
+ var dynamic = dynamicChanges == null ? null : dynamicChanges.get(key);
+ if (dynamic != null && !dynamic.isEmpty()) {
+ Set names = new TreeSet<>(record == null ? Collections.emptySet() : record.keySet());
+ dynamic.keySet().forEach(code -> names.add("#" + code));
+ return Collections.unmodifiableSet(names);
+ }
return record != null ? Collections.unmodifiableSet(record.keySet()) : Collections.emptySet();
}
}
diff --git a/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java b/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java
index 335cdaca..d1cdd4cd 100644
--- a/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java
+++ b/teaql-core/src/main/java/io/teaql/core/EntityMutationLedger.java
@@ -45,6 +45,10 @@ public void set(EntityKey key, String field, Object value) {
changeSets.set(key, field, value);
}
+ public void setDynamic(EntityKey key, io.teaql.data.dynamic.DynamicFieldMutation mutation) {
+ changeSets.currentMut().setDynamic(key, mutation);
+ }
+
public Object get(EntityKey key, String field) {
return changeSets.get(key, field);
}
@@ -139,10 +143,12 @@ public Long getOriginalVersion(EntityKey key) {
public boolean mergeEntityFrom(EntityMutationLedger other, EntityKey key) {
if (other == null || other == this) return false;
Map fields = other.currentChangeSet().changes().get(key);
+ var dynamic = other.currentChangeSet().dynamicChanges().get(key);
boolean pending = (fields != null && !fields.isEmpty()) || other.newKeys.contains(key)
- || other.deletedKeys.contains(key) || other.recoveredKeys.contains(key);
+ || other.deletedKeys.contains(key) || other.recoveredKeys.contains(key) || (dynamic != null && !dynamic.isEmpty());
if (!pending) return false;
if (fields != null) fields.forEach((field, value) -> set(key, field, value));
+ if (dynamic != null) dynamic.values().forEach(mutation -> setDynamic(key, mutation));
if (other.deletedKeys.contains(key)) markAsDelete(key);
if (other.recoveredKeys.contains(key)) markAsRecover(key);
if (other.newKeys.contains(key)) markAsNew(key);
@@ -160,6 +166,7 @@ public void mergeFrom(EntityMutationLedger other) {
// Merge change sets
EntityChangeSet otherChangeSet = other.currentChangeSet();
+ otherChangeSet.dynamicChanges().forEach((key, mutations) -> mutations.values().forEach(mutation -> setDynamic(key, mutation)));
for (Map.Entry> entry : otherChangeSet.changes().entrySet()) {
EntityKey key = entry.getKey();
for (Map.Entry fieldEntry : entry.getValue().entrySet()) {
diff --git a/teaql-core/src/main/java/io/teaql/core/FieldLayout.java b/teaql-core/src/main/java/io/teaql/core/FieldLayout.java
new file mode 100644
index 00000000..1f30b3b2
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/FieldLayout.java
@@ -0,0 +1,151 @@
+package io.teaql.core;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Immutable, type-local fixed positions supplied by the generated library. */
+public final class FieldLayout {
+ private static final ClassValue TYPES = new ClassValue<>() {
+ @Override
+ protected Installation computeValue(Class> type) {
+ return new Installation();
+ }
+ };
+
+ private static final class Installation {
+ private volatile FieldLayout layout;
+
+ FieldLayout resolve(Class> type) {
+ FieldLayout current = layout;
+ if (current != null) return current;
+ synchronized (this) {
+ if (layout == null) layout = new FieldLayout(type, "unindexed", Map.of(), Map.of(), Set.of(), false);
+ return layout;
+ }
+ }
+
+ synchronized FieldLayout install(FieldLayout candidate) {
+ if (layout == null) {
+ layout = candidate;
+ } else if (!layout.generated || !layout.revision.equals(candidate.revision)
+ || !layout.indexes.equals(candidate.indexes) || !layout.aliases.equals(candidate.aliases)
+ || !layout.members.equals(candidate.members)
+ || !layout.relations.equals(candidate.relations)) {
+ throw new IllegalArgumentException("Incompatible or late generated field-layout installation: " + candidate.entityType.getName());
+ }
+ return layout;
+ }
+ }
+
+ private final Class> entityType;
+ private final String revision;
+ private final Map indexes;
+ private final Map aliases;
+ private final Map members;
+ private final Set relations;
+ private final boolean generated;
+ private final LoadState empty;
+
+ private FieldLayout(Class> entityType, String revision, Map indexes,
+ Map> mappings, Set relations, boolean generated) {
+ this.entityType = java.util.Objects.requireNonNull(entityType, "entityType");
+ if (revision == null || revision.isBlank()) throw new IllegalArgumentException("Missing field-layout revision");
+ this.revision = revision;
+ this.generated = generated;
+ this.indexes = Map.copyOf(indexes);
+ this.relations = Set.copyOf(relations);
+ Map aliases = new HashMap<>();
+ Map members = new HashMap<>();
+ Set positions = new HashSet<>();
+ if (!mappings.keySet().equals(indexes.keySet())) {
+ throw new IllegalArgumentException("Incomplete field mappings for " + entityType.getName());
+ }
+ for (Map.Entry field : indexes.entrySet()) {
+ String name = field.getKey();
+ Integer index = field.getValue();
+ if (name == null || name.isBlank() || name.startsWith("#") || name.startsWith("_")) {
+ throw new IllegalArgumentException("Invalid fixed field: " + name);
+ }
+ if (index == null || index < 0 || index >= indexes.size() || !positions.add(index)) {
+ throw new IllegalArgumentException("Invalid or duplicate fixed index: " + name + "=" + index);
+ }
+ List mapping = mappings.get(name);
+ if (mapping == null || mapping.size() != 2) {
+ throw new IllegalArgumentException("Expected member/column mapping for " + name);
+ }
+ addAlias(aliases, name, index);
+ addAlias(aliases, mapping.get(0), index);
+ addAlias(aliases, mapping.get(1), index);
+ members.put(name, mapping.get(0));
+ }
+ if (generated && (!indexes.containsKey("id") || !indexes.containsKey("version"))) {
+ throw new IllegalArgumentException("Generated layout must include id and version: " + entityType.getName());
+ }
+ this.aliases = Map.copyOf(aliases);
+ this.members = Map.copyOf(members);
+ empty = LoadState.empty(this);
+ }
+
+ public static FieldLayout generated(Class> entityType, String revision, Map indexes,
+ Map> mappings, Set relations) {
+ return new FieldLayout(entityType, revision, indexes, mappings, relations, true);
+ }
+
+ @FrameworkInternal("Generated metadata and hydration infrastructure only")
+ public static FieldLayout forType(Class> entityType) {
+ return TYPES.get(entityType).resolve(entityType);
+ }
+
+ /** Generated static initialization supplies metadata directly, without reflection or row construction. */
+ @FrameworkInternal("Generated metadata installation only")
+ public static FieldLayout installGenerated(FieldLayout layout) {
+ java.util.Objects.requireNonNull(layout, "layout");
+ if (!layout.generated) throw new IllegalArgumentException("Only generated field layouts can be installed");
+ return TYPES.get(layout.entityType).install(layout);
+ }
+
+ public Class> entityType() { return entityType; }
+ public String revision() { return revision; }
+ public boolean isGenerated() { return generated; }
+ public Map indexes() { return indexes; }
+ public Integer findIndex(String fieldOrAlias) { return aliases.get(fieldOrAlias); }
+ /** Generated language-native accessor key, not a SQL column or reflected bean property. */
+ public String memberName(String canonicalField) { return members.get(canonicalField); }
+ /** Shared named relation metadata; these members do not consume fixed-field positions. */
+ @FrameworkInternal("Typed graph hydration and presentation only")
+ public Set relationNames() { return relations; }
+ public LoadState emptyState() { return empty; }
+
+ int indexForLoad(String name) {
+ Integer index = findIndex(name);
+ if (index != null) return index;
+ if (name == null || name.isBlank() || name.startsWith("_")) {
+ throw new IllegalArgumentException("Not a loadable field: " + name);
+ }
+ if (!generated || name.startsWith("#") || relations.contains(name)) return -1;
+ throw new IllegalArgumentException("Unknown field in generated layout: " + entityType.getName() + "." + name);
+ }
+
+ public void validateExpectedMembers(Set members) {
+ if (!generated) return;
+ Set expected = new HashSet<>();
+ for (String member : members) {
+ Integer index = findIndex(member);
+ if (index == null) throw new IllegalArgumentException("Missing generated field index: " + member);
+ expected.add(index);
+ }
+ if (expected.size() != indexes.size()) {
+ throw new IllegalArgumentException("Generated layout disagrees with entity descriptor: " + entityType.getName());
+ }
+ }
+
+ private static void addAlias(Map aliases, String alias, int index) {
+ if (alias == null || alias.isBlank()) throw new IllegalArgumentException("Blank field alias");
+ Integer previous = aliases.putIfAbsent(alias, index);
+ if (previous != null && previous != index) throw new IllegalArgumentException("Ambiguous field alias: " + alias);
+ }
+
+}
diff --git a/teaql-core/src/main/java/io/teaql/core/LoadState.java b/teaql-core/src/main/java/io/teaql/core/LoadState.java
new file mode 100644
index 00000000..ba72722c
--- /dev/null
+++ b/teaql-core/src/main/java/io/teaql/core/LoadState.java
@@ -0,0 +1,155 @@
+package io.teaql.core;
+
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+
+/** Immutable availability snapshot; it never contains entity values or mutation ownership. */
+public final class LoadState {
+ private static final String[] NO_DYNAMIC_CODES = new String[0];
+ private final FieldLayout layout;
+ private final long bits;
+ private final Set overflow;
+ // Dynamic field selection, named relation materialization and unindexed adapter fields.
+ private final Set selectedNames;
+ private final DynamicPropertyMetadata dynamicPropertyMetadata;
+ private final int hash;
+ // Value-free memoization shared by the snapshot, never a per-entity payload.
+ private volatile String[] dynamicCodes;
+
+ private LoadState(FieldLayout layout, long bits, Set overflow, Set selectedNames) {
+ this(layout, bits, overflow, selectedNames, selectedNames.isEmpty() ? NO_DYNAMIC_CODES : null);
+ }
+
+ private LoadState(FieldLayout layout, long bits, Set overflow, Set selectedNames,
+ String[] dynamicCodes) {
+ this(layout, bits, overflow, selectedNames, dynamicCodes, null);
+ }
+
+ private LoadState(FieldLayout layout, long bits, Set overflow, Set selectedNames,
+ String[] dynamicCodes, DynamicPropertyMetadata metadata) {
+ this.layout = Objects.requireNonNull(layout, "layout");
+ this.bits = bits;
+ this.overflow = overflow;
+ this.selectedNames = selectedNames;
+ this.dynamicCodes = dynamicCodes;
+ this.dynamicPropertyMetadata = metadata;
+ int result = System.identityHashCode(layout);
+ result = 31 * result + Long.hashCode(bits);
+ result = 31 * result + overflow.hashCode();
+ result = 31 * result + selectedNames.hashCode();
+ hash = 31 * result + Objects.hashCode(metadata);
+ }
+
+ static LoadState empty(FieldLayout layout) {
+ return new LoadState(layout, 0L, Set.of(), Set.of());
+ }
+
+ /** Build once per actual query shape, not once per row. */
+ public static LoadState projection(FieldLayout layout, Iterable fields) {
+ long bits = 0L;
+ Set overflow = null;
+ Set names = null;
+ for (String field : fields) {
+ int index = layout.indexForLoad(field);
+ if (index >= 0 && index < Long.SIZE) bits |= 1L << index;
+ else if (index >= Long.SIZE) {
+ if (overflow == null) overflow = new HashSet<>();
+ overflow.add(index);
+ } else {
+ if (names == null) names = new HashSet<>();
+ names.add(field);
+ }
+ }
+ if (bits == 0L && overflow == null && names == null) return layout.emptyState();
+ return new LoadState(layout, bits, overflow == null ? Set.of() : Set.copyOf(overflow),
+ names == null ? Set.of() : Set.copyOf(names));
+ }
+
+ public FieldLayout layout() { return layout; }
+ public long bits() { return bits; }
+ public Set overflow() { return overflow; }
+ public Set selectedNames() { return selectedNames; }
+
+ public DynamicPropertyMetadata dynamicPropertyMetadata() { return dynamicPropertyMetadata; }
+
+ /** Shared schema only: never marks a readonly property as loaded or present. */
+ public LoadState withDynamicPropertyMetadata(DynamicPropertyMetadata metadata) {
+ if (Objects.equals(dynamicPropertyMetadata, metadata)) return this;
+ return new LoadState(layout, bits, overflow, selectedNames, dynamicCodes, metadata);
+ }
+
+ /** Build once for a dynamic-field batch shape, preserving fixed slots and relation markers. */
+ public LoadState withDynamicSelection(Set codes) {
+ // A custom sorted comparator may equate different literal field codes.
+ // Preserve the original String-identity semantics before the fast membership check.
+ if (codes instanceof java.util.SortedSet> sorted && sorted.comparator() != null) {
+ codes = Set.copyOf(codes);
+ }
+ String[] current = dynamicCodes();
+ boolean same = current.length == codes.size();
+ if (same) {
+ for (String code : current) {
+ if (!codes.contains(code)) { same = false; break; }
+ }
+ }
+ if (same) return this;
+ String[] stableCodes = codes.isEmpty() ? NO_DYNAMIC_CODES : codes.toArray(String[]::new);
+ Set