Problem/Motivation

SegmentResolver::buildConditionGroup() builds a nested AND/OR condition structure and applies it to an entity query. Entity API's QueryAccess\ConditionGroup is the same structure — nested conjunctions, addCondition($field, $value, $operator) — with one capability this module lacks: ViewsQueryAlter applies the same tree directly to a Views query.

That capability is the real motivation. Plugin/views/filter/SegmentAudience currently resolves a segment to a full ID list and filters on it, so filtering a View by a large segment materializes every member ID in PHP first. A tree both backends can consume removes that step.

Proposed resolution

Have queryable plugins contribute to an Entity API ConditionGroup rather than directly to a ConditionInterface, then hand the finished group to EntityQueryAlter for resolve() and to ViewsQueryAlter for the audience filter.

QueryableSegmentPluginInterface::applyToQuery() changes shape. SegmentResolverInterface does not.

Remaining tasks

  • Confirm ConditionGroup supports the reference traversal the entity query gets from field.entity.subfield paths.
  • Benchmark the Views audience filter before and after on a large audience.
  • Decide the fate of the set-based fallback path, which cannot be expressed as a query group and would still materialize IDs.

User interface changes

None.

API changes

drupal/entity becomes a dependency of the base engine; it is currently present only transitively via group and crm. QueryableSegmentPluginInterface changes, breaking third-party queryable plugins. The module is alpha with no backwards-compatibility promise, but this belongs in the release notes.

Data model changes

None. The stored condition tree format is unchanged.

Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

jdleonard created an issue. See original summary.

jdleonard’s picture

