Просмотр исходного кода

fix(core): Correct behaviour of complex list filtering (#4068)

Michael Bromley 4 недель назад
Родитель
Сommit
68cde97eb4

+ 95 - 9
packages/core/e2e/fixtures/test-plugins/list-query-plugin.ts

@@ -22,7 +22,17 @@ import {
     VendurePlugin,
 } from '@vendure/core';
 import gql from 'graphql-tag';
-import { Column, Entity, JoinColumn, JoinTable, ManyToOne, OneToMany, OneToOne, Relation } from 'typeorm';
+import {
+    Column,
+    Entity,
+    JoinColumn,
+    JoinTable,
+    ManyToMany,
+    ManyToOne,
+    OneToMany,
+    OneToOne,
+    Relation,
+} from 'typeorm';
 
 import { Calculated } from '../../../src/common/calculated-decorator';
 
@@ -52,6 +62,26 @@ export class CustomFieldOtherRelationTestEntity extends VendureEntity {
     parent: Relation<TestEntity>;
 }
 
+/**
+ * Entity used to test ManyToMany relations with customPropertyMap
+ * for the duplicate filter fix (GitHub issue #3267)
+ */
+@Entity()
+export class TestEntityTag extends VendureEntity {
+    constructor(input: Partial<TestEntityTag>) {
+        super(input);
+    }
+
+    @Column()
+    name: string;
+
+    @Column({ default: 0 })
+    priority: number;
+
+    @ManyToMany(() => TestEntity, testEntity => testEntity.tags)
+    testEntities: Relation<TestEntity[]>;
+}
+
 class TestEntityCustomFields {
     @OneToMany(() => CustomFieldRelationTestEntity, child => child.parent)
     relation: Relation<CustomFieldRelationTestEntity[]>;
@@ -145,11 +175,15 @@ export class TestEntity extends VendureEntity implements Translatable, HasCustom
     @Column(() => TestEntityCustomFields)
     customFields: TestEntityCustomFields;
 
-    @ManyToOne(() => TestEntity, (type) => type.parent)
+    @ManyToOne(() => TestEntity, type => type.parent)
     parent: TestEntity | null;
 
     @Column('int', { nullable: true })
     parentId: ID | null;
+
+    @ManyToMany(() => TestEntityTag, tag => tag.testEntities)
+    @JoinTable()
+    tags: Relation<TestEntityTag[]>;
 }
 
 @Entity()
@@ -199,9 +233,12 @@ export class ListQueryResolver {
                     'orderRelation.customer',
                     'customFields.relation',
                     'customFields.otherRelation',
+                    'tags',
                 ],
                 customPropertyMap: {
                     customerLastName: 'orderRelation.customer.lastName',
+                    tagId: 'tags.id',
+                    tagPriority: 'tags.priority',
                 },
             })
             .getManyAndCount()
