Skip to content

Filters

Filter builder for V2 API filtering support.

Provides a type-safe, Pythonic way to build filter expressions for V2 list endpoints. The builder handles proper escaping and quoting of user inputs.

Example

from affinity.filters import Filter, F

filter = ( F.field("name").contains("Acme") & F.field("status").equals("Active") ) companies = client.companies.list(filter=filter)

Or build complex filters

filter = ( (F.field("name").contains("Corp") | F.field("name").contains("Inc")) & ~F.field("archived").equals(True) )

Raw filter string escape hatch (power users)

companies = client.companies.list(filter='name =~ "Acme"')

AndExpression dataclass

Bases: FilterExpression

& combination of two expressions.

Source code in affinity/filters.py
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
@dataclass
class AndExpression(FilterExpression):
    """`&` combination of two expressions."""

    left: FilterExpression
    right: FilterExpression

    def to_string(self) -> str:
        left_str = self.left.to_string()
        right_str = self.right.to_string()
        # Wrap in parentheses for correct precedence
        return f"({left_str}) & ({right_str})"

    def matches(self, entity: dict[str, Any]) -> bool:
        """Both sides must match."""
        return self.left.matches(entity) and self.right.matches(entity)

matches(entity: dict[str, Any]) -> bool

Both sides must match.

Source code in affinity/filters.py
233
234
235
def matches(self, entity: dict[str, Any]) -> bool:
    """Both sides must match."""
    return self.left.matches(entity) and self.right.matches(entity)

FieldBuilder

Builder for field-based filter expressions.

Source code in affinity/filters.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
class FieldBuilder:
    """Builder for field-based filter expressions."""

    def __init__(self, field_name: str):
        self._field_name = field_name

    def equals(self, value: Any) -> FieldComparison:
        """Field equals value (exact match)."""
        return FieldComparison(self._field_name, "=", value)

    def not_equals(self, value: Any) -> FieldComparison:
        """Field does not equal value."""
        return FieldComparison(self._field_name, "!=", value)

    def contains(self, value: str) -> FieldComparison:
        """Field contains substring (case-insensitive)."""
        return FieldComparison(self._field_name, "=~", value)

    def starts_with(self, value: str) -> FieldComparison:
        """Field starts with prefix."""
        return FieldComparison(self._field_name, "=^", value)

    def ends_with(self, value: str) -> FieldComparison:
        """Field ends with suffix."""
        return FieldComparison(self._field_name, "=$", value)

    def greater_than(self, value: int | float | datetime | date) -> FieldComparison:
        """Field is greater than value."""
        return FieldComparison(self._field_name, ">", value)

    def greater_than_or_equal(self, value: int | float | datetime | date) -> FieldComparison:
        """Field is greater than or equal to value."""
        return FieldComparison(self._field_name, ">=", value)

    def less_than(self, value: int | float | datetime | date) -> FieldComparison:
        """Field is less than value."""
        return FieldComparison(self._field_name, "<", value)

    def less_than_or_equal(self, value: int | float | datetime | date) -> FieldComparison:
        """Field is less than or equal to value."""
        return FieldComparison(self._field_name, "<=", value)

    def is_null(self) -> FieldComparison:
        """Field is null."""
        return FieldComparison(self._field_name, "!=", RawToken("*"))

    def is_not_null(self) -> FieldComparison:
        """Field is not null."""
        return FieldComparison(self._field_name, "=", RawToken("*"))

    def in_list(self, values: list[Any]) -> FilterExpression:
        """Field value is in the given list (OR of equals)."""
        if not values:
            raise ValueError("in_list() requires at least one value")
        expressions: list[FilterExpression] = [self.equals(v) for v in values]
        result: FilterExpression = expressions[0]
        for expr in expressions[1:]:
            result = result | expr
        return result

contains(value: str) -> FieldComparison

Field contains substring (case-insensitive).

Source code in affinity/filters.py
283
284
285
def contains(self, value: str) -> FieldComparison:
    """Field contains substring (case-insensitive)."""
    return FieldComparison(self._field_name, "=~", value)

ends_with(value: str) -> FieldComparison

Field ends with suffix.

Source code in affinity/filters.py
291
292
293
def ends_with(self, value: str) -> FieldComparison:
    """Field ends with suffix."""
    return FieldComparison(self._field_name, "=$", value)

equals(value: Any) -> FieldComparison

Field equals value (exact match).

Source code in affinity/filters.py
275
276
277
def equals(self, value: Any) -> FieldComparison:
    """Field equals value (exact match)."""
    return FieldComparison(self._field_name, "=", value)

greater_than(value: int | float | datetime | date) -> FieldComparison

