Skip to content

Views Integration

The CRM module exposes all of its content entities to Drupal's Views system, plus the non-entity relationship closure index. Site builders can create contact lists and reports from the Views UI using the base tables below; the rest of this page documents handlers, plugins, and Views data hooks for developers extending that data. Each entity registers a views_data handler in its PHP attribute, the contact entity overrides that handler to customize the relationship-statistics field table, and hook implementations add cross-entity relationships, the closure base table, and render-only admin fields that cannot be expressed through entity metadata alone.

Available base tables

Content entities produce a Views base table automatically through their views_data handler. The relationship closure index is registered separately.

Source Base table views_data handler
crm_contact crm_contact ContactViewsData (custom)
crm_contact_method crm_contact_method EntityViewsData (core)
crm_relationship crm_relationship EntityViewsData (core)
crm_user_contact_mapping crm_user_contact_mapping EntityViewsData (core)
Relationship closure index crm_relationship_closure ClosureViewsHooks::viewsData()

The custom handler for crm_contact is Drupal\crm\ContactViewsData. The content entities other than contact delegate entirely to Drupal core's EntityViewsData, which automatically exposes every base field, revision field, and field storage table. The closure index is an ordinary database table, so it is registered with hook_views_data() rather than an entity handler.

Adding a new entity to Views

If you create a new CRM entity and want it available in Views, declare EntityViewsData::class (or your own subclass) as the views_data handler in the entity PHP attribute:

handlers: [
  'views_data' => \Drupal\views\EntityViewsData::class,
  // ...
],

Relationship statistics field

The crm_contact entity has a multi-value base field named relationship_statistics (field type crm_relationship_statistics). Drupal stores it in the crm_contact__relationship_statistics table, which Views exposes automatically through EntityViewsData.

ContactViewsData::getViewsData() then customizes that table before Views sees it:

Customization Reason
Removes the relationship_statistics_count column from Views field options The count is an implementation detail; filtering by type and rendering the full summary through the formatter is the intended use-case.
Retitles relationship_statistics_value to "Relationship Type" Gives site builders a clear, domain-language label instead of the raw field column name.
Attaches the crm_relationship_statistics_type filter plugin to that column Provides relationship-type-aware filter options (see below).

crm_relationship_statistics_type filter plugin

Class: Drupal\crm\Plugin\views\filter\RelationshipStatisticsType

This plugin extends Views core InOperator. It builds its option list dynamically from all crm_relationship_type config entities at query time:

  • Symmetric relationship types – produce one option keyed by the type id; label is the type label.
  • Asymmetric relationship types – produce two options, keyed {type_id}:a and {type_id}:b, labelled using label_a / label_b (with a fallback to the main type label when those fields are empty).

Use this filter on any View whose base table is crm_contact (or that has a relationship to it) to filter contacts by the kind of relationships they participate in. Do not attach this plugin to the closure table: its asymmetric option keys ({type_id}:a) do not match crm_relationship_closure.relationship_type values. Use crm_relationship_closure_type there instead.

Example: contacts with a specific relationship type

In the Views UI, add a filter on the relationship-statistics table:

  1. Add the crm_contact__relationship_statistics relationship to your View (relationship handler: standard).
  2. In the Filter criteria section, add the "Relationship Type" filter from that table.
  3. The filter renders as an "is one of" select list populated with all configured relationship types.

Relationship closure index

The crm_relationship_closure table stores every reachable descendant / ancestor pair for transitive relationship types. ClosureViewsHooks exposes it as a Views base table so site builders can list hierarchies without walking the graph in PHP.

The path-step table (crm_relationship_closure_path) is not exposed.

Fields, filters, and relationships

Views data key Type Description
relationship_type field / filter / sort / argument Machine name. The filter plugin crm_relationship_closure_type lists only transitive types, keyed by type id.
descendant_id, ancestor_id field / filter / sort / argument Contact IDs on each side of the path. The field handler crm_relationship_closure_contact_label renders the contact label (optionally linked) and checks view access before display. Filter, sort, and argument handlers remain numeric.
depth field / filter / sort / argument Hop count (1 = direct). Use the numeric filter with operator "Is less than or equal to" for a maximum depth.
path_hash, created, changed field / filter / sort Path identity and timestamps.
descendant, ancestor relationship Join the closure row to the descendant or ancestor crm_contact.
shortest_path_only filter only See path multiplicity below.

From a Contacts view, hook_views_data_alter() adds:

Views data key Description
closure_as_descendant Join crm_contact.iddescendant_id. Use this to list ancestors of the base contact.
closure_as_ancestor Join crm_contact.idancestor_id. Use this to list descendants of the base contact.