@@ -282,6 +319,15 @@ const apiExtensions = gql`
         nullableDate: DateTime
         customFields: TestEntityCustomFields!
         parent: TestEntity
+        tags: [TestEntityTag!]!
+    }
+
+    type TestEntityTag implements Node {
+        id: ID!
+        createdAt: DateTime!
+        updatedAt: DateTime!
+        name: String!
+        priority: Int!
     }
 
     type TestEntityList implements PaginatedList {
@@ -296,6 +342,8 @@ const apiExtensions = gql`
 
     input TestEntityFilterParameter {
         customerLastName: StringOperators
+        tagId: IDOperators
+        tagPriority: NumberOperators
     }
 
     input TestEntitySortParameter {
@@ -311,6 +359,7 @@ const apiExtensions = gql`
         TestEntity,
         TestEntityPrice,
         TestEntityTranslation,
+        TestEntityTag,
         CustomFieldRelationTestEntity,
         CustomFieldOtherRelationTestEntity,
     ],
@@ -403,7 +452,9 @@ export class ListQueryPlugin implements OnApplicationBootstrap {
             // test entity with self-referencing relation without tree structure decorator
             testEntities[0].parent = testEntities[1];
             testEntities[3].parent = testEntities[1];
-            await this.connection.rawConnection.getRepository(TestEntity).save([testEntities[0], testEntities[3]]);
+            await this.connection.rawConnection
+                .getRepository(TestEntity)
+                .save([testEntities[0], testEntities[3]]);
 
             const translations: any = {
                 A: { [LanguageCode.en]: 'apple', [LanguageCode.de]: 'apfel' },
@@ -420,6 +471,39 @@ export class ListQueryPlugin implements OnApplicationBootstrap {
                 C: [{ data: 'C' }],
             };
 
+            // Create tags for testing ManyToMany filtering with duplicate fields in _and blocks
+            // This tests the fix for GitHub issue #3267
+            // Priority values: tag1=10, tag2=20, tag3=30 (for testing BETWEEN operator)
+            const tags = await this.connection.rawConnection
+                .getRepository(TestEntityTag)
+                .save([
+                    new TestEntityTag({ name: 'tag1', priority: 10 }),
+                    new TestEntityTag({ name: 'tag2', priority: 20 }),
+                    new TestEntityTag({ name: 'tag3', priority: 30 }),
+                ]);
+
+            // Assign tags to test entities:
+            // A: tag1, tag2     (both tags)
+            // B: tag1, tag2     (both tags)
+            // C: tag1           (only tag1)
+            // D: tag2           (only tag2)
+            // E: tag1, tag2, tag3 (all tags)
+            // F: (no tags)
+            const entityTagAssignments: Record<string, string[]> = {
+                A: ['tag1', 'tag2'],
+                B: ['tag1', 'tag2'],
+                C: ['tag1'],
+                D: ['tag2'],
+                E: ['tag1', 'tag2', 'tag3'],
+                F: [],
+            };
+
+            for (const testEntity of testEntities) {
+                const tagNames = entityTagAssignments[testEntity.label] || [];
+                testEntity.tags = tags.filter(t => tagNames.includes(t.name));
+            }
+            await this.connection.rawConnection.getRepository(TestEntity).save(testEntities);
+
             for (const testEntity of testEntities) {
                 await this.connection.rawConnection.getRepository(TestEntityPrice).save([
                     new TestEntityPrice({
@@ -455,12 +539,14 @@ export class ListQueryPlugin implements OnApplicationBootstrap {
                                 data: nestedContent.data,
                             }),
                         );
-                        await this.connection.rawConnection.getRepository(CustomFieldOtherRelationTestEntity).save(
-                            new CustomFieldOtherRelationTestEntity({
-                                parent: testEntity,
-                                data: nestedContent.data,
-                            }),
-                        );
+                        await this.connection.rawConnection
+                            .getRepository(CustomFieldOtherRelationTestEntity)
+                            .save(
+                                new CustomFieldOtherRelationTestEntity({
+                                    parent: testEntity,
+                                    data: nestedContent.data,
+                                }),
+                            );
                     }
                 }
             }

+ 418 - 0
packages/core/e2e/list-query-builder.e2e-spec.ts

@@ -1436,6 +1436,408 @@ describe('ListQueryBuilder', () => {
             ]);
         });
     });
+
+    // https://github.com/vendure-ecommerce/vendure/issues/3267
+    describe('filtering with duplicate custom property fields in _and blocks', () => {
+        it('filters by single tagId', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        tagId: { eq: 'T_1' }, // tag1
+                    },
+                },
+            });
+
+            // Entities with tag1: A, B, C, E
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'C', 'E']);
+        });
+
+        it('filters by multiple tagIds with _and (same field used twice)', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [{ tagId: { eq: 'T_1' } }, { tagId: { eq: 'T_2' } }], // tag1 AND tag2
+                    },
+                },
+            });
+
+            // Entities with BOTH tag1 AND tag2: A, B, E
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'E']);
+        });
+
+        it('filters by three tagIds with _and (same field used three times)', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [{ tagId: { eq: 'T_1' } }, { tagId: { eq: 'T_2' } }, { tagId: { eq: 'T_3' } }], // tag1 AND tag2 AND tag3
+                    },
+                },
+            });
+
+            // Only entity E has all three tags
+            expect(getItemLabels(testEntities.items)).toEqual(['E']);
+        });
+
+        it('filters by tagIds with _or (same field used twice)', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _or: [{ tagId: { eq: 'T_1' } }, { tagId: { eq: 'T_2' } }], // tag1 OR tag2
+                    },
+                },
+            });
+
+            // Entities with tag1 OR tag2: A, B, C, D, E
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'C', 'D', 'E']);
+        });
+
+        it('filters by tagId combined with other fields in _and', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [{ tagId: { eq: 'T_1' } }, { tagId: { eq: 'T_2' } }, { active: { eq: true } }],
+                    },
+                },
+            });
+
+            // Entities with tag1 AND tag2 AND active=true: A, B (E is not active)
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B']);
+        });
+
+        // ===== NESTED _and WITHIN _and =====
+
+        it('filters with nested _and within _and (same field)', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [
+                            { _and: [{ tagId: { eq: 'T_1' } }, { tagId: { eq: 'T_2' } }] },
+                            { tagId: { eq: 'T_3' } },
+                        ],
+                    },
+                },
+            });
+
+            // (tag1 AND tag2) AND tag3 => Only E has all three
+            expect(getItemLabels(testEntities.items)).toEqual(['E']);
+        });
+
+        it('filters with deeply nested _and (three levels)', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [
+                            {
+                                _and: [{ tagId: { eq: 'T_1' } }, { _and: [{ tagId: { eq: 'T_2' } }] }],
+                            },
+                        ],
+                    },
+                },
+            });
+
+            // Deeply nested: tag1 AND tag2 => A, B, E
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'E']);
+        });
+
+        // ===== _and WITHIN _or =====
+
+        it('filters with _and within _or (same field in inner _and)', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _or: [
+                            { _and: [{ tagId: { eq: 'T_1' } }, { tagId: { eq: 'T_2' } }] },
+                            { label: { eq: 'F' } },
+                        ],
+                    },
+                },
+            });
+
+            // (tag1 AND tag2) OR label='F' => A, B, E (have both tags) + F (by label)
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'E', 'F']);
+        });
+
+        it('filters with multiple _and blocks within _or', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _or: [
+                            { _and: [{ tagId: { eq: 'T_1' } }, { tagId: { eq: 'T_3' } }] },
+                            { _and: [{ tagId: { eq: 'T_2' } }, { active: { eq: true } }] },
+                        ],
+                    },
+                },
+            });
+
+            // (tag1 AND tag3) => E only
+            // OR (tag2 AND active) => A, B, D
+            // Combined: A, B, D, E
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'D', 'E']);
+        });
+
+        // ===== _or WITHIN _and =====
+
+        it('filters with _or within _and (same field in inner _or)', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [
+                            { _or: [{ tagId: { eq: 'T_1' } }, { tagId: { eq: 'T_2' } }] },
+                            { active: { eq: true } },
+                        ],
+                    },
+                },
+            });
+
+            // (tag1 OR tag2) AND active => A, B, D (C has tag1 but inactive, E has both but inactive)
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'D']);
+        });
+
+        it('filters with same field in both _or and _and at different levels', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [
+                            { tagId: { eq: 'T_1' } },
+                            { _or: [{ tagId: { eq: 'T_2' } }, { tagId: { eq: 'T_3' } }] },
+                        ],
+                    },
+                },
+            });
+
+            // tag1 AND (tag2 OR tag3)
+            // tag1: A, B, C, E
+            // tag2 OR tag3: A, B, D, E
+            // Intersection: A, B, E
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'E']);
+        });
+
+        // ===== COMPLEX MIXED SCENARIOS =====
+
+        it('filters with complex nested structure (_and containing _or containing _and)', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [
+                            {
+                                _or: [
+                                    { _and: [{ tagId: { eq: 'T_1' } }, { tagId: { eq: 'T_3' } }] },
+                                    { label: { eq: 'D' } },
+                                ],
+                            },
+                            { active: { eq: false } },
+                        ],
+                    },
+                },
+            });
+
+            // ((tag1 AND tag3) OR label='D') AND inactive
+            // (tag1 AND tag3): E
+            // label='D': D
+            // Combined for OR: D, E
+            // AND inactive: E only (D is active)
+            expect(getItemLabels(testEntities.items)).toEqual(['E']);
+        });
+
+        it('filters with same field appearing at multiple nesting levels', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [
+                            { tagId: { eq: 'T_1' } },
+                            {
+                                _and: [
+                                    { tagId: { eq: 'T_2' } },
+                                    { _or: [{ tagId: { eq: 'T_3' } }, { active: { eq: true } }] },
+                                ],
+                            },
+                        ],
+                    },
+                },
+            });
+
+            // tag1 AND (tag2 AND (tag3 OR active))
+            // tag1: A, B, C, E
+            // tag2: A, B, D, E
+            // tag3 OR active: E (tag3) + A, B, D (active) = A, B, D, E
+            // tag1 AND tag2: A, B, E
+            // tag1 AND tag2 AND (tag3 OR active): A, B, E (E has tag3, A and B are active)
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'E']);
+        });
+
+        // ===== USING "in" OPERATOR WITH SAME FIELD =====
+
+        it('filters with "in" operator combined with "eq" on same field', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [{ tagId: { in: ['T_1', 'T_2'] } }, { tagId: { eq: 'T_3' } }],
+                    },
+                },
+            });
+
+            // (tag1 or tag2) AND tag3 => Only E has tag3 and also has tag1 or tag2
+            expect(getItemLabels(testEntities.items)).toEqual(['E']);
+        });
+
+        it('filters with multiple "in" operators on same field in _and', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [{ tagId: { in: ['T_1'] } }, { tagId: { in: ['T_2'] } }],
+                    },
+                },
+            });
+
+            // Must have tag1 AND must have tag2 => A, B, E
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'E']);
+        });
+
+        // ===== TOP-LEVEL SAME FIELD (regression test) =====
+
+        it('handles same field at top level (outside _and/_or)', async () => {
+            // This tests that a single use of a *-to-Many field still works correctly
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        tagId: { in: ['T_1', 'T_2'] },
+                    },
+                },
+            });
+
+            // Entities with tag1 OR tag2: A, B, C, D, E
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'C', 'D', 'E']);
+        });
+
+        it('handles single tagId filter combined with top-level _and', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [{ tagId: { eq: 'T_3' } }, { active: { eq: false } }],
+                    },
+                },
+            });
+
+            // tag3 AND inactive => E only (only entity with tag3, and it's inactive)
+            expect(getItemLabels(testEntities.items)).toEqual(['E']);
+        });
+
+        // ===== filterOperator: OR at root level =====
+
+        it('respects filterOperator OR with duplicate tagId filters', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filterOperator: LogicalOperator.OR,
+                    filter: {
+                        tagId: { eq: 'T_3' },
+                        label: { eq: 'F' },
+                    },
+                },
+            });
+
+            // tag3 OR label='F' => E (has tag3) + F (by label)
+            expect(getItemLabels(testEntities.items)).toEqual(['E', 'F']);
+        });
+
+        // ===== EDGE CASES =====
+
+        it('handles empty _and array gracefully', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [],
+                    },
+                },
+            });
+
+            // Empty _and should return all entities
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'C', 'D', 'E', 'F']);
+        });
+
+        it('handles _and with single condition on *-to-Many field', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [{ tagId: { eq: 'T_3' } }],
+                    },
+                },
+            });
+
+            // Single condition in _and: tag3 => E only
+            expect(getItemLabels(testEntities.items)).toEqual(['E']);
+        });
+
+        it('handles notEq operator on *-to-Many field', async () => {
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        tagId: { notEq: 'T_3' },
+                    },
+                },
+            });
+
+            // Entities that have at least one tag that is NOT tag3
+            // A (tag1, tag2), B (tag1, tag2), C (tag1), D (tag2), E (tag1, tag2, tag3 - has non-T_3 tags)
+            // F has no tags, so no tag satisfies notEq
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'C', 'D', 'E']);
+        });
+
+        it('handles BETWEEN operator on *-to-Many field', async () => {
+            // Tag priorities: tag1=10, tag2=20, tag3=30
+            // Find entities with tags having priority between 15 and 25 (i.e., tag2 with priority 20)
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        tagPriority: { between: { start: 15, end: 25 } },
+                    },
+                },
+            });
+
+            // Entities with tag2 (priority 20): A, B, D, E
+            expect(getItemLabels(testEntities.items)).toEqual(['A', 'B', 'D', 'E']);
+        });
+
+        it('handles BETWEEN with _and on *-to-Many field', async () => {
+            // Find entities with tags having priority between 5 and 15 (tag1=10)
+            // AND tags having priority between 25 and 35 (tag3=30)
+            const { testEntities } = await shopClient.query(GET_LIST_WITH_TAGS, {
+                options: {
+                    sort: { label: SortOrder.ASC },
+                    filter: {
+                        _and: [
+                            { tagPriority: { between: { start: 5, end: 15 } } },
+                            { tagPriority: { between: { start: 25, end: 35 } } },
+                        ],
+                    },
+                },
+            });
+
+            // Only E has both tag1 (priority 10) and tag3 (priority 30)
+            expect(getItemLabels(testEntities.items)).toEqual(['E']);
+        });
+    });
 });
 
 const GET_LIST = gql`
@@ -1544,3 +1946,19 @@ const GET_LIST_WITH_MULTIPLE_CUSTOM_FIELD_RELATION = gql`
         }
     }
 `;
+
+const GET_LIST_WITH_TAGS = gql`
+    query GetTestEntitiesWithTags($options: TestEntityListOptions) {
+        testEntities(options: $options) {
+            totalItems
+            items {
+                id
+                label
+                tags {
+                    id
+                    name
+                }
+            }
+        }
+    }
+`;

+ 269 - 15
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts

@@ -29,9 +29,15 @@ import { joinTreeRelationsDynamically } from '../utils/tree-relations-qb-joiner'
 
 import { getColumnMetadata, getEntityAlias } from './connection-utils';
 import { getCalculatedColumns } from './get-calculated-columns';
-import { parseFilterParams, WhereGroup } from './parse-filter-params';
+import { parseFilterParams, WhereCondition, WhereGroup } from './parse-filter-params';
 import { parseSortParams } from './parse-sort-params';
 
+/**
+ * Counter for generating unique aliases in EXISTS subqueries.
+ * Using a module-level counter ensures uniqueness across all queries in a session.
+ */
+let existsSubqueryCounter = 0;
+
 /**
  * @description
  * Options which can be passed to the ListQueryBuilder's `build()` method.
@@ -291,9 +297,14 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         this.joinCalculatedColumnRelations(qb, entity, options);
 
         const { customPropertyMap } = extendedOptions;
+        // Store the original customPropertyMap before normalization for EXISTS subquery generation.
+        // This is needed because normalizeCustomPropertyMap mutates customPropertyMap, but
+        // parseFilterParams needs the original paths to detect *-to-Many relations.
+        const originalCustomPropertyMap = customPropertyMap ? { ...customPropertyMap } : undefined;
         if (customPropertyMap) {
             this.normalizeCustomPropertyMap(customPropertyMap, options, qb);
         }
+
         const customFieldsForType = this.configService.customFields[entity.name as keyof CustomFields];
         const sortParams = Object.assign({}, options.sort, extendedOptions.orderBy);
         this.applyTranslationConditions(qb, entity, sortParams, extendedOptions.ctx);
@@ -305,7 +316,15 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
             qb.alias,
             customFieldsForType,
         );
-        const filter = parseFilterParams(qb.connection, entity, options.filter, customPropertyMap, qb.alias);
+
+        const filter = parseFilterParams({
+            connection: qb.connection,
+            entity,
+            filterParams: options.filter,
+            customPropertyMap,
+            originalCustomPropertyMap,
+            entityAlias: qb.alias,
+        });
 
         if (filter.length) {
             const filterOperator = options.filterOperator ?? LogicalOperator.AND;
@@ -313,13 +332,9 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
                 new Brackets(qb1 => {
                     for (const condition of filter) {
                         if ('conditions' in condition) {
-                            this.addNestedWhereClause(qb1, condition, filterOperator);
+                            this.addNestedWhereClause(qb1, condition, filterOperator, qb, entity);
                         } else {
-                            if (filterOperator === LogicalOperator.AND) {
-                                qb1.andWhere(condition.clause, condition.parameters);
-                            } else {
-                                qb1.orWhere(condition.clause, condition.parameters);
-                            }
+                            this.applyWhereCondition(qb1, condition, filterOperator, qb, entity);
                         }
                     }
                 }),
@@ -336,22 +351,20 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         return qb;
     }
 
-    private addNestedWhereClause(
+    private addNestedWhereClause<T extends VendureEntity>(
         qb: WhereExpressionBuilder,
         whereGroup: WhereGroup,
         parentOperator: LogicalOperator,
+        mainQb: SelectQueryBuilder<T>,
+        entity: Type<T>,
     ) {
         if (whereGroup.conditions.length) {
             const subQb = new Brackets(qb1 => {
                 whereGroup.conditions.forEach(condition => {
                     if ('conditions' in condition) {
-                        this.addNestedWhereClause(qb1, condition, whereGroup.operator);
+                        this.addNestedWhereClause(qb1, condition, whereGroup.operator, mainQb, entity);
                     } else {
-                        if (whereGroup.operator === LogicalOperator.AND) {
-                            qb1.andWhere(condition.clause, condition.parameters);
-                        } else {
-                            qb1.orWhere(condition.clause, condition.parameters);
-                        }
+                        this.applyWhereCondition(qb1, condition, whereGroup.operator, mainQb, entity);
                     }
                 });
             });
@@ -363,6 +376,247 @@ export class ListQueryBuilder implements OnApplicationBootstrap {
         }
     }
 
+    /**
+     * Applies a WHERE condition to the query builder. For conditions that need EXISTS subquery
+     * treatment (duplicate custom property fields in _and blocks), generates an EXISTS subquery
+     * instead of a simple WHERE clause.
+     */
+    private applyWhereCondition<T extends VendureEntity>(
+        qb: WhereExpressionBuilder,
+        condition: WhereCondition,
+        operator: LogicalOperator,
+        mainQb: SelectQueryBuilder<T>,
+        entity: Type<T>,
+    ) {
+        if (condition.isExistsCondition) {
+            // Generate EXISTS subquery for duplicate custom property conditions
+            const existsClause = this.buildExistsSubquery(condition, mainQb, entity);
+            if (existsClause) {
+                if (operator === LogicalOperator.AND) {
+                    qb.andWhere(existsClause.clause, existsClause.parameters);
+                } else {
+                    qb.orWhere(existsClause.clause, existsClause.parameters);
+                }
+                return;
+            }
+        }
+
+        // Standard WHERE clause handling
+        if (operator === LogicalOperator.AND) {
+            qb.andWhere(condition.clause, condition.parameters);
+        } else {
+            qb.orWhere(condition.clause, condition.parameters);
+        }
+    }
+
+    /**
+     * Builds an EXISTS subquery for a custom property condition on a *-to-Many relation.
+     * This is necessary because a simple WHERE clause on a joined table cannot express
+     * "entity has related item with value A AND entity has related item with value B"
+     * when those are in separate rows of the related table.
+     *
+     * Supports both:
+     * - ManyToMany relations (uses junction table)
+     * - OneToMany relations (direct foreign key on the related table)
+     *
+     * @see https://github.com/vendure-ecommerce/vendure/issues/3267
+     */
+    private buildExistsSubquery<T extends VendureEntity>(
+        condition: WhereCondition,
+        mainQb: SelectQueryBuilder<T>,
+        entity: Type<T>,
+    ): { clause: string; parameters: Record<string, any> } | null {
+        if (!condition.isExistsCondition) {
+            return null;
+        }
+
+        const { customPropertyPath } = condition.isExistsCondition;
+        const pathParts = customPropertyPath.split('.');
+
+        if (pathParts.length < 2) {
+            return null;
+        }
+
+        const relationName = pathParts[0]; // e.g., 'facetValues' or 'orderLines'
+        const columnName = pathParts[1]; // e.g., 'id'
+
+        const metadata = mainQb.expressionMap.mainAlias?.metadata;
+        if (!metadata) {
+            return null;
+        }
+
+        const relation = metadata.findRelationWithPropertyPath(relationName);
+        if (!relation) {
+            return null;
+        }
+
+        // Get the related entity's table and column info
+        const inverseEntityMeta = relation.inverseEntityMetadata;
+        const inverseTableName = inverseEntityMeta.tableName;
+
+        // Generate unique alias using counter
+        existsSubqueryCounter++;
+        const aliasBase = `lqb_exists_${existsSubqueryCounter}`;
+
+        // Determine the comparison operator from the original clause
+        const comparisonOperator = this.extractComparisonOperator(condition.clause);
+
+        // Copy all parameters with 'exists_' prefix to ensure uniqueness.
+        // This handles operators with multiple params like BETWEEN (arg1_a, arg1_b).
+        const parameters: Record<string, any> = {};
+        const paramKeys = Object.keys(condition.parameters);
+        for (const key of paramKeys) {
+            parameters[`exists_${key}`] = condition.parameters[key];
+        }
+        // Use the first param key as the base for the WHERE clause construction.
+        // For BETWEEN this will be 'exists_arg1' (we strip the _a/_b suffix).
+        const baseParamKey = paramKeys[0]?.replace(/_[ab]$/, '') ?? 'arg';
+        const newParamKey = `exists_${baseParamKey}`;
+
+        // Helper to escape identifiers for the current database driver (handles PostgreSQL quoting)
+        const escapeId = (name: string) => mainQb.connection.driver.escape(name);
+
+        let existsQuery: string;
+
+        if (relation.isManyToMany) {
+            // ManyToMany: Uses a junction table
+            const junctionMeta = relation.junctionEntityMetadata;
+            if (!junctionMeta) {
+                return null;
+            }
+
+            const junctionTableName = junctionMeta.tableName;
+            const ownerColumn = junctionMeta.ownerColumns[0];
+            const inverseColumn = junctionMeta.inverseColumns[0];
+
+            if (!ownerColumn || !inverseColumn) {
+                return null;
+            }
+
+            const junctionAlias = aliasBase;
+            const relatedAlias = `${aliasBase}_related`;
+            const whereCondition = this.buildWhereConditionClause(
+                relatedAlias,
+                columnName,
+                comparisonOperator,
+                newParamKey,
+                escapeId,
+            );
+
+            // EXISTS (SELECT 1 FROM junction_table jt
+            //         INNER JOIN related_table rt ON jt.inverseColumn = rt.id
+            //         WHERE jt.ownerColumn = main_entity.id AND rt.columnName = :paramValue)
+            existsQuery = `EXISTS (
+                SELECT 1 FROM ${escapeId(junctionTableName)} ${escapeId(junctionAlias)}
+                INNER JOIN ${escapeId(inverseTableName)} ${escapeId(relatedAlias)}
+                    ON ${escapeId(junctionAlias)}.${escapeId(inverseColumn.databaseName)} = ${escapeId(relatedAlias)}.${escapeId('id')}
+                    WHERE ${escapeId(junctionAlias)}.${escapeId(ownerColumn.databaseName)} = ${escapeId(mainQb.alias)}.${escapeId('id')} AND ${whereCondition}
+            )`;
+        } else if (relation.isOneToMany) {
+            // OneToMany: The related table has a foreign key back to the main entity
+            const relatedAlias = aliasBase;
+
+            // Find the foreign key column on the related entity that points back to the main entity
+            const inverseRelation = relation.inverseRelation;
+            if (!inverseRelation) {
+                return null;
+            }
+
+            // Get the join columns from the inverse relation (ManyToOne side)
+            const joinColumns = inverseRelation.joinColumns;
+            if (!joinColumns || joinColumns.length === 0) {
+                return null;
+            }
+
+            const foreignKeyColumn = joinColumns[0].databaseName;
+            if (!foreignKeyColumn) {
+                return null;
+            }
+
+            const whereCondition = this.buildWhereConditionClause(
+                relatedAlias,
+                columnName,
+                comparisonOperator,
+                newParamKey,
+                escapeId,
+            );
+
+            // EXISTS (SELECT 1 FROM related_table rt
+            //         WHERE rt.foreignKey = main_entity.id AND rt.columnName = :paramValue)
+            existsQuery = `EXISTS (
+                SELECT 1 FROM ${escapeId(inverseTableName)} ${escapeId(relatedAlias)}
+                WHERE ${escapeId(relatedAlias)}.${escapeId(foreignKeyColumn)} = ${escapeId(mainQb.alias)}.${escapeId('id')} AND ${whereCondition}
+            )`;
+        } else {
+            // Not a *-to-Many relation, shouldn't happen but fall back gracefully
+            return null;
+        }
+
+        return {
+            clause: existsQuery,
+            parameters,
+        };
+    }
+
+    /**
+     * Extracts the comparison operator from a SQL clause string.
+     */
+    private extractComparisonOperator(clause: string): string {
+        if (clause.includes('!=')) {
+            return '!=';
+        } else if (clause.includes('>=')) {
+            return '>=';
+        } else if (clause.includes('<=')) {
+            return '<=';
+        } else if (clause.includes('>')) {
+            return '>';
+        } else if (clause.includes('<')) {
+            return '<';
+        } else if (clause.includes(' IN ')) {
+            return 'IN';
+        } else if (clause.includes(' NOT IN ')) {
+            return 'NOT IN';
+        } else if (clause.includes(' ILIKE ')) {
+            return 'ILIKE';
+        } else if (clause.includes(' NOT LIKE ') || clause.includes(' NOT ILIKE ')) {
+            return clause.includes('ILIKE') ? 'NOT ILIKE' : 'NOT LIKE';
+        } else if (clause.includes(' LIKE ')) {
+            return 'LIKE';
+        } else if (clause.includes(' IS NULL')) {
+            return 'IS NULL';
+        } else if (clause.includes(' IS NOT NULL')) {
+            return 'IS NOT NULL';
+        } else if (clause.includes(' BETWEEN ')) {
+            return 'BETWEEN';
+        }
+        return '=';
+    }
+
+    /**
+     * Builds a WHERE condition clause string for the EXISTS subquery.
+     */
+    private buildWhereConditionClause(
+        alias: string,
+        columnName: string,
+        operator: string,
+        paramKey: string,
+        escapeId: (name: string) => string,
+    ): string {
+        const col = `${escapeId(alias)}.${escapeId(columnName)}`;
+        if (operator === 'IN') {
+            return `${col} IN (:...${paramKey})`;
+        } else if (operator === 'NOT IN') {
+            return `${col} NOT IN (:...${paramKey})`;
+        } else if (operator === 'IS NULL') {
+            return `${col} IS NULL`;
+        } else if (operator === 'IS NOT NULL') {
+            return `${col} IS NOT NULL`;
+        } else if (operator === 'BETWEEN') {
+            return `${col} BETWEEN :${paramKey}_a AND :${paramKey}_b`;
+        }
+        return `${col} ${operator} :${paramKey}`;
+    }
+
     private parseTakeSkipParams(
         apiType: ApiType,
         options: ListQueryOptions<any>,

+ 173 - 61
packages/core/src/service/helpers/list-query-builder/parse-filter-params.spec.ts

@@ -5,15 +5,29 @@ import { FilterParameter } from '../../../common/types/common-types';
 import { ProductTranslation } from '../../../entity/product/product-translation.entity';
 import { Product } from '../../../entity/product/product.entity';
 
-import { parseFilterParams } from './parse-filter-params';
+import { parseFilterParams, WhereCondition, WhereGroup } from './parse-filter-params';
 import { MockConnection } from './parse-sort-params.spec';
 
+// Helper function to check if a result is a WhereCondition (not a WhereGroup)
+function isWhereCondition(item: WhereCondition | WhereGroup): item is WhereCondition {
+    return 'clause' in item;
+}
+
+// Helper function to check if a result is a WhereGroup
+function isWhereGroup(item: WhereCondition | WhereGroup): item is WhereGroup {
+    return 'conditions' in item;
+}
+
 describe('parseFilterParams()', () => {
     it('works with no params', () => {
         const connection = new MockConnection();
         connection.setColumns(Product, [{ propertyName: 'id' }, { propertyName: 'image' }]);
 
-        const result = parseFilterParams(connection as any, Product, {});
+        const result = parseFilterParams({
+            connection: connection as any,
+            entity: Product,
+            filterParams: {},
+        });
         expect(result).toEqual([]);
     });
 
@@ -25,9 +39,14 @@ describe('parseFilterParams()', () => {
                 eq: 'foo',
             },
         };
-        const result = parseFilterParams(connection as any, Product, filterParams);
-        expect(result[0].clause).toBe('product.name = :arg1');
-        expect(result[0].parameters).toEqual({ arg1: 'foo' });
+        const result = parseFilterParams({
+            connection: connection as any,
+            entity: Product,
+            filterParams,
+        });
+        const first = result[0];
+        expect(isWhereCondition(first) && first.clause).toBe('product.name = :arg1');
+        expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: 'foo' });
     });
 
     it('works with multiple params', () => {
@@ -41,11 +60,17 @@ describe('parseFilterParams()', () => {
                 eq: '123',
             },
         };
-        const result = parseFilterParams(connection as any, Product, filterParams);
-        expect(result[0].clause).toBe('product.name = :arg1');
-        expect(result[0].parameters).toEqual({ arg1: 'foo' });
-        expect(result[1].clause).toBe('product.id = :arg2');
-        expect(result[1].parameters).toEqual({ arg2: '123' });
+        const result = parseFilterParams({
+            connection: connection as any,
+            entity: Product,
+            filterParams,
+        });
+        const first = result[0];
+        const second = result[1];
+        expect(isWhereCondition(first) && first.clause).toBe('product.name = :arg1');
+        expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: 'foo' });
+        expect(isWhereCondition(second) && second.clause).toBe('product.id = :arg2');
+        expect(isWhereCondition(second) && second.parameters).toEqual({ arg2: '123' });
     });
 
     it('works with localized fields', () => {
@@ -65,11 +90,17 @@ describe('parseFilterParams()', () => {
                 eq: '123',
             },
         };
-        const result = parseFilterParams(connection as any, Product, filterParams);
-        expect(result[0].clause).toBe('product__translations.name = :arg1');
-        expect(result[0].parameters).toEqual({ arg1: 'foo' });
-        expect(result[1].clause).toBe('product.id = :arg2');
-        expect(result[1].parameters).toEqual({ arg2: '123' });
+        const result = parseFilterParams({
+            connection: connection as any,
+            entity: Product,
+            filterParams,
+        });
+        const first = result[0];
+        const second = result[1];
+        expect(isWhereCondition(first) && first.clause).toBe('product__translations.name = :arg1');
+        expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: 'foo' });
+        expect(isWhereCondition(second) && second.clause).toBe('product.id = :arg2');
+        expect(isWhereCondition(second) && second.parameters).toEqual({ arg2: '123' });
     });
 
     describe('string operators', () => {
@@ -81,9 +112,14 @@ describe('parseFilterParams()', () => {
                     eq: 'foo',
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.name = :arg1');
-            expect(result[0].parameters).toEqual({ arg1: 'foo' });
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe('product.name = :arg1');
+            expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: 'foo' });
         });
 
         it('contains', () => {
@@ -94,9 +130,14 @@ describe('parseFilterParams()', () => {
                     contains: 'foo',
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.name LIKE :arg1');
-            expect(result[0].parameters).toEqual({ arg1: '%foo%' });
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe('product.name LIKE :arg1');
+            expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: '%foo%' });
         });
     });
 
