Onyx Database Relationships
Many-to-Many Relationship Tutorial
The many-to-many relationship captures associations where multiple instances of one entity correspond to multiple instances of another. Use this guide to compare the ORM workflow in Onyx Open Source with the resolver-driven pattern in Onyx Cloud.
Switch between Onyx Open Source and Onyx Cloud to view the relevant steps and code examples for your runtime.
Onyx Open Source Tutorial
Switch to Onyx Open Source to review the ORM walkthrough and annotated entity snippets.
Onyx Cloud Tutorial
Model the relationship with resolver-backed attributes that read from a dedicated join table. Persist both the base records and join rows, then hydrate collections on demand with resolve().
1
Capture the schema revision
Declare the
Movie, Actor, and MovieCast tables in JSON. Attach resolvers named after the collections you want to expose so queries remain consistent across the API and SDK.1{
2 "tables": [
3 {
4 "name": "Movie",
5 "identifier": {
6 "name": "movieId",
7 "type": "String",
8 "generator": "None"
9 },
10 "attributes": [
11 {
12 "name": "movieId",
13 "type": "String"
14 },
15 {
16 "name": "title",
17 "type": "String"
18 }
19 ],
20 "indexes": [],
21 "resolvers": [
22 {
23 "name": "actors",
24 "resolver": "const castLinks = await db\n .from("MovieCast")\n .where(eq("movieId", this.movieId))\n .list();\n\nif (castLinks.length === 0) {\n return [];\n}\n\nconst actors = await db\n .from("Actor")\n .where(inOp("actorId", castLinks.map((link) => link.actorId)))\n .list();\n\nreturn actors;"
25 }
26 ],
27 "triggers": []
28 },
29 {
30 "name": "Actor",
31 "identifier": {
32 "name": "actorId",
33 "type": "String",
34 "generator": "None"
35 },
36 "attributes": [
37 {
38 "name": "actorId",
39 "type": "String"
40 },
41 {
42 "name": "actorFirstName",
43 "type": "String"
44 },
45 {
46 "name": "actorLastName",
47 "type": "String"
48 }
49 ],
50 "indexes": [],
51 "resolvers": [
52 {
53 "name": "movies",
54 "resolver": "const castLinks = await db\n .from("MovieCast")\n .where(eq("actorId", this.actorId))\n .list();\n\nif (castLinks.length === 0) {\n return [];\n}\n\nconst movies = await db\n .from("Movie")\n .where(inOp("movieId", castLinks.map((link) => link.movieId)))\n .list();\n\nreturn movies;"
55 }
56 ],
57 "triggers": []
58 },
59 {
60 "name": "MovieCast",
61 "identifier": {
62 "name": "id",
63 "type": "String",
64 "generator": "None"
65 },
66 "attributes": [
67 {
68 "name": "id",
69 "type": "String"
70 },
71 {
72 "name": "movieId",
73 "type": "String"
74 },
75 {
76 "name": "actorId",
77 "type": "String"
78 },
79 {
80 "name": "role",
81 "type": "String"
82 }
83 ],
84 "indexes": [],
85 "resolvers": [],
86 "triggers": []
87 }
88 ],
89 "revisionDescription": "Initial schema for many-to-many relationship example"
90}- Include both
movieIdandactorIdcolumns on the join table to filter efficiently inside resolvers. - Return arrays from to-many resolvers and leverage
inOp()to fetch the related side in a single round trip.
2
Persist movies, actors, and join rows
Create deterministic identifiers for each record, save the core tables, and then insert the
MovieCast rows that represent the relationship.1// Define Harrison Ford Actor
2const harrisonFordActor = new Actor();
3harrisonFordActor.actorFirstName = "Harrison";
4harrisonFordActor.actorLastName = "Ford";
5harrisonFordActor.actorId = "actor_harrisonFord";
6
7// Define Mark Hamill Actor
8const markHamillActor = new Actor();
9markHamillActor.actorFirstName = "Mark";
10markHamillActor.actorLastName = "Hamill";
11markHamillActor.actorId = "actor_markHamill";
12
13// Create Star Wars Movie
14const starWarsMovie = new Movie();
15starWarsMovie.title = "A New Hope";
16starWarsMovie.movieId = "movie_starWars";
17
18// Create Indiana Jones Movie
19const indianaJonesMovie = new Movie();
20indianaJonesMovie.title = "Raiders of the Lost Ark";
21indianaJonesMovie.movieId = "movie_indianaJones";
22
23// Persist the core records
24await db.save(starWarsMovie);
25await db.save(indianaJonesMovie);
26await db.save(harrisonFordActor);
27await db.save(markHamillActor);
28
29// Create the join rows that link movies and actors
30const starWarsHarrison = new MovieCast();
31starWarsHarrison.id = "cast_001";
32starWarsHarrison.movieId = starWarsMovie.movieId;
33starWarsHarrison.actorId = harrisonFordActor.actorId;
34starWarsHarrison.role = "Han Solo";
35
36const starWarsMark = new MovieCast();
37starWarsMark.id = "cast_002";
38starWarsMark.movieId = starWarsMovie.movieId;
39starWarsMark.actorId = markHamillActor.actorId;
40starWarsMark.role = "Luke Skywalker";
41
42const indianaJonesHarrison = new MovieCast();
43indianaJonesHarrison.id = "cast_003";
44indianaJonesHarrison.movieId = indianaJonesMovie.movieId;
45indianaJonesHarrison.actorId = harrisonFordActor.actorId;
46indianaJonesHarrison.role = "Indiana Jones";
47
48await db.save(starWarsHarrison);
49await db.save(starWarsMark);
50await db.save(indianaJonesHarrison);- Resolver-backed attributes are derived; persist the join rows explicitly to make the association durable.
- Stable IDs simplify testing and keep the example output predictable—use sequences or UUIDs in production.
3
Query through the resolvers
Resolve the
actors collection when fetching a movie and hydrate movies when retrieving an actor to verify both sides of the relationship.1// Fetch a movie with its cast hydrated via the resolver
2const savedMovie = await db
3 .from("Movie")
4 .resolve("actors")
5 .where(eq("movieId", "movie_starWars"))
6 .firstOrNull();
7
8console.log(`${savedMovie?.title} features ${savedMovie?.actors?.length ?? 0} actors`);
9
10// Fetch an actor and hydrate the inverse resolver
11const savedActor = await db
12 .from("Actor")
13 .resolve("movies")
14 .where(eq("actorId", "actor_harrisonFord"))
15 .firstOrNull();
16
17console.log(`${savedActor?.actorFirstName} has appeared in ${savedActor?.movies?.length ?? 0} movies`);- Combine
resolve()with predicates likewhere(eq('movieId', ...))to scope lookups. - The same resolver names work with REST responses, GraphQL, and the JavaScript SDK.
Important Notes
Onyx Open Source
- Cascade behaviour, not parent/child semantics, controls which entities persist together—pick the policy that matches your workflow.
- Defining inverse collections is optional at runtime; the ORM infers them from your annotations after persistence.
- Double-check
inverseClassandinversevalues to avoid runtime mapping errors.
Onyx Cloud Database
- Resolver attributes expose many-to-many data by reading from join tables—keep resolver names consistent with the properties your clients consume.
- Join tables remain the source of truth; resolvers simply shape read models for the API and SDK.
- Use batching helpers like
inOp()inside resolvers to minimize round trips when hydrating related IDs.
Troubleshooting
Onyx Open Source
- Missing or invalid inverse: Ensure the
inverseproperty matches the related field name and the inverse class references the correct entity. - Duplicate naming: Keep table, attribute, and relationship names unique to avoid schema validation errors.
- Relationship mismatch: Pair many-to-many relationships on both sides and verify cascade/fetch policies are valid.
Onyx Cloud Database
- Empty resolver results: Confirm join rows exist and that resolver filters query the correct identifiers.
- Schema drift: Update resolver logic after renaming IDs or columns so joins still align with the schema.
- Missing hydration: Include resolver names in
resolve()calls or API query parameters whenever you need related collections.