Field is greater than value.

Source code in affinity/filters.py
295
296
297
def greater_than(self, value: int | float | datetime | date) -> FieldComparison:
    """Field is greater than value."""
    return FieldComparison(self._field_name, ">", value)

greater_than_or_equal(value: int | float | datetime | date) -> FieldComparison

Field is greater than or equal to value.

Source code in affinity/filters.py
299
300
301
def greater_than_or_equal(self, value: int | float | datetime | date) -> FieldComparison:
    """Field is greater than or equal to value."""
    return FieldComparison(self._field_name, ">=", value)

in_list(values: list[Any]) -> FilterExpression

Field value is in the given list (OR of equals).

Source code in affinity/filters.py
319
320
321
322
323
324
325
326
327
def in_list(self, values: list[Any]) -> FilterExpression:
    """Field value is in the given list (OR of equals)."""
    if not values:
        raise ValueError("in_list() requires at least one value")
    expressions: list[FilterExpression] = [self.equals(v) for v in values]
    result: FilterExpression = expressions[0]
    for expr in expressions[1:]:
        result = result | expr
    return result

is_not_null() -> FieldComparison

Field is not null.

Source code in affinity/filters.py
315
316
317
def is_not_null(self) -> FieldComparison:
    """Field is not null."""
    return FieldComparison(self._field_name, "=", RawToken("*"))

is_null() -> FieldComparison

Field is null.

Source code in affinity/filters.py
311
312
313
def is_null(self) -> FieldComparison:
    """Field is null."""
    return FieldComparison(self._field_name, "!=", RawToken("*"))

less_than(value: int | float | datetime | date) -> FieldComparison

Field is less than value.

Source code in affinity/filters.py
303
304
305
def less_than(self, value: int | float | datetime | date) -> FieldComparison:
    """Field is less than value."""
    return FieldComparison(self._field_name, "<", value)

less_than_or_equal(value: int | float | datetime | date) -> FieldComparison

Field is less than or equal to value.

Source code in affinity/filters.py
307
308
309
def less_than_or_equal(self, value: int | float | datetime | date) -> FieldComparison:
    """Field is less than or equal to value."""
    return FieldComparison(self._field_name, "<=", value)

not_equals(value: Any) -> FieldComparison

Field does not equal value.

Source code in affinity/filters.py
279
280
281
def not_equals(self, value: Any) -> FieldComparison:
    """Field does not equal value."""
    return FieldComparison(self._field_name, "!=", value)

starts_with(value: str) -> FieldComparison

Field starts with prefix.

Source code in affinity/filters.py
287
288
289
def starts_with(self, value: str) -> FieldComparison:
    """Field starts with prefix."""
    return FieldComparison(self._field_name, "=^", value)

FieldComparison dataclass

Bases: FilterExpression

A comparison operation on a field.

Source code in affinity/filters.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
@dataclass
class FieldComparison(FilterExpression):
    """A comparison operation on a field."""

    field_name: str
    operator: str
    value: Any

    def to_string(self) -> str:
        formatted_value = _format_value(self.value)
        return f"{self.field_name} {self.operator} {formatted_value}"

    def matches(self, entity: dict[str, Any]) -> bool:
        """Evaluate field comparison against an entity dict.

        For multi-select dropdown fields (arrays), the operators have special semantics:
        - `=` with scalar: checks if value is IN the array (membership)
        - `=` with list: checks set equality (order-insensitive)
        - `!=` with scalar: checks if value is NOT in the array
        - `!=` with list: checks set inequality
        - `=~` (contains): checks if any array element contains the substring
        - `=^` (starts_with): checks if any array element starts with the prefix
        - `=$` (ends_with): checks if any array element ends with the suffix
        - `>`, `>=`, `<`, `<=`: numeric/date comparisons

        Uses the shared compare module for consistent behavior across SDK and Query tool.
        """
        field_value = _get_entity_value(entity, self.field_name)

        # Normalize dropdown dicts and multi-select arrays
        field_value = normalize_value(field_value)

        # Handle NULL checks (Affinity convention: =* means NOT NULL, !=* means IS NULL)
        if isinstance(self.value, RawToken) and self.value.token == "*":
            if self.operator == "=":
                return compare_values(field_value, None, "is_not_null")
            elif self.operator == "!=":
                return compare_values(field_value, None, "is_null")

        # Extract target value
        target = self.value if not isinstance(self.value, RawToken) else self.value.token

        # Map SDK operator symbol to canonical operator name
        try:
            canonical_op = map_operator(self.operator)
        except ValueError:
            raise ValueError(
                f"Unsupported operator '{self.operator}' for client-side matching. "
                f"Supported operators: =, !=, =~, =^, =$, >, >=, <, <=, "
                f"contains, starts_with, ends_with, gt, gte, lt, lte, "
                f"is null, is not null, is empty, "
                f"in, between, has_any, has_all, contains_any, contains_all"
            ) from None

        return compare_values(field_value, target, canonical_op)

