# 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 com.binarygolem.clotho clotho-spring-boot-starter 0.1.0-SNAPSHOT ``` No `@Enable*` annotation is needed. Autoconfiguration activates automatically when the starter is on the classpath. ### Without Spring (core only) ```xml com.binarygolem.clotho clotho-core 0.1.0-SNAPSHOT ``` --- ## 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 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 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` and annotate with `@ClothoRepository`. Spring picks it up automatically when it is in a scanned package. ```java @ClothoRepository public class PersonRepository extends ClothoRepository { public PersonRepository(ClothoSessionFactory factory) { super(factory); } // Built-in: findAll(), findById(), count(), save(), delete(), findWithPagination() public List findByName(String name) { return execute(g().V().has("Person", "name", name)); } public List findOlderThan(int minAge) { return execute(allEntities(), t -> t.has("age", P.gt(minAge))); } public List 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 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 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 { public EmploymentRepository(ClothoSessionFactory factory) { super(factory); } public List 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 |