Table of Contents

Relationships

A relationship is a named link between two resource types, including a direction. They are similar to navigation properties in Entity Framework Core.

Relationships come in two flavors: to-one and to-many. The left side of a relationship is where the relationship is declared, the right side is the resource type it points to.

HasOne

This exposes a to-one relationship.

#nullable enable

public class TodoItem : Identifiable<int>
{
    [HasOne]
    public Person? Owner { get; set; }
}

The left side of this relationship is of type TodoItem (public name: "todoItems") and the right side is of type Person (public name: "persons").

One-to-one relationships in Entity Framework Core

By default, Entity Framework Core tries to generate an identifying foreign key for a one-to-one relationship whenever possible. In that case, no foreign key column is generated. Instead the primary keys point to each other directly.

That mechanism does not make sense for JSON:API, because patching a relationship would result in also changing the identity of a resource. Naming the foreign key explicitly fixes the problem, which enforces to create a foreign key column.

The next example defines that each car requires an engine, while an engine is optionally linked to a car.

#nullable enable

public sealed class Car : Identifiable<int>
{
    [HasOne]
    public Engine Engine { get; set; } = null!;
}

public sealed class Engine : Identifiable<int>
{
    [HasOne]
    public Car? Car { get; set; }
}

public sealed class AppDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder builder)
    {
        builder.Entity<Car>()
            .HasOne(car => car.Engine)
            .WithOne(engine => engine.Car)
            .HasForeignKey<Car>();
    }
}

Which results in Entity Framework Core generating the next database objects:

CREATE TABLE "Engine" (
    "Id" integer GENERATED BY DEFAULT AS IDENTITY,
    CONSTRAINT "PK_Engine" PRIMARY KEY ("Id")
);

CREATE TABLE "Cars" (
    "Id" integer NOT NULL,
    CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"),
    CONSTRAINT "FK_Cars_Engine_Id" FOREIGN KEY ("Id") REFERENCES "Engine" ("Id")
        ON DELETE CASCADE
);

To fix this, name the foreign key explicitly:

protected override void OnModelCreating(ModelBuilder builder)
{
    builder.Entity<Car>()
        .HasOne(car => car.Engine)
        .WithOne(engine => engine.Car)
        .HasForeignKey<Car>("EngineId"); // <-- Explicit foreign key name added
}

Which generates the correct database objects:

CREATE TABLE "Engine" (
    "Id" integer GENERATED BY DEFAULT AS IDENTITY,
    CONSTRAINT "PK_Engine" PRIMARY KEY ("Id")
);

CREATE TABLE "Cars" (
    "Id" integer GENERATED BY DEFAULT AS IDENTITY,
    "EngineId" integer NOT NULL,
    CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"),
    CONSTRAINT "FK_Cars_Engine_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engine" ("Id")
        ON DELETE CASCADE
);

CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId");

Optional one-to-one relationships in Entity Framework Core

For optional one-to-one relationships, Entity Framework Core uses DeleteBehavior.ClientSetNull by default, instead of DeleteBehavior.SetNull. This means that Entity Framework Core tries to handle the cascading effects (by sending multiple SQL statements), instead of leaving it up to the database. Of course that's only going to work when all the related resources are loaded in the change tracker upfront, which is expensive because it requires fetching more data than necessary.

The reason for this odd default is poor support in SQL Server, as explained here and here.

Our testing shows that these limitations don't exist when using PostgreSQL. Therefore the general advice is to map the delete behavior of optional one-to-one relationships explicitly with .OnDelete(DeleteBehavior.SetNull). This is simpler and more efficient.

The next example defines that each car optionally has an engine, while an engine is optionally linked to a car.

#nullable enable

public sealed class Car : Identifiable<int>
{
    [HasOne]
    public Engine? Engine { get; set; }
}

public sealed class Engine : Identifiable<int>
{
    [HasOne]
    public Car? Car { get; set; }
}

public sealed class AppDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder builder)
    {
        builder.Entity<Car>()
            .HasOne(car => car.Engine)
            .WithOne(engine => engine.Car)
            .HasForeignKey<Car>("EngineId");
    }
}

Which results in Entity Framework Core generating the next database objects:

CREATE TABLE "Engines" (
    "Id" integer GENERATED BY DEFAULT AS IDENTITY,
    CONSTRAINT "PK_Engines" PRIMARY KEY ("Id")
);

CREATE TABLE "Cars" (
    "Id" integer GENERATED BY DEFAULT AS IDENTITY,
    "EngineId" integer NULL,
    CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"),
    CONSTRAINT "FK_Cars_Engines_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engines" ("Id")
);

CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId");

To fix this, set the delete behavior explicitly:

public sealed class AppDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder builder)
    {
        builder.Entity<Car>()
            .HasOne(car => car.Engine)
            .WithOne(engine => engine.Car)
            .HasForeignKey<Car>("EngineId")
            .OnDelete(DeleteBehavior.SetNull); // <-- Explicit delete behavior set
    }
}

Which generates the correct database objects:

CREATE TABLE "Engines" (
    "Id" integer GENERATED BY DEFAULT AS IDENTITY,
    CONSTRAINT "PK_Engines" PRIMARY KEY ("Id")
);