matches(entity: dict[str, Any]) -> bool

Evaluate field comparison against an entity dict.

For multi-select dropdown fields (arrays), the operators have special semantics: - = with scalar: checks if value is IN the array (membership) - = with list: checks set equality (order-insensitive) - != with scalar: checks if value is NOT in the array - != with list: checks set inequality - =~ (contains): checks if any array element contains the substring - =^ (starts_with): checks if any array element starts with the prefix - =$ (ends_with): checks if any array element ends with the suffix - >, >=, <, <=: numeric/date comparisons

Uses the shared compare module for consistent behavior across SDK and Query tool.

Source code in affinity/filters.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def matches(self, entity: dict[str, Any]) -> bool:
    """Evaluate field comparison against an entity dict.

    For multi-select dropdown fields (arrays), the operators have special semantics:
    - `=` with scalar: checks if value is IN the array (membership)
    - `=` with list: checks set equality (order-insensitive)
    - `!=` with scalar: checks if value is NOT in the array
    - `!=` with list: checks set inequality
    - `=~` (contains): checks if any array element contains the substring
    - `=^` (starts_with): checks if any array element starts with the prefix
    - `=$` (ends_with): checks if any array element ends with the suffix
    - `>`, `>=`, `<`, `<=`: numeric/date comparisons

    Uses the shared compare module for consistent behavior across SDK and Query tool.
    """
    field_value = _get_entity_value(entity, self.field_name)

    # Normalize dropdown dicts and multi-select arrays
    field_value = normalize_value(field_value)

    # Handle NULL checks (Affinity convention: =* means NOT NULL, !=* means IS NULL)
    if isinstance(self.value, RawToken) and self.value.token == "*":
        if self.operator == "=":
            return compare_values(field_value, None, "is_not_null")
        elif self.operator == "!=":
            return compare_values(field_value, None, "is_null")

    # Extract target value
    target = self.value if not isinstance(self.value, RawToken) else self.value.token

    # Map SDK operator symbol to canonical operator name
    try:
        canonical_op = map_operator(self.operator)
    except ValueError:
        raise ValueError(
            f"Unsupported operator '{self.operator}' for client-side matching. "
            f"Supported operators: =, !=, =~, =^, =$, >, >=, <, <=, "
            f"contains, starts_with, ends_with, gt, gte, lt, lte, "
            f"is null, is not null, is empty, "
            f"in, between, has_any, has_all, contains_any, contains_all"
        ) from None

    return compare_values(field_value, target, canonical_op)

Filter

Factory for building filter expressions.

Example

Simple comparison

Filter.field("name").contains("Acme")

Complex boolean logic

(Filter.field("status").equals("Active") & Filter.field("type").in_list(["customer", "prospect"]))

Negation

~Filter.field("archived").equals(True)

Source code in affinity/filters.py
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
class Filter:
    """
    Factory for building filter expressions.

    Example:
        # Simple comparison
        Filter.field("name").contains("Acme")

        # Complex boolean logic
        (Filter.field("status").equals("Active") &
         Filter.field("type").in_list(["customer", "prospect"]))

        # Negation
        ~Filter.field("archived").equals(True)
    """

    @staticmethod
    def field(name: str) -> FieldBuilder:
        """Start building a filter on a field."""
        return FieldBuilder(name)

    @staticmethod
    def raw(expression: str) -> RawFilter:
        """
        Create a raw filter expression (escape hatch).

        Use this when you need filter syntax not supported by the builder.
        The expression is passed directly to the API without modification.

        Args:
            expression: Raw filter string (e.g., 'name =~ "Acme"')
        """
        return RawFilter(expression)

    @staticmethod
    def and_(*expressions: FilterExpression) -> FilterExpression:
        """Combine multiple expressions with `&`."""
        if not expressions:
            raise ValueError("and_() requires at least one expression")
        result = expressions[0]
        for expr in expressions[1:]:
            result = result & expr
        return result

    @staticmethod
    def or_(*expressions: FilterExpression) -> FilterExpression:
        """Combine multiple expressions with `|`."""
        if not expressions:
            raise ValueError("or_() requires at least one expression")
        result = expressions[0]
        for expr in expressions[1:]:
            result = result | expr
        return result

and_(*expressions: FilterExpression) -> FilterExpression staticmethod