Example: all descendants of a contact

  1. Add a View with base table Relationship closure.
  2. Add a contextual filter (or regular filter) on Ancestor contact ID.
  3. Add a filter on Relationship type and choose the hierarchy (for example, Parent).
  4. Optionally add Depth <= n to limit how far the tree is walked.
  5. Add Descendant contact ID as a field to show each descendant's label (enable "Link to entity" if you want a link to the contact). Keep the Descendant contact relationship when you need other contact fields beyond the label.

Path multiplicity

The unique key on the index includes path_hash, so two different routes between the same contacts produce two rows. That is intentional: the index stores every path, not one row per pair.

Leave Shortest path only off to see every path. Enable it to keep every row whose depth equals MIN(depth) for that (relationship_type, descendant_id, ancestor_id) triple. Tied shortest paths remain; only longer alternatives are removed. Views DISTINCT and aggregation cannot express this and are not a substitute.

ClosureService::getDistance() already returns the minimum depth; the filter matches that semantics while still exposing individual shortest paths.

Access

Queries that use the closure table — whether as the View base table or via a contact-base relationship such as closure_as_ancestor / closure_as_descendant — are tagged crm_relationship_closure_access. A closure row is included only when the current user may view both the descendant and the ancestor, using the same bundle, mapped-contact, and grant rules as hook_query_crm_contact_access_alter(). Users with administer crm or view any crm contact bypass the restriction.

That dual-endpoint filter applies to every closure table alias in the query, so chaining Descendant contact or Ancestor contact and adding normal crm_contact fields cannot disclose an endpoint the user cannot view. Prefer a Relationship closure base View when building hierarchy listings; the same access rules apply either way.

Because access is enforced with WHERE conditions on the endpoint ID columns, a non-required (LEFT) closure relationship still hides base rows whose joined endpoint fails the check — the same fail-closed behavior as filtering on a related table.

When descendant_id or ancestor_id are rendered as fields, the label plugin also checks contact view access before printing a label or link. That is a render-time defense in depth; query access is the primary control.

Staleness

The index is derived from crm_relationship entities. When crm.relationship.settings.async_mode defers large updates to cron or a queue worker, Views can briefly lag behind the live relationship graph. Rebuild from /admin/config/crm/relationship/settings or drush crm:rebuild-relationship-closure if a report must be exact.

User/contact mapping Views integration

The crm_user_contact_mapping entity type stores the one-to-one link between a Drupal user account and a CRM person contact. Because core Views cannot automatically express the users_field_datacrm_user_contact_mappingcrm_contact join chain through entity metadata alone, the module adds it in UserHooks::viewsDataAlter().

Note: This hook implementation carries a @todo to be removed once Drupal core issue #2706431 is resolved.

Cross-table relationships added by hook_views_data_alter()

The following entries are injected into the Views data array:

From users_field_data

Views data key Type Description
crm_user_contact_mapping relationship Joins users_field_data.uidcrm_user_contact_mapping.user. Use this to traverse from a Users base View to contact-mapping data.
crm_user_contact_mapping_sync_form field Exposes the crm_contact_user field plugin on user rows.
crm_core_user_sync_form field Exposes the crm_user_contact_mapping field plugin on user rows.

From crm_contact

Views data key Type Description
crm_user_contact_mapping relationship Joins crm_contact.idcrm_user_contact_mapping.crm_contact. Use this to traverse from a Contacts base View to mapping data.
crm_core_user_sync_form field Exposes the crm_user_contact_mapping field plugin on contact rows, using the user field name for its link target.

crm_user_contact_mapping Views field plugin

Class: Drupal\crm\Plugin\views\field\UserContactMappingField

This is a render-only field plugin. Its query() method is intentionally empty — it adds no SQL to the query. All output is generated at render time from the current row's entity and the crm.user_contact_mapping service.

The plugin's render() method inspects the row entity type and renders an appropriate admin action link:

Row entity No mapping exists Mapping exists
crm_contact "Add Mapping" link → entity.crm_user_contact_mapping.add_form with ?destination=… Edit link → entity.crm_user_contact_mapping.edit_form
user "Add Mapping" link → entity.crm_user_contact_mapping.add_form with ?destination=… Edit link → entity.crm_user_contact_mapping.edit_form

Because this field does not touch the query, it can be added to any View whose base table (or related table) returns crm_contact or user rows, at no SQL cost.

Architecture overview