@@ -109,9 +150,14 @@ describe('parseFilterParams()', () => {
                     eq: 123,
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.price = :arg1');
-            expect(result[0].parameters).toEqual({ arg1: 123 });
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe('product.price = :arg1');
+            expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: 123 });
         });
 
         it('lt', () => {
@@ -122,9 +168,14 @@ describe('parseFilterParams()', () => {
                     lt: 123,
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.price < :arg1');
-            expect(result[0].parameters).toEqual({ arg1: 123 });
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe('product.price < :arg1');
+            expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: 123 });
         });
 
         it('lte', () => {
@@ -135,9 +186,14 @@ describe('parseFilterParams()', () => {
                     lte: 123,
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.price <= :arg1');
-            expect(result[0].parameters).toEqual({ arg1: 123 });
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe('product.price <= :arg1');
+            expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: 123 });
         });
 
         it('gt', () => {
@@ -148,9 +204,14 @@ describe('parseFilterParams()', () => {
                     gt: 123,
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.price > :arg1');
-            expect(result[0].parameters).toEqual({ arg1: 123 });
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe('product.price > :arg1');
+            expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: 123 });
         });
 
         it('gte', () => {
@@ -161,9 +222,14 @@ describe('parseFilterParams()', () => {
                     gte: 123,
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.price >= :arg1');
-            expect(result[0].parameters).toEqual({ arg1: 123 });
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe('product.price >= :arg1');
+            expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: 123 });
         });
 
         it('between', () => {
@@ -177,9 +243,14 @@ describe('parseFilterParams()', () => {
                     },
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.price BETWEEN :arg1_a AND :arg1_b');
-            expect(result[0].parameters).toEqual({ arg1_a: 10, arg1_b: 50 });
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe('product.price BETWEEN :arg1_a AND :arg1_b');
+            expect(isWhereCondition(first) && first.parameters).toEqual({ arg1_a: 10, arg1_b: 50 });
         });
     });
 
