|
|
@@ -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
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+`;
|