flowchart TD
    subgraph entities [Entity views_data handlers]
        Contact["crm_contact\n(ContactViewsData)"]
        ContactMethod["crm_contact_method\n(EntityViewsData)"]
        Relationship["crm_relationship\n(EntityViewsData)"]
        Mapping["crm_user_contact_mapping\n(EntityViewsData)"]
    end

    subgraph closure [Non-entity views_data]
        ClosureHooks["ClosureViewsHooks::viewsData()"]
        ClosureTable["crm_relationship_closure"]
    end

    subgraph plugins [Views plugins]
        Filter["crm_relationship_statistics_type\nfilter plugin"]
        ClosureType["crm_relationship_closure_type\nfilter plugin"]
        Shortest["crm_relationship_closure_shortest_path\nfilter plugin"]
        ClosureLabel["crm_relationship_closure_contact_label\nfield plugin"]
        Field["crm_user_contact_mapping\nfield plugin"]
    end

    subgraph hook [Views data alter]
        Alter["UserHooks::viewsDataAlter()"]
        ClosureAlter["ClosureViewsHooks::viewsDataAlter()"]
    end

    Contact -->|"crm_contact__relationship_statistics\ntable customization"| Filter
    ClosureHooks --> ClosureTable
    ClosureTable --> ClosureType
    ClosureTable --> Shortest
    ClosureTable --> ClosureLabel
    ClosureAlter -->|"relationship: crm_contact → closure"| ClosureTable
    Alter -->|"relationship: users_field_data → mapping"| Mapping
    Alter -->|"relationship: crm_contact → mapping"| Mapping
    Alter -->|"field: crm_core_user_sync_form"| Field
    Alter -->|"field: crm_user_contact_mapping_sync_form"| Field

Extending Views data

Adding new fields or relationships to existing CRM tables

Use hook_views_data_alter() in your own module:

<?php

declare(strict_types=1);

use Drupal\Core\Hook\Attribute\Hook;

class MyModuleHooks {

  #[Hook('views_data_alter')]
  public function viewsDataAlter(array &$data): void {
    // Add a custom relationship from crm_contact to your entity.
    $data['crm_contact']['my_module_relation'] = [
      'title' => t('My custom relation'),
      'help'  => t('Joins crm_contact to my_custom_entity.'),
      'relationship' => [
        'base'       => 'my_custom_entity',
        'base field' => 'contact_id',
        'field'      => 'id',
        'id'         => 'standard',
        'label'      => t('My entity'),
      ],
    ];
  }

}

Customizing Views data for a new CRM entity

Subclass EntityViewsData and override getViewsData():

<?php

declare(strict_types=1);

namespace Drupal\my_module;

use Drupal\views\EntityViewsData;

/**
 * Provides Views data for the My Entity entity.
 */
class MyEntityViewsData extends EntityViewsData {

  /**
   * {@inheritdoc}
   */
  public function getViewsData(): array {
    $data = parent::getViewsData();

    // Customize the auto-generated data here.
    $data['my_entity_table']['my_field']['title'] = $this->t('Custom title');

    return $data;
  }

}

Then declare the handler in the entity PHP attribute:

handlers: [
  'views_data' => \Drupal\my_module\MyEntityViewsData::class,
  // ...
],

Testing

The CRM module tests the Views integration at both the unit and kernel level.

Test class Type What it covers
Drupal\Tests\crm\Kernel\Plugin\views\RelationshipStatisticsViewsTest Kernel Views data presence for crm_contact__relationship_statistics; filter plugin discovery and option building; contact statistics with real data
Drupal\Tests\crm\Kernel\Plugin\views\ClosureViewsTest Kernel Closure base table metadata; contact relationships; type/shortest-path plugins; contact-label field discovery and rendering; all-path, shortest-path, depth, and join queries
Drupal\Tests\crm\Kernel\Views\ClosureViewsAccessTest Kernel Closure Views honor contact access on both endpoints for closure-base and contact-base joins; chained contact fields and labels stay hidden without view access
Drupal\Tests\crm\Kernel\Plugin\views\UserContactMappingFieldKernelTest Kernel Field plugin discovery; noop query()
Drupal\Tests\crm\Unit\Plugin\views\filter\RelationshipStatisticsTypeTest Unit Filter option building for symmetric and asymmetric types
Drupal\Tests\crm\Unit\Plugin\views\filter\ClosureTransitiveTypeTest Unit Transitive-only type options and exception handling
Drupal\Tests\crm\Unit\Plugin\views\field\UserContactMappingFieldTest Unit render() output and URL generation for contact and user rows

To run these tests in isolation:

ddev phpunit --filter=RelationshipStatisticsViewsTest
ddev phpunit --filter=ClosureViewsTest
ddev phpunit --filter=ClosureViewsAccessTest
ddev phpunit --filter=UserContactMappingFieldKernelTest
ddev phpunit --filter=RelationshipStatisticsTypeTest
ddev phpunit --filter=ClosureTransitiveTypeTest
ddev phpunit --filter=UserContactMappingFieldTest

When writing your own Views integration tests for custom handlers or plugins, use the kernel tests as a reference: they load the views module alongside CRM, then call Views::viewsData()->getAll() to assert table keys, and use the plugin manager to assert plugin definitions.