tephra_types/query.rs
1//! Query model.
2//!
3//! Pure data, no I/O and no match logic (the match predicate lives in the engine, where
4//! it decodes an event). A [`Query`] describes which events a decision depends on, and the
5//! same query goes into the [`AppendCondition`] that guards the append. That reuse is what
6//! makes the consistency boundary dynamic: it covers exactly the events the decision read,
7//! nothing more.
8//!
9//! # Semantics
10//!
11//! A query is a set of [`QueryItem`]s OR'd together, plus a separate [`Query::All`]
12//! variant that matches everything. Within an item:
13//!
14//! - the event **type** must match *one* of the listed types (an empty type list
15//! matches *any* type), and
16//! - the event **tags** must contain *all* of the item's tags.
17//!
18//! So the shape is **OR across items, AND within an item's tags**. An item with no
19//! tags constrains only on type; an empty [`Query::Items`] set matches nothing (an OR
20//! over zero alternatives).
21
22use crate::name::{EventType, Tags};
23use crate::position::Position;
24
25/// One alternative in a [`Query`]: a type constraint AND a tag constraint.
26///
27/// An event matches the item when its type is one of [`types`](Self::types) (or the
28/// list is empty, matching any type) and its tags are a superset of
29/// [`tags`](Self::tags).
30#[derive(Clone, Debug, Default, PartialEq, Eq)]
31pub struct QueryItem {
32 /// The types this item accepts. Empty means "any type". Not sorted: the list is
33 /// tiny (types are low cardinality) so membership is a linear scan.
34 pub types: Vec<EventType>,
35 /// Tags the event must contain *all* of. Sorted and duplicate-free by construction.
36 pub tags: Tags,
37}
38
39impl QueryItem {
40 /// An item constraining on both a set of types and a set of tags.
41 pub fn new(types: Vec<EventType>, tags: Tags) -> Self {
42 QueryItem { types, tags }
43 }
44
45 /// An item constraining only on type (matches any tags).
46 pub fn of_types(types: Vec<EventType>) -> Self {
47 QueryItem {
48 types,
49 tags: Tags::empty(),
50 }
51 }
52
53 /// An item constraining only on tags (matches any type).
54 pub fn with_tags(tags: Tags) -> Self {
55 QueryItem {
56 types: Vec::new(),
57 tags,
58 }
59 }
60}
61
62/// A query: a set of [`QueryItem`]s OR'd together, or the catch-all [`Query::All`].
63///
64/// [`All`](Query::All) is a distinct variant rather than "an item with no
65/// constraints" so the read and condition paths can recognise a full scan and bypass
66/// the index entirely. An empty [`Items`](Query::Items) set is the opposite: it matches
67/// nothing.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub enum Query {
70 /// Matches every event. Full scans and broad projection catch-up use this and skip
71 /// the index.
72 All,
73 /// Matches an event if *any* contained item matches (logical OR). Empty matches
74 /// nothing.
75 Items(Vec<QueryItem>),
76}
77
78impl Query {
79 /// The catch-all query, matching every event.
80 pub fn all() -> Self {
81 Query::All
82 }
83
84 /// A query over a set of items, OR'd together.
85 pub fn items(items: impl Into<Vec<QueryItem>>) -> Self {
86 Query::Items(items.into())
87 }
88
89 /// A query with a single item.
90 pub fn item(item: QueryItem) -> Self {
91 Query::Items(vec![item])
92 }
93}
94
95/// The guard on an `append` call.
96///
97/// The store ignores everything at or before [`after`](Self::after) and rejects the
98/// append if anything matching [`fail_if_events_match`](Self::fail_if_events_match)
99/// landed since. `after` is the highest position the client observed while building
100/// its decision model, which may be higher than the last matching event's position.
101///
102/// Positions are 1-based, so [`after`](Self::after) `= Position::ZERO` (the default)
103/// means "consider the whole log": the spec's "omit `after`" case, i.e. fail if *any*
104/// event matches (the uniqueness-guard pattern).
105///
106/// This type only *holds* the condition. Evaluating it (position filtering plus the
107/// match predicate over the durable suffix) is the engine's job.
108#[derive(Clone, Debug, PartialEq, Eq)]
109pub struct AppendCondition {
110 /// Reject the append if any event after [`after`](Self::after) matches this query.
111 pub fail_if_events_match: Query,
112 /// Ignore everything at or before this position. `Position::ZERO` means the whole
113 /// log.
114 pub after: Position,
115}
116
117impl AppendCondition {
118 /// A condition checking the whole log (`after = Position::ZERO`): fail if any event
119 /// matches.
120 pub fn new(fail_if_events_match: Query) -> Self {
121 AppendCondition {
122 fail_if_events_match,
123 after: Position::ZERO,
124 }
125 }
126
127 /// Sets the exclusive lower bound: only events strictly after `after` are checked.
128 pub fn after(mut self, after: Position) -> Self {
129 self.after = after;
130 self
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn item_constructors() {
140 assert!(QueryItem::of_types(Vec::new()).tags.is_empty());
141 assert!(QueryItem::with_tags(Tags::empty()).types.is_empty());
142 assert_eq!(
143 QueryItem::default(),
144 QueryItem::new(Vec::new(), Tags::empty())
145 );
146 }
147
148 #[test]
149 fn query_constructors() {
150 assert_eq!(Query::all(), Query::All);
151 assert_eq!(Query::items(Vec::new()), Query::Items(Vec::new()));
152 assert_eq!(
153 Query::item(QueryItem::default()),
154 Query::Items(vec![QueryItem::default()])
155 );
156 }
157
158 #[test]
159 fn condition_defaults_to_position_zero() {
160 // Omitting `after` means "check the whole log", which under 1-based positions
161 // is `after: 0`.
162 let cond = AppendCondition::new(Query::all());
163 assert_eq!(cond.after, Position::ZERO);
164 assert_eq!(cond.fail_if_events_match, Query::All);
165 }
166
167 #[test]
168 fn condition_after_sets_bound() {
169 let cond = AppendCondition::new(Query::all()).after(Position::new(42));
170 assert_eq!(cond.after, Position::new(42));
171 }
172}