Combine multiple expressions with &.

Source code in affinity/filters.py
364
365
366
367
368
369
370
371
372
@staticmethod
def and_(*expressions: FilterExpression) -> FilterExpression:
    """Combine multiple expressions with `&`."""
    if not expressions:
        raise ValueError("and_() requires at least one expression")
    result = expressions[0]
    for expr in expressions[1:]:
        result = result & expr
    return result

field(name: str) -> FieldBuilder staticmethod

Start building a filter on a field.

Source code in affinity/filters.py
346
347
348
349
@staticmethod
def field(name: str) -> FieldBuilder:
    """Start building a filter on a field."""
    return FieldBuilder(name)

or_(*expressions: FilterExpression) -> FilterExpression staticmethod

Combine multiple expressions with |.

Source code in affinity/filters.py
374
375
376
377
378
379
380
381
382
@staticmethod
def or_(*expressions: FilterExpression) -> FilterExpression:
    """Combine multiple expressions with `|`."""
    if not expressions:
        raise ValueError("or_() requires at least one expression")
    result = expressions[0]
    for expr in expressions[1:]:
        result = result | expr
    return result

raw(expression: str) -> RawFilter staticmethod

Create a raw filter expression (escape hatch).

Use this when you need filter syntax not supported by the builder. The expression is passed directly to the API without modification.

Parameters:

Name Type Description Default
expression str

Raw filter string (e.g., 'name =~ "Acme"')

required
Source code in affinity/filters.py
351
352
353
354
355
356
357
358
359
360
361
362
@staticmethod
def raw(expression: str) -> RawFilter:
    """
    Create a raw filter expression (escape hatch).

    Use this when you need filter syntax not supported by the builder.
    The expression is passed directly to the API without modification.

    Args:
        expression: Raw filter string (e.g., 'name =~ "Acme"')
    """
    return RawFilter(expression)

FilterExpression

Bases: ABC

Base class for filter expressions.

Source code in affinity/filters.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
class FilterExpression(ABC):
    """Base class for filter expressions."""

    @abstractmethod
    def to_string(self) -> str:
        """Convert the expression to a filter string."""
        ...

    @abstractmethod
    def matches(self, entity: dict[str, Any]) -> bool:
        """
        Evaluate filter against an entity dict (client-side).

        Used for --expand-filter in list export where filtering happens
        after fetching data from the API.
        """
        ...

    def __and__(self, other: FilterExpression) -> FilterExpression:
        """Combine two expressions with `&`."""
        return AndExpression(self, other)

    def __or__(self, other: FilterExpression) -> FilterExpression:
        """Combine two expressions with `|`."""
        return OrExpression(self, other)

    def __invert__(self) -> FilterExpression:
        """Negate the expression with `!`."""
        return NotExpression(self)

    def __str__(self) -> str:
        return self.to_string()

    def __repr__(self) -> str:
        return f"Filter({self.to_string()!r})"

__and__(other: FilterExpression) -> FilterExpression

Combine two expressions with &.

Source code in affinity/filters.py
127
128
129
def __and__(self, other: FilterExpression) -> FilterExpression:
    """Combine two expressions with `&`."""
    return AndExpression(self, other)

__invert__() -> FilterExpression

Negate the expression with !.

Source code in affinity/filters.py
135
136
137
def __invert__(self) -> FilterExpression:
    """Negate the expression with `!`."""
    return NotExpression(self)

__or__(other: FilterExpression) -> FilterExpression

Combine two expressions with |.

Source code in affinity/filters.py
131
132
133
def __or__(self, other: FilterExpression) -> FilterExpression:
    """Combine two expressions with `|`."""
    return OrExpression(self, other)

matches(entity: dict[str, Any]) -> bool abstractmethod

Evaluate filter against an entity dict (client-side).

Used for --expand-filter in list export where filtering happens after fetching data from the API.

Source code in affinity/filters.py
117
118
119
120
121
122
123
124
125
@abstractmethod
def matches(self, entity: dict[str, Any]) -> bool:
    """
    Evaluate filter against an entity dict (client-side).

    Used for --expand-filter in list export where filtering happens
    after fetching data from the API.
    """
    ...

to_string() -> str abstractmethod

Convert the expression to a filter string.

Source code in affinity/filters.py
112
113
114
115
@abstractmethod
def to_string(self) -> str:
    """Convert the expression to a filter string."""
    ...

NotExpression dataclass

Bases: FilterExpression

! negation of an expression.

