Skip to content

fix(segments): count contacts without the field as outside a referenced dynamic segment - #582

Open
joanproductivo wants to merge 1 commit into
useplunk:nextfrom
joanproductivo:fix/segment-not-member-missing-data
Open

joanproductivo wants to merge 1 commit into
useplunk:nextfrom
joanproductivo:fix/segment-not-member-missing-data

Conversation

@joanproductivo

Copy link
Copy Markdown
Contributor

Description

notMemberOfSegment on a dynamic segment that does not track membership negates that segment's filter. A data.* comparison on a contact without the key is NULL in Postgres, and NOT NULL is still NULL, so the contact is dropped from both sides: "not in {plan = pro}" leaves out every contact without a plan, including contacts created without any data. Static and tracked segments are not affected (they go through segmentMemberships: {none}, added in #566).

  • Every data.* comparison now requires its key first ({AND: [{NOT: missing}, comparison]}, with missing = {data: {path, equals: Prisma.AnyNull}}), so it is always true or false and the negation is the segment's complement. exists/notExists produce the same SQL as before (AnyNull is the OR[DbNull, JsonNull] they used, now written once).
  • On their own, filters match the same contacts as before, except notEquals on a key explicitly set to null, which no longer matches. That is what operators.ts documents and what workflow conditions already do. equals/notEquals with a null value are left unwrapped, so equals null still matches a JSON null, as in workflow conditions.
  • The documented semantics of notEquals/notContains do not change: contacts without the field still don't match them. The wiki now says so (instead of "Negation of equals") and explains how to include them: OR the filter with notExists, nested in the same group.
  • Known gap, unchanged: a value of another type than the filter (a number or an array where contains compares text) matches neither side, because Prisma's NOT on a typed JSON filter also requires the type.

Cost: one extra IS NULL OR = 'null' per data.* leaf. Measured on 200,000 contacts, both before and after are a sequential scan (the #> path never used the GIN index), about +6 ms.

Type of Change

  • feat: New feature (MINOR version bump)
  • fix: Bug fix (PATCH version bump)
  • feat!: Breaking change - new feature (MAJOR version bump)
  • fix!: Breaking change - bug fix (MAJOR version bump)
  • docs: Documentation update (no version bump)
  • chore: Maintenance/dependencies (no version bump)
  • refactor: Code refactoring (no version bump)
  • test: Adding tests (no version bump)
  • perf: Performance improvement (PATCH version bump)

Testing

  • SegmentService.segmentFilters.test.ts gains 12 tests, through SegmentService.create/getContacts, with contacts that have the field, {}, a key set to null, a JSON null column (what ContactService.create stores without data) and a NULL column: notMemberOfSegment of equals, notEquals, contains, notContains and exists segments; a chain of two untracked dynamic segments; a value of another type (the known gap); notEquals/equals with a key set to null; exists/notExists without data; and the nested OR with notExists, next to another filter (an unsubscribed contact checks it stays inside the group) and alone. Without the fix, 6 of them fail.
  • Full suite on next with the change: 1664 tests passing. ESLint --max-warnings 0 and tsc --noEmit clean for api.
  • No schema or migration changes.

Checklist

  • PR title follows conventional commits format
  • Code builds successfully
  • Tests pass locally
  • Documentation updated (if needed)

Related Issues

Follow-up to #566.

🤖 Generated with Claude Code

…ed dynamic segment

notMemberOfSegment on a dynamic segment without membership tracking negated
that segment's filter. A data.* comparison on a contact without the key is NULL
in Postgres, and NOT NULL is still NULL, so "not in {plan = pro}" left out every
contact without a plan, including those created without data.

Every data.* comparison now requires its key first, so it is always true or
false and the negation is the segment's complement. On their own, filters match
the same contacts as before, except notEquals on a key explicitly set to null,
which no longer matches, as documented in operators.ts and as workflow
conditions already behave. equals/notEquals null still compare against a JSON
null. A value of another type than the filter (a number or an array for
contains) still matches neither side: Prisma's NOT on a typed JSON filter also
requires the type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant