Skip to main content

kyyn_core/
query.rs

1//! Backend-neutral analytical query plans and the typed builder schemas use
2//! to produce them. The builder closure runs once against typed field proxies;
3//! only the resulting serde AST crosses the schema/engine boundary.
4
5use std::marker::PhantomData;
6
7use chrono::NaiveDate;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct QueryDecl {
12    pub name: String,
13    #[serde(default, skip_serializing_if = "String::is_empty")]
14    pub doc: String,
15    pub bindings: Vec<BindingDecl>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub filter: Option<BoolExpr>,
18    #[serde(default, skip_serializing_if = "Vec::is_empty")]
19    pub group_by: Vec<ScalarExpr>,
20    pub columns: Vec<OutputColumn>,
21    #[serde(default, skip_serializing_if = "Vec::is_empty")]
22    pub order_by: Vec<QueryOrder>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub limit: Option<usize>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct BindingDecl {
29    pub id: u32,
30    pub kind: String,
31    pub source: BindingSource,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub enum BindingSource {
36    Root,
37    Follow {
38        from: u32,
39        field: String,
40        direction: EdgeDirection,
41        #[serde(default = "yes")]
42        required: bool,
43    },
44}
45
46fn yes() -> bool {
47    true
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum EdgeDirection {
52    Out,
53    In,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct FieldRef {
58    pub binding: u32,
59    pub field: String,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub enum ScalarExpr {
64    Field(FieldRef),
65    Literal(TypedLiteral),
66    Aggregate {
67        op: AggregateOp,
68        #[serde(default, skip_serializing_if = "Option::is_none")]
69        expr: Option<Box<ScalarExpr>>,
70    },
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub enum TypedLiteral {
75    String(String),
76    Int(i64),
77    Decimal(String),
78    Date(NaiveDate),
79    Bool(bool),
80    Enum { type_name: String, variant: String },
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84pub enum AggregateOp {
85    Count,
86    CountDistinct,
87    Sum,
88    Min,
89    Max,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub enum BoolExpr {
94    And(Vec<BoolExpr>),
95    Or(Vec<BoolExpr>),
96    Not(Box<BoolExpr>),
97    Compare {
98        left: ScalarExpr,
99        op: CompareOp,
100        right: ScalarExpr,
101    },
102}
103
104impl BoolExpr {
105    pub fn and(self, other: BoolExpr) -> BoolExpr {
106        match self {
107            BoolExpr::And(mut xs) => {
108                xs.push(other);
109                BoolExpr::And(xs)
110            }
111            one => BoolExpr::And(vec![one, other]),
112        }
113    }
114
115    pub fn or(self, other: BoolExpr) -> BoolExpr {
116        match self {
117            BoolExpr::Or(mut xs) => {
118                xs.push(other);
119                BoolExpr::Or(xs)
120            }
121            one => BoolExpr::Or(vec![one, other]),
122        }
123    }
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127pub enum CompareOp {
128    Eq,
129    Ne,
130    Gt,
131    Gte,
132    Lt,
133    Lte,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct OutputColumn {
138    pub name: String,
139    pub expr: ScalarExpr,
140    /// Display-only formatting; the underlying value stays exact and sorting
141    /// uses the raw value, never the rendered text.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub format: Option<ColumnFormat>,
144}
145
146/// Declarative column formatting — serializable AST data the gate can check
147/// and every face renders identically. Thousands grouping uses a SPACE
148/// (29 669 069.38): the KB-wide convention.
149#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
150pub struct ColumnFormat {
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub prefix: Option<String>,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub suffix: Option<String>,
155    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
156    pub thousands: bool,
157    /// Display rounding/padding only.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub decimals: Option<u8>,
160}
161
162impl ColumnFormat {
163    /// The money shape: prefix + space-grouped thousands + 2 decimals.
164    pub fn money(prefix: impl Into<String>) -> ColumnFormat {
165        ColumnFormat {
166            prefix: Some(prefix.into()),
167            suffix: None,
168            thousands: true,
169            decimals: Some(2),
170        }
171    }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175pub struct QueryOrder {
176    pub expr: ScalarExpr,
177    pub direction: SortDirection,
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181pub enum SortDirection {
182    Asc,
183    Desc,
184}
185
186/// A schema model that can produce a typed proxy for one query binding.
187/// Implementations are normally generated alongside the registry declaration.
188pub trait QueryModel: Sized {
189    type Fields;
190    const KIND: &'static str;
191    fn fields(binding: u32) -> Self::Fields;
192}
193
194/// A typed reference to one field. `T` makes incompatible comparisons fail in
195/// Rust before a plan can be emitted; serde sees only the contained FieldRef.
196#[derive(Debug)]
197pub struct FieldExpr<T> {
198    field: FieldRef,
199    marker: PhantomData<fn() -> T>,
200}
201
202impl<T> Clone for FieldExpr<T> {
203    fn clone(&self) -> Self {
204        Self {
205            field: self.field.clone(),
206            marker: PhantomData,
207        }
208    }
209}
210
211impl<T> FieldExpr<T> {
212    pub fn new(binding: u32, field: impl Into<String>) -> Self {
213        Self {
214            field: FieldRef {
215                binding,
216                field: field.into(),
217            },
218            marker: PhantomData,
219        }
220    }
221
222    pub fn expr(&self) -> ScalarExpr {
223        ScalarExpr::Field(self.field.clone())
224    }
225
226    fn compare(&self, op: CompareOp, value: T) -> BoolExpr
227    where
228        T: QueryLiteral,
229    {
230        BoolExpr::Compare {
231            left: self.expr(),
232            op,
233            right: ScalarExpr::Literal(value.into_query_literal()),
234        }
235    }
236
237    pub fn eq(&self, value: T) -> BoolExpr
238    where
239        T: QueryLiteral,
240    {
241        self.compare(CompareOp::Eq, value)
242    }
243
244    pub fn ne(&self, value: T) -> BoolExpr
245    where
246        T: QueryLiteral,
247    {
248        self.compare(CompareOp::Ne, value)
249    }
250
251    pub fn gt(&self, value: T) -> BoolExpr
252    where
253        T: QueryLiteral,
254    {
255        self.compare(CompareOp::Gt, value)
256    }
257
258    pub fn gte(&self, value: T) -> BoolExpr
259    where
260        T: QueryLiteral,
261    {
262        self.compare(CompareOp::Gte, value)
263    }
264
265    pub fn lt(&self, value: T) -> BoolExpr
266    where
267        T: QueryLiteral,
268    {
269        self.compare(CompareOp::Lt, value)
270    }
271
272    pub fn lte(&self, value: T) -> BoolExpr
273    where
274        T: QueryLiteral,
275    {
276        self.compare(CompareOp::Lte, value)
277    }
278
279    /// The binding this field belongs to — what join builders anchor on
280    /// until slice 3's typed EdgeExpr replaces the string field name.
281    pub fn binding(&self) -> u32 {
282        self.field.binding
283    }
284}
285
286pub trait QueryLiteral {
287    fn into_query_literal(self) -> TypedLiteral;
288}
289
290impl QueryLiteral for String {
291    fn into_query_literal(self) -> TypedLiteral {
292        TypedLiteral::String(self)
293    }
294}
295impl QueryLiteral for &str {
296    fn into_query_literal(self) -> TypedLiteral {
297        TypedLiteral::String(self.to_string())
298    }
299}
300impl QueryLiteral for i64 {
301    fn into_query_literal(self) -> TypedLiteral {
302        TypedLiteral::Int(self)
303    }
304}
305impl QueryLiteral for bool {
306    fn into_query_literal(self) -> TypedLiteral {
307        TypedLiteral::Bool(self)
308    }
309}
310impl QueryLiteral for NaiveDate {
311    fn into_query_literal(self) -> TypedLiteral {
312        TypedLiteral::Date(self)
313    }
314}
315
316/// Builder used inside a schema crate. It is deliberately stateful: the
317/// closure describes one plan, then `finish()` returns plain serializable data.
318pub struct QueryBuilder {
319    query: QueryDecl,
320    next_binding: u32,
321}
322
323impl QueryBuilder {
324    pub fn new(name: impl Into<String>, doc: impl Into<String>) -> Self {
325        Self {
326            query: QueryDecl {
327                name: name.into(),
328                doc: doc.into(),
329                bindings: vec![],
330                filter: None,
331                group_by: vec![],
332                columns: vec![],
333                order_by: vec![],
334                limit: None,
335            },
336            next_binding: 0,
337        }
338    }
339
340    pub fn from<M: QueryModel>(&mut self) -> M::Fields {
341        let id = self.next_binding;
342        self.next_binding += 1;
343        self.query.bindings.push(BindingDecl {
344            id,
345            kind: M::KIND.into(),
346            source: BindingSource::Root,
347        });
348        M::fields(id)
349    }
350
351    pub fn filter(&mut self, expr: BoolExpr) {
352        self.query.filter = Some(expr);
353    }
354
355    pub fn group_by<T>(&mut self, field: &FieldExpr<T>) {
356        self.query.group_by.push(field.expr());
357    }
358
359    pub fn field<T>(&mut self, name: impl Into<String>, field: &FieldExpr<T>) -> ColumnHandle<'_> {
360        self.push_column(name, field.expr())
361    }
362
363    fn push_column(&mut self, name: impl Into<String>, expr: ScalarExpr) -> ColumnHandle<'_> {
364        self.query.columns.push(OutputColumn {
365            name: name.into(),
366            expr,
367            format: None,
368        });
369        ColumnHandle {
370            column: self.query.columns.last_mut().expect("just pushed"),
371        }
372    }
373
374    pub fn count(&mut self, name: impl Into<String>) -> ColumnHandle<'_> {
375        self.push_column(
376            name,
377            ScalarExpr::Aggregate {
378                op: AggregateOp::Count,
379                expr: None,
380            },
381        )
382    }
383
384    /// Count rows where the expression is non-null — the SQL COUNT(col)
385    /// semantics; with a LEFT join this is how "0 related" stays 0.
386    pub fn count_of<T>(
387        &mut self,
388        name: impl Into<String>,
389        field: &FieldExpr<T>,
390    ) -> ColumnHandle<'_> {
391        self.aggregate(name, AggregateOp::Count, field)
392    }
393
394    pub fn count_distinct<T>(
395        &mut self,
396        name: impl Into<String>,
397        field: &FieldExpr<T>,
398    ) -> ColumnHandle<'_> {
399        self.aggregate(name, AggregateOp::CountDistinct, field)
400    }
401
402    pub fn sum<T>(&mut self, name: impl Into<String>, field: &FieldExpr<T>) -> ColumnHandle<'_> {
403        self.aggregate(name, AggregateOp::Sum, field)
404    }
405
406    pub fn min<T>(&mut self, name: impl Into<String>, field: &FieldExpr<T>) -> ColumnHandle<'_> {
407        self.aggregate(name, AggregateOp::Min, field)
408    }
409
410    pub fn max<T>(&mut self, name: impl Into<String>, field: &FieldExpr<T>) -> ColumnHandle<'_> {
411        self.aggregate(name, AggregateOp::Max, field)
412    }
413
414    fn aggregate<T>(
415        &mut self,
416        name: impl Into<String>,
417        op: AggregateOp,
418        field: &FieldExpr<T>,
419    ) -> ColumnHandle<'_> {
420        self.push_column(
421            name,
422            ScalarExpr::Aggregate {
423                op,
424                expr: Some(Box::new(field.expr())),
425            },
426        )
427    }
428
429    /// JOIN: bind records of `M` whose OWN link field `field` points at the
430    /// `anchor` binding's record (SQL: FROM anchor JOIN M ON M.field = anchor).
431    /// `required: false` is a LEFT join — anchor rows without a match keep one
432    /// row with the M binding absent. The field name is a string until slice
433    /// 3's typed EdgeExpr; the registry gate validates it at emission.
434    pub fn follow_in<M: QueryModel>(
435        &mut self,
436        anchor: u32,
437        field: impl Into<String>,
438        required: bool,
439    ) -> M::Fields {
440        self.follow::<M>(anchor, field, EdgeDirection::In, required)
441    }
442
443    /// JOIN along a link field ON THE ANCHOR pointing out at records of `M`.
444    pub fn follow_out<M: QueryModel>(
445        &mut self,
446        anchor: u32,
447        field: impl Into<String>,
448        required: bool,
449    ) -> M::Fields {
450        self.follow::<M>(anchor, field, EdgeDirection::Out, required)
451    }
452
453    fn follow<M: QueryModel>(
454        &mut self,
455        anchor: u32,
456        field: impl Into<String>,
457        direction: EdgeDirection,
458        required: bool,
459    ) -> M::Fields {
460        let id = self.next_binding;
461        self.next_binding += 1;
462        self.query.bindings.push(BindingDecl {
463            id,
464            kind: M::KIND.into(),
465            source: BindingSource::Follow {
466                from: anchor,
467                field: field.into(),
468                direction,
469                required,
470            },
471        });
472        M::fields(id)
473    }
474
475    pub fn order_by<T>(&mut self, field: &FieldExpr<T>, direction: SortDirection) {
476        self.query.order_by.push(QueryOrder {
477            expr: field.expr(),
478            direction,
479        });
480    }
481
482    /// Order by an output column's expression (aggregates included).
483    pub fn order_by_column(&mut self, name: &str, direction: SortDirection) {
484        if let Some(col) = self.query.columns.iter().find(|c| c.name == name) {
485            self.query.order_by.push(QueryOrder {
486                expr: col.expr.clone(),
487                direction,
488            });
489        }
490    }
491
492    pub fn limit(&mut self, limit: usize) {
493        self.query.limit = Some(limit);
494    }
495
496    pub fn finish(self) -> QueryDecl {
497        self.query
498    }
499}
500
501/// Fluent formatting on the just-declared column:
502/// `q.sum("total budget", &f).money("R ")`.
503pub struct ColumnHandle<'a> {
504    column: &'a mut OutputColumn,
505}
506
507impl ColumnHandle<'_> {
508    pub fn format(self, format: ColumnFormat) -> Self {
509        self.column.format = Some(format);
510        self
511    }
512
513    pub fn money(self, prefix: impl Into<String>) -> Self {
514        self.format(ColumnFormat::money(prefix))
515    }
516}
517
518pub fn query(
519    name: impl Into<String>,
520    doc: impl Into<String>,
521    build: impl FnOnce(&mut QueryBuilder),
522) -> QueryDecl {
523    let mut q = QueryBuilder::new(name, doc);
524    build(&mut q);
525    q.finish()
526}