Identified relationships¶
Map an entity to a Neo4j relationship that has its own application key with ToGraphIdentifiedRelationship. Use it when the same two nodes can be connected more than once, such as an actor with two roles in one movie, or when a relationship needs a stable ID of its own.
public sealed class Credit
{
public Guid Id { get; set; }
public Guid PersonId { get; set; }
public Guid MovieId { get; set; }
public string Role { get; set; } = "";
public int Billing { get; set; }
}
model.Entity<Credit>(credit =>
{
credit.HasOne<Person>().WithMany().HasForeignKey(c => c.PersonId);
credit.HasOne<Movie>().WithMany().HasForeignKey(c => c.MovieId);
credit.Property(c => c.Id).ValueGeneratedNever().HasGraphPropertyName("id");
credit.Property(c => c.Role).HasGraphPropertyName("role");
credit.Property(c => c.Billing).HasGraphPropertyName("billing");
credit.ToGraphIdentifiedRelationship("ACTED_IN", nameof(Credit.Id), [nameof(Credit.PersonId)], [nameof(Credit.MovieId)]);
});
Each Credit is one (:Person)-[:ACTED_IN {id, role, billing}]->(:Movie) relationship. The key and the payload are stored on the relationship; PersonId and MovieId come from the endpoint nodes' keys. Hugo Weaving plays two roles in Cloud Atlas, so the graph holds two ACTED_IN relationships between the same two nodes, each with its own id.
var query = db.Credits
.Where(c => c.PersonId == SeedData.Hugo && c.MovieId == SeedData.CloudAtlas)
.OrderBy(c => c.Billing);
var credits = await query.ToListAsync(cancellationToken); // Bill Smoke, Nurse Noakes
MATCH (s:`Person`)-[r:`ACTED_IN`]->(t:`Movie`)
CALL (r) {
MATCH ()-[candidate:`ACTED_IN`]->()
WHERE candidate.`id` = r.`id`
WITH candidate LIMIT 2
RETURN count(*) AS `__gb_identified_edge_count`
}
WITH s, r, t, `__gb_identified_edge_count`
WHERE (coalesce(s.`id` = $p0, (s.`id` IS NULL AND $p0 IS NULL)) AND coalesce(t.`id` = $p1, (t.`id` IS NULL AND $p1 IS NULL)))
RETURN r.`id` AS `c0`, t.`id` AS `c2`, s.`id` AS `c3`, r.`billing` AS `c1`, r.`role` AS `c4`, `__gb_identified_edge_count` AS `__gb_identified_edge_count`
ORDER BY r.`billing` IS NULL DESC, r.`billing` ASC
The CALL block checks that the relationship's key is unique across all ACTED_IN relationships, before filtering and paging. A row is shaped only when exactly one relationship has that key.
Mapping contract¶
ToGraphIdentifiedRelationship(relationshipType, keyProperty, sourceForeignKeyProperties, targetForeignKeyProperties) takes the relationship type, the name of the key property and two arrays of foreign-key property names. Each array names one complete EF foreign key in its declared order. The relationship points from the source foreign key's node to the target foreign key's node.
The model is validated when it is built; a violation throws InvalidOperationException (GraphBridge model '<Entity>': ...).
- Key: exactly one primary-key property, named by
keyPropertyand separate from the endpoint foreign keys. It is required and stable (not a row version) and has typeGuid,string,intorlong. String keys cannot be blank. Assign it yourself, as with node keys. Composite relationship keys are not supported. - Endpoints: two required, non-nullable, many-to-one foreign keys to the primary keys of mapped nodes. Composite endpoint keys are supported. Endpoint foreign keys use the same type and converter instance as the node keys they reference.
- Payload: every other mapped property is stored on the relationship.
IsRowVersion()on alongproperty andIsConcurrencyToken()work as on nodes. - Navigations: reference navigations from the relationship entity to its endpoints, with inverse collections on the nodes, are supported. Skip navigations through an identified relationship are rejected.
- Ownership: a relationship type has one identified-relationship owner, or several told apart by their endpoint types; see shared relationship types. The type cannot be shared with an association or a native reference, and
ToGraphIdentifiedRelationshipandToGraphRelationshipare mutually exclusive. - An identified relationship cannot be the endpoint of another relationship. If something needs to point at it, model it as a node.
Saves and reads require existing uniqueness constraints for the relationship key and for both endpoint node keys:
CREATE CONSTRAINT `<name>` FOR ()-[r:`ACTED_IN`]-() REQUIRE (r.`id`) IS UNIQUE
GraphBridge checks them and never creates them. A missing relationship constraint throws InvalidOperationException (GraphBridge requires an existing relationship uniqueness constraint for :ACTED_IN (id)...). Schema inspection proposes it; see schema preflight.
Parallel relationships¶
Different keys can connect the same endpoint pair. Adding a third role is just another entity:
db.Credits.Add(new Credit { Id = id, PersonId = SeedData.Hugo, MovieId = SeedData.CloudAtlas, Role = "Old Georgie", Billing = 9 });
await db.SaveChangesAsync(cancellationToken); // a new relationship between the same two nodes
An add always uses CREATE, never MERGE, between existing endpoints. Endpoints added in the same save are created first. Updates write only the modified payload properties and check concurrency tokens; deletes remove only the relationship. Every operation must affect exactly one relationship, or the whole save rolls back. The save count is the number of EF entries.
Shared relationship types¶
Several entity types can own one relationship type, as neo4j-graphrag's FROM_CHUNK links every kind of entity to its chunk. Each owner is mapped as usual, with the same type:
model.Entity<PersonMention>(m =>
{
m.HasOne<Person>().WithMany().HasForeignKey(x => x.PersonId);
m.HasOne<Chunk>().WithMany().HasForeignKey(x => x.ChunkId);
m.Property(x => x.Id).ValueGeneratedNever().HasGraphPropertyName("id");
m.ToGraphIdentifiedRelationship("FROM_CHUNK", nameof(PersonMention.Id), [nameof(PersonMention.PersonId)], [nameof(PersonMention.ChunkId)]);
});
model.Entity<OrganizationMention>(m =>
{
m.HasOne<Organization>().WithMany().HasForeignKey(x => x.OrganizationId);
m.HasOne<Chunk>().WithMany().HasForeignKey(x => x.ChunkId);
m.Property(x => x.Id).ValueGeneratedNever().HasGraphPropertyName("id");
m.ToGraphIdentifiedRelationship("FROM_CHUNK", nameof(OrganizationMention.Id), [nameof(OrganizationMention.OrganizationId)], [nameof(OrganizationMention.ChunkId)]);
});
- Told apart by their endpoints: every read and keyed write names both endpoint labels, so each owner sees and changes only the relationships between its own endpoint types. From
ChunktoPersonwould be a different pair fromPersontoChunk, and allowed. - One key space: all owners store their key under one property name, here
id, and one uniqueness constraint on the type covers them all, so keys are unique across owners. A key another owner holds is refused: by the constraint whenSaveChangesadds it, by the endpoint check (DbUpdateConcurrencyException) when a keyed update comes through the wrong owner, and by the preflight ofExecuteUpsertAsync. - Refused when the model is built: two owners with the same source and target node types, which reads couldn't tell apart, and owners whose key property names differ.
- Paths: a bounded or undirected Fluent path over a type with several mappings is refused with
GBFC203, because it constrains only its two ends and could cross another owner's relationships. - Schema: schema comparison reports the type as shared (
SharedRelationshipType) and checks and proposes its key constraint once. The mapping generator doesn't produce shared types; write these mappings by hand.
Design: ADR 0056.
Endpoints are immutable¶
A relationship's endpoints are part of its identity. Changing PersonId or MovieId on a saved Credit throws NotSupportedException when you save (Changing an identified-edge key or rebinding its endpoints is unsupported...).
To move a credit, delete it and add a new one with a new key. Deleting and re-adding the same key in one save is also refused with NotSupportedException.
Query¶
Identified-relationship roots support the same filter, ordering, paging and direct scalar projection subset as node roots, with EF identity resolution and reference and inverse fix-up. Scalar projections keep hidden key and endpoint values, so they are validated too. Validation covers returned rows only; it is not a whole-graph scan.
Limits¶
Count,LongCountandAnyon an identified-relationship root are refused with GBQ170.Includeof an identified relationship is refused with GBQ140.- No endpoint rebinding, composite relationship keys or skip navigations.
- To match patterns over these relationships (co-actors, two roles in one movie), use Fluent Cypher.
Try it¶
- Workshop lesson 11 ยท Identified relationships.
- The Organization sample generates parallel employment relationships; the Movies sample scaffolds identified
ACTED_INandDIRECTEDrelationships from an existing database.
Design: ADR-0016. Evidence: EF behavior and conformance matrix.