@posthog/core 1.48.9 → 1.48.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,174 @@
1
+ import type { PropertyFilters, PropertyOperator } from '../types'
2
+ import { isMatchingRegex, isValidRegex, matchPropertyFilters, propertyComparisons } from './property-matching'
3
+
4
+ const operators: PropertyOperator[] = [
5
+ 'exact',
6
+ 'is_not',
7
+ 'regex',
8
+ 'not_regex',
9
+ 'icontains',
10
+ 'not_icontains',
11
+ 'gt',
12
+ 'lt',
13
+ ]
14
+
15
+ const filter = (operator: PropertyOperator, values: string[]): PropertyFilters => ({
16
+ property: { operator, values },
17
+ })
18
+
19
+ describe('shared property matching contract', () => {
20
+ describe('regex validation', () => {
21
+ it('validates and matches regular expressions without throwing', () => {
22
+ expect(isValidRegex('^/docs/.*')).toBe(true)
23
+ expect(isMatchingRegex('/docs/getting-started', '^/docs/')).toBe(true)
24
+ expect(isMatchingRegex('/pricing', '^/docs/')).toBe(false)
25
+ })
26
+
27
+ it('returns false for invalid regular expressions', () => {
28
+ expect(isValidRegex('[invalid')).toBe(false)
29
+ expect(isMatchingRegex('anything', '[invalid')).toBe(false)
30
+ })
31
+ })
32
+
33
+ describe('property comparisons', () => {
34
+ it.each<{
35
+ operator: PropertyOperator
36
+ targets: string[]
37
+ values: string[]
38
+ expected: boolean
39
+ }>([
40
+ { operator: 'exact', targets: ['premium'], values: ['basic', 'premium'], expected: true },
41
+ { operator: 'exact', targets: ['premium'], values: ['basic', 'trial'], expected: false },
42
+ { operator: 'is_not', targets: ['basic', 'trial'], values: ['premium', 'enterprise'], expected: true },
43
+ { operator: 'is_not', targets: ['basic', 'trial'], values: ['premium', 'trial'], expected: false },
44
+ { operator: 'regex', targets: ['^/app/', '^/docs/'], values: ['/home', '/docs/start'], expected: true },
45
+ { operator: 'regex', targets: ['^/app/', '^/docs/'], values: ['/home', '/pricing'], expected: false },
46
+ { operator: 'not_regex', targets: ['^/app/', '^/docs/'], values: ['/home', '/pricing'], expected: true },
47
+ { operator: 'not_regex', targets: ['^/app/', '^/docs/'], values: ['/home', '/docs/start'], expected: false },
48
+ { operator: 'icontains', targets: ['CHECKOUT'], values: ['home', 'Start Checkout'], expected: true },
49
+ { operator: 'icontains', targets: ['CHECKOUT'], values: ['home', 'pricing'], expected: false },
50
+ { operator: 'not_icontains', targets: ['SPAM', 'BOT'], values: ['welcome', 'human'], expected: true },
51
+ { operator: 'not_icontains', targets: ['SPAM', 'BOT'], values: ['welcome', 'chatBot'], expected: false },
52
+ { operator: 'gt', targets: ['10', '20'], values: ['9', '21'], expected: true },
53
+ { operator: 'gt', targets: ['10', '20'], values: ['8', '9'], expected: false },
54
+ { operator: 'lt', targets: ['10', '20'], values: ['21', '9'], expected: true },
55
+ { operator: 'lt', targets: ['10', '20'], values: ['20', '21'], expected: false },
56
+ ])('$operator preserves its array quantifier semantics', ({ operator, targets, values, expected }) => {
57
+ expect(propertyComparisons[operator](targets, values)).toBe(expected)
58
+ })
59
+
60
+ it.each<{
61
+ operator: PropertyOperator
62
+ expected: boolean
63
+ }>([
64
+ { operator: 'exact', expected: false },
65
+ { operator: 'is_not', expected: true },
66
+ { operator: 'regex', expected: false },
67
+ { operator: 'not_regex', expected: true },
68
+ { operator: 'icontains', expected: false },
69
+ { operator: 'not_icontains', expected: true },
70
+ { operator: 'gt', expected: false },
71
+ { operator: 'lt', expected: false },
72
+ ])('$operator preserves empty-target behavior', ({ operator, expected }) => {
73
+ expect(propertyComparisons[operator]([], ['value'])).toBe(expected)
74
+ })
75
+
76
+ it('preserves parseFloat numeric coercion', () => {
77
+ expect(propertyComparisons.gt(['9widgets'], ['10items'])).toBe(true)
78
+ expect(propertyComparisons.lt(['10items'], ['9widgets'])).toBe(true)
79
+ expect(propertyComparisons.gt(['9'], ['not a number'])).toBe(false)
80
+ expect(propertyComparisons.gt(['not a number'], ['10'])).toBe(false)
81
+ expect(propertyComparisons.lt(['10'], ['10items'])).toBe(false)
82
+ })
83
+
84
+ it('treats invalid regexes as non-matches before applying negative quantifiers', () => {
85
+ expect(propertyComparisons.regex(['[invalid'], ['value'])).toBe(false)
86
+ expect(propertyComparisons.not_regex(['[invalid'], ['value'])).toBe(true)
87
+ })
88
+ })
89
+
90
+ describe('map-level matching', () => {
91
+ it('matches absent and empty filter maps', () => {
92
+ expect(matchPropertyFilters(undefined, undefined)).toBe(true)
93
+ expect(matchPropertyFilters({}, undefined)).toBe(true)
94
+ })
95
+
96
+ it('requires every configured property filter to match', () => {
97
+ expect(
98
+ matchPropertyFilters(
99
+ {
100
+ plan: { values: ['premium'], operator: 'exact' },
101
+ role: { values: ['admin'], operator: 'is_not' },
102
+ },
103
+ { plan: 'premium', role: 'member' }
104
+ )
105
+ ).toBe(true)
106
+ expect(
107
+ matchPropertyFilters(
108
+ {
109
+ plan: { values: ['premium'], operator: 'exact' },
110
+ role: { values: ['admin'], operator: 'is_not' },
111
+ },
112
+ { plan: 'premium', role: 'admin' }
113
+ )
114
+ ).toBe(false)
115
+ })
116
+
117
+ it.each(operators)('rejects missing and null values for %s, including negative operators', (operator) => {
118
+ expect(matchPropertyFilters(filter(operator, ['target']), {})).toBe(false)
119
+ expect(matchPropertyFilters(filter(operator, ['target']), { property: undefined })).toBe(false)
120
+ expect(matchPropertyFilters(filter(operator, ['target']), { property: null })).toBe(false)
121
+ })
122
+
123
+ it('coerces each event property to one string value', () => {
124
+ expect(matchPropertyFilters(filter('exact', ['5']), { property: 5 })).toBe(true)
125
+ expect(matchPropertyFilters(filter('exact', ['true']), { property: true })).toBe(true)
126
+ expect(matchPropertyFilters(filter('exact', ['premium,vip']), { property: ['premium', 'vip'] })).toBe(true)
127
+ })
128
+
129
+ it.each<{
130
+ operator: PropertyOperator
131
+ value: string
132
+ expected: boolean
133
+ }>([
134
+ { operator: 'exact', value: 'target', expected: true },
135
+ { operator: 'is_not', value: 'other', expected: true },
136
+ { operator: 'regex', value: '/docs/start', expected: true },
137
+ { operator: 'not_regex', value: '/pricing', expected: true },
138
+ { operator: 'icontains', value: 'TARGET value', expected: true },
139
+ { operator: 'not_icontains', value: 'other value', expected: true },
140
+ { operator: 'gt', value: '11items', expected: true },
141
+ { operator: 'lt', value: '9items', expected: true },
142
+ ])('uses the shared $operator comparison', ({ operator, value, expected }) => {
143
+ const target =
144
+ operator === 'regex' || operator === 'not_regex'
145
+ ? '^/docs/'
146
+ : operator === 'gt' || operator === 'lt'
147
+ ? '10items'
148
+ : 'target'
149
+ expect(matchPropertyFilters(filter(operator, [target]), { property: value })).toBe(expected)
150
+ })
151
+
152
+ it('returns false for invalid regex and numeric targets', () => {
153
+ expect(matchPropertyFilters(filter('regex', ['[invalid']), { property: 'value' })).toBe(false)
154
+ expect(matchPropertyFilters(filter('gt', ['not a number']), { property: '10' })).toBe(false)
155
+ expect(matchPropertyFilters(filter('lt', ['not a number']), { property: '10' })).toBe(false)
156
+ })
157
+
158
+ it('preserves empty-target behavior through the map matcher', () => {
159
+ expect(matchPropertyFilters(filter('exact', []), { property: 'value' })).toBe(false)
160
+ expect(matchPropertyFilters(filter('regex', []), { property: 'value' })).toBe(false)
161
+ expect(matchPropertyFilters(filter('icontains', []), { property: 'value' })).toBe(false)
162
+ expect(matchPropertyFilters(filter('gt', []), { property: '10' })).toBe(false)
163
+ expect(matchPropertyFilters(filter('lt', []), { property: '10' })).toBe(false)
164
+ expect(matchPropertyFilters(filter('is_not', []), { property: 'value' })).toBe(true)
165
+ expect(matchPropertyFilters(filter('not_regex', []), { property: 'value' })).toBe(true)
166
+ expect(matchPropertyFilters(filter('not_icontains', []), { property: 'value' })).toBe(true)
167
+ })
168
+
169
+ it('returns false for an unknown operator from malformed remote data', () => {
170
+ const malformedFilters = filter('unknown' as PropertyOperator, ['target'])
171
+ expect(matchPropertyFilters(malformedFilters, { property: 'target' })).toBe(false)
172
+ })
173
+ })
174
+ })
@@ -0,0 +1,75 @@
1
+ import type { Properties } from '@posthog/types'
2
+
3
+ import type { PropertyFilters, PropertyOperator } from '../types'
4
+
5
+ export const isValidRegex = (pattern: string): boolean => {
6
+ try {
7
+ new RegExp(pattern)
8
+ } catch {
9
+ return false
10
+ }
11
+ return true
12
+ }
13
+
14
+ export const isMatchingRegex = (value: string, pattern: string): boolean => {
15
+ if (!isValidRegex(pattern)) {
16
+ return false
17
+ }
18
+
19
+ try {
20
+ return new RegExp(pattern).test(value)
21
+ } catch {
22
+ return false
23
+ }
24
+ }
25
+
26
+ const toLowerCase = (value: string): string => value.toLowerCase()
27
+
28
+ export const propertyComparisons: Record<PropertyOperator, (targets: string[], values: string[]) => boolean> = {
29
+ exact: (targets, values) => values.some((value) => targets.some((target) => value === target)),
30
+ is_not: (targets, values) => values.every((value) => targets.every((target) => value !== target)),
31
+ regex: (targets, values) => values.some((value) => targets.some((target) => isMatchingRegex(value, target))),
32
+ not_regex: (targets, values) => values.every((value) => targets.every((target) => !isMatchingRegex(value, target))),
33
+ icontains: (targets, values) =>
34
+ values.map(toLowerCase).some((value) => targets.map(toLowerCase).some((target) => value.includes(target))),
35
+ not_icontains: (targets, values) =>
36
+ values.map(toLowerCase).every((value) => targets.map(toLowerCase).every((target) => !value.includes(target))),
37
+ gt: (targets, values) =>
38
+ values.some((value) => {
39
+ const numValue = parseFloat(value)
40
+ return !isNaN(numValue) && targets.some((target) => numValue > parseFloat(target))
41
+ }),
42
+ lt: (targets, values) =>
43
+ values.some((value) => {
44
+ const numValue = parseFloat(value)
45
+ return !isNaN(numValue) && targets.some((target) => numValue < parseFloat(target))
46
+ }),
47
+ }
48
+
49
+ /**
50
+ * Matches every configured filter against event properties. Missing and null
51
+ * properties never match, including for negative operators.
52
+ */
53
+ export function matchPropertyFilters(
54
+ propertyFilters: PropertyFilters | undefined,
55
+ eventProperties: Properties | undefined
56
+ ): boolean {
57
+ if (!propertyFilters) {
58
+ return true
59
+ }
60
+
61
+ return Object.entries(propertyFilters).every(([propertyName, filter]) => {
62
+ const eventPropertyValue = eventProperties?.[propertyName]
63
+
64
+ if (eventPropertyValue === undefined || eventPropertyValue === null) {
65
+ return false
66
+ }
67
+
68
+ const comparisonFunction = propertyComparisons[filter.operator]
69
+ if (!comparisonFunction) {
70
+ return false
71
+ }
72
+
73
+ return comparisonFunction(filter.values, [String(eventPropertyValue)])
74
+ })
75
+ }
package/src/types.ts CHANGED
@@ -832,6 +832,22 @@ export const SurveyMatchType = {
832
832
  } as const
833
833
  export type SurveyMatchType = (typeof SurveyMatchType)[keyof typeof SurveyMatchType]
834
834
 
835
+ export type PropertyMatchType = SurveyMatchType
836
+ export type PropertyOperator = PropertyMatchType | 'gt' | 'lt'
837
+
838
+ export type PropertyFilters = Record<
839
+ string,
840
+ {
841
+ values: string[]
842
+ operator: PropertyOperator
843
+ }
844
+ >
845
+
846
+ export interface SurveyEventWithFilters {
847
+ name: string
848
+ propertyFilters?: PropertyFilters
849
+ }
850
+
835
851
  export const SurveySchedule = {
836
852
  Once: 'once',
837
853
  Recurring: 'recurring',
@@ -881,9 +897,7 @@ export type Survey = {
881
897
  urlMatchType?: SurveyMatchType
882
898
  events?: {
883
899
  repeatedActivation?: boolean
884
- values?: {
885
- name: string
886
- }[]
900
+ values?: SurveyEventWithFilters[]
887
901
  }
888
902
  actions?: {
889
903
  values: SurveyActionType[]