Relationships
Entities rarely live in isolation. The Vyuh Entity System provides focused tools for modeling and rendering relationships:
EntityReferencefor the lightweight FK + name shape returned by the API.ReferenceFieldTypefor picker UI on FK fields andReferenceFieldDef<T>in the generated field registry.FieldCompanion.lookupfor read-side embedded projections from FK targets.RelatedEntitiesLayout<T, R>for embedding a related list in a detail tab.StandardRelatedApifor managing N:M relationships through conventional REST endpoints.EntityNameCacheService+EntityNameText/EntityLinkfor resolving IDs to display names without manual joins.
EntityReference
EntityReference is the standard FK shape returned by APIs that join the parent name into the child payload:
class EntityReference {
final String id;
final String name;
final String? description;
const EntityReference({
required this.id,
required this.name,
this.description,
});
}Use it on entity models to carry the joined name without a second round trip:
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Course extends $CourseBase {
@Field(
label: 'Instructor',
filterable: true,
type: ReferenceFieldType(entityType: 'trainers'),
companion: FieldCompanion.lookup(
alias: 'instructor',
refTable: 'trainers',
fields: ['name', 'description'],
),
)
final String? instructorId;
/// Embedded by the server. `includeToJson: false` keeps it out of writes.
@JsonKey(name: 'instructor', includeToJson: false)
final EntityReference? instructor;
Course({
required super.id,
required super.name,
this.instructorId,
this.instructor,
// ...
});
}The wire shape:
{
"id": "course-1",
"name": "Dart Fundamentals",
"instructor_id": "trainer-456",
"instructor": {
"id": "trainer-456",
"name": "Dr. Smith",
"description": "Machine Learning Specialist"
}
}The pair instructor_id (writable FK) + instructor (read-only joined reference) is the canonical pattern. ReferenceFieldType powers picker UI, the filter dialog, and the default form; FieldCompanion.lookup tells the server descriptor which embedded reference columns to project.
Cross-Schema View Trick (PostgREST)
If you're on Supabase / PostgREST and the related entity lives in a different schema, PostgREST cannot embed across schemas. The standard workaround is a same-schema VIEW:
-- lms.users is a VIEW over sso.users that adds a computed `name` column.
CREATE VIEW lms.users AS
SELECT
id,
first_name,
last_name,
first_name || ' ' || last_name AS name
FROM sso.users;Then PostgREST embeds work:
// Good — uses the lms.users VIEW
.select('*, operator:users!operator_id(id, name)')
// Bad — PostgREST cannot resolve sso.users in an embed
.select('*, operator:sso.users!operator_id(id, name)')Inside a SQL function, you can join sso.users directly — the limitation is PostgREST-specific.
RelatedEntitiesLayout
RelatedEntitiesLayout<T, R> is a detail-tab widget that renders a list of related entities, with optional add / remove flows:
class RelatedEntitiesLayout<T extends EntityBase, R extends EntityBase>
extends StatelessWidget {
final T entity;
final EntityApi<R> relatedApi;
final Map<String, String> Function(T entity) getFilterCriteria;
final Widget Function(BuildContext context, R entity) itemBuilder;
final String title;
final String emptyMessage;
final RelatedEntitiesActionsConfig<R>? actions;
final Future<List<R>> Function(T parent)? fetchRelated;
}Filter-Based (1:N)
When the relationship is a simple foreign key on the related entity (Module has a course_id column), use getFilterCriteria:
final courseModulesLayout = EntityDetailLayout<Course>(
identifier: 'modules',
title: 'Modules',
icon: FluentIcons.list_24_regular,
build: (context, course) => RelatedEntitiesLayout<Course, Module>(
entity: course,
relatedApi: const ModuleApi(),
getFilterCriteria: (course) => {'course_id': course.id},
itemBuilder: (context, module) => ListTile(
leading: const Icon(FluentIcons.bookmark_24_regular),
title: Text(module.name),
subtitle: Text('${module.lessonCount} lessons'),
),
title: 'Modules',
emptyMessage: 'No modules yet — add one to structure this course.',
),
);Custom Fetch (N:M via StandardRelatedApi)
For many-to-many through a join table, point fetchRelated at a StandardRelatedApi:
final courseParticipantsApi = StandardRelatedApi(
parentIdentifier: 'courses',
relationSegment: 'participants',
requestBodyKey: 'participant_ids',
);
final courseParticipantsLayout = EntityDetailLayout<Course>(
identifier: 'participants',
title: 'Participants',
icon: FluentIcons.people_24_regular,
authorize: Authorize.permission('lms.enrollments.view'),
build: (context, course) => RelatedEntitiesLayout<Course, Participant>(
entity: course,
relatedApi: const ParticipantApi(),
getFilterCriteria: (_) => const {},
fetchRelated: (course) async {
final rows = await courseParticipantsApi.list(course.id);
return rows.map((j) => Participant.fromJson(j)).toList();
},
itemBuilder: (context, participant) => ListTile(
leading: CircleAvatar(child: Text(participant.name[0])),
title: Text(participant.name),
subtitle: Text(participant.email),
),
title: 'Enrolled Participants',
emptyMessage: 'No participants enrolled yet.',
actions: RelatedEntitiesActionsConfig<Participant>(
addConfig: EntityPickerConfig<Participant>(
onPicked: (selected) async {
await courseParticipantsApi.link(
course.id,
selected.map((p) => p.id).toList(),
);
vyuh.entity?.services.queryCache?.invalidateById(
'courses', course.id,
);
},
fetchEntities: (page, pageSize, query) async {
final rows = await courseParticipantsApi.listAvailable(
course.id,
query: query,
);
return rows.map((j) => Participant.fromJson(j)).toList();
},
),
removeConfig: RelatedRemoveConfig<Participant>(
onRemove: (selected) async {
await courseParticipantsApi.unlink(
course.id,
selected.map((p) => p.id).toList(),
);
vyuh.entity?.services.queryCache?.invalidateById(
'courses', course.id,
);
},
itemBuilder: (context, p) => ListTile(
title: Text(p.name),
subtitle: Text(p.email),
),
),
),
),
);StandardRelatedApi follows REST conventions:
| Operation | HTTP | Route |
|---|---|---|
| List linked | GET | /{parent}/{id}/{relation} |
| List available | GET | /{parent}/{id}/{relation}?available=true |
| Link | POST | /{parent}/{id}/{relation} body { "{key}_ids": [...] } |
| Unlink | DELETE | /{parent}/{id}/{relation} body { "{key}_ids": [...] } |
Resolving IDs to Display Names
When you have an FK in hand and need its display name (audit trails, notifications, analytics), use the field formatter API rather than querying caches by hand:
final formatter = FieldFormatter.instance;
// Resolve a course id to its display name.
final widget = formatter.formatEntity('courses', courseId);
// Resolve a user id (sso.users) to "First Last".
final user = formatter.formatUser(userId);formatEntity and formatUser return widgets that internally use EntityNameCacheService and rebuild reactively when the cache populates. Do not wrap a FutureBuilder around the cache yourself — you'll reintroduce flicker, missed-cache invalidations, and unnecessary network fan-out.
For inline links, prefer the high-level widgets:
EntityLink<Trainer>(entityId: trainerId)
EntityNameText<Trainer>(entityId: trainerId)Both auto-resolve, react to cache updates, and delegate navigation to the entity's route.view(...).
Reference Fields in Forms
The annotation drives the picker UI. Choose the right type:
// Inline picker (popover)
@Field(
label: 'Trainer',
type: ReferenceFieldType(
entityType: 'trainers',
subtitleFields: ['email'],
searchFields: ['name', 'email'],
showId: false,
),
)
final String? trainerId;
// Allow inline-create from the picker
@Field(
label: 'Category',
type: ReferenceFieldType(
entityType: 'area_categories',
allowCreate: true,
subtitleFields: ['code'],
searchFields: ['name', 'code'],
),
)
final String? areaCategoryId;
// Multi-value references are modeled with a dedicated multi-select field or
// a custom editor; ReferenceFieldType is the scalar FK picker.Cross-Entity Cache Invalidation
When an API mutation in one entity affects related rows, the API method that owns the mutation must announce the targeted invalidations. Read sites must not duplicate this logic.
class EnrollmentApi extends HttpEntityApi<Enrollment> {
// ...
@override
Future<Enrollment> create(Map<String, dynamic> data) async {
final created = await super.create(data);
// Enrollment changed — Course detail tabs need to refresh.
final cache = vyuh.entity?.services.queryCache;
cache?.invalidateById('courses', created.courseId);
cache?.invalidateOperation('courses', 'list');
return created;
}
}LMS Relationship Diagram
Next Steps
- Defining Entities —
ReferenceFieldTypeandFieldCompanionpatterns - Custom Services —
EntityNameCacheServicedeep dive - Complete LMS Example — full relationship wiring