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
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
@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
211
212
213
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
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
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
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
261
262
263
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
269
270
271
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
253
254
255
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
273
274
275
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
277
278
279
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
297
298
299
300
301
302
303
304
305
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
293
294
295
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
289
290
291
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
281
282
283
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
285
286
287
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
257
258
259
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
265
266
267
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
144
145
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
@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."""
        value = _get_entity_value(entity, self.field_name)

        # Handle NULL checks (Affinity convention: =* means NOT NULL, !=* means IS NULL)
        if self.operator == "=" and isinstance(self.value, RawToken) and self.value.token == "*":
            return value is not None and value != ""
        if self.operator == "!=" and isinstance(self.value, RawToken) and self.value.token == "*":
            return value is None or value == ""

        # Handle equality/inequality - coerce to strings for comparison
        entity_str = str(value) if value is not None else ""
        target_str = str(self.value) if not isinstance(self.value, RawToken) else self.value.token

        if self.operator == "=":
            return entity_str == target_str
        elif self.operator == "!=":
            return entity_str != target_str
        elif self.operator == "=~":
            # Contains (case-insensitive)
            return target_str.lower() in entity_str.lower()
        else:
            raise ValueError(f"Unsupported operator for client-side matching: {self.operator}")

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

Evaluate field comparison against an entity dict.

Source code in affinity/filters.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def matches(self, entity: dict[str, Any]) -> bool:
    """Evaluate field comparison against an entity dict."""
    value = _get_entity_value(entity, self.field_name)

    # Handle NULL checks (Affinity convention: =* means NOT NULL, !=* means IS NULL)
    if self.operator == "=" and isinstance(self.value, RawToken) and self.value.token == "*":
        return value is not None and value != ""
    if self.operator == "!=" and isinstance(self.value, RawToken) and self.value.token == "*":
        return value is None or value == ""

    # Handle equality/inequality - coerce to strings for comparison
    entity_str = str(value) if value is not None else ""
    target_str = str(self.value) if not isinstance(self.value, RawToken) else self.value.token

    if self.operator == "=":
        return entity_str == target_str
    elif self.operator == "!=":
        return entity_str != target_str
    elif self.operator == "=~":
        # Contains (case-insensitive)
        return target_str.lower() in entity_str.lower()
    else:
        raise ValueError(f"Unsupported operator for client-side matching: {self.operator}")

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
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
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
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
342
343
344
345
346
347
348
349
350
@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
324
325
326
327
@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
352
353
354
355
356
357
358
359
360
@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
329
330
331
332
333
334
335
336
337
338
339
340
@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
107
108
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
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
125
126
127
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
133
134
135
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
129
130
131
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
115
116
117
118
119
120
121
122
123
@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
110
111
112
113
@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
233
234
235
236
237
238
239
240
241
242
243
244
@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
242
243
244
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
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
@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
228
229
230
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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
@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
190
191
192
193
194
195
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
36
37
38
39
40
41
42
43
44
@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
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
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()