Compare commits

..

No commits in common. "32b005691bf5122847aea2bf0e8e0e03fe683ce0" and "586c3ed59237dfda7eb94fa990a9be786163e46d" have entirely different histories.

8 changed files with 3 additions and 1255 deletions

684
README.md
View File

@ -1,684 +0,0 @@
# Clotho
A Java OGM (Object-Graph Mapper) for ArcadeDB and OrientDB, built on Apache TinkerPop/Gremlin.
Clotho is **not** a 1:1 node-to-object mapper. A "Clotho object" is a *subgraph* — a root vertex plus a declared set of edges and adjacent vertices spanning any number of hops. Complex graph traversals and analytics remain the developer's responsibility via raw Gremlin. Clotho handles bounded object loading and persistence.
---
## Requirements
- Java 21+
- Maven 3.9+
- ArcadeDB (recommended) or OrientDB
---
## Database Support
| Database | Support level |
|---|---|
| **ArcadeDB** | Full — all features including transactions, `@Version` OCC, and batch auto-save |
| **OrientDB** | Supported — transactions are degraded (per-request semantics only); `@Version` OCC still works |
Clotho connects over the **Gremlin WebSocket** endpoint (`DriverRemoteConnection`). No JDBC or native driver is needed.
---
## Installation
### Spring Boot (recommended)
```xml
<dependency>
<groupId>com.binarygolem.clotho</groupId>
<artifactId>clotho-spring-boot-starter</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>
```
No `@Enable*` annotation is needed. Autoconfiguration activates automatically when the starter is on the classpath.
### Without Spring (core only)
```xml
<dependency>
<groupId>com.binarygolem.clotho</groupId>
<artifactId>clotho-core</artifactId>
<version>0.1.0-SNAPSHOT</version>
</dependency>
```
---
## Configuration
### ArcadeDB (application.properties)
```properties
# Required — Gremlin WebSocket endpoint
clotho.gremlin.url=ws://localhost:2480/gremlin
# Credentials
clotho.gremlin.username=root
clotho.gremlin.password=playwithdata
# Traversal source name as configured on ArcadeDB
clotho.gremlin.traversal-source=g
# Packages to scan for @VertexType, @EdgeType, and @ClothoRepository classes
clotho.packages=com.example.myapp.model,com.example.myapp.repository
```
### OrientDB (application.properties)
```properties
clotho.gremlin.url=ws://localhost:8182/gremlin
clotho.gremlin.username=root
clotho.gremlin.password=secret
clotho.gremlin.traversal-source=g
clotho.packages=com.example.myapp.model,com.example.myapp.repository
```
> **OrientDB limitation:** when OrientDB is configured with `orient-transactional=false`, Clotho detects this at startup and skips `g.tx()` calls. `openTransactionalSession()` and Spring `@Transactional` still compile and run without errors, but they provide no rollback guarantee. `@Version` OCC works regardless.
### Full configuration reference
| Property | Default | Description |
|---|---|---|
| `clotho.gremlin.url` | `ws://localhost:8182/gremlin` | WebSocket URL for the Gremlin endpoint |
| `clotho.gremlin.username` | `""` | Database username |
| `clotho.gremlin.password` | `""` | Database password |
| `clotho.gremlin.traversal-source` | `g` | Traversal source name on the server |
| `clotho.packages` | *(none)* | Comma-separated base packages to scan — **required** |
| `clotho.default-direction` | `OUT` | Default edge direction for `@Via` fields (`OUT`, `IN`, `BOTH`) |
| `clotho.unknown-type-behavior` | `STRICT` | Behaviour when a graph label has no registered Java class: `STRICT` (throw), `NEAREST_ANCESTOR`, `GENERIC` |
---
## Core Concepts
### Schema-first
Clotho does **not** generate DDL. Your ArcadeDB or OrientDB schema (vertex types, edge types, properties) must exist before the application starts. Use your database's schema management tooling to create and evolve the schema.
### Object boundary
Clotho loads a bounded subgraph per object. Fields annotated with `@Include` and `@Via` declare which adjacent edges and vertices belong to an object's boundary. Everything within the boundary is fetched in a **single** Gremlin `project()` query — no N+1 problem.
---
## Defining Entities
### Vertex type
Vertex classes must extend `ClothoVertex` and carry `@VertexType` with the ArcadeDB label.
```java
@VertexType("Person")
@Getter
public class Person extends ClothoVertex {
@Property("name")
private @Nullable String name;
@Property("email")
private @Nullable String email;
@Property("age")
private @Nullable Integer age;
// Edges and adjacent vertices that belong to this object's boundary
@Include
@Via("WORKS_AT")
private @Nullable List<WorksAt> jobs;
// Setters MUST call notifyFieldChanged — do NOT use Lombok @Setter on these fields
public void setName(@Nullable String name) {
this.name = name;
notifyFieldChanged("name");
}
public void setEmail(@Nullable String email) {
this.email = email;
notifyFieldChanged("email");
}
public void setAge(@Nullable Integer age) {
this.age = age;
notifyFieldChanged("age");
}
public void setJobs(@Nullable List<WorksAt> jobs) {
this.jobs = jobs;
notifyFieldChanged("jobs");
}
}
```
> **Dirty tracking rule:** Any field annotated with `@Property`, `@Include`, `@OutVertex`, `@InVertex`, or `@Version` on a `ClothoVertex` or `ClothoEdge` subclass **must** have a manual setter that calls `notifyFieldChanged("fieldName")`. Lombok `@Setter` silently bypasses dirty tracking and must not be used on those fields. Lombok is fine for `id`, `outVertexId`, and `inVertexId` because those are managed by the framework.
### Edge type
Edge classes must extend `ClothoEdge` and carry `@EdgeType` with the edge label.
```java
@EdgeType("WORKS_AT")
@Getter
public class WorksAt extends ClothoEdge {
public static final String LABEL = "WORKS_AT";
@Property("role")
private @Nullable String role;
@Property("startYear")
private @Nullable Integer startYear;
@OutVertex
private @Nullable Person person; // source vertex
@InVertex
private @Nullable Company company; // target vertex
public void setRole(@Nullable String role) {
this.role = role;
notifyFieldChanged("role");
}
public void setStartYear(@Nullable Integer startYear) {
this.startYear = startYear;
notifyFieldChanged("startYear");
}
public void setCompany(@Nullable Company company) {
this.company = company;
notifyFieldChanged("company");
}
}
```
### Vertex with no included fields
```java
@VertexType("Company")
@Getter
public class Company extends ClothoVertex {
@Property("name")
private @Nullable String name;
@Property("industry")
private @Nullable String industry;
public void setName(@Nullable String name) {
this.name = name;
notifyFieldChanged("name");
}
public void setIndustry(@Nullable String industry) {
this.industry = industry;
notifyFieldChanged("industry");
}
}
```
---
## Session Usage
`ClothoSession` is the unit of work. Always use it in a try-with-resources block.
### Create
```java
try (ClothoSession session = sessionFactory.openSession()) {
Company acme = new Company();
acme.setName("Acme Corp");
acme.setIndustry("Technology");
WorksAt job = new WorksAt();
job.setRole("Senior Engineer");
job.setStartYear(2020);
job.setCompany(acme);
Person alice = new Person();
alice.setName("Alice Smith");
alice.setEmail("alice@example.com");
alice.setJobs(List.of(job));
session.save(alice); // cascades: alice → job → acme
System.out.println(alice.getId()); // ARID like "#10:0"
}
```
### Load
```java
try (ClothoSession session = sessionFactory.openSession()) {
Person alice = session.load(Person.class, ARID.of("#10:0"));
// alice.getJobs() is already populated — no extra round-trip needed
System.out.println(alice.getName());
}
```
### Update (auto-save on close)
```java
try (ClothoSession session = sessionFactory.openSession()) {
Person alice = session.load(Person.class, id);
alice.setEmail("updated@example.com"); // dirty tracking fires
// No explicit save() needed — session.close() auto-saves all dirty MANAGED entities
}
```
You can also call `session.save(entity)` explicitly at any point.
### Delete
```java
try (ClothoSession session = sessionFactory.openSession()) {
Person alice = session.load(Person.class, id);
session.delete(alice);
}
```
> **No automatic cascade delete:** removing an entity from an `@Include` collection does not delete it from the graph. Call `session.delete()` explicitly.
### Transactions (ArcadeDB)
```java
try (ClothoSession session = sessionFactory.openTransactionalSession()) {
Company newCo = new Company();
newCo.setName("NewCo");
Person bob = new Person();
bob.setName("Bob");
session.save(newCo);
session.save(bob);
} // commits on close; RuntimeException triggers rollback
```
---
## Repositories
Extend `ClothoRepository<T>` and annotate with `@ClothoRepository`. Spring picks it up automatically when it is in a scanned package.
```java
@ClothoRepository
public class PersonRepository extends ClothoRepository<Person> {
public PersonRepository(ClothoSessionFactory factory) {
super(factory);
}
// Built-in: findAll(), findById(), count(), save(), delete(), findWithPagination()
public List<Person> findByName(String name) {
return execute(g().V().has("Person", "name", name));
}
public List<Person> findOlderThan(int minAge) {
return execute(allEntities(), t -> t.has("age", P.gt(minAge)));
}
public List<Person> findEmployed() {
return execute(allEntities(), t -> t.where(__.outE(WorksAt.LABEL)));
}
// Pass a session to get MANAGED entities — mutations auto-save on session close
public List<Person> findCoworkers(Person person, ClothoSession session) {
return execute(session,
g().V(person.getId().asString()).as("self")
.out(WorksAt.LABEL).in(WorksAt.LABEL)
.where(P.neq("self"))
);
}
public long countByIndustryConn(String industry) {
return (long) executeRaw(
g().V().has("Company", "industry", industry)
.in(WorksAt.LABEL).count()
).get(0);
}
}
```
### Built-in repository operations
| Method | Description |
|---|---|
| `findAll()` | All entities of this type with boundaries populated |
| `findAll(filter)` | Filtered variant |
| `findById(ARID)` | Returns `null` when not found |
| `count()` / `count(filter)` | Element count |
| `findWithPagination(offset, limit)` | Paginated fetch |
| `save(T)` | Persist entity |
| `delete(T)` / `delete(ARID)` | Remove entity |
---
## Spring `@Transactional`
When using the Spring Boot starter, `ClothoTransactionManager` is registered automatically. Annotate service methods with Spring's `@Transactional` as usual.
```java
@Service
@RequiredArgsConstructor
public class HiringService {
private final ClothoSessionFactory sessionFactory;
@Transactional
public void hire(ARID personId, ARID companyId, String role) {
try (ClothoSession session = sessionFactory.openSession()) { // borrows the tx session
Person person = session.load(Person.class, personId);
Company company = session.load(Company.class, companyId);
WorksAt job = new WorksAt();
job.setRole(role);
job.setStartYear(LocalDate.now().getYear());
job.setCompany(company);
person.setJobs(List.of(job));
// session.close() is a no-op when borrowing — Spring commits on method return
}
}
}
```
> **OrientDB:** `@Transactional` compiles and runs without errors on OrientDB, but provides no rollback guarantee in `orient-transactional=false` mode.
---
## Optimistic Locking (`@Version`)
Add `@Version` to a `Long` field on any entity to enable optimistic concurrency control (OCC). Clotho stores the version as a regular graph property (defaulting to `_version`), checks it before every write, and increments it on success. A concurrent modification throws `OptimisticLockException` before any data is written.
```java
@VertexType("Order")
@Getter
public class Order extends ClothoVertex {
@Property("status")
private @Nullable String status;
@Version // stored as "_version" property; override with @Version("myProp")
private @Nullable Long version;
public void setStatus(@Nullable String status) {
this.status = status;
notifyFieldChanged("status");
}
}
```
```java
int attempts = 0;
while (attempts++ < 3) {
try (ClothoSession session = sessionFactory.openSession()) {
Order order = session.load(Order.class, orderId);
order.setStatus("SHIPPED");
session.save(order); // version pre-flight runs here; auto-save on close also works
break;
} catch (OptimisticLockException e) {
if (attempts == 3) throw e;
// reload fresh state and retry
}
}
```
> **Both ArcadeDB and OrientDB** support `@Version` — it uses a plain graph property, not any database-internal version mechanism.
---
## Custom Version-Checked Updates
`session.save()` and auto-save handle OCC for field mutations on registered entities. For **arbitrary Gremlin write traversals** — multi-hop rewrites, conditional graph surgery, updates spanning unregistered types — use `session.executeUpdate()`.
The contract is always the same: read first (so Clotho has the baseline version), declare which elements the traversal touches, then write.
### With registered entities
```java
try (ClothoSession session = sessionFactory.openSession()) {
Order order = session.load(Order.class, orderId);
Account acct = session.load(Account.class, accountId);
// Arbitrary traversal — Clotho pre-flights versions before it fires
session.executeUpdate(
g().V(orderId.asString())
.property("status", "SHIPPED")
.sideEffect(__.inV("PLACED_BY").property("orderCount", newCount)),
order, acct // both @Version-checked; if either is stale → OptimisticLockException
);
}
```
After a successful call, version fields are incremented in both the graph and in memory, and dirty fields are cleared so the session's auto-save on close does not re-apply the same writes.
### With unregistered types (`VersionGuard`)
For graph elements not mapped to a Clotho entity class — `GenericVertex`, `GenericEdge`, or any vertex/edge from an unregistered label — use `VersionGuard`. No entity class declaration is needed.
```java
ARID id = ARID.of("#10:0");
// Read the version from the graph
long ver = (long) executeRaw(g().V(id.asString()).values("_version")).get(0);
// Write with OCC — stale version → OptimisticLockException before the traversal fires
session.executeUpdate(
g().V(id.asString()).property("status", "archived"),
VersionGuard.onVertex(id, ver)
);
```
```java
// Edge guard
session.executeUpdate(
g().E(edgeId.asString()).property("weight", 0.5),
VersionGuard.onEdge(edgeId, edgeVersion)
);
// Custom version property name (when not using the default "_version")
VersionGuard.onVertex(id, "myVersionProp", 5L)
// Mix entity guards and VersionGuards in one call
session.executeUpdate(
complexTraversal,
registeredOrder, // ClothoEntity — @Version checked
VersionGuard.onVertex(unregisteredId, ver) // raw vertex — VersionGuard checked
);
```
### Race condition behaviour
The version pre-flight is atomic: if any guarded element is stale, `OptimisticLockException` is thrown before the traversal runs. This means either all guards pass and the traversal executes, or nothing is written.
```java
// Concurrent writers both read version 0
// Writer A succeeds → graph version becomes 1
// Writer B (still holding version 0) → OptimisticLockException, traversal never ran
```
The retry pattern is the same regardless of whether you use entities or `VersionGuard`:
```java
int attempts = 0;
while (attempts++ < 3) {
try (ClothoSession session = sessionFactory.openSession()) {
Order order = session.load(Order.class, orderId);
session.executeUpdate(
g().V(orderId.asString()).property("status", "SHIPPED"),
order
);
break;
} catch (OptimisticLockException e) {
if (attempts == 3) throw e;
}
}
```
### `executeRaw` is for reads only
Use `executeRaw()` for traversals that return scalars, counts, or property values — things that produce no entity. Never use it to write to versioned elements; it bypasses all version checking entirely.
---
## Lazy Loading
By default, all `@Include` fields are loaded eagerly when their parent vertex is loaded. Declare `fetchType = FetchType.LAZY` to skip a field during the initial load. Populate it on demand with `session.loadField()`.
```java
@VertexType("Department")
@Getter
public class Department extends ClothoVertex {
@Property("name")
private @Nullable String name;
@Include(fetchType = FetchType.LAZY)
@Via("MEMBER_OF")
@Direction(TraversalDirection.IN)
private @Nullable List<Person> members; // null until explicitly loaded
// setter...
}
```
```java
try (ClothoSession session = sessionFactory.openSession()) {
Department dept = session.load(Department.class, deptId);
// dept.getMembers() is null here
session.loadField(dept, "members");
// now populated
System.out.println(dept.getMembers().size());
}
```
---
## Subgraph Queries
A `@SubgraphType` is a composite Java object assembled from multiple vertices and edges via a Gremlin `select()` traversal. It does not extend `ClothoVertex` or `ClothoEdge`, and it has no ID of its own.
```java
@SubgraphType
public class EmploymentSummary {
Person person; // field name matches Gremlin as("person")
@Alias("employer") Company company; // Gremlin alias "employer", field name "company"
WorksAt job;
}
```
```java
@ClothoRepository
public class EmploymentRepository extends ClothoRepository<Person> {
public EmploymentRepository(ClothoSessionFactory factory) {
super(factory);
}
public List<EmploymentSummary> findAll() {
return executeSubgraph(EmploymentSummary.class,
g().V().hasLabel("Person").as("person")
.outE(WorksAt.LABEL).as("job")
.inV().as("employer")
.select("person", "job", "employer")
);
}
}
```
---
## Inheritance
### Single-parent
Simply extend the parent Clotho class.
```java
@VertexType("Manager")
public class Manager extends Employee { ... }
```
### Diamond (multi-parent) inheritance
ArcadeDB supports multi-parent inheritance that Java cannot express. Use `additionalParents` to declare extra ArcadeDB parent labels.
```java
@VertexType(value = "SeniorManager", additionalParents = {"Auditor"})
public class SeniorManager extends Manager { ... }
```
### Divergent Java/ArcadeDB hierarchy
When the ArcadeDB parent label differs from the Java parent class label, use `parentLabel`.
```java
@VertexType(value = "ContractEmployee", parentLabel = "Employee")
public class ContractEmployee extends Person { ... }
```
---
## Without Spring
Construct `ClothoSessionFactory` manually using its builder.
```java
Cluster cluster = Cluster.build()
.addContactPoint("localhost")
.port(2480)
.credentials("root", "playwithdata")
.create();
DriverRemoteConnection connection = DriverRemoteConnection.using(cluster, "g");
GraphTraversalSource g = AnonymousTraversalSource.traversal().withRemote(connection);
TypeRegistry registry = TypeRegistry.scan("com.example.myapp.model");
ClothoSessionFactory sessionFactory = ClothoSessionFactory.builder()
.g(g)
.registry(registry)
.unknownTypeBehavior(UnknownTypeBehavior.STRICT)
.build();
// Use sessionFactory ...
// Shutdown
sessionFactory.close();
connection.close();
cluster.close();
```
---
## Important Limitations
- **No DDL generation.** Clotho is schema-first. Create and migrate your database schema externally.
- **Not thread-safe.** Sessions bind to the current thread via `ThreadLocal`. Use one session per thread (or per virtual thread).
- **No transparent proxies.** Lazy loading requires an explicit `session.loadField()` call. There is no bytecode enhancement.
- **No automatic cascade delete.** Removing an entity from an `@Include` collection does not delete it from the graph. Call `session.delete()` explicitly.
- **Manual setters required.** `@Property`, `@Include`, `@OutVertex`, `@InVertex`, and `@Version` fields must use hand-written setters that call `notifyFieldChanged()`. Lombok `@Setter` must not be used on those fields.
- **OrientDB transactions.** In `orient-transactional=false` mode, `openTransactionalSession()` and `@Transactional` work at per-request granularity only. No multi-request rollback is available.
---
## Further Reading
| Document | Contents |
|---|---|
| `docs/TYPE_SYSTEM.md` | Entity hierarchy, annotation reference, inheritance, result mapping |
| `docs/OBJECT_BOUNDARY.md` | `@Include`/`@Via`, multi-hop, edge-only inclusion, cycle detection |
| `docs/ANNOTATIONS.md` | Complete annotation reference with all attributes |
| `docs/QUERY_MODEL.md` | Repository pattern, `@SubgraphType` assembly, return type rules |
| `docs/SPRING_INTEGRATION.md` | Autoconfigure, `@Transactional`, full `application.properties` reference |
| `docs/TRANSACTIONS.md` | Transactional sessions, Spring `@Transactional`, `@Version` interaction, ArcadeDB/OrientDB limitations |
| `docs/ARCHITECTURE.md` | Module breakdown, data flow, session identity map, package structure |

View File

@ -12,7 +12,6 @@ import com.binarygolem.clotho.model.ClothoEntity;
import com.binarygolem.clotho.model.ClothoVertex;
import com.binarygolem.clotho.model.EntityState;
import com.binarygolem.clotho.model.TraversalDirection;
import com.binarygolem.clotho.model.VersionGuard;
import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversal;
import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource;
import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__;
@ -89,116 +88,6 @@ public final class SubgraphPersistor {
}
}
/**
* Executes an arbitrary developer-supplied Gremlin update traversal under optimistic
* concurrency protection for the declared {@code guarded} entities.
*
* <p>Execution proceeds in three Gremlin round-trips:
* <ol>
* <li><b>Version pre-flight</b>: verify every guarded entity that carries
* {@code @Version} still holds its expected version in the graph.
* Any mismatch throws {@link OptimisticLockException} immediately the
* user traversal is never executed.</li>
* <li><b>User traversal</b>: {@code userTraversal.iterate()}.</li>
* <li><b>Version increment</b>: a {@code union()} of
* {@code .V/E(id).property(versionProp, v+1)} branches one per
* {@code @Version}-carrying guarded entity.</li>
* </ol>
*
* <p>Entities without a {@code @Version} field are silently skipped in steps 1 and 3;
* the user traversal still executes regardless. Entities where the {@code @Version}
* value is {@code null} (not yet persisted) are also skipped.
*
* <p>After execution, {@link ClothoEntity#clearDirtyFields()} is called on every
* guarded entity to prevent the session's auto-save from re-applying the same
* mutations on close.
*
* <p><b>TOCTOU caveat:</b> a narrow gap exists between the pre-flight (step 1) and
* the version increment (step 3). This is the same inherent limitation as
* {@link #persistAllDirty} and cannot be eliminated without native ArcadeDB
* transaction support over the Gremlin WebSocket interface.
*
* @param userTraversal the arbitrary update traversal to execute; consumed by
* {@code .iterate()} do not reuse after this call
* @param guarded the entities whose {@code @Version} fields gate the write
* @throws OptimisticLockException if any versioned guarded entity is stale
*/
public void executeVersionedWrite(GraphTraversal<?, ?> userTraversal,
List<? extends ClothoEntity> guarded) {
List<VersionedExpectation> versionChecks = new ArrayList<>();
for (ClothoEntity entity : guarded) collectVersionExpectationUnconditional(entity, versionChecks);
runVersionedWrite(userTraversal, versionChecks);
Map<ARID, VersionedExpectation> versionMap = new HashMap<>();
for (VersionedExpectation ve : versionChecks) versionMap.put(ve.id(), ve);
for (ClothoEntity entity : guarded) {
bumpVersionField(entity, versionMap);
entity.clearDirtyFields();
}
}
/**
* Variant of {@link #executeVersionedWrite(GraphTraversal, List)} that accepts
* {@link VersionGuard} instances instead of loaded Clotho entities.
*
* <p>Use this when the graph elements being updated are not mapped to a registered
* Clotho entity class for example {@link com.binarygolem.clotho.model.GenericVertex},
* {@link com.binarygolem.clotho.model.GenericEdge}, or vertices and edges from labels
* with no {@code @VertexType}/{@code @EdgeType} declaration.
*
* <p>The same three-round-trip execution model and TOCTOU caveat apply.
* Because {@link VersionGuard} is a value type, there is no in-memory state to
* update after the call the caller must create a new guard (or reload the entity)
* before the next version-checked write.
*
* @param userTraversal the arbitrary update traversal to execute; consumed do not reuse
* @param guards version expectations for each graph element the traversal touches
* @throws OptimisticLockException if any guard's expected version does not match the graph
*/
public void executeVersionedWrite(GraphTraversal<?, ?> userTraversal,
VersionGuard... guards) {
List<VersionedExpectation> versionChecks = java.util.Arrays.stream(guards)
.map(vg -> new VersionedExpectation(
vg.id(), vg.expectedVersion(), vg.versionProperty(), vg.vertex()))
.toList();
runVersionedWrite(userTraversal, versionChecks);
}
/**
* Shared execution core for both {@code executeVersionedWrite} overloads:
* pre-flight check user traversal version increment.
*/
@SuppressWarnings({"unchecked", "rawtypes"})
private void runVersionedWrite(GraphTraversal<?, ?> userTraversal,
List<VersionedExpectation> versionChecks) {
// --- 1. Version pre-flight ---
if (!versionChecks.isEmpty()) {
long matched = (long) unionOf(g.inject(0),
versionChecks.stream().map(VersionedExpectation::freshBranch).toList())
.count().next();
if (matched < versionChecks.size()) {
Set<String> present = new HashSet<>(
(List<String>) (List<?>) unionOf(g.inject(0),
versionChecks.stream().map(VersionedExpectation::freshBranch).toList())
.toList()
);
VersionedExpectation conflict = versionChecks.stream()
.filter(c -> !present.contains(c.id().asString()))
.findFirst().orElseThrow();
throw new OptimisticLockException(conflict.id(), conflict.expectedVersion());
}
}
// --- 2. Execute the developer's traversal ---
userTraversal.iterate();
// --- 3. Version increment ---
if (!versionChecks.isEmpty()) {
unionOf(g.inject(0),
versionChecks.stream().map(SubgraphPersistor::buildVersionIncrementBranch).toList())
.iterate();
}
}
/**
* Persists all dirty MANAGED entities as two Gremlin round-trips that together provide
* all-or-nothing semantics for {@code @Version} conflicts:
@ -299,30 +188,6 @@ public final class SubgraphPersistor {
checks.add(new VersionedExpectation(entity.getId(), ver, vProp, entity instanceof ClothoVertex));
}
/**
* Like {@link #collectVersionExpectation} but ignores dirty-field status.
* Used by {@link #executeVersionedWrite} where the caller declares which entities
* their traversal touches regardless of whether any setter was called locally.
*/
private static void collectVersionExpectationUnconditional(ClothoEntity entity,
List<VersionedExpectation> checks) {
if (entity.getId() == null) return;
Field vf = FieldMetadataCache.get(entity.getClass()).versionField();
if (vf == null) return;
Long ver = (Long) readField(vf, entity);
if (ver == null) return;
String vProp = vf.getAnnotation(com.binarygolem.clotho.annotation.Version.class).value();
checks.add(new VersionedExpectation(entity.getId(), ver, vProp, entity instanceof ClothoVertex));
}
/** Builds a version-only increment branch — no dirty field writes. */
@SuppressWarnings({"unchecked", "rawtypes"})
private static GraphTraversal<?, ?> buildVersionIncrementBranch(VersionedExpectation ve) {
String idStr = ve.id().asString();
GraphTraversal t = ve.isVertex() ? __.V(idStr) : __.E(idStr);
return t.property(ve.versionProp(), ve.expectedVersion() + 1);
}
/** Builds one anonymous update branch: writes dirty @Property fields and increments @Version. */
@SuppressWarnings({"unchecked", "rawtypes"})
private void buildUpdateBranch(ClothoEntity entity, boolean isVertex,

View File

@ -1,50 +0,0 @@
package com.binarygolem.clotho.model;
/**
* A manual version guard for version-checked writes on graph elements that are not
* mapped to a registered Clotho entity class such as {@link GenericVertex},
* {@link GenericEdge}, or any vertex/edge from an unregistered label.
*
* <p>Use the factory methods {@link #onVertex} and {@link #onEdge} for clarity.
* The default version property name ({@value #DEFAULT_VERSION_PROPERTY}) matches
* the default value of {@code @Version}.
*
* <p>Example version-checked write on an unregistered vertex:
* <pre>{@code
* // Read the version from the graph
* long v = (long) executeRaw(g().V(id.asString()).values("_version")).get(0);
*
* session.executeUpdate(
* g().V(id.asString()).property("status", "archived"),
* VersionGuard.onVertex(id, v)
* );
* }</pre>
*
* <p>After a successful {@code executeUpdate}, the version in the graph is
* {@code expectedVersion + 1}. This guard instance is consumed create a new one
* (or reload the entity) before the next write.
*/
public record VersionGuard(ARID id, String versionProperty, long expectedVersion, boolean vertex) {
public static final String DEFAULT_VERSION_PROPERTY = "_version";
/** Guard on a vertex using the default {@value #DEFAULT_VERSION_PROPERTY} property. */
public static VersionGuard onVertex(ARID id, long expectedVersion) {
return new VersionGuard(id, DEFAULT_VERSION_PROPERTY, expectedVersion, true);
}
/** Guard on a vertex using a custom version property name. */
public static VersionGuard onVertex(ARID id, String versionProperty, long expectedVersion) {
return new VersionGuard(id, versionProperty, expectedVersion, true);
}
/** Guard on an edge using the default {@value #DEFAULT_VERSION_PROPERTY} property. */
public static VersionGuard onEdge(ARID id, long expectedVersion) {
return new VersionGuard(id, DEFAULT_VERSION_PROPERTY, expectedVersion, false);
}
/** Guard on an edge using a custom version property name. */
public static VersionGuard onEdge(ARID id, String versionProperty, long expectedVersion) {
return new VersionGuard(id, versionProperty, expectedVersion, false);
}
}

View File

@ -12,7 +12,6 @@ import com.binarygolem.clotho.model.ARID;
import com.binarygolem.clotho.model.ClothoEdge;
import com.binarygolem.clotho.model.ClothoEntity;
import com.binarygolem.clotho.model.ClothoVertex;
import com.binarygolem.clotho.model.VersionGuard;
import com.binarygolem.clotho.session.ClothoSession;
import com.binarygolem.clotho.session.ClothoSessionFactory;
import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversal;
@ -21,7 +20,6 @@ import org.apache.tinkerpop.gremlin.structure.Edge;
import org.apache.tinkerpop.gremlin.structure.Vertex;
import org.jspecify.annotations.Nullable;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
import java.util.ArrayList;
import java.util.List;
@ -255,43 +253,6 @@ public abstract class ClothoRepository<T extends ClothoEntity> {
.toList();
}
/**
* Like {@link #executeSubgraph(Class, GraphTraversal)} but integrates with the session's
* identity map. Every {@link ClothoVertex} and {@link ClothoEdge} field declared on the
* assembled {@code @SubgraphType} POJO is registered as MANAGED mutations via setters
* are tracked and auto-saved on session close, with full {@code @Version} pre-flight
* checking applied to every dirty entity.
*
* <p>Use this when complex multi-hop queries produce results you intend to mutate:
* <pre>{@code
* try (ClothoSession session = sessionFactory.openSession()) {
* List<EmploymentSummary> results = employmentRepo.executeSubgraph(
* session, EmploymentSummary.class,
* g().V().hasLabel("Person").as("person")
* .outE("WORKS_AT").as("job")
* .inV().as("company")
* .select("person", "job", "company"));
* results.forEach(s -> s.person().setStatus("ACTIVE")); // dirty auto-saved
* } // pre-flight version check + batch write on close
* }</pre>
*/
@SuppressWarnings("unchecked")
public <S> List<S> executeSubgraph(ClothoSession session, Class<S> subgraphType,
GraphTraversal<?, ?> traversal) {
return traversal.toList().stream()
.map(item -> {
if (!(item instanceof Map<?, ?> m)) {
throw new ClothoException(
"executeSubgraph() expected Map results but got: "
+ item.getClass().getName());
}
S assembled = assembler.assemble(subgraphType, (Map<String, Object>) m);
registerSubgraphEntities(assembled, session);
return assembled;
})
.toList();
}
/**
* Executes a traversal and returns the raw results without any Clotho mapping.
* Use for scalar results such as counts, property values, or aggregations.
@ -305,17 +266,6 @@ public abstract class ClothoRepository<T extends ClothoEntity> {
return (List<R>) traversal.toList();
}
private void registerSubgraphEntities(Object subgraphPojo, ClothoSession session) {
for (Field field : subgraphPojo.getClass().getDeclaredFields()) {
try {
field.setAccessible(true);
Object value = field.get(subgraphPojo);
if (value instanceof ClothoVertex v) session.register(v);
else if (value instanceof ClothoEdge e) session.register(e);
} catch (IllegalAccessException ignored) {}
}
}
// --- Built-in operations ---
/** Returns all entities of this repository's type, with boundaries populated. */
@ -389,44 +339,6 @@ public abstract class ClothoRepository<T extends ClothoEntity> {
}
}
/**
* Executes an arbitrary Gremlin update traversal with {@code @Version} pre-flight
* checking on the declared {@code guarded} entities, integrating with the session's
* identity map and auto-save lifecycle.
*
* <p>All guarded entities that carry {@code @Version} are verified before the traversal
* fires. A stale version throws {@link com.binarygolem.clotho.exception.OptimisticLockException}
* and the traversal is never executed. On success, version fields are incremented and
* {@code clearDirtyFields()} is called on every guarded entity so the session's
* auto-save on close does not re-apply the same writes.
*
* <p>Guarded entities must be loaded via the session before being passed here.
*
* @param session the active Clotho session
* @param traversal the update traversal; consumed do not reuse after this call
* @param guarded entities whose {@code @Version} fields gate the write
* @throws com.binarygolem.clotho.exception.OptimisticLockException if any versioned
* entity is stale
*/
public void executeUpdate(ClothoSession session, GraphTraversal<?, ?> traversal,
ClothoEntity... guarded) {
session.executeUpdate(traversal, guarded);
}
/**
* Variant of {@link #executeUpdate(ClothoSession, GraphTraversal, ClothoEntity...)}
* for graph elements not mapped to a registered Clotho entity class.
*
* @param session the active Clotho session
* @param traversal the update traversal; consumed do not reuse after this call
* @param guards version expectations for each graph element the traversal touches
* @throws com.binarygolem.clotho.exception.OptimisticLockException if any guard is stale
*/
public void executeUpdate(ClothoSession session, GraphTraversal<?, ?> traversal,
VersionGuard... guards) {
session.executeUpdate(traversal, guards);
}
/**
* Removes the entity from the graph.
*

View File

@ -12,8 +12,6 @@ import com.binarygolem.clotho.model.ClothoEdge;
import com.binarygolem.clotho.model.ClothoEntity;
import com.binarygolem.clotho.model.ClothoVertex;
import com.binarygolem.clotho.model.EntityState;
import com.binarygolem.clotho.model.VersionGuard;
import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversal;
import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource;
import org.jspecify.annotations.Nullable;
@ -227,67 +225,6 @@ public class ClothoSession implements AutoCloseable {
return transactional;
}
/**
* Executes an arbitrary Gremlin update traversal with {@code @Version} pre-flight
* checking on the declared {@code guarded} entities.
*
* <p>All guarded entities that carry {@code @Version} are verified before the traversal
* fires. A stale version throws {@link com.binarygolem.clotho.exception.OptimisticLockException}
* and the traversal is never executed. On success, version fields are incremented in
* both the graph and in memory, and {@code clearDirtyFields()} is called on every
* guarded entity so the session's auto-save on close does not re-apply the same writes.
*
* <p>Guarded entities must be loaded via this session (or explicitly
* {@link #register}-ed) before being passed here so that Clotho holds the correct
* baseline version.
*
* <p>Example:
* <pre>{@code
* Order order = session.load(Order.class, orderId);
* Account acct = session.load(Account.class, accountId);
*
* session.executeUpdate(
* g().V(orderId.asString()).property("status", "SHIPPED")
* .sideEffect(__.inV("PLACED_BY").property("orderCount", newCount)),
* order, acct
* );
* }</pre>
*
* @param traversal the update traversal; consumed do not reuse after this call
* @param guarded entities whose {@code @Version} fields gate the write
* @throws com.binarygolem.clotho.exception.OptimisticLockException if any versioned
* entity is stale
*/
public void executeUpdate(GraphTraversal<?, ?> traversal, ClothoEntity... guarded) {
persistor.executeVersionedWrite(traversal, List.of(guarded));
}
/**
* Variant of {@link #executeUpdate(GraphTraversal, ClothoEntity...)} for graph elements
* that are not mapped to a registered Clotho entity class such as
* {@link com.binarygolem.clotho.model.GenericVertex},
* {@link com.binarygolem.clotho.model.GenericEdge}, or any vertex/edge from a label
* with no {@code @VertexType}/{@code @EdgeType} declaration.
*
* <p>Example reading the version and writing with OCC on an unregistered vertex:
* <pre>{@code
* ARID id = ARID.of("#10:0");
* long ver = (long) executeRaw(g().V(id.asString()).values("_version")).get(0);
*
* session.executeUpdate(
* g().V(id.asString()).property("status", "archived"),
* VersionGuard.onVertex(id, ver)
* );
* }</pre>
*
* @param traversal the update traversal; consumed do not reuse after this call
* @param guards version expectations for each graph element the traversal touches
* @throws com.binarygolem.clotho.exception.OptimisticLockException if any guard is stale
*/
public void executeUpdate(GraphTraversal<?, ?> traversal, VersionGuard... guards) {
persistor.executeVersionedWrite(traversal, guards);
}
/**
* Evicts all loaded objects from the session identity map without touching the database.
* Forces a fresh graph fetch on the next {@link #load} call for any previously-loaded ID.
@ -368,23 +305,15 @@ public class ClothoSession implements AutoCloseable {
Object value = readField(field, vertex);
if (value instanceof List<?> list) {
for (Object item : list) {
if (item instanceof ClothoEdge e) register(e);
if (item instanceof ClothoEdge e) registerEdge(e);
else if (item instanceof ClothoVertex v) register(v);
}
} else if (value instanceof ClothoEdge e) register(e);
} else if (value instanceof ClothoEdge e) registerEdge(e);
else if (value instanceof ClothoVertex v) register(v);
}
}
/**
* Registers an already-hydrated edge (and its {@code @OutVertex}/{@code @InVertex}
* vertices) into this session's identity map as MANAGED.
* After this call, dirty-field tracking and auto-save-on-close apply to the edge.
*
* <p>Entities already cached by this session are skipped, so calling register()
* on a graph with shared references is safe and cycle-free.
*/
public void register(ClothoEdge edge) {
private void registerEdge(ClothoEdge edge) {
if (edge.getId() == null || edgeCache.containsKey(edge.getId())) return;
markManaged(edge);
edgeCache.put(edge.getId(), edge);

View File

@ -24,7 +24,6 @@ import org.junit.jupiter.api.Test;
import com.binarygolem.clotho.annotation.Version;
import com.binarygolem.clotho.exception.OptimisticLockException;
import com.binarygolem.clotho.model.EntityState;
import com.binarygolem.clotho.model.VersionGuard;
import java.util.List;
@ -489,211 +488,4 @@ class SubgraphPersistorTest {
assertEquals(4L, g.V("vi-6").values("_version").next());
assertEquals(4L, y.version);
}
// --- executeVersionedWrite with ClothoEntity ---
@Test
void executeVersionedWrite_entity_executesTraversalAndIncrementsVersion() {
graph.addVertex(T.id, "ev-1", T.label, "VersionedItem", "label", "original", "_version", 0L);
VersionedItem item = new VersionedItem();
item.setId(ARID.of("ev-1"));
item.version = 0L;
item.setState(EntityState.MANAGED);
persistor.executeVersionedWrite(
g.V("ev-1").property("label", "from-traversal"),
List.of(item));
assertEquals("from-traversal", g.V("ev-1").values("label").next());
assertEquals(1L, g.V("ev-1").values("_version").next(), "version must be incremented in graph");
assertEquals(1L, item.version, "in-memory version must be bumped");
}
@Test
void executeVersionedWrite_entity_throwsBeforeTraversalOnStaleVersion() {
graph.addVertex(T.id, "ev-2", T.label, "VersionedItem", "label", "untouched", "_version", 3L);
VersionedItem item = new VersionedItem();
item.setId(ARID.of("ev-2"));
item.version = 1L; // stale graph has 3
item.setState(EntityState.MANAGED);
assertThrows(OptimisticLockException.class, () ->
persistor.executeVersionedWrite(
g.V("ev-2").property("label", "should-not-apply"),
List.of(item)));
assertEquals("untouched", g.V("ev-2").values("label").next(), "traversal must not have run");
assertEquals(3L, g.V("ev-2").values("_version").next(), "version must be unchanged");
}
@Test
void executeVersionedWrite_entity_clearsDirtyFieldsOnSuccess() {
graph.addVertex(T.id, "ev-3", T.label, "VersionedItem", "label", "old", "_version", 0L);
VersionedItem item = new VersionedItem();
item.setId(ARID.of("ev-3"));
item.version = 0L;
item.setState(EntityState.MANAGED);
item.setLabel("dirty"); // marks field dirty
persistor.executeVersionedWrite(
g.V("ev-3").property("label", "via-traversal"),
List.of(item));
assertTrue(item.getDirtyFields().isEmpty(), "dirty fields must be cleared after executeVersionedWrite");
}
@Test
void executeVersionedWrite_entity_skipsVersionCheckForEntityWithoutVersionField() {
// Employee has no @Version executeVersionedWrite should still execute the traversal
graph.addVertex(T.id, "ev-4", T.label, "Employee", "name", "before");
Employee emp = new Employee();
emp.setId(ARID.of("ev-4"));
emp.setState(EntityState.MANAGED);
assertDoesNotThrow(() ->
persistor.executeVersionedWrite(
g.V("ev-4").property("name", "after"),
List.of(emp)));
assertEquals("after", g.V("ev-4").values("name").next());
}
@Test
void executeVersionedWrite_entity_simulatesRaceCondition() {
// Both "sessions" load version 0
graph.addVertex(T.id, "ev-5", T.label, "VersionedItem", "label", "initial", "_version", 0L);
VersionedItem sessionA = new VersionedItem();
sessionA.setId(ARID.of("ev-5"));
sessionA.version = 0L;
sessionA.setState(EntityState.MANAGED);
VersionedItem sessionB = new VersionedItem();
sessionB.setId(ARID.of("ev-5"));
sessionB.version = 0L; // same stale snapshot
sessionB.setState(EntityState.MANAGED);
// Session A writes first succeeds, version is now 1 in the graph
persistor.executeVersionedWrite(
g.V("ev-5").property("label", "written-by-A"),
List.of(sessionA));
assertEquals(1L, g.V("ev-5").values("_version").next());
// Session B attempts to write pre-flight detects stale version 0
assertThrows(OptimisticLockException.class, () ->
persistor.executeVersionedWrite(
g.V("ev-5").property("label", "written-by-B"),
List.of(sessionB)));
// Session A's write survives; session B's traversal never ran
assertEquals("written-by-A", g.V("ev-5").values("label").next());
assertEquals(1L, g.V("ev-5").values("_version").next());
}
// --- executeVersionedWrite with VersionGuard ---
@Test
void executeVersionedWrite_guard_executesTraversalAndIncrementsVersion() {
graph.addVertex(T.id, "gv-1", T.label, "AnyLabel", "status", "pending", "_version", 0L);
persistor.executeVersionedWrite(
g.V("gv-1").property("status", "done"),
VersionGuard.onVertex(ARID.of("gv-1"), 0L));
assertEquals("done", g.V("gv-1").values("status").next());
assertEquals(1L, g.V("gv-1").values("_version").next());
}
@Test
void executeVersionedWrite_guard_throwsBeforeTraversalOnStaleVersion() {
graph.addVertex(T.id, "gv-2", T.label, "AnyLabel", "status", "original", "_version", 5L);
assertThrows(OptimisticLockException.class, () ->
persistor.executeVersionedWrite(
g.V("gv-2").property("status", "overwrite-attempt"),
VersionGuard.onVertex(ARID.of("gv-2"), 2L))); // stale graph has 5
assertEquals("original", g.V("gv-2").values("status").next(), "traversal must not have run");
assertEquals(5L, g.V("gv-2").values("_version").next());
}
@Test
void executeVersionedWrite_guard_worksOnUnregisteredVertexType() {
// Label "UnknownThing" has no @VertexType class VersionGuard works regardless
graph.addVertex(T.id, "gv-3", T.label, "UnknownThing", "value", "old", "_version", 0L);
assertDoesNotThrow(() ->
persistor.executeVersionedWrite(
g.V("gv-3").property("value", "new"),
VersionGuard.onVertex(ARID.of("gv-3"), 0L)));
assertEquals("new", g.V("gv-3").values("value").next());
assertEquals(1L, g.V("gv-3").values("_version").next());
}
@Test
void executeVersionedWrite_guard_worksOnEdge() {
var empV = graph.addVertex(T.id, "gv-emp", T.label, "Employee");
var coV = graph.addVertex(T.id, "gv-co", T.label, "Company");
empV.addEdge("WORKS_AT", coV, T.id, "gv-e1", "role", "junior", "_version", 0L);
persistor.executeVersionedWrite(
g.E("gv-e1").property("role", "senior"),
VersionGuard.onEdge(ARID.of("gv-e1"), 0L));
assertEquals("senior", g.E("gv-e1").values("role").next());
assertEquals(1L, g.E("gv-e1").values("_version").next());
}
@Test
void executeVersionedWrite_guard_customVersionProperty() {
graph.addVertex(T.id, "gv-4", T.label, "AnyLabel", "data", "before", "rev", 7L);
persistor.executeVersionedWrite(
g.V("gv-4").property("data", "after"),
VersionGuard.onVertex(ARID.of("gv-4"), "rev", 7L));
assertEquals("after", g.V("gv-4").values("data").next());
assertEquals(8L, g.V("gv-4").values("rev").next());
}
@Test
void executeVersionedWrite_guard_multipleGuards_allPassOrNoneFire() {
graph.addVertex(T.id, "gv-5", T.label, "AnyLabel", "x", "A", "_version", 1L);
graph.addVertex(T.id, "gv-6", T.label, "AnyLabel", "x", "B", "_version", 2L);
// Both versions correct traversal fires, both versions increment
persistor.executeVersionedWrite(
g.V("gv-5").property("x", "A2").V("gv-6").property("x", "B2"),
VersionGuard.onVertex(ARID.of("gv-5"), 1L),
VersionGuard.onVertex(ARID.of("gv-6"), 2L));
assertEquals("A2", g.V("gv-5").values("x").next());
assertEquals(2L, g.V("gv-5").values("_version").next());
assertEquals("B2", g.V("gv-6").values("x").next());
assertEquals(3L, g.V("gv-6").values("_version").next());
}
@Test
void executeVersionedWrite_guard_simulatesRaceCondition() {
graph.addVertex(T.id, "gv-7", T.label, "AnyLabel", "val", "start", "_version", 0L);
// Writer A reads version 0 and succeeds
persistor.executeVersionedWrite(
g.V("gv-7").property("val", "A-wins"),
VersionGuard.onVertex(ARID.of("gv-7"), 0L));
assertEquals(1L, g.V("gv-7").values("_version").next());
// Writer B had also read version 0 now stale
assertThrows(OptimisticLockException.class, () ->
persistor.executeVersionedWrite(
g.V("gv-7").property("val", "B-loses"),
VersionGuard.onVertex(ARID.of("gv-7"), 0L)));
assertEquals("A-wins", g.V("gv-7").values("val").next(), "B's traversal must not have run");
}
}

View File

@ -18,10 +18,6 @@
exploratory testing during library development. Not a production artifact.
</description>
<properties>
<maven.deploy.skip>true</maven.deploy.skip>
</properties>
<dependencies>
<dependency>

12
pom.xml
View File

@ -19,20 +19,8 @@
<module>clotho-dev-app</module>
</modules>
<distributionManagement>
<repository>
<id>gitea</id>
<url>${gitea.url}</url>
</repository>
<snapshotRepository>
<id>gitea</id>
<url>${gitea.url}</url>
</snapshotRepository>
</distributionManagement>
<properties>
<java.version>21</java.version>
<gitea.url>https://gitea.dev.binarygolem.com/api/packages/binarygolem/maven</gitea.url>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>