@@ -192,9 +263,14 @@ describe('parseFilterParams()', () => {
                     eq: new Date('2018-01-01T10:00:00.000Z'),
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.createdAt = :arg1');
-            expect(result[0].parameters).toEqual({ arg1: '2018-01-01 10:00:00.000' });
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe('product.createdAt = :arg1');
+            expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: '2018-01-01 10:00:00.000' });
         });
 
         it('before', () => {
@@ -205,9 +281,14 @@ describe('parseFilterParams()', () => {
                     before: new Date('2018-01-01T10:00:00.000Z'),
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.createdAt < :arg1');
-            expect(result[0].parameters).toEqual({ arg1: '2018-01-01 10:00:00.000' });
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe('product.createdAt < :arg1');
+            expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: '2018-01-01 10:00:00.000' });
         });
 
         it('after', () => {
@@ -218,9 +299,14 @@ describe('parseFilterParams()', () => {
                     after: new Date('2018-01-01T10:00:00.000Z'),
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.createdAt > :arg1');
-            expect(result[0].parameters).toEqual({ arg1: '2018-01-01 10:00:00.000' });
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe('product.createdAt > :arg1');
+            expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: '2018-01-01 10:00:00.000' });
         });
 
         it('between', () => {
@@ -234,9 +320,16 @@ describe('parseFilterParams()', () => {
                     },
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.createdAt BETWEEN :arg1_a AND :arg1_b');
-            expect(result[0].parameters).toEqual({
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe(
+                'product.createdAt BETWEEN :arg1_a AND :arg1_b',
+            );
+            expect(isWhereCondition(first) && first.parameters).toEqual({
                 arg1_a: '2018-01-01 10:00:00.000',
                 arg1_b: '2018-02-01 10:00:00.000',
             });
@@ -252,9 +345,14 @@ describe('parseFilterParams()', () => {
                     eq: true,
                 },
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].clause).toBe('product.available = :arg1');
-            expect(result[0].parameters).toEqual({ arg1: true });
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereCondition(first) && first.clause).toBe('product.available = :arg1');
+            expect(isWhereCondition(first) && first.parameters).toEqual({ arg1: true });
         });
     });
 
