Skip to main content

sql_orm_query/
lib.rs

1//! Query AST foundations for the ORM.
2
3mod delete;
4mod expr;
5mod insert;
6mod join;
7mod order;
8mod pagination;
9mod predicate;
10mod select;
11mod update;
12
13use sql_orm_core::{CrateIdentity, SqlValue};
14
15pub use delete::DeleteQuery;
16pub use expr::{BinaryOp, ColumnRef, Expr, TableRef, UnaryOp};
17pub use insert::InsertQuery;
18pub use join::{Join, JoinType};
19pub use order::{OrderBy, SortDirection};
20pub use pagination::Pagination;
21pub use predicate::Predicate;
22pub use select::{CountQuery, SelectProjection, SelectQuery};
23pub use update::UpdateQuery;
24
25#[derive(Debug, Clone, PartialEq)]
26pub struct CompiledQuery {
27    pub sql: String,
28    pub params: Vec<SqlValue>,
29}
30
31impl CompiledQuery {
32    pub fn new(sql: impl Into<String>, params: Vec<SqlValue>) -> Self {
33        Self {
34            sql: sql.into(),
35            params,
36        }
37    }
38}
39
40#[derive(Debug, Clone, PartialEq)]
41pub enum Query {
42    Select(SelectQuery),
43    Insert(InsertQuery),
44    Update(UpdateQuery),
45    Delete(DeleteQuery),
46    Count(CountQuery),
47}
48
49pub const CRATE_IDENTITY: CrateIdentity = CrateIdentity {
50    name: "sql-orm-query",
51    responsibility: "typed AST and query builder primitives without SQL generation",
52};
53
54#[cfg(test)]
55mod tests {
56    use super::{
57        BinaryOp, CRATE_IDENTITY, ColumnRef, CompiledQuery, CountQuery, DeleteQuery, Expr,
58        InsertQuery, Join, JoinType, OrderBy, Pagination, Predicate, Query, SelectProjection,
59        SelectQuery, SortDirection, TableRef, UpdateQuery,
60    };
61    use sql_orm_core::{
62        Changeset, ColumnMetadata, ColumnValue, Entity, EntityColumn, EntityMetadata,
63        IdentityMetadata, Insertable, PrimaryKeyMetadata, SqlServerType, SqlValue,
64    };
65
66    #[allow(dead_code)]
67    struct Customer;
68
69    #[allow(dead_code)]
70    struct Order;
71
72    static CUSTOMER_COLUMNS: [ColumnMetadata; 4] = [
73        ColumnMetadata {
74            rust_field: "id",
75            column_name: "id",
76            renamed_from: None,
77            sql_type: SqlServerType::BigInt,
78            nullable: false,
79            primary_key: true,
80            identity: Some(IdentityMetadata::new(1, 1)),
81            default_sql: None,
82            computed_sql: None,
83            rowversion: false,
84            insertable: false,
85            updatable: false,
86            max_length: None,
87            precision: None,
88            scale: None,
89        },
90        ColumnMetadata {
91            rust_field: "email",
92            column_name: "email",
93            renamed_from: None,
94            sql_type: SqlServerType::NVarChar,
95            nullable: false,
96            primary_key: false,
97            identity: None,
98            default_sql: None,
99            computed_sql: None,
100            rowversion: false,
101            insertable: true,
102            updatable: true,
103            max_length: Some(160),
104            precision: None,
105            scale: None,
106        },
107        ColumnMetadata {
108            rust_field: "active",
109            column_name: "active",
110            renamed_from: None,
111            sql_type: SqlServerType::Bit,
112            nullable: false,
113            primary_key: false,
114            identity: None,
115            default_sql: Some("1"),
116            computed_sql: None,
117            rowversion: false,
118            insertable: true,
119            updatable: true,
120            max_length: None,
121            precision: None,
122            scale: None,
123        },
124        ColumnMetadata {
125            rust_field: "created_at",
126            column_name: "created_at",
127            renamed_from: None,
128            sql_type: SqlServerType::DateTime2,
129            nullable: false,
130            primary_key: false,
131            identity: None,
132            default_sql: Some("SYSUTCDATETIME()"),
133            computed_sql: None,
134            rowversion: false,
135            insertable: true,
136            updatable: true,
137            max_length: None,
138            precision: None,
139            scale: None,
140        },
141    ];
142
143    static CUSTOMER_METADATA: EntityMetadata = EntityMetadata {
144        rust_name: "Customer",
145        schema: "sales",
146        table: "customers",
147        renamed_from: None,
148        columns: &CUSTOMER_COLUMNS,
149        primary_key: PrimaryKeyMetadata::new(Some("pk_customers"), &["id"]),
150        indexes: &[],
151        foreign_keys: &[],
152        navigations: &[],
153    };
154
155    impl Entity for Customer {
156        fn metadata() -> &'static EntityMetadata {
157            &CUSTOMER_METADATA
158        }
159    }
160
161    static ORDER_COLUMNS: [ColumnMetadata; 3] = [
162        ColumnMetadata {
163            rust_field: "id",
164            column_name: "id",
165            renamed_from: None,
166            sql_type: SqlServerType::BigInt,
167            nullable: false,
168            primary_key: true,
169            identity: Some(IdentityMetadata::new(1, 1)),
170            default_sql: None,
171            computed_sql: None,
172            rowversion: false,
173            insertable: false,
174            updatable: false,
175            max_length: None,
176            precision: None,
177            scale: None,
178        },
179        ColumnMetadata {
180            rust_field: "customer_id",
181            column_name: "customer_id",
182            renamed_from: None,
183            sql_type: SqlServerType::BigInt,
184            nullable: false,
185            primary_key: false,
186            identity: None,
187            default_sql: None,
188            computed_sql: None,
189            rowversion: false,
190            insertable: true,
191            updatable: true,
192            max_length: None,
193            precision: None,
194            scale: None,
195        },
196        ColumnMetadata {
197            rust_field: "total_cents",
198            column_name: "total_cents",
199            renamed_from: None,
200            sql_type: SqlServerType::BigInt,
201            nullable: false,
202            primary_key: false,
203            identity: None,
204            default_sql: None,
205            computed_sql: None,
206            rowversion: false,
207            insertable: true,
208            updatable: true,
209            max_length: None,
210            precision: None,
211            scale: None,
212        },
213    ];
214
215    static ORDER_METADATA: EntityMetadata = EntityMetadata {
216        rust_name: "Order",
217        schema: "sales",
218        table: "orders",
219        renamed_from: None,
220        columns: &ORDER_COLUMNS,
221        primary_key: PrimaryKeyMetadata::new(Some("pk_orders"), &["id"]),
222        indexes: &[],
223        foreign_keys: &[],
224        navigations: &[],
225    };
226
227    impl Entity for Order {
228        fn metadata() -> &'static EntityMetadata {
229            &ORDER_METADATA
230        }
231    }
232
233    #[allow(non_upper_case_globals)]
234    impl Customer {
235        const id: EntityColumn<Customer> = EntityColumn::new("id", "id");
236        const email: EntityColumn<Customer> = EntityColumn::new("email", "email");
237        const active: EntityColumn<Customer> = EntityColumn::new("active", "active");
238        const created_at: EntityColumn<Customer> = EntityColumn::new("created_at", "created_at");
239    }
240
241    #[allow(non_upper_case_globals)]
242    impl Order {
243        const customer_id: EntityColumn<Order> = EntityColumn::new("customer_id", "customer_id");
244        const total_cents: EntityColumn<Order> = EntityColumn::new("total_cents", "total_cents");
245    }
246
247    struct NewCustomer {
248        email: String,
249        active: bool,
250    }
251
252    impl Insertable<Customer> for NewCustomer {
253        fn values(&self) -> Vec<ColumnValue> {
254            vec![
255                ColumnValue::new("email", SqlValue::String(self.email.clone())),
256                ColumnValue::new("active", SqlValue::Bool(self.active)),
257            ]
258        }
259    }
260
261    struct UpdateCustomer {
262        email: Option<String>,
263    }
264
265    impl Changeset<Customer> for UpdateCustomer {
266        fn changes(&self) -> Vec<ColumnValue> {
267            self.email
268                .clone()
269                .map(|email| vec![ColumnValue::new("email", SqlValue::String(email))])
270                .unwrap_or_default()
271        }
272    }
273
274    #[test]
275    fn keeps_query_layer_sql_free() {
276        assert!(
277            CRATE_IDENTITY
278                .responsibility
279                .contains("without SQL generation")
280        );
281    }
282
283    #[test]
284    fn entity_columns_become_table_aware_column_refs() {
285        let column = ColumnRef::for_entity_column(Customer::email);
286
287        assert_eq!(column.table, TableRef::new("sales", "customers"));
288        assert_eq!(column.rust_field, "email");
289        assert_eq!(column.column_name, "email");
290    }
291
292    #[test]
293    fn expr_supports_columns_values_functions_and_operations() {
294        let expr = Expr::binary(
295            Expr::function("LOWER", vec![Expr::from(Customer::email)]),
296            BinaryOp::Add,
297            Expr::value(SqlValue::String("@example.com".to_string())),
298        );
299
300        match expr {
301            Expr::Binary { left, op, right } => {
302                assert_eq!(op, BinaryOp::Add);
303                assert!(matches!(*left, Expr::Function { .. }));
304                assert_eq!(
305                    *right,
306                    Expr::Value(SqlValue::String("@example.com".to_string()))
307                );
308            }
309            other => panic!("unexpected expr shape: {other:?}"),
310        }
311    }
312
313    #[test]
314    fn predicates_can_be_composed_without_sql_rendering() {
315        let predicate = Predicate::and(vec![
316            Predicate::eq(
317                Expr::from(Customer::active),
318                Expr::value(SqlValue::Bool(true)),
319            ),
320            Predicate::like(
321                Expr::from(Customer::email),
322                Expr::value(SqlValue::String("%@example.com".to_string())),
323            ),
324        ]);
325
326        match predicate {
327            Predicate::And(parts) => assert_eq!(parts.len(), 2),
328            other => panic!("unexpected predicate shape: {other:?}"),
329        }
330    }
331
332    #[test]
333    fn select_query_captures_projection_filters_order_and_pagination() {
334        let query = SelectQuery::from_entity::<Customer>()
335            .select(vec![Expr::from(Customer::id), Expr::from(Customer::email)])
336            .filter(Predicate::eq(
337                Expr::from(Customer::active),
338                Expr::value(SqlValue::Bool(true)),
339            ))
340            .filter(Predicate::like(
341                Expr::from(Customer::email),
342                Expr::value(SqlValue::String("%@example.com".to_string())),
343            ))
344            .order_by(OrderBy::desc(Customer::created_at))
345            .paginate(Pagination::page(2, 20));
346
347        assert_eq!(query.from, TableRef::new("sales", "customers"));
348        assert!(query.joins.is_empty());
349        assert_eq!(
350            query.projection,
351            vec![
352                SelectProjection::column(Customer::id),
353                SelectProjection::column(Customer::email)
354            ]
355        );
356        assert_eq!(
357            query.order_by,
358            vec![OrderBy::new(
359                TableRef::new("sales", "customers"),
360                "created_at",
361                SortDirection::Desc,
362            )]
363        );
364        assert_eq!(query.pagination, Some(Pagination::new(20, 20)));
365        assert!(matches!(query.predicate, Some(Predicate::And(_))));
366    }
367
368    #[test]
369    fn select_query_captures_explicit_joins_without_sql_rendering() {
370        let query = SelectQuery::from_entity::<Customer>()
371            .inner_join::<Order>(Predicate::eq(
372                Expr::from(Customer::id),
373                Expr::from(Order::customer_id),
374            ))
375            .join(Join::left(
376                TableRef::new("sales", "orders"),
377                Predicate::gt(
378                    Expr::from(Order::total_cents),
379                    Expr::value(SqlValue::I64(0)),
380                ),
381            ));
382
383        assert_eq!(query.joins.len(), 2);
384        assert_eq!(query.joins[0].join_type, JoinType::Inner);
385        assert_eq!(query.joins[0].table, TableRef::new("sales", "orders"));
386        assert!(matches!(query.joins[0].on, Predicate::Eq(_, _)));
387        assert_eq!(query.joins[1].join_type, JoinType::Left);
388        assert_eq!(query.joins[1].table, TableRef::new("sales", "orders"));
389        assert!(matches!(query.joins[1].on, Predicate::Gt(_, _)));
390    }
391
392    #[test]
393    fn table_refs_capture_optional_aliases_without_sql_rendering() {
394        let table = TableRef::for_entity_as::<Customer>("root");
395        let column = ColumnRef::for_entity_column_as(Customer::email, "root");
396        let expr = Expr::column_as(Customer::id, "root");
397
398        assert_eq!(table.schema, "sales");
399        assert_eq!(table.table, "customers");
400        assert_eq!(table.alias, Some("root"));
401        assert_eq!(table.reference_name(), "root");
402        assert_eq!(table.without_alias(), TableRef::new("sales", "customers"));
403        assert_eq!(column.table, table);
404
405        match expr {
406            Expr::Column(column) => {
407                assert_eq!(column.table.alias, Some("root"));
408                assert_eq!(column.column_name, "id");
409            }
410            other => panic!("unexpected expr shape: {other:?}"),
411        }
412    }
413
414    #[test]
415    fn select_query_captures_aliased_sources_and_repeated_joins() {
416        let query = SelectQuery::from_entity_as::<Customer>("c")
417            .inner_join_as::<Order>(
418                "created_orders",
419                Predicate::eq(
420                    Expr::column_as(Customer::id, "c"),
421                    Expr::column_as(Order::customer_id, "created_orders"),
422                ),
423            )
424            .left_join_as::<Order>(
425                "completed_orders",
426                Predicate::gt(
427                    Expr::column_as(Order::total_cents, "completed_orders"),
428                    Expr::value(SqlValue::I64(0)),
429                ),
430            );
431
432        assert_eq!(query.from, TableRef::with_alias("sales", "customers", "c"));
433        assert_eq!(query.joins.len(), 2);
434        assert_eq!(
435            query.joins[0].table,
436            TableRef::with_alias("sales", "orders", "created_orders")
437        );
438        assert_eq!(
439            query.joins[1].table,
440            TableRef::with_alias("sales", "orders", "completed_orders")
441        );
442        assert_ne!(query.joins[0].table, query.joins[1].table);
443    }
444
445    #[test]
446    fn select_projection_captures_default_and_explicit_aliases() {
447        let column_projection = SelectProjection::column(Customer::email);
448        assert_eq!(column_projection.alias, Some("email"));
449        assert_eq!(column_projection.expr, Expr::from(Customer::email));
450
451        let expression_projection = SelectProjection::expr_as(
452            Expr::function("LOWER", vec![Expr::from(Customer::email)]),
453            "email_lower",
454        );
455        assert_eq!(expression_projection.alias, Some("email_lower"));
456
457        let unaliased_expression =
458            SelectProjection::expr(Expr::function("LOWER", vec![Expr::from(Customer::email)]));
459        assert_eq!(unaliased_expression.alias, None);
460    }
461
462    #[test]
463    fn insert_update_delete_and_count_queries_capture_operation_data() {
464        let insert = InsertQuery::for_entity::<Customer, _>(&NewCustomer {
465            email: "ana@example.com".to_string(),
466            active: true,
467        });
468        let update = UpdateQuery::for_entity::<Customer, _>(&UpdateCustomer {
469            email: Some("ana.maria@example.com".to_string()),
470        })
471        .filter(Predicate::eq(
472            Expr::from(Customer::id),
473            Expr::value(SqlValue::I64(7)),
474        ));
475        let delete = DeleteQuery::from_entity::<Customer>().filter(Predicate::eq(
476            Expr::from(Customer::id),
477            Expr::value(SqlValue::I64(7)),
478        ));
479        let count = CountQuery::from_entity::<Customer>().filter(Predicate::eq(
480            Expr::from(Customer::active),
481            Expr::value(SqlValue::Bool(true)),
482        ));
483
484        assert_eq!(insert.into, TableRef::new("sales", "customers"));
485        assert_eq!(insert.values.len(), 2);
486        assert_eq!(update.table, TableRef::new("sales", "customers"));
487        assert_eq!(update.changes.len(), 1);
488        assert!(update.predicate.is_some());
489        assert_eq!(delete.from, TableRef::new("sales", "customers"));
490        assert!(delete.predicate.is_some());
491        assert_eq!(count.from, TableRef::new("sales", "customers"));
492        assert!(count.predicate.is_some());
493
494        assert!(matches!(Query::Insert(insert.clone()), Query::Insert(_)));
495        assert!(matches!(Query::Update(update.clone()), Query::Update(_)));
496        assert!(matches!(Query::Delete(delete.clone()), Query::Delete(_)));
497        assert!(matches!(Query::Count(count.clone()), Query::Count(_)));
498    }
499
500    #[test]
501    fn compiled_query_keeps_sql_and_parameter_order() {
502        let compiled = CompiledQuery::new(
503            "SELECT [id] FROM [sales].[customers] WHERE [active] = @P1 AND [email] LIKE @P2",
504            vec![
505                SqlValue::Bool(true),
506                SqlValue::String("%@example.com".to_string()),
507            ],
508        );
509
510        assert_eq!(
511            compiled.sql,
512            "SELECT [id] FROM [sales].[customers] WHERE [active] = @P1 AND [email] LIKE @P2"
513        );
514        assert_eq!(
515            compiled.params,
516            vec![
517                SqlValue::Bool(true),
518                SqlValue::String("%@example.com".to_string()),
519            ]
520        );
521    }
522}