Skip to content

Relationships

Entities rarely live in isolation. The Vyuh Entity System provides focused tools for modeling and rendering relationships:

  • EntityReference for the lightweight FK + name shape returned by the API.
  • ReferenceFieldType for picker UI on FK fields and ReferenceFieldDef<T> in the generated field registry.
  • FieldCompanion.lookup for read-side embedded projections from FK targets.
  • RelatedEntitiesLayout<T, R> for embedding a related list in a detail tab.
  • StandardRelatedApi for managing N:M relationships through conventional REST endpoints.
  • EntityNameCacheService + EntityNameText / EntityLink for 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:

dart
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:

dart
@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:

json
{
  "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:

sql
-- 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:

dart
// 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:

dart
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:

dart
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:

dart
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:

OperationHTTPRoute
List linkedGET/{parent}/{id}/{relation}
List availableGET/{parent}/{id}/{relation}?available=true
LinkPOST/{parent}/{id}/{relation} body { "{key}_ids": [...] }
UnlinkDELETE/{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:

dart
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:

dart
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:

dart
// 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.

dart
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