Status: Active » Closed (won't fix)

Investigated against drupal/entity 8.x-1.6 (HEAD of 8.x-1.x, the only active branch — there is no 2.x). The answer to remaining task 1 is no, and it turns out to be the smaller of two blockers. Closing this as won't fix.

1. ConditionGroup cannot express reference traversal (remaining task #1)

ViewsQueryAlter::mapConditions() parses a field path like this:

$field = $condition->getField();
$property_name = NULL;
if (strpos($field, '.') !== FALSE) {
  list($field, $property_name) = explode('.', $field);
}
if (!isset($field_storage_definitions[$field])) {
  continue;
}

explode() is unbounded and list() takes the first two elements, so every segment past the second is silently discarded:

name                   -> field=name       property=NULL     discarded: -
full_name.given        -> field=full_name  property='given'  discarded: -
org.entity.name        -> field=org        property='entity' discarded: name
a.entity.b.entity.c    -> field=a          property='entity' discarded: b,entity,c

entity is then handed to DefaultTableMapping::getFieldColumnName(), which does not validate the property name. Kernel-test output on entity_test with a configurable reference field org and the base reference field user_id:

--- org (dedicated table) ---
columns: target_id
getFieldColumnName(.., 'target_id') => org_target_id
getFieldColumnName(.., 'entity')    => org_entity        <-- no such column

--- user_id (shared table) ---
columns: target_id
getFieldColumnName(.., 'target_id') => user_id
getFieldColumnName(.., 'entity')    => user_id           <-- silently the target-ID column

So a traversal path fails in one of two ways depending on how the reference field is stored:

  • Dedicated table — the query references a column that does not exist and dies with a database exception at execution time.
  • Shared table (any base reference field) — no error at all. user_id.entity.name = 'Acme' compiles to base_table.user_id = 'Acme', comparing an entity ID against a name. Wrong rows, silently.

For comparison, the entity query gets the same path right, because it goes through core's Drupal\Core\Entity\Query\Sql\Tables:

SELECT FROM {entity_test} "base_table"
INNER JOIN {entity_test__org} "entity_test__org" ON [entity_test__org].[entity_id] = [base_table].[id]
LEFT OUTER JOIN {entity_test} "entity_test" ON [entity_test].[id] = [entity_test__org].[org_target_id]
INNER JOIN {entity_test} "entity_test_2" ON [entity_test_2].[id] = [entity_test].[id]
WHERE "entity_test_2"."name" LIKE :db_condition_placeholder_0 ESCAPE '\'

EntityQueryAlter uses Tables and would be fine; ViewsQueryAlter has no join-through-a-reference logic anywhere in the class. Traversal is architecturally absent there, not merely buggy — which defeats the whole premise, since the premise is one tree, two backends.

Worth flagging separately: that continue on an unrecognised field means ViewsQueryAlter silently drops conditions it cannot map. For access filtering, dropping a condition fails open on a query that was already permission-scoped. For a segment audience filter it silently widens the audience — and SegmentAudience has a privileged raw mode. That is not a failure mode we want on this path.

2. Condition rejects three operators we already ship

Drupal\entity\QueryAccess\Condition::$supportedOperators is a closed list, and the constructor throws \InvalidArgumentException for anything outside it:

=  <>  >  >=  <  <=  IN  NOT IN  BETWEEN  NOT BETWEEN  IS NULL  IS NOT NULL   -> OK
STARTS_WITH  CONTAINS  ENDS_WITH                                              -> REJECTED

FieldValue::operatorLabels() offers all three for the string category. So this is not a Views-only regression: any stored, currently valid segment using "contains", "starts with" or "ends with" would throw inside resolve() — the primary path. Excluding those operators from push-down instead would mean forcing the set-based fallback exactly where push-down matters most (string matching over a large base).

3. Neither Alter class is reusable as an applier

The proposed resolution is to "hand the finished group to EntityQueryAlter … and to ViewsQueryAlter". There is no public API for that:

  • Both classes are @internal.
  • EntityQueryAlter::alter(SelectInterface $query, EntityTypeInterface $entity_type) fetches its own conditions from the entity type's query_access handler. It cannot be handed a caller-supplied group.
  • ViewsQueryAlter::alter(Sql $query, ViewExecutable $view) does the same, and returns early unless the entity type hasHandlerClass('query_access').
  • mapConditions() is protected in both.

The only ways through are to register a query_access handler on every segment target type — which hijacks a global access mechanism to smuggle in a per-View filter, and would apply to every query of that type rather than this one View — or to copy both mappers into this module. The second means taking a hard dependency on drupal/entity for a data structure, while writing ourselves the only part that actually works.

4. ConditionGroup::addCondition() rewrites the tree

  • A nested group with count() === 0 is silently dropped.
  • A nested group with count() === 1 is flattened into its parent.

We define an empty group as selecting no entities; dropping it from an AND parent makes it select every entity — an exact inversion. Today SegmentResolver::analyze() marks empty groups non-queryable so they never reach a builder, so this is latent rather than live. But the stored tree is this module's data model, and the flattening also breaks the "the tree you see is the tree that runs" property that ConditionTreeRenderer and the diff builder lean on.

5. The motivation is weaker than the summary states

SegmentAudience defaults to mode: filtered, which routes to AudienceAccess::filtered()SegmentResolver::resolveAccessible() — and that loads every audience entity and calls ->access('view') on each. A condition tree pushed into the Views query removes the ID materialization only in raw mode. In the default mode the per-entity access loop remains, cannot be expressed as a query condition under any design, and dominates the cost. So even a working version of this change would leave the default configuration exactly as expensive as it is now.

Remaining task 3 — the set-based fallback

Independent of this proposal, and unchanged by it: a set-based plugin's resolveToIds() is arbitrary PHP, so it can never become a query group. It has to stay. Any future push-down has to accept a mixed tree with IN (ids) leaves where a set-based child sits.

Remaining task 2 — benchmark

Not run, deliberately. It was scoped as before/after on this change, and there is no "after" to measure. It moves to the follow-up below, where it can gate a real alternative.

Closing this issue

Marking this won't fix. QueryableSegmentPluginInterface does not change, the stored tree format does not change, and there are no release notes to write.

The motivation is still worth pursuing, but with core primitives rather than Entity API's QueryAccess structures. Drupal\Core\Entity\Query\Sql\Tables — the class EntityQueryAlter gets right and ViewsQueryAlter lacks — is public API, is instantiable against any SelectInterface, and already implements entity. traversal and the full operator set. A follow-up can build a SELECT id subquery of the target type from the wholly-queryable part of the tree and hand it to Views as IN (subquery), keeping IN (ids) leaves for set-based children. That keeps traversal, keeps every operator, adds no dependency, and does not rewrite the stored tree. It would still only help raw mode, per point 5, so the benchmark comes first and decides whether it is worth building at all.

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.