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}:aand{type_id}:b, labelled usinglabel_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:
- Add the
crm_contact__relationship_statisticsrelationship to your View (relationship handler: standard). - In the Filter criteria section, add the "Relationship Type" filter from that table.
- 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.id → descendant_id. Use this to list ancestors of the base contact. |
closure_as_ancestor |
Join crm_contact.id → ancestor_id. Use this to list descendants of the base contact. |
Example: all descendants of a contact
- Add a View with base table Relationship closure.
- Add a contextual filter (or regular filter) on Ancestor contact ID.
- Add a filter on Relationship type and choose the hierarchy (for example, Parent).
- Optionally add Depth
<=n to limit how far the tree is walked. - 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_data ↔ crm_user_contact_mapping ↔
crm_contact join chain through entity metadata alone, the module adds it in
UserHooks::viewsDataAlter().
Note: This hook implementation carries a
@todoto 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.uid → crm_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.id → crm_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.
Related documentation
- Contact entity — full contact field reference
- Relationship entity — symmetric vs asymmetric relationship type details
- Relationship closure — index tables, service API, and update routing
- User integration — user/contact mapping concepts and field mapping
- Comment integration — commenting on contacts