Skip to main content

linear_api/
filter.rs

1//! Typed comparators and entity filters mirroring Linear's GraphQL filter
2//! inputs. These serialize into query *variables*, so the schema-validation
3//! harness cannot check their field names — they were extracted from the SDL
4//! by hand and pinned by snapshot tests.
5
6use bon::Builder;
7use serde::Serialize;
8
9/// Comparator for `String` fields.
10#[derive(Debug, Clone, Default, Serialize, Builder)]
11#[serde(rename_all = "camelCase")]
12#[non_exhaustive]
13pub struct StringComparator {
14    /// Equals.
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub eq: Option<String>,
17    /// Not equals.
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub neq: Option<String>,
20    /// Value is in the given list.
21    #[serde(rename = "in", skip_serializing_if = "Option::is_none")]
22    pub r#in: Option<Vec<String>>,
23    /// Value is not in the given list.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub nin: Option<Vec<String>>,
26    /// Contains the given substring (case-sensitive).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub contains: Option<String>,
29    /// Does not contain the given substring.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub not_contains: Option<String>,
32    /// Contains the given substring, ignoring case.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub contains_ignore_case: Option<String>,
35    /// Starts with the given prefix.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub starts_with: Option<String>,
38    /// Ends with the given suffix.
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub ends_with: Option<String>,
41    /// Equals, ignoring case.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub eq_ignore_case: Option<String>,
44    /// Not equals, ignoring case.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub neq_ignore_case: Option<String>,
47}
48
49/// Comparator for `ID` fields.
50#[derive(Debug, Clone, Default, Serialize, Builder)]
51#[serde(rename_all = "camelCase")]
52#[non_exhaustive]
53pub struct IdComparator {
54    /// Equals.
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub eq: Option<String>,
57    /// Not equals.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub neq: Option<String>,
60    /// Value is in the given list.
61    #[serde(rename = "in", skip_serializing_if = "Option::is_none")]
62    pub r#in: Option<Vec<String>>,
63    /// Value is not in the given list.
64    #[serde(skip_serializing_if = "Option::is_none")]
65    pub nin: Option<Vec<String>>,
66}
67
68/// Comparator for non-nullable number fields.
69#[derive(Debug, Clone, Default, Serialize, Builder)]
70#[serde(rename_all = "camelCase")]
71#[non_exhaustive]
72pub struct NumberComparator {
73    /// Equals.
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub eq: Option<f64>,
76    /// Not equals.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub neq: Option<f64>,
79    /// Less than.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub lt: Option<f64>,
82    /// Less than or equal.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub lte: Option<f64>,
85    /// Greater than.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub gt: Option<f64>,
88    /// Greater than or equal.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub gte: Option<f64>,
91    /// Value is in the given list.
92    #[serde(rename = "in", skip_serializing_if = "Option::is_none")]
93    pub r#in: Option<Vec<f64>>,
94    /// Value is not in the given list.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub nin: Option<Vec<f64>>,
97}
98
99/// Comparator for nullable number fields (e.g. `priority`, `estimate`).
100#[derive(Debug, Clone, Default, Serialize, Builder)]
101#[serde(rename_all = "camelCase")]
102#[non_exhaustive]
103pub struct NullableNumberComparator {
104    /// Equals.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub eq: Option<f64>,
107    /// Not equals.
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub neq: Option<f64>,
110    /// Less than.
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub lt: Option<f64>,
113    /// Less than or equal.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub lte: Option<f64>,
116    /// Greater than.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub gt: Option<f64>,
119    /// Greater than or equal.
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub gte: Option<f64>,
122    /// Value is in the given list.
123    #[serde(rename = "in", skip_serializing_if = "Option::is_none")]
124    pub r#in: Option<Vec<f64>>,
125    /// Value is not in the given list.
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub nin: Option<Vec<f64>>,
128    /// Whether the value is null.
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub null: Option<bool>,
131}
132
133/// Comparator for date fields. Values are strings: ISO-8601 instants
134/// (`"2026-07-01T00:00:00Z"`) **or** relative durations like `"-P2W"`.
135#[derive(Debug, Clone, Default, Serialize, Builder)]
136#[serde(rename_all = "camelCase")]
137#[non_exhaustive]
138pub struct DateComparator {
139    /// Equals.
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub eq: Option<String>,
142    /// Not equals.
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub neq: Option<String>,
145    /// Before.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub lt: Option<String>,
148    /// Before or at.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub lte: Option<String>,
151    /// After.
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub gt: Option<String>,
154    /// After or at.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub gte: Option<String>,
157}
158
159/// Comparator for boolean fields.
160#[derive(Debug, Clone, Default, Serialize, Builder)]
161#[serde(rename_all = "camelCase")]
162#[non_exhaustive]
163pub struct BooleanComparator {
164    /// Equals.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub eq: Option<bool>,
167    /// Not equals.
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub neq: Option<bool>,
170}
171
172/// Comparator asserting the existence (or absence) of a relation.
173#[derive(Debug, Clone, Default, Serialize, Builder)]
174#[serde(rename_all = "camelCase")]
175#[non_exhaustive]
176pub struct RelationExistsComparator {
177    /// The relation exists.
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub eq: Option<bool>,
180    /// The relation does not exist.
181    #[serde(skip_serializing_if = "Option::is_none")]
182    pub neq: Option<bool>,
183}
184
185/// Filter for issues (`IssueFilter` input).
186#[derive(Debug, Clone, Default, Serialize, Builder)]
187#[serde(rename_all = "camelCase")]
188#[non_exhaustive]
189pub struct IssueFilter {
190    /// All sub-filters must match.
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub and: Option<Vec<IssueFilter>>,
193    /// At least one sub-filter must match.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub or: Option<Vec<IssueFilter>>,
196    /// Filter by title.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub title: Option<StringComparator>,
199    /// Filter by description.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub description: Option<StringComparator>,
202    /// Filter by issue number.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub number: Option<NumberComparator>,
205    /// Filter by priority (0–4; nullable).
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub priority: Option<NullableNumberComparator>,
208    /// Filter by estimate (nullable).
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub estimate: Option<NullableNumberComparator>,
211    /// Filter by due date.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub due_date: Option<DateComparator>,
214    /// Filter by creation time.
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub created_at: Option<DateComparator>,
217    /// Filter by last update time.
218    #[serde(skip_serializing_if = "Option::is_none")]
219    pub updated_at: Option<DateComparator>,
220    /// Filter by completion time.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub completed_at: Option<DateComparator>,
223    /// Filter by workflow state.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub state: Option<WorkflowStateFilter>,
226    /// Filter by team.
227    #[serde(skip_serializing_if = "Option::is_none")]
228    pub team: Option<TeamFilter>,
229    /// Filter by assignee.
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub assignee: Option<UserFilter>,
232    /// Filter by creator.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub creator: Option<UserFilter>,
235    /// Filter by labels.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub labels: Option<IssueLabelFilter>,
238    /// Filter by project.
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub project: Option<ProjectFilter>,
241    /// Filter by project milestone.
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub project_milestone: Option<ProjectMilestoneFilter>,
244    /// Filter by parent issue.
245    #[serde(skip_serializing_if = "Option::is_none")]
246    #[builder(into)]
247    pub parent: Option<Box<IssueFilter>>,
248    /// Whether the issue has blocked-by relations.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub has_blocked_by_relations: Option<RelationExistsComparator>,
251    /// Whether the issue has blocking relations.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub has_blocking_relations: Option<RelationExistsComparator>,
254    /// Whether the issue has duplicate relations.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub has_duplicate_relations: Option<RelationExistsComparator>,
257    /// Whether the issue has related relations.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub has_related_relations: Option<RelationExistsComparator>,
260}
261
262/// Filter for teams (`TeamFilter` input).
263#[derive(Debug, Clone, Default, Serialize, Builder)]
264#[serde(rename_all = "camelCase")]
265#[non_exhaustive]
266pub struct TeamFilter {
267    /// All sub-filters must match.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub and: Option<Vec<TeamFilter>>,
270    /// At least one sub-filter must match.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub or: Option<Vec<TeamFilter>>,
273    /// Filter by team ID.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub id: Option<IdComparator>,
276    /// Filter by team key (e.g. `"ENG"`).
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub key: Option<StringComparator>,
279    /// Filter by team name.
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub name: Option<StringComparator>,
282}
283
284/// Filter for users (`UserFilter` input).
285#[derive(Debug, Clone, Default, Serialize, Builder)]
286#[serde(rename_all = "camelCase")]
287#[non_exhaustive]
288pub struct UserFilter {
289    /// All sub-filters must match.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub and: Option<Vec<UserFilter>>,
292    /// At least one sub-filter must match.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub or: Option<Vec<UserFilter>>,
295    /// Filter by user ID.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub id: Option<IdComparator>,
298    /// Filter by full name.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub name: Option<StringComparator>,
301    /// Filter by display name.
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub display_name: Option<StringComparator>,
304    /// Filter by email address.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub email: Option<StringComparator>,
307    /// Filter by active status.
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub active: Option<BooleanComparator>,
310}
311
312/// Filter for workflow states (`WorkflowStateFilter` input).
313#[derive(Debug, Clone, Default, Serialize, Builder)]
314#[serde(rename_all = "camelCase")]
315#[non_exhaustive]
316pub struct WorkflowStateFilter {
317    /// All sub-filters must match.
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub and: Option<Vec<WorkflowStateFilter>>,
320    /// At least one sub-filter must match.
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub or: Option<Vec<WorkflowStateFilter>>,
323    /// Filter by state ID.
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub id: Option<IdComparator>,
326    /// Filter by state name.
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub name: Option<StringComparator>,
329    /// Filter by state type (`"triage"`, `"backlog"`, `"unstarted"`,
330    /// `"started"`, `"completed"`, `"canceled"`).
331    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
332    pub r#type: Option<StringComparator>,
333    /// Filter by owning team.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub team: Option<TeamFilter>,
336}
337
338/// Filter for issue labels (`IssueLabelFilter` input).
339#[derive(Debug, Clone, Default, Serialize, Builder)]
340#[serde(rename_all = "camelCase")]
341#[non_exhaustive]
342pub struct IssueLabelFilter {
343    /// All sub-filters must match.
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub and: Option<Vec<IssueLabelFilter>>,
346    /// At least one sub-filter must match.
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub or: Option<Vec<IssueLabelFilter>>,
349    /// Filter by label ID.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub id: Option<IdComparator>,
352    /// Filter by label name.
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub name: Option<StringComparator>,
355    /// Filter by owning team.
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub team: Option<TeamFilter>,
358    /// Filter by creator.
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub creator: Option<UserFilter>,
361}
362
363/// Filter for projects (`ProjectFilter` input).
364#[derive(Debug, Clone, Default, Serialize, Builder)]
365#[serde(rename_all = "camelCase")]
366#[non_exhaustive]
367pub struct ProjectFilter {
368    /// All sub-filters must match.
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub and: Option<Vec<ProjectFilter>>,
371    /// At least one sub-filter must match.
372    #[serde(skip_serializing_if = "Option::is_none")]
373    pub or: Option<Vec<ProjectFilter>>,
374    /// Filter by project ID.
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub id: Option<IdComparator>,
377    /// Filter by project name.
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub name: Option<StringComparator>,
380    /// Filter by project state.
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub state: Option<StringComparator>,
383    /// Filter by project health (`"onTrack"`, `"atRisk"`, `"offTrack"`).
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub health: Option<StringComparator>,
386    /// Filter by priority (nullable).
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub priority: Option<NullableNumberComparator>,
389    /// Filter by start date.
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub start_date: Option<DateComparator>,
392    /// Filter by target date.
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub target_date: Option<DateComparator>,
395    /// Filter by creation time.
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub created_at: Option<DateComparator>,
398    /// Filter by last update time.
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub updated_at: Option<DateComparator>,
401    /// Filter by project lead.
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub lead: Option<UserFilter>,
404}
405
406/// Filter for project milestones (`ProjectMilestoneFilter` input).
407#[derive(Debug, Clone, Default, Serialize, Builder)]
408#[serde(rename_all = "camelCase")]
409#[non_exhaustive]
410pub struct ProjectMilestoneFilter {
411    /// All sub-filters must match.
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub and: Option<Vec<ProjectMilestoneFilter>>,
414    /// At least one sub-filter must match.
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub or: Option<Vec<ProjectMilestoneFilter>>,
417    /// Filter by milestone ID.
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub id: Option<IdComparator>,
420    /// Filter by milestone name.
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub name: Option<StringComparator>,
423    /// Filter by target date.
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub target_date: Option<DateComparator>,
426}
427
428/// Filter for comments (`CommentFilter` input).
429#[derive(Debug, Clone, Default, Serialize, Builder)]
430#[serde(rename_all = "camelCase")]
431#[non_exhaustive]
432pub struct CommentFilter {
433    /// All sub-filters must match.
434    #[serde(skip_serializing_if = "Option::is_none")]
435    pub and: Option<Vec<CommentFilter>>,
436    /// At least one sub-filter must match.
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub or: Option<Vec<CommentFilter>>,
439    /// Filter by comment ID.
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub id: Option<IdComparator>,
442    /// Filter by comment body.
443    #[serde(skip_serializing_if = "Option::is_none")]
444    pub body: Option<StringComparator>,
445    /// Filter by author.
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub user: Option<UserFilter>,
448    /// Filter by parent issue.
449    #[serde(skip_serializing_if = "Option::is_none")]
450    #[builder(into)]
451    pub issue: Option<Box<IssueFilter>>,
452    /// Filter by creation time.
453    #[serde(skip_serializing_if = "Option::is_none")]
454    pub created_at: Option<DateComparator>,
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn filters_skip_unset_fields_and_rename_in() {
463        let filter = IssueFilter::builder()
464            .title(
465                StringComparator::builder()
466                    .contains("bug".to_string())
467                    .build(),
468            )
469            .state(
470                WorkflowStateFilter::builder()
471                    .r#type(
472                        StringComparator::builder()
473                            .r#in(vec!["started".to_string(), "unstarted".to_string()])
474                            .build(),
475                    )
476                    .build(),
477            )
478            .parent(IssueFilter::default())
479            .build();
480        let value = serde_json::to_value(&filter).unwrap();
481        assert_eq!(
482            value,
483            serde_json::json!({
484                "title": { "contains": "bug" },
485                "state": { "type": { "in": ["started", "unstarted"] } },
486                "parent": {},
487            })
488        );
489    }
490}