Skip to content

Many-to-Many Relationships

Technical reference for the M2M feature — join table lifecycle, metadata creation, query integration, and frontend rendering.

Architecture Overview

M2M uses a Simple mode pattern: the system creates and manages a join table invisibly. Users see colored chips in the grid and a multi-select popover for editing.

Relationship Picker → RelationshipPathService (join table DDL)
    → Schema Processor (CREATE TABLE + FKs)
    → ColumnOperationHandler (metadata creation)
    → MetadataQueryBuilder (subquery in list queries)
    → M2MAssociationService (association CRUD)
    → MultiSelectSelectorComponent (frontend editor)

Join Table DDL Generation

Schema Processor (DynamicSessionFactoryBuilder + HibernateSchemaTools)

When a MANY_TO_MANY relationship is in the entity metadata:

  1. extractJoinTableRequests() builds JoinTableDDLRequest objects from RelationshipMetadataDTO.joinTable()
  2. buildJoinTablesDDL() generates:
    • CREATE TABLE IF NOT EXISTS with both FK columns (bigint NOT NULL)
    • Composite PRIMARY KEY on both FK columns (prevents duplicate associations)
    • Two FK constraints with ON DELETE CASCADE (referencing source + target entity tables)
  3. buildDropJoinTablesDDL() generates DROP TABLE IF EXISTS for rollback

Join tables are created after the entity table DDL — ensuring both referenced tables exist.

Join Table Naming Convention

Auto-generated: {source_table}_{target_table} (sorted alphabetically for consistency)

  • users + rolesroles_users (alphabetical)
  • FK columns: {table}_id (e.g., users_id, roles_id)

Metadata Creation Flow

Quarkus: RelationshipPathService.createManyToManyRelationship()

  1. Frontend sends POST /api/columns with relationshipType: "MANY_TO_MANY"
  2. Service resolves source + target entities and auto-detects PK columns
  3. Builds schema changes:
    • create_table — join table DDL
    • add_foreign_key × 2 — FK constraints (idempotent DO $$ ... END $$ blocks)
  4. Builds ManyToManyCreationContext (sealed subtype of CreationContext)
  5. Sends to processor via RabbitMQ

Quarkus: ColumnOperationHandler.createManyToManyMetadata()

After processor reports SUCCESS:

  1. Creates Relationship entity with type=MANY_TO_MANY and joinTableName
  2. Creates synthetic ColumnMetadata (_m2m_{name}, type MANY_TO_MANY)
  3. Creates visible ViewColumn with widgetType=RELATIONSHIP, relationshipEditMode=ASSOCIATION
  4. Creates ViewColumnPath linking to the relationship
  5. Broadcasts columnCreated SSE event

List Query Integration

MetadataQueryBuilder.processM2mRelationshipColumn()

M2M columns use a correlated subquery instead of JOINs:

sql
SELECT
  "users"."id",
  "users"."name",
  (SELECT string_agg("_m2m_t"."name"::text, ', ' ORDER BY "_m2m_t"."name")
   FROM "users_roles" "_m2m_jt"
   JOIN "roles" "_m2m_t" ON "_m2m_t"."id" = "_m2m_jt"."roles_id"
   WHERE "_m2m_jt"."users_id" = "users"."id") AS "rolesName"
FROM "users"

Key decisions:

  • string_agg() produces comma-separated display values for the grid
  • Correlated subquery (references "users"."id") — no GROUP BY needed on outer query
  • M2M columns are not sortable (aggregate expressions)
  • Alias uses buildRelationshipColumnAlias() (same as regular relationships)

Association CRUD API

M2MAssociationService + DataResource endpoints:

GET /api/data/{viewUuid}/m2m/{columnUuid}

Fetches target records with selected/unselected state for a specific source row.

Query params: rowId, search, page, size

Response:

json
{
  "options": [
    { "id": 1, "label": "Admin", "selected": true },
    { "id": 2, "label": "Editor", "selected": false }
  ],
  "page": 0, "size": 50, "totalElements": 5, "totalPages": 1
}

SQL pattern:

sql
SELECT t."id", t."name",
  CASE WHEN jt."role_id" IS NOT NULL THEN true ELSE false END AS selected
FROM "roles" t
LEFT JOIN "user_roles" jt ON t."id" = jt."role_id" AND jt."user_id" = $1
ORDER BY selected DESC, t."name" ASC
LIMIT $2 OFFSET $3

POST /api/data/{viewUuid}/m2m/{columnUuid}

Adds/removes associations atomically.

Request:

json
{ "rowId": "42", "addTargetIds": [1, 3], "removeTargetIds": [2] }

Uses INSERT ... ON CONFLICT DO NOTHING for adds and DELETE for removes, then returns updated displayValue.

Frontend Architecture

Widget Type

RELATIONSHIP — registered in widget-types.ts (category: relation, isRelation: true)

Cell Display

Grid cells render M2M values as chips using splitM2mValue():

  • Splits comma-separated string from list query
  • Each value rendered as .cell-m2m-chip (M3 primary-container tokens)

Multi-Select Editor

MultiSelectSelectorComponent — standalone Angular component in relationship-editors/

Features:

  • Search input with 300ms debounce
  • Checkbox list with instant-save per toggle (no batch Apply)
  • Selected chips at top (click × to remove)
  • Pagination ("Load more...")
  • Optimistic toggle with rollback on failure
  • Uses M2MAssociationService for API calls (not the regular cell edit flow)

Integration: CellEditPopoverComponent routes to multi-select when column.widgetType === 'RELATIONSHIP'. The onM2mValueChange() handler emits skipApiCall: true since saves happen per-toggle via the M2M service.

SSE Sync

M2M cell updates reuse the existing view.data.cell.edited SSE event. After M2MAssociationService.updateAssociations() succeeds, the updated comma-separated display value is set on the cell. Other users receive the SSE event and see the updated chips.

Testing

TestTypeWhat it verifies
ManyToManyDDLUnitTestUnit (7 tests)DDL extraction, generation, mixed relationships
ManyToManyJoinTableTestIntegrationEnd-to-end: table creation, composite PK, FKs, CASCADE, duplicates
ManyToManyColumnOperationHandlerTestIntegrationMetadata creation after migration success
ManyToManyColumnDeleteTestIntegrationCascade cleanup of M2M metadata on column deletion

OneToMany Support

OneToMany uses the same subquery infrastructure as M2M but simpler — no join table involved.

Subquery Generation

MetadataQueryBuilder.processOneToManyRelationshipColumn()

sql
-- Count mode (displayField=null, transform=null or COUNT)
(SELECT COUNT(*) FROM "orders" "_o2m_t" WHERE "_o2m_t"."customer_id" = "customers"."id")

-- Aggregate mode (transform=SUM, displayField=total)
(SELECT SUM("_o2m_t"."total") FROM "orders" "_o2m_t" WHERE "_o2m_t"."customer_id" = "customers"."id")

-- Chips mode (displayField set, no aggregate transform)
(SELECT string_agg("_o2m_t"."name"::text, ', ') FROM "orders" "_o2m_t" WHERE "_o2m_t"."customer_id" = "customers"."id")

Display Modes

Controlled by ViewColumn.displayField and ViewColumn.transform:

displayFieldtransformModeSQL
nullnull/COUNTCountCOUNT(*)
nullSUM/AVG/MIN/MAXAggregateSUM(field) etc
setnullChipsstring_agg(field)

Cell Behavior

OneToMany columns are always read-only from the parent view. The isCellEditable() function returns false for isOneToMany columns.

Relationship Discovery

OneToMany relationships are included in isTraversableRelationship() and appear in the "Existing Relationships" discovery tree with a 1:N label.

SchemaStack Internal Developer Documentation