CREATE TABLE "Cars" (
    "Id" integer GENERATED BY DEFAULT AS IDENTITY,
    "EngineId" integer NULL,
    CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"),
    CONSTRAINT "FK_Cars_Engines_EngineId" FOREIGN KEY ("EngineId") REFERENCES "Engines" ("Id") ON DELETE SET NULL
);

CREATE UNIQUE INDEX "IX_Cars_EngineId" ON "Cars" ("EngineId");

HasMany

This exposes a to-many relationship.

public class Person : Identifiable<int>
{
    [HasMany]
    public ICollection<TodoItem> TodoItems { get; set; } = new HashSet<TodoItem>();
}

The left side of this relationship is of type Person (public name: "persons") and the right side is of type TodoItem (public name: "todoItems").

HasManyThrough

removed since v5.0

Earlier versions of Entity Framework Core (up to v5) did not support many-to-many relationships without a join entity. For this reason, earlier versions of JsonApiDotNetCore filled this gap by allowing applications to declare a relationship as HasManyThrough, which would expose the relationship to the client the same way as any other HasMany relationship. However, under the covers it would use the join type and Entity Framework Core's APIs to get and set the relationship.

#nullable disable

public class Article : Identifiable<int>
{
    // tells Entity Framework Core to ignore this property
    [NotMapped]

    // tells JsonApiDotNetCore to use the join table below
    [HasManyThrough(nameof(ArticleTags))]
    public ICollection<Tag> Tags { get; set; }

    // this is the Entity Framework Core navigation to the join table
    public ICollection<ArticleTag> ArticleTags { get; set; }
}

The left side of this relationship is of type Article (public name: "articles") and the right side is of type Tag (public name: "tags").

Name

There are two ways the exposed relationship name is determined:

  1. Using the configured naming convention.

  2. Individually using the attribute's constructor.

#nullable enable
public class TodoItem : Identifiable<int>
{
    [HasOne(PublicName = "item-owner")]
    public Person Owner { get; set; } = null!;
}

Capabilities

since v5.1

Default JSON:API relationship capabilities are specified in JsonApiOptions and JsonApiOptions:

options.DefaultHasOneCapabilities = HasOneCapabilities.None; // default: All
options.DefaultHasManyCapabilities = HasManyCapabilities.None; // default: All

This can be overridden per relationship.

AllowView

Indicates whether the relationship can be returned in responses. When not allowed and requested using ?fields[]=, it results in an HTTP 400 response. Otherwise, the relationship (and its related resources, when included) are silently omitted.

Warning

This setting does not affect retrieving the related resources directly.

#nullable enable

public class User : Identifiable<int>
{
    [HasOne(Capabilities = ~HasOneCapabilities.AllowView)]
    public LoginAccount Account { get; set; } = null!;
}

AllowInclude

Indicates whether the relationship can be included. When not allowed and used in ?include=, an HTTP 400 is returned.

#nullable enable

public class User : Identifiable<int>
{
    [HasMany(Capabilities = ~HasManyCapabilities.AllowInclude)]
    public ISet<Group> Groups { get; set; } = new HashSet<Group>();
}

AllowFilter

For to-many relationships only. Indicates whether it can be used in the count() and has() filter functions. When not allowed and used in ?filter=, an HTTP 400 is returned.

#nullable enable

public class User : Identifiable<int>
{
    [HasMany(Capabilities = HasManyCapabilities.AllowFilter)]
    public ISet<Group> Groups { get; set; } = new HashSet<Group>();
}

AllowSet

Indicates whether POST and PATCH requests can replace the relationship. When sent but not allowed, an HTTP 422 response is returned.

#nullable enable

public class User : Identifiable<int>
{
    [HasOne(Capabilities = ~HasOneCapabilities.AllowSet)]
    public LoginAccount Account { get; set; } = null!;
}

AllowAdd

For to-many relationships only. Indicates whether POST requests can add resources to the relationship. When sent but not allowed, an HTTP 422 response is returned.

#nullable enable

public class User : Identifiable<int>
{
    [HasMany(Capabilities = ~HasManyCapabilities.AllowAdd)]
    public ISet<Group> Groups { get; set; } = new HashSet<Group>();
}

AllowRemove

For to-many relationships only. Indicates whether DELETE requests can remove resources from the relationship. When sent but not allowed, an HTTP 422 response is returned.

#nullable enable

public class User : Identifiable<int>
{
    [HasMany(Capabilities = ~HasManyCapabilities.AllowRemove)]
    public ISet<Group> Groups { get; set; } = new HashSet<Group>();
}

CanInclude

obsolete since v5.1

Relationships can be marked to disallow including them using the ?include= query string parameter. When not allowed, it results in an HTTP 400 response.

#nullable enable

public class TodoItem : Identifiable<int>
{
    [HasOne(CanInclude: false)]
    public Person? Owner { get; set; }
}

Eager loading

since v4.0

Your resource may expose a calculated property, whose value depends on a related entity that is not exposed as a JSON:API resource. So for the calculated property to be evaluated correctly, the related entity must always be retrieved. You can achieve that using EagerLoad, for example:

#nullable enable

public class ShippingAddress : Identifiable<int>
{
    [Attr]
    public string Street { get; set; } = null!;

    [Attr]
    public string? CountryName => Country?.DisplayName;

    // not exposed as resource, but adds .Include("Country") to the query
    [EagerLoad]
    public Country? Country { get; set; }
}

public class Country
{
    public string IsoCode { get; set; } = null!;
    public string DisplayName { get; set; } = null!;
}