@@ -275,9 +373,14 @@ describe('parseFilterParams()', () => {
                     },
                 ],
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].operator).toBe(LogicalOperator.AND);
-            expect(result[0].conditions).toEqual([
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereGroup(first) && first.operator).toBe(LogicalOperator.AND);
+            expect(isWhereGroup(first) && first.conditions).toEqual([
                 { clause: 'product.name = :arg1', parameters: { arg1: 'foo' } },
                 { clause: 'product.slug = :arg2', parameters: { arg2: 'bar' } },
             ]);
@@ -299,9 +402,14 @@ describe('parseFilterParams()', () => {
                     },
                 ],
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
-            expect(result[0].operator).toBe(LogicalOperator.OR);
-            expect(result[0].conditions).toEqual([
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
+            const first = result[0];
+            expect(isWhereGroup(first) && first.operator).toBe(LogicalOperator.OR);
+            expect(isWhereGroup(first) && first.conditions).toEqual([
                 { clause: 'product.name = :arg1', parameters: { arg1: 'foo' } },
                 { clause: 'product.slug = :arg2', parameters: { arg2: 'bar' } },
             ]);
@@ -323,7 +431,11 @@ describe('parseFilterParams()', () => {
                     },
                 ],
             };
-            const result = parseFilterParams(connection as any, Product, filterParams);
+            const result = parseFilterParams({
+                connection: connection as any,
+                entity: Product,
+                filterParams,
+            });
             expect(result).toEqual([
                 {
                     operator: LogicalOperator.AND,

+ 170 - 20
packages/core/src/service/helpers/list-query-builder/parse-filter-params.ts

@@ -27,28 +27,80 @@ export interface WhereGroup {
 export interface WhereCondition {
     clause: string;
     parameters: { [param: string]: string | number | string[] };
+    /**
+     * When defined, this condition should be converted to an EXISTS subquery
+     * instead of a simple WHERE clause. This is used for custom property fields
+     * that map to *-to-Many relations (OneToMany or ManyToMany), where standard
+     * JOIN + WHERE semantics cannot correctly express AND logic across multiple
+     * related rows.
+     *
+     * @see https://github.com/vendure-ecommerce/vendure/issues/3267
+     */
+    isExistsCondition?: {
+        /**
+         * The custom property key from the customPropertyMap
+         */
+        customPropertyKey: string;
+        /**
+         * The original path from customPropertyMap (e.g., 'facetValues.id')
+         */
+        customPropertyPath: string;
+    };
 }
 
 type AllOperators = StringOperators & BooleanOperators & NumberOperators & DateOperators & ListOperators;
 type Operator = { [K in keyof AllOperators]-?: K }[keyof AllOperators];
 
-export function parseFilterParams<
-    T extends VendureEntity,
-    FP extends NullOptionals<FilterParameter<T>>,
-    R extends FP extends { _and: Array<FilterParameter<T>> }
-        ? WhereGroup[]
-        : FP extends { _or: Array<FilterParameter<T>> }
-          ? WhereGroup[]
-          : WhereCondition[],
->(
-    connection: DataSource,
-    entity: Type<T>,
-    filterParams?: FP | null,
-    customPropertyMap?: { [name: string]: string },
-    entityAlias?: string,
-): R {
+/**
+ * @description
+ * Options for the parseFilterParams function.
+ */
+export interface ParseFilterParamsOptions<T extends VendureEntity> {
+    /**
+     * The TypeORM DataSource connection.
+     */
+    connection: DataSource;
+    /**
+     * The entity type being queried.
+     */
+    entity: Type<T>;
+    /**
+     * The filter parameters from the GraphQL query.
+     */
+    filterParams?: NullOptionals<FilterParameter<T>> | null;
+    /**
+     * Map of custom property names to their relation paths (after normalization).
+     * Note: This map gets mutated by the ListQueryBuilder's normalizeCustomPropertyMap method.
+     */
+    customPropertyMap?: { [name: string]: string };
+    /**
+     * Original custom property map before normalization, containing the original relation paths.
+     * This is needed to detect *-to-Many relations and to generate EXISTS subqueries with
+     * the correct table/column references.
+     */
+    originalCustomPropertyMap?: { [name: string]: string };
+    /**
+     * The alias used for the main entity in the query.
+     */
+    entityAlias?: string;
+}
+
+/**
+ * @description
+ * Parses filter parameters from a GraphQL query and converts them into SQL WHERE conditions.
+ *
+ * For custom property fields that map to *-to-Many relations, all conditions will be marked
+ * for EXISTS subquery treatment to ensure correct AND semantics when filtering across
+ * multiple related rows.
+ */
+export function parseFilterParams<T extends VendureEntity>(
+    options: ParseFilterParamsOptions<T>,
+): Array<WhereCondition | WhereGroup> {
+    const { connection, entity, filterParams, customPropertyMap, originalCustomPropertyMap, entityAlias } =
+        options;
+
     if (!filterParams) {
-        return [] as unknown as R;
+        return [];
     }
     const { columns, translationColumns, alias: defaultAlias } = getColumnMetadata(connection, entity);
     const alias = entityAlias ?? defaultAlias;
@@ -57,11 +109,26 @@ export function parseFilterParams<
     const dbType = connection.options.type;
     let argIndex = 1;
 
+    // Detect which custom property fields map to *-to-Many relations.
+    // All filter conditions on these fields will use EXISTS subqueries for correct AND semantics.
+    const toManyRelationCustomProperties = getToManyRelationCustomProperties(
+        connection,
+        entity,
+        originalCustomPropertyMap,
+        filterParams,
+    );
+
     function buildConditionsForField(key: string, operation: FilterParameter<T>): WhereCondition[] {
         const output: WhereCondition[] = [];
         const calculatedColumnDef = calculatedColumns.find(c => c.name === key);
         const instruction = calculatedColumnDef?.listQuery;
         const calculatedColumnExpression = instruction?.expression;
+
+        // Mark ALL conditions on *-to-Many relation custom properties for EXISTS subquery treatment.
+        // This ensures correct AND semantics regardless of how many times the field is used.
+        const isToManyCustomProperty =
+            toManyRelationCustomProperties.has(key) && originalCustomPropertyMap?.[key];
+
         for (const [operator, operand] of Object.entries(operation as object)) {
             let fieldName: string;
             if (columns.find(c => c.propertyName === key)) {
@@ -77,18 +144,28 @@ export function parseFilterParams<
                 throw new UserInputError('error.invalid-filter-field');
             }
             const condition = buildWhereCondition(fieldName, operator as Operator, operand, argIndex, dbType);
+
+            // Mark *-to-Many custom property fields for EXISTS subquery treatment
+            if (isToManyCustomProperty) {
+                condition.isExistsCondition = {
+                    customPropertyKey: key,
+                    customPropertyPath: originalCustomPropertyMap[key],
+                };
+            }
+
             output.push(condition);
             argIndex++;
         }
         return output;
     }
 
-    function processFilterParameter(param: FilterParameter<T>) {
+    function processFilterParameter(param: FilterParameter<T>): Array<WhereCondition | WhereGroup> {
         const result: Array<WhereCondition | WhereGroup> = [];
         for (const [key, operation] of Object.entries(param)) {
             if (key === '_and' || key === '_or') {
+                const isAndOperator = key === '_and';
                 result.push({
-                    operator: key === '_and' ? LogicalOperator.AND : LogicalOperator.OR,
+                    operator: isAndOperator ? LogicalOperator.AND : LogicalOperator.OR,
                     conditions: operation.map(o => processFilterParameter(o)).flat(),
                 });
             } else if (operation && !Array.isArray(operation)) {
@@ -98,9 +175,82 @@ export function parseFilterParams<
         return result;
     }
 
-    const conditions = processFilterParameter(filterParams as FilterParameter<T>);
+    return processFilterParameter(filterParams as FilterParameter<T>);
+}
 
-    return conditions as R;
+/**
+ * @description
+ * Identifies which custom property keys map to *-to-Many relations (OneToMany or ManyToMany).
+ * These fields require EXISTS subqueries for correct AND semantics when filtering across
+ * multiple related rows.
+ *
+ * @see https://github.com/vendure-ecommerce/vendure/issues/3267
+ */
+function getToManyRelationCustomProperties<T extends VendureEntity>(
+    connection: DataSource,
+    entity: Type<T>,
+    originalCustomPropertyMap: { [name: string]: string } | undefined,
+    filterParams: NullOptionals<FilterParameter<T>>,
+): Set<string> {
+    const toManyProperties = new Set<string>();
+    if (!originalCustomPropertyMap) {
+        return toManyProperties;
+    }
+
+    const metadata = connection.getMetadata(entity);
+
+    for (const [property, path] of Object.entries(originalCustomPropertyMap)) {
+        // Only check properties that are actually being used in filters
+        if (!isPropertyUsedInFilter(property, filterParams as NullOptionals<FilterParameter<any>>)) {
+            continue;
+        }
+
+        // Parse the path to get the relation name (e.g., 'facetValues.id' -> 'facetValues')
+        const pathParts = path.split('.');
+        if (pathParts.length < 2) {
+            continue;
+        }
+
+        const relationName = pathParts[0];
+        const relationMetadata = metadata.findRelationWithPropertyPath(relationName);
+
+        if (relationMetadata && (relationMetadata.isOneToMany || relationMetadata.isManyToMany)) {
+            toManyProperties.add(property);
+        }
+    }
+
+    return toManyProperties;
+}
+
+/**
+ * Checks if a property is used anywhere in the filter parameters,
+ * including nested _and/_or blocks.
+ */
+function isPropertyUsedInFilter(
+    property: string,
+    filter: NullOptionals<FilterParameter<any>> | null | undefined,
+): boolean {
+    if (!filter) {
+        return false;
+    }
+    if (filter[property]) {
+        return true;
+    }
+    if (filter._and) {
+        for (const nestedFilter of filter._and) {
+            if (isPropertyUsedInFilter(property, nestedFilter)) {
+                return true;
+            }
+        }
+    }
+    if (filter._or) {
+        for (const nestedFilter of filter._or) {
+            if (isPropertyUsedInFilter(property, nestedFilter)) {
+                return true;
+            }
+        }
+    }
+    return false;
 }
 
 function buildWhereCondition(