Associations¶
Store an EF many-to-many join entity as one directed Neo4j relationship with ToGraphRelationship. Use it for links between things, optionally with properties on the link: a person likes a movie with a number of stars, a person follows another person.
model.Entity<Person>()
.HasMany(p => p.LikedMovies).WithMany(m => m.LikedBy)
.UsingEntity<Like>(
right => right.HasOne(l => l.Movie).WithMany(m => m.Likes).HasForeignKey(l => l.MovieId),
left => left.HasOne(l => l.Person).WithMany(p => p.Likes).HasForeignKey(l => l.PersonId),
join =>
{
join.HasKey(l => new { l.PersonId, l.MovieId });
join.Property(l => l.Stars).HasGraphPropertyName("stars");
join.ToGraphRelationship("LIKES", [nameof(Like.PersonId)], [nameof(Like.MovieId)]);
});
// An association between two people, with no navigations: (:Person)-[:FOLLOWS]->(:Person).
model.Entity<Follow>(follows =>
{
follows.HasKey(f => new { f.FollowerId, f.FolloweeId });
follows.HasOne<Person>().WithMany().HasForeignKey(f => f.FollowerId);
follows.HasOne<Person>().WithMany().HasForeignKey(f => f.FolloweeId);
follows.ToGraphRelationship("FOLLOWS", [nameof(Follow.FollowerId)], [nameof(Follow.FolloweeId)]);
});
Each Like entity is exactly one (:Person)-[:LIKES {stars: 4}]->(:Movie) relationship. There is no join node, and the endpoint keys are not copied onto the relationship: Like.PersonId and Like.MovieId come from the endpoint nodes' keys. The join is still a normal EF entity that you can query, change and delete, and EF maintains the skip navigations Person.LikedMovies and Movie.LikedBy.
Query the association through its DbSet:
var query = db.Likes.Where(l => l.Stars >= 4).OrderByDescending(l => l.Stars);
var likes = await query.ToListAsync(cancellationToken);
MATCH (s:`Person`)-[r:`LIKES`]->(t:`Movie`)
CALL (s, t) {
MATCH (s)-[candidate:`LIKES`]->(t)
WITH candidate LIMIT 2
RETURN count(*) AS `__gb_association_count`
}
WITH s, r, t, `__gb_association_count`
WHERE coalesce(r.`stars` >= $p0, false)
RETURN s.`id` AS `c0`, t.`id` AS `c1`, r.`stars` AS `c2`, `__gb_association_count` AS `__gb_association_count`
ORDER BY r.`stars` IS NULL ASC, r.`stars` DESC
The CALL block counts the LIKES relationships between each returned pair, independently of the filter. A row is shaped only when exactly one exists.
Mapping contract¶
ToGraphRelationship(relationshipType, sourceForeignKeyProperties, targetForeignKeyProperties) takes the relationship type and two arrays of foreign-key property names. Each array names one complete EF foreign key in its declared order (composite foreign keys are supported). The relationship always points from the source foreign key's node to the target foreign key's node; nothing is inferred from names.
The model is validated when it is built; a violation throws InvalidOperationException (GraphBridge model '<Entity>': ...).
- The join entity has exactly two foreign keys. Both are required, non-nullable, non-generated, many-to-one, and target the primary key of a mapped node.
- The join's primary key is exactly the union of the two foreign keys. This is what makes it a pair association.
- Every other mapped property is a payload property stored on the relationship.
HasGraphPropertyNameapplies as usual. - Endpoint foreign keys use the same type and the same converter instance as the node keys they reference.
- Supported navigations are references from the join to its endpoints (
Like.Person) with their inverse collections (Person.Likes), and skip navigations (Person.LikedMovies,Movie.LikedBy) that use the two mapped roles. Associations without navigations, likeFollow, and self-associations (person to person) are supported. - EF shared-type dictionary joins (
UsingEntity<Dictionary<string, object>>("Favorite", ...)) work when mapped the same way; query them withdb.Set<Dictionary<string, object>>("Favorite"). - Two association entities cannot map the same source label, relationship type and target label. A relationship type cannot be shared with a native reference or an identified relationship.
- Not supported: alternate keys on the join, a join that is itself the endpoint of another relationship,
ToGraphNodeorToGraphReferenceon the same entity.ToGraphRelationshipandToGraphIdentifiedRelationshipare mutually exclusive.
Both endpoint labels need a reviewed uniqueness constraint on their keys. Saves and association reads check that the constraints exist in the selected database; GraphBridge never creates them. See schema preflight.
One relationship per pair¶
A pair association allows one relationship of its type per (source, target) pair. The pair is the identity:
- EF itself refuses to track two
Likeentities with the same key, so a second like for the same person and movie fails before anything reaches Neo4j. - If the database holds two physical
LIKESrelationships for one pair (written outside GraphBridge), reads of that pair throw before EF shapes a row, and updates and deletes of it fail. GraphBridge never picks one of them.
For several relationships between the same two nodes (two roles in one movie), use an identified relationship.
Save changes¶
var hugo = await db.People.SingleAsync(p => p.Id == SeedData.Hugo, cancellationToken);
var wick = await db.Movies.SingleAsync(m => m.Id == SeedData.JohnWick, cancellationToken);
hugo.LikedMovies.Add(wick); // EF creates the Like join entity
await db.SaveChangesAsync(cancellationToken); // one LIKES relationship
- Add creates the relationship between existing endpoints with
MERGEand sets its payload on creation. Endpoints added in the same save are created first. If the relationship already exists, the add succeeds only when its stored payload matches; otherwise the save throwsDbUpdateConcurrencyExceptionand nothing is overwritten. A missing endpoint also throwsDbUpdateConcurrencyException. - Update writes only the modified payload properties.
IsRowVersion()on alongpayload property andIsConcurrencyToken()give optimistic concurrency, as on nodes. - Delete removes only the relationship; both nodes stay. In one save, relationship deletions run before node deletions, so you can delete a node together with its associations.
- Move an association by removing the old join and adding a new one; you cannot change its endpoint keys. Removing and re-adding the same pair in one save is refused; save the removal first.
Every operation locks its endpoints and must affect exactly one relationship, or the whole save rolls back. The save count is the number of EF entries. A writer outside GraphBridge that locks in a different order can make Neo4j abort a save with a deadlock error; GraphBridge does not retry automatically.
Query¶
Association roots support the same filter, ordering, paging and direct scalar projection subset as node roots. EF performs tracking and fixes up reference, inverse and skip navigations as endpoints and joins are tracked. Querying an association does not mark a whole navigation as loaded.
Each returned row is validated (one physical relationship, well-formed endpoint keys) before it is shaped, including scalar projections. This covers returned rows only; it is not a whole-graph scan, and rows shaped before a failing row may already be tracked.
Limits¶
Count,LongCountandAnyon an association root are refused with GBQ170.Includeof an association or skip navigation is refused with GBQ140. Navigation predicates such asp.LikedMovies.Any()are not translated.- One relationship per endpoint pair; endpoints cannot be changed in place.
- No automatic retry after a deadlock abort or an uncertain commit; see transactions and concurrency.
- To match relationship patterns (co-actors, friends of friends), use Fluent Cypher.
Try it¶
- Workshop lesson 10 ยท Associations (many-to-many).
- The Fluent Cypher sample maps a
Friendshipassociation and matches it inEXISTSpatterns and bounded paths.
Design: ADR-0015. Evidence: EF behavior and conformance matrix.