Source code in affinity/filters.py
255
256
257
258
259
260
261
262
263
264
265
266
@dataclass
class NotExpression(FilterExpression):
    """`!` negation of an expression."""

    expr: FilterExpression

    def to_string(self) -> str:
        return f"!({self.expr.to_string()})"

    def matches(self, entity: dict[str, Any]) -> bool:
        """Invert the inner expression."""
        return not self.expr.matches(entity)

matches(entity: dict[str, Any]) -> bool

Invert the inner expression.

Source code in affinity/filters.py
264
265
266
def matches(self, entity: dict[str, Any]) -> bool:
    """Invert the inner expression."""
    return not self.expr.matches(entity)

OrExpression dataclass

Bases: FilterExpression

| combination of two expressions.

Source code in affinity/filters.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
@dataclass
class OrExpression(FilterExpression):
    """`|` combination of two expressions."""

    left: FilterExpression
    right: FilterExpression

    def to_string(self) -> str:
        left_str = self.left.to_string()
        right_str = self.right.to_string()
        return f"({left_str}) | ({right_str})"

    def matches(self, entity: dict[str, Any]) -> bool:
        """Either side must match."""
        return self.left.matches(entity) or self.right.matches(entity)

matches(entity: dict[str, Any]) -> bool

Either side must match.

Source code in affinity/filters.py
250
251
252
def matches(self, entity: dict[str, Any]) -> bool:
    """Either side must match."""
    return self.left.matches(entity) or self.right.matches(entity)

RawFilter dataclass

Bases: FilterExpression

A raw filter string (escape hatch for power users).

Source code in affinity/filters.py
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
@dataclass
class RawFilter(FilterExpression):
    """A raw filter string (escape hatch for power users)."""

    expression: str

    def to_string(self) -> str:
        return self.expression

    def matches(self, entity: dict[str, Any]) -> bool:
        """RawFilter cannot be evaluated client-side."""
        raise NotImplementedError(
            "RawFilter cannot be evaluated client-side. "
            "Use structured filter expressions for --expand-filter."
        )

matches(entity: dict[str, Any]) -> bool

RawFilter cannot be evaluated client-side.

Source code in affinity/filters.py
212
213
214
215
216
217
def matches(self, entity: dict[str, Any]) -> bool:
    """RawFilter cannot be evaluated client-side."""
    raise NotImplementedError(
        "RawFilter cannot be evaluated client-side. "
        "Use structured filter expressions for --expand-filter."
    )

RawToken dataclass

A raw token inserted into a filter expression without quoting.

Used for special Affinity Filtering Language literals like *.

Source code in affinity/filters.py
38
39
40
41
42
43
44
45
46
@dataclass(frozen=True)
class RawToken:
    """
    A raw token inserted into a filter expression without quoting.

    Used for special Affinity Filtering Language literals like `*`.
    """

    token: str

parse(filter_string: str) -> FilterExpression

Parse a filter string into a FilterExpression AST.

This function converts a human-readable filter string into a structured FilterExpression that can be used for client-side filtering with matches().

Parameters:

Name Type Description Default
filter_string str

The filter expression to parse

required

Returns:

Type Description
FilterExpression

A FilterExpression AST representing the filter

Raises:

Type Description
ValueError

If the filter string is invalid

Examples:

>>> expr = parse('name = "Alice"')
>>> expr.matches({"name": "Alice"})
True
>>> expr = parse('status = Active | status = Pending')
>>> expr.matches({"status": "Active"})
True
>>> expr = parse('email = *')  # IS NOT NULL
>>> expr.matches({"email": "test@example.com"})
True
>>> expr = parse('email != *')  # IS NULL
>>> expr.matches({"email": None})
True
Source code in affinity/filters.py
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
def parse(filter_string: str) -> FilterExpression:
    """
    Parse a filter string into a FilterExpression AST.

    This function converts a human-readable filter string into a structured
    FilterExpression that can be used for client-side filtering with matches().

    Args:
        filter_string: The filter expression to parse

    Returns:
        A FilterExpression AST representing the filter

    Raises:
        ValueError: If the filter string is invalid

    Examples:
        >>> expr = parse('name = "Alice"')
        >>> expr.matches({"name": "Alice"})
        True

        >>> expr = parse('status = Active | status = Pending')
        >>> expr.matches({"status": "Active"})
        True

        >>> expr = parse('email = *')  # IS NOT NULL
        >>> expr.matches({"email": "test@example.com"})
        True

        >>> expr = parse('email != *')  # IS NULL
        >>> expr.matches({"email": None})
        True
    """
    if not filter_string or not filter_string.strip():
        raise ValueError("Empty filter expression")

    tokenizer = _Tokenizer(filter_string)
    tokens = tokenizer.tokenize()
    parser = _Parser(tokens)
    return parser.parse()