Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,17 @@
]
},
"hooks": {
"PreToolUse": [
"SessionStart": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.agents/scripts/protect-version-file.sh"
"command": "$CLAUDE_PROJECT_DIR/init-submodules"
}
]
},
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
Expand Down
2 changes: 1 addition & 1 deletion .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
path = .agents/shared
url = https://github.com/SpineEventEngine/agents.git
branch = master
update = checkout
update = merge
ignore = all
13 changes: 10 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,15 @@ links to a shared requirements file (e.g. `jvm-project.md`), read that too.

Shared skills, scripts, and guidelines come from the `.agents/shared` submodule (the
[`agents`][agents-repo] repository) exposed via symlinks.
`./config/pull` initializes and floats it automatically; on a fresh clone that skips
`pull`, run `git submodule update --init --remote .agents/shared`.
`./config/pull` initializes and floats them automatically. But a fresh `git worktree`
(and some shallow clones / cloud checkouts) start with NO submodules checked out, so
those symlinks dangle and no skills are found. Bootstrap such a tree with
**`./init-submodules`** — a root script that materializes the missing submodules
(`config`, `.agents/shared`, …) at their pinned commits. It depends on no pre-existing
`config` submodule, so it works before `./config/pull` (which lives inside the `config`
submodule) can. Claude Code runs it automatically via a `SessionStart` hook; other
agents and humans run it by hand, then `./config/pull` to float the shared submodules
to their branch tips.

## Commit and history safety

Expand Down Expand Up @@ -115,7 +122,7 @@ In consumer repositories, skip without comment any path matching:
- `.claude/**`, `.idea/**`, `.junie/**`
- `.github/copilot-instructions.md`
- `buildSrc/**` (except `buildSrc/src/main/kotlin/module.gradle.kts`)
- `gradle/`, `gradlew`, `gradlew.bat`
- `gradle/`, `gradlew`, `gradlew.bat`, `init-submodules`
- `.codecov.yml`, `.gitignore`, `gradle.properties`, `lychee.toml`
- `.github/workflows/` — unless the workflow was introduced by this repo

Expand Down
19 changes: 19 additions & 0 deletions base/src/main/java/io/spine/base/Field.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import com.google.protobuf.Descriptors.EnumValueDescriptor;
import com.google.protobuf.Descriptors.FieldDescriptor;
import com.google.protobuf.Message;
import com.google.protobuf.ProtocolMessageEnum;
import io.spine.annotation.VisibleForTesting;
import io.spine.code.proto.ScalarType;
import io.spine.type.TypeName;
Expand Down Expand Up @@ -273,6 +274,7 @@ public static <I> Optional<FieldDescriptor> findIdField(Class<I> idClass, Descri
.stream()
.filter(idType::matchField)
.filter(f -> idType != IdType.MESSAGE || sameMessageType(idClass, f))
.filter(f -> idType != IdType.ENUM || sameEnumType(idClass, f))
.findFirst();
return found;
}
Expand All @@ -288,6 +290,23 @@ private static <I> boolean sameMessageType(Class<I> idClass, FieldDescriptor f)
return fieldType.equals(messageType);
}

/**
* Verifies if the class of identifiers and the type of the field represent the same enum type.
*
* <p>The {@code matchField} check of {@code IdType.ENUM} accepts any enum field because it
* does not know the requested enum class. This check, performed once the class is known,
* ensures that an enum ID field of one type is not mistaken for a field of another enum
* type declared in the same message.
*/
private static <I> boolean sameEnumType(Class<I> idClass, FieldDescriptor f) {
var idEnum = (ProtocolMessageEnum) Identifier.defaultValue(idClass);
var idEnumType = idEnum.getDescriptorForType()
.getFullName();
var fieldEnumType = f.getEnumType()
.getFullName();
return fieldEnumType.equals(idEnumType);
}

/**
* Checks if the field is a nested field.
*/
Expand Down
97 changes: 97 additions & 0 deletions base/src/main/java/io/spine/base/IdType.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,18 @@

import com.google.protobuf.Any;
import com.google.protobuf.Descriptors.FieldDescriptor;
import com.google.protobuf.EnumValue;
import com.google.protobuf.Int32Value;
import com.google.protobuf.Int64Value;
import com.google.protobuf.Message;
import com.google.protobuf.ProtocolMessageEnum;
import com.google.protobuf.StringValue;
import io.spine.protobuf.AnyPacker;
import io.spine.protobuf.Messages;
import io.spine.protobuf.TypeConverter;

import static io.spine.util.Exceptions.newIllegalStateException;

/**
* Supported types of identifiers.
*/
Expand Down Expand Up @@ -142,6 +146,73 @@ boolean matchField(FieldDescriptor field) {
}
},

/**
* A Protobuf enum used as an identifier.
*
* <p>The Java class generated for a Protobuf enum implements {@link ProtocolMessageEnum}.
* The constant with the number zero is reserved by convention for the "undefined" value
* and is treated as an {@linkplain Identifier#isEmpty(Object) empty} identifier.
*/
ENUM {
@Override
<I> boolean matchValue(I id) {
// Require an actual Java enum constant, not merely a `ProtocolMessageEnum`
// implementor, consistent with `matchClass()`. Later paths (such as
// `Identifier.toString()`) cast the value to `Enum`.
return id instanceof Enum<?> && id instanceof ProtocolMessageEnum;
}

/**
* Always returns {@code false}.
*
* <p>A Protobuf enum is packed into {@link Any} as an {@link EnumValue}, which carries
* only the {@linkplain EnumValue#getName() name} and {@linkplain EnumValue#getNumber()
* number} of the value, but not the enum type. Restoring the original Java enum constant
* therefore requires the target class, which is unavailable in this method. Enum
* identifiers are restored only via {@link Identifier#unpack(Any, Class)}.
*
* <p>Returning {@code false} keeps the raw {@code EnumValue} handled by {@link #MESSAGE}
* in the class-less {@link Identifier#unpack(Any)}, preserving its behavior.
*/
@Override
boolean matchMessage(Message message) {
return false;
}

@Override
<I> boolean matchClass(Class<I> idClass) {
// Require an actual Java `enum`, not merely a `ProtocolMessageEnum` implementor:
// the `ProtocolMessageEnum` interface itself (and any non-enum implementation) has
// no enum constants, so `defaultValue()` would fail on `getEnumConstants()`.
return idClass.isEnum() && ProtocolMessageEnum.class.isAssignableFrom(idClass);
}

/**
* Always throws {@link IllegalStateException}.
*
* <p>This method is never called because {@link #matchMessage(Message)} returns
* {@code false} for this type. Restoring an enum identifier requires the target class;
* use {@link Identifier#unpack(Any, Class)} instead.
*/
@Override
Object fromMessage(Message message) {
throw newIllegalStateException(
"An enum identifier must be restored with the target class" +
" via `Identifier.unpack(Any, Class)`.");
}

@Override
<I> I defaultValue(Class<I> idClass) {
var undefined = zeroValue(idClass);
return (I) undefined;
}

@Override
boolean matchField(FieldDescriptor field) {
return FieldDescriptor.JavaType.ENUM == field.getJavaType();
Comment thread
alexander-yevsyukov marked this conversation as resolved.
}
},

MESSAGE {
@Override
<I> boolean matchValue(I id) {
Expand Down Expand Up @@ -263,4 +334,30 @@ <I> Any pack(I id) {
var result = AnyPacker.pack(msg);
return result;
}

/**
* Obtains the constant reserved by convention for the "undefined" identifier value —
* the one with the number zero — declared in the given Protobuf enum class.
*
* <p>The constant is located by its number through the enum descriptor, so it is correct
* regardless of the declaration order. If the enum declares no constant with the number
* zero — possible only for {@code proto2} — the first declared constant is returned.
*/
private static Object zeroValue(Class<?> enumClass) {
var constants = enumClass.getEnumConstants();
var enumDescriptor = ((ProtocolMessageEnum) constants[0]).getDescriptorForType();
var zero = enumDescriptor.findValueByNumber(0);
if (zero == null) {
return constants[0];
}
return enumConstant(enumClass, zero.getName());
}

/**
* Obtains the enum constant of the given class by its name.
*/
@SuppressWarnings({"unchecked", "rawtypes"}) // `enumClass` is an `enum`, ensured by `matchClass()`.
private static Object enumConstant(Class<?> enumClass, String name) {
return Enum.valueOf((Class) enumClass, name);
}
}
Loading
Loading