diff --git a/CHANGELOG.md b/CHANGELOG.md index 40267265..60717be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,35 @@ This file is the authoritative user-facing release chronology. The former [release-notes ledger](docs/archive/2026/graphql-orm-release-notes.md) is retained for historical context. +## 0.30.0 - 2026-08-31 + +Companion macros crate: `graphql-orm-macros` **0.30.0**. Backend-neutral +semantic owner: `graphql-orm-operation-catalog` **0.4.0**. + +- Corrected generated calendar filters to use sargable half-open timestamp + ranges. `IsToday`, `InFuture`, `RecentDays`, `WithinDays`, and relative upper + bounds now include or exclude complete calendar dates as documented without + applying a function to the persisted column. +- Added one recursive `DatabaseFilter::validate` execution seam. Generated + filters reject zero, negative, or excessive calendar spans, excessive signed + offsets, malformed or reversed ranges, and GraphQL `Between` inputs missing + either required bound. Invalid direct rendering produces a false predicate; + query execution returns `INVALID_INPUT` before database work. +- Made SQLite spatial fallback evaluate every date operator with one + deterministic UTC calendar anchor and SQL three-valued NULL semantics across + direct, `And`, `Or`, and `Not` expressions. +- Corrected semantic date-field detection to take precedence over the Rust + backing type. Date fields now advertise the exact generated `DateFilter` + grammar, including calendar and relative operators and excluding unsupported + membership and string operators. + +PostgreSQL derives today from the connection session's `CURRENT_DATE`; SQL +Server uses the server-local date from `GETDATE()`; SQLite uses UTC through +`date('now')`, with the same UTC basis in spatial fallback. Exact comparison +values remain unchanged and unnormalized. No table, column, constraint, or +stored-data migration is required. Regenerate SDL, semantic catalogues, and +dependent fingerprints. + ## 0.29.0 - 2026-08-31 Companion macros crate: `graphql-orm-macros` **0.29.0**. diff --git a/Cargo.lock b/Cargo.lock index 0db5b512..304f4c80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3075,7 +3075,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.29.0" +version = "0.30.0" dependencies = [ "agql-auth", "async-graphql", @@ -3172,7 +3172,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.29.0" +version = "0.30.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", @@ -3183,7 +3183,7 @@ dependencies = [ [[package]] name = "graphql-orm-operation-catalog" -version = "0.3.0" +version = "0.4.0" dependencies = [ "graphql-orm-router-protocol", "serde", diff --git a/Cargo.toml b/Cargo.toml index 98f65e35..d0062c6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,10 +37,10 @@ cynic-parser = { version = "=0.11.2", features = ["pretty"] } futures = "0.3" getrandom = "0.3" graphql-composition = "=0.12.2" -graphql-orm = { path = "crates/graphql-orm", version = "0.29.0", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.30.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.10.2" } graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.2", default-features = false } -graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.3.0" } +graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.0" } graphql-orm-router-protocol = { path = "crates/graphql-orm-router-protocol", version = "0.2.1" } graphql-orm-storage = { path = "crates/graphql-orm-storage", version = "0.6.2", default-features = false } hive-router = "=0.0.87" diff --git a/MIGRATION.md b/MIGRATION.md index 9146cf12..d1904c44 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -13,6 +13,44 @@ supersedes: [] `graphql-orm` is distributed from GitHub only. Use a reviewed full 40-character commit in `rev`; neither the runtime nor macros crate is published to crates.io. +## 0.29.0 to 0.30.0: checked calendar-date filters + +Adopt `graphql-orm` and `graphql-orm-macros` 0.30.0 together from one reviewed +full Git revision. Backend-neutral semantic consumers must adopt +`graphql-orm-operation-catalog` 0.4.0 from that revision. The AI tool-profile +package remains 0.10.2; rebuild its generated capabilities against the new +semantic catalogue. + +Generated calendar predicates now use half-open ranges over the persisted +column: today is `[today, tomorrow)`, future begins at tomorrow, the past ends +at today, `RecentDays(N)` includes exactly today plus `N - 1` preceding dates, +and `WithinDays(N)` includes exactly `N` dates beginning today. `LteRelative` +is inclusive through its selected calendar date by rendering an exclusive +next-date upper bound. Ordinary `Eq`, `Ne`, `Lt`, `Lte`, `Gt`, and `Gte` +continue binding the supplied value without normalization. + +`DateRangeInput.start` and `DateRangeInput.end` are now required `String` +fields. Update Rust struct literals that supplied `Option`. Both bounds +must be parseable date/timestamp values and start must not follow end. +`RecentDays` and `WithinDays` accept 1 through 36,600; signed relative offsets +accept -36,600 through 36,600. Generated query execution validates recursively +and returns `INVALID_INPUT` before database work. Handwritten `DatabaseFilter` +implementations remain source compatible because `validate` has a permissive +default; implement it when a custom filter has structured invariants. + +The meaning of today remains backend-owned and is now internally consistent: +PostgreSQL uses session `CURRENT_DATE`, SQL Server uses the server-local date +from `GETDATE()`, and SQLite uses UTC `date('now')`. SQLite's in-memory spatial +fallback captures one UTC date per filtering pass and does not push +clock-dependent predicates into its SQL prefilter. This release does not add a +cross-backend application-timezone setting or normalize exact input strings. +A future typed date/time input and context-bound calendar-clock design requires +separate compatibility review. + +Date semantic metadata and catalogue fingerprints change. Rebuild reviewed +catalogues, automatic query capabilities, and retained bindings. There is no +database schema, stored-data, backup, or migration-history change. + ## 0.27.0 to 0.29.0: computed/relation ordering and complex relation composition Computed fields can join generated ordering without a handwritten query: diff --git a/README.md b/README.md index 314b33d8..0571a62d 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,12 @@ schema changes explicitly. Packages are distributed from this repository, not crates.io. Pin the reviewed release revision, not a moving branch or tag. The current coordinated -`graphql-orm` version is 0.29.0. Replace the placeholder below with the final +`graphql-orm` version is 0.30.0. Replace the placeholder below with the final reviewed full SHA for the release: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.29.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.30.0", default-features = false, features = ["sqlite"] } ``` Choose exactly the backend support needed by each service. Cargo can unify diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 19116ada..370bf13b 100644 --- a/crates/graphql-orm-macros/Cargo.toml +++ b/crates/graphql-orm-macros/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-macros" -version = "0.29.0" +version = "0.30.0" edition = "2024" authors = ["Toby Martin"] description = "Procedural macros for async-graphql and ORM-backed entities, relations, and CRUD operations." diff --git a/crates/graphql-orm-macros/README.md b/crates/graphql-orm-macros/README.md index 5f99fba5..cdc1ffae 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -16,13 +16,13 @@ macro/runtime versions aligned: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.29.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.30.0", default-features = false, features = ["sqlite"] } ``` Direct use is supported for tooling that needs the macro package: ```toml -graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.29.0", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.30.0", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the @@ -135,5 +135,11 @@ mapping and target entity table. Entities that also have handwritten complex fie handwritten impl so generated relations are flattened into the same `ComplexObject` surface. +Generated `DateFilter` fields use sargable half-open calendar ranges and a +recursive validation path shared by GraphQL and programmatic queries. Between +bounds are required; calendar spans and signed relative offsets are bounded. +The generated semantic operator set follows the date grammar rather than the +field's Rust backing type. + See [core runtime documentation](../graphql-orm/README.md), and the [macro and attribute reference](../../docs/reference/graphql-orm/macros-and-attributes.md). diff --git a/crates/graphql-orm-macros/src/entity.rs b/crates/graphql-orm-macros/src/entity.rs index e98c8341..889496f0 100644 --- a/crates/graphql-orm-macros/src/entity.rs +++ b/crates/graphql-orm-macros/src/entity.rs @@ -2692,27 +2692,6 @@ fn generate_entity_impl( let lit = syn::LitStr::new(current_epoch_expr, proc_macro2::Span::call_site()); quote! { #lit } }; - let current_date_runtime = if backend == BackendKind::Postgres { - quote! { "CURRENT_DATE" } - } else if backend == BackendKind::Mssql { - quote! { "CAST(GETDATE() AS date)" } - } else { - quote! { "date('now')" } - }; - let days_ago_runtime = if backend == BackendKind::Postgres { - quote! { format!("(CURRENT_DATE - INTERVAL '{} days')::date", days) } - } else if backend == BackendKind::Mssql { - quote! { format!("DATEADD(day, -{}, CAST(GETDATE() AS date))", days) } - } else { - quote! { format!("date('now', '-{} days')", days) } - }; - let days_ahead_runtime = if backend == BackendKind::Postgres { - quote! { format!("(CURRENT_DATE + INTERVAL '{} days')::date", days) } - } else if backend == BackendKind::Mssql { - quote! { format!("DATEADD(day, {}, CAST(GETDATE() AS date))", days) } - } else { - quote! { format!("date('now', '+{} days')", days) } - }; let spatial_sql_value_body = if backend == BackendKind::Sqlite { quote! { ::graphql_orm::graphql::orm::spatial::canonical_geojson_sql_value(value, spatial) @@ -3035,6 +3014,7 @@ fn generate_entity_impl( let mut filter_to_entity_match = Vec::new(); let mut filter_is_empty_checks = Vec::new(); let mut filter_contains_spatial_checks = Vec::new(); + let mut filter_validation_checks = Vec::new(); let mut filter_referenced_field_checks = Vec::new(); let mut from_row_fields = Vec::new(); let mut relation_metadata_defs = Vec::new(); @@ -3463,7 +3443,25 @@ fn generate_entity_impl( let is_decimal = type_name == "Decimal"; let is_string = type_name == "String"; let filter_operators = if field_meta.filter && field_meta.filterable.is_some() { - if is_string { + if field_meta.is_date_field { + quote! { vec![ + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::Equal, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::NotEqual, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::LessThan, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::LessThanOrEqual, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::GreaterThan, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::GreaterThanOrEqual, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::Between, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::IsNull, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::InPast, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::InFuture, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::IsToday, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::RecentDays, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::WithinDays, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::GteRelative, + ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::LteRelative, + ] } + } else if is_string { quote! { vec![ ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::Equal, ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::NotEqual, @@ -3472,7 +3470,7 @@ fn generate_entity_impl( ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::StartsWith, ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::EndsWith, ] } - } else if is_numeric || field_meta.is_date_field { + } else if is_numeric { quote! { vec![ ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::Equal, ::graphql_orm::graphql::orm::GraphqlSemanticFilterOperator::NotEqual, @@ -4156,6 +4154,13 @@ fn generate_entity_impl( filter_to_entity_match.push(entity_match_gen); filter_is_empty_checks.push(is_empty_check); filter_contains_spatial_checks.push(contains_spatial_check); + if filter_type == "date" { + filter_validation_checks.push(quote! { + if let Some(filter) = &self.#filter_field_name { + filter.validate()?; + } + }); + } } } @@ -4702,9 +4707,31 @@ fn generate_entity_impl( } impl ::graphql_orm::graphql::orm::DatabaseFilter for #where_input_name { + fn validate(&self) -> ::graphql_orm::Result<()> { + #(#filter_validation_checks)* + if let Some(filters) = &self.and { + for filter in filters { + ::graphql_orm::graphql::orm::DatabaseFilter::validate(filter)?; + } + } + if let Some(filters) = &self.or { + for filter in filters { + ::graphql_orm::graphql::orm::DatabaseFilter::validate(filter)?; + } + } + if let Some(filter) = &self.not { + ::graphql_orm::graphql::orm::DatabaseFilter::validate(filter.as_ref())?; + } + Ok(()) + } + fn to_sql_conditions(&self) -> (Vec, Vec<::graphql_orm::graphql::orm::SqlValue>) { + if ::graphql_orm::graphql::orm::DatabaseFilter::validate(self).is_err() { + return (vec!["1 = 0".to_owned()], Vec::new()); + } let mut conditions = Vec::new(); let mut values = Vec::new(); + let _gom_spatial_prefilter = false; #(#filter_to_sql)* @@ -4795,6 +4822,17 @@ fn generate_entity_impl( fn matches_entity( &self, entity: &(dyn ::std::any::Any + Send + Sync), + ) -> ::graphql_orm::Result { + let calendar_today = ::graphql_orm::chrono::DateTime::<::graphql_orm::chrono::Utc>::from( + ::std::time::SystemTime::now() + ).date_naive(); + self.matches_entity_at(entity, calendar_today) + } + + fn matches_entity_at( + &self, + entity: &(dyn ::std::any::Any + Send + Sync), + calendar_today: ::graphql_orm::chrono::NaiveDate, ) -> ::graphql_orm::Result { let entity = entity.downcast_ref::<#struct_name>().ok_or_else(|| { ::graphql_orm::sqlx::Error::Decode(Box::new(::std::io::Error::new( @@ -4802,14 +4840,18 @@ fn generate_entity_impl( concat!("in-memory filter entity type mismatch for ", stringify!(#struct_name)), ))) })?; - self.__gom_matches_entity(entity) + Ok(self.__gom_matches_entity(entity, calendar_today)? == Some(true)) } } impl #where_input_name { fn __gom_to_sql_prefilter_conditions(&self) -> (Vec, Vec<::graphql_orm::graphql::orm::SqlValue>) { + if ::graphql_orm::graphql::orm::DatabaseFilter::validate(self).is_err() { + return (vec!["1 = 0".to_owned()], Vec::new()); + } let mut conditions = Vec::new(); let mut values = Vec::new(); + let _gom_spatial_prefilter = true; #(#filter_to_sql)* @@ -4872,37 +4914,55 @@ fn generate_entity_impl( false } - fn __gom_matches_entity(&self, entity: &#struct_name) -> ::graphql_orm::Result { + fn __gom_matches_entity( + &self, + entity: &#struct_name, + calendar_today: ::graphql_orm::chrono::NaiveDate, + ) -> ::graphql_orm::Result> { + let mut _gom_unknown = false; #(#filter_to_entity_match)* if let Some(ref and_filters) = self.and { for filter in and_filters { - if !filter.__gom_matches_entity(entity)? { - return Ok(false); + match filter.__gom_matches_entity(entity, calendar_today)? { + Some(true) => {} + Some(false) => return Ok(Some(false)), + None => _gom_unknown = true, } } } if let Some(ref or_filters) = self.or { let mut matched = false; + let mut unknown = false; for filter in or_filters { - if filter.__gom_matches_entity(entity)? { - matched = true; - break; + match filter.__gom_matches_entity(entity, calendar_today)? { + Some(true) => { + matched = true; + break; + } + Some(false) => {} + None => unknown = true, } } if !matched { - return Ok(false); + if unknown { + _gom_unknown = true; + } else { + return Ok(Some(false)); + } } } if let Some(ref not_filter) = self.not { - if not_filter.__gom_matches_entity(entity)? { - return Ok(false); + match not_filter.__gom_matches_entity(entity, calendar_today)? { + Some(true) => return Ok(Some(false)), + Some(false) => {} + None => _gom_unknown = true, } } - Ok(true) + Ok(if _gom_unknown { None } else { Some(true) }) } } @@ -4980,17 +5040,6 @@ fn generate_entity_impl( #current_epoch_runtime } - pub(crate) fn __gom_current_date_expr() -> &'static str { - #current_date_runtime - } - - pub(crate) fn __gom_days_ago_expr(days: i64) -> String { - #days_ago_runtime - } - - pub(crate) fn __gom_days_ahead_expr(days: i64) -> String { - #days_ahead_runtime - } } impl ::graphql_orm::graphql::orm::DatabaseSchema for #struct_name { @@ -5665,7 +5714,7 @@ fn generate_filter_field( f, ::graphql_orm::graphql::orm::SpatialColumnDef::geometry(#geometry_type, #srid), )? { - return Ok(false); + return Ok(Some(false)); } } } @@ -5740,7 +5789,7 @@ fn generate_filter_field( || filter.not_in.as_ref().is_some_and(|list| value.is_some_and(|value| list.contains(value))) || filter.is_null.is_some_and(|expected| expected != value.is_none()) { - return Ok(false); + return Ok(Some(false)); } } } @@ -5754,7 +5803,7 @@ fn generate_filter_field( || filter.not_in.as_ref().is_some_and(|list| list.contains(value)) || filter.is_null == Some(true) { - return Ok(false); + return Ok(Some(false)); } } } @@ -5852,7 +5901,7 @@ fn generate_filter_field( quote! { if let Some(ref f) = self.#filter_field_name { if !::graphql_orm::graphql::orm::spatial::string_filter_matches(#value_expr, f) { - return Ok(false); + return Ok(Some(false)); } } } @@ -5941,7 +5990,7 @@ fn generate_filter_field( quote! { if let Some(ref f) = self.#filter_field_name { if !::graphql_orm::graphql::orm::spatial::int_filter_matches(#value_expr, f) { - return Ok(false); + return Ok(Some(false)); } } } @@ -6009,7 +6058,7 @@ fn generate_filter_field( quote! { if let Some(ref f) = self.#filter_field_name { if !::graphql_orm::graphql::orm::spatial::uuid_filter_matches(#value_expr, f) { - return Ok(false); + return Ok(Some(false)); } } } @@ -6054,7 +6103,7 @@ fn generate_filter_field( quote! { if let Some(ref f) = self.#filter_field_name { if !::graphql_orm::graphql::orm::spatial::bool_filter_matches(#value_expr, f) { - return Ok(false); + return Ok(Some(false)); } } } @@ -6070,91 +6119,22 @@ fn generate_filter_field( }; let sql = quote! { if let Some(ref f) = self.#filter_field_name { - if let Some(ref v) = f.eq { - let placeholder = #struct_name::__gom_placeholder(values.len() + 1); - conditions.push(format!("{} = {}", #db_col, placeholder)); - values.push(::graphql_orm::graphql::orm::SqlValue::String(v.clone())); - } - if let Some(ref v) = f.ne { - let placeholder = #struct_name::__gom_placeholder(values.len() + 1); - conditions.push(format!("{} != {}", #db_col, placeholder)); - values.push(::graphql_orm::graphql::orm::SqlValue::String(v.clone())); - } - if let Some(ref v) = f.lt { - let placeholder = #struct_name::__gom_placeholder(values.len() + 1); - conditions.push(format!("{} < {}", #db_col, placeholder)); - values.push(::graphql_orm::graphql::orm::SqlValue::String(v.clone())); - } - if let Some(ref v) = f.lte { - let placeholder = #struct_name::__gom_placeholder(values.len() + 1); - conditions.push(format!("{} <= {}", #db_col, placeholder)); - values.push(::graphql_orm::graphql::orm::SqlValue::String(v.clone())); - } - if let Some(ref v) = f.gt { - let placeholder = #struct_name::__gom_placeholder(values.len() + 1); - conditions.push(format!("{} > {}", #db_col, placeholder)); - values.push(::graphql_orm::graphql::orm::SqlValue::String(v.clone())); - } - if let Some(ref v) = f.gte { - let placeholder = #struct_name::__gom_placeholder(values.len() + 1); - conditions.push(format!("{} >= {}", #db_col, placeholder)); - values.push(::graphql_orm::graphql::orm::SqlValue::String(v.clone())); - } - if let Some(ref range) = f.between { - if let (Some(start), Some(end)) = (&range.start, &range.end) { - let start_placeholder = #struct_name::__gom_placeholder(values.len() + 1); - let end_placeholder = #struct_name::__gom_placeholder(values.len() + 2); - conditions.push(format!("{} BETWEEN {} AND {}", #db_col, start_placeholder, end_placeholder)); - values.push(::graphql_orm::graphql::orm::SqlValue::String(start.clone())); - values.push(::graphql_orm::graphql::orm::SqlValue::String(end.clone())); + let rendered = if _gom_spatial_prefilter { + f.render_sql_prefilter(#backend_expr, #db_col, values.len() + 1) + } else { + f.render_sql(#backend_expr, #db_col, values.len() + 1) + }; + match rendered { + Ok((date_conditions, date_values)) => { + conditions.extend(date_conditions); + values.extend(date_values); } - } - // IsNull / IsNotNull - if let Some(is_null) = f.is_null { - if is_null { - conditions.push(format!("{} IS NULL", #db_col)); - } else { - conditions.push(format!("{} IS NOT NULL", #db_col)); + Err(_) => { + // Direct rendering remains fail-closed. Query execution + // reports the validation error through DatabaseFilter::validate. + conditions.push("1 = 0".to_owned()); } } - // Date arithmetic operators - if f.in_past == Some(true) { - conditions.push(format!("{} < {}", #db_col, #struct_name::__gom_current_date_expr())); - } - if f.in_future == Some(true) { - conditions.push(format!("{} > {}", #db_col, #struct_name::__gom_current_date_expr())); - } - if f.is_today == Some(true) { - conditions.push(format!("{} = {}", #db_col, #struct_name::__gom_current_date_expr())); - } - if let Some(days) = f.recent_days { - // Within the last N days (inclusive of today) - conditions.push(format!( - "{} >= {} AND {} <= {}", - #db_col, - #struct_name::__gom_days_ago_expr(days.into()), - #db_col, - #struct_name::__gom_current_date_expr() - )); - } - if let Some(days) = f.within_days { - // Within the next N days (inclusive of today) - conditions.push(format!( - "{} >= {} AND {} <= {}", - #db_col, - #struct_name::__gom_current_date_expr(), - #db_col, - #struct_name::__gom_days_ahead_expr(days.into()) - )); - } - if let Some(ref rel) = f.gte_relative { - let expr = rel.to_sql_expr(#backend_expr); - conditions.push(format!("{} >= {}", #db_col, expr)); - } - if let Some(ref rel) = f.lte_relative { - let expr = rel.to_sql_expr(#backend_expr); - conditions.push(format!("{} <= {}", #db_col, expr)); - } } }; let entity_match = if backend == BackendKind::Sqlite { @@ -6162,8 +6142,14 @@ fn generate_filter_field( quote! { if let Some(ref f) = self.#filter_field_name { let __gom_date_value = entity.#field_name.as_ref().map(|value| value.to_string()); - if !::graphql_orm::graphql::orm::spatial::date_filter_matches(__gom_date_value.as_deref(), f) { - return Ok(false); + match ::graphql_orm::graphql::orm::spatial::date_filter_truth_at( + __gom_date_value.as_deref(), + f, + calendar_today, + ) { + Some(true) => {} + Some(false) => return Ok(Some(false)), + None => _gom_unknown = true, } } } @@ -6171,8 +6157,14 @@ fn generate_filter_field( quote! { if let Some(ref f) = self.#filter_field_name { let __gom_date_value = entity.#field_name.to_string(); - if !::graphql_orm::graphql::orm::spatial::date_filter_matches(Some(__gom_date_value.as_str()), f) { - return Ok(false); + match ::graphql_orm::graphql::orm::spatial::date_filter_truth_at( + Some(__gom_date_value.as_str()), + f, + calendar_today, + ) { + Some(true) => {} + Some(false) => return Ok(Some(false)), + None => _gom_unknown = true, } } } diff --git a/crates/graphql-orm-macros/src/operations.rs b/crates/graphql-orm-macros/src/operations.rs index 6cec24fd..ac0d5647 100644 --- a/crates/graphql-orm-macros/src/operations.rs +++ b/crates/graphql-orm-macros/src/operations.rs @@ -4150,7 +4150,7 @@ pub(crate) fn generate_graphql_operations( None } else { let query = EntityQuery::::new().filter(&expected); - let (delete_sql, expected_values) = query.build_delete_sql(); + let (delete_sql, expected_values) = query.try_build_delete_sql()?; let clause = delete_sql.split_once(" WHERE ") .map(|(_, clause)| clause.to_string()) .ok_or_else(|| Self::__gom_runtime_error("expected predicates produced empty SQL"))?; @@ -5708,7 +5708,7 @@ pub(crate) fn generate_graphql_operations( return Err(Self::__gom_runtime_error("No fields to update")); } let expected_query = EntityQuery::::new().filter(&expected); - let (delete_sql, expected_values) = expected_query.build_delete_sql(); + let (delete_sql, expected_values) = expected_query.try_build_delete_sql()?; let expected_clause = delete_sql.split_once(" WHERE ") .map(|(_, clause)| clause) .ok_or_else(|| Self::__gom_runtime_error("conditional predicate rendered empty SQL"))?; diff --git a/crates/graphql-orm-macros/src/relations.rs b/crates/graphql-orm-macros/src/relations.rs index 0f0bbf36..8993f2a2 100644 --- a/crates/graphql-orm-macros/src/relations.rs +++ b/crates/graphql-orm-macros/src/relations.rs @@ -800,6 +800,12 @@ pub(crate) fn generate_graphql_relations( ::graphql_orm::graphql::orm::EntityAccessSurface::GraphqlRelation, ).await?; + if let Some(filter) = &where_input { + filter + .validate() + .map_err(::graphql_orm::graphql::errors::graphql_error_from_sqlx)?; + } + if where_input.is_none() && order_by.is_none() && page.is_none() && !self.#field_name.is_empty() { #preloaded_entities let edges: Vec<#edge_type> = entities diff --git a/crates/graphql-orm-operation-catalog/Cargo.toml b/crates/graphql-orm-operation-catalog/Cargo.toml index a0ae9802..8f52f507 100644 --- a/crates/graphql-orm-operation-catalog/Cargo.toml +++ b/crates/graphql-orm-operation-catalog/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-operation-catalog" -version = "0.3.0" +version = "0.4.0" edition = "2024" authors = ["Toby Martin "] description = "Backend-neutral generated GraphQL operation metadata for graphql-orm" diff --git a/crates/graphql-orm-operation-catalog/README.md b/crates/graphql-orm-operation-catalog/README.md index 709884a5..d67ac78e 100644 --- a/crates/graphql-orm-operation-catalog/README.md +++ b/crates/graphql-orm-operation-catalog/README.md @@ -25,7 +25,7 @@ row, field, resolver, provider-egress, or database policy. ```toml [dependencies] -graphql-orm-operation-catalog = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.3.0" } +graphql-orm-operation-catalog = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.4.0" } serde_json = "1" ``` @@ -84,6 +84,7 @@ read-only policy. | `GraphqlSemanticClassification` / `GraphqlSemanticExport` | inherited classification and structural provider-export eligibility; neither authorizes disclosure | | `GraphqlSemanticResultDisclosure` | explicit classification/export disposition for a custom scalar or enum result, plus a positive bound for an exportable scalar list; unclassified leaves fail safe as `Secret`/`NeverExport` | | `GraphqlAggregateOperator` / `GraphqlAggregateValueKind` | canonical portable aggregate capabilities used by field and generated-operation metadata | +| `GraphqlSemanticFilterOperator` | closed public filter capabilities, including exact date comparison, range, null, calendar, and relative-date operators | | `GraphqlSubscriptionObservationDescriptor` | truthful best-effort or replay-then-live subscription semantics plus bounded wait and closed condition capabilities; metadata alone does not register a runtime replay source | | `AiMutationExecutionPolicy` | closed `Automatic`, `ApprovalRequired`, or default `Prohibited` classification for public mutations; metadata never grants execution or resolver authority | | discovery fingerprint | algorithm `graphql-orm-sha256-len-v1`; detects generated declaration/exposure drift, not authorization or disclosure | diff --git a/crates/graphql-orm-operation-catalog/src/semantic.rs b/crates/graphql-orm-operation-catalog/src/semantic.rs index 37f00ef8..22d9f40e 100644 --- a/crates/graphql-orm-operation-catalog/src/semantic.rs +++ b/crates/graphql-orm-operation-catalog/src/semantic.rs @@ -105,6 +105,24 @@ pub enum GraphqlSemanticFilterOperator { StartsWith, /// Suffix match. EndsWith, + /// Inclusive two-value range. + Between, + /// Null-state predicate. + IsNull, + /// Timestamp is before the start of today. + InPast, + /// Timestamp is at or after the start of tomorrow. + InFuture, + /// Timestamp is within today's half-open calendar range. + IsToday, + /// Positive bounded calendar span ending with today. + RecentDays, + /// Positive bounded calendar span beginning with today. + WithinDays, + /// Inclusive lower bound at a signed day offset from today. + GteRelative, + /// Inclusive calendar-date upper bound at a signed day offset from today. + LteRelative, } /// Model-neutral classification inherited by a public semantic field. diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index 9a2dd851..15503050 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.29.0" +version = "0.30.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -65,7 +65,7 @@ futures = "0.3" geo = { version = "0.33", optional = true, default-features = false } geo-types = { version = "0.7", optional = true } geojson = { version = "1", optional = true, default-features = true } -graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.29.0", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.30.0", default-features = false } graphql-orm-operation-catalog = { workspace = true } rust_decimal = { workspace = true } serde = { version = "1", features = ["derive"] } diff --git a/crates/graphql-orm/README.md b/crates/graphql-orm/README.md index 87d35245..2284da4d 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -29,7 +29,7 @@ backend: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.29.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.30.0", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the @@ -137,6 +137,10 @@ README remains project-neutral. clients never provide SQL, identifiers, values, or aggregate functions. Missing primary-key columns are appended as deterministic pagination tie-breakers. +- **Checked calendar filters:** generated date predicates use half-open + calendar ranges and recursively reject invalid spans, offsets, and ranges + before database work. PostgreSQL uses session `CURRENT_DATE`, SQL Server uses + server-local `GETDATE()`, and SQLite uses UTC `date('now')`. ## Errors and security boundaries diff --git a/crates/graphql-orm/src/graphql/filters.rs b/crates/graphql-orm/src/graphql/filters.rs index 2c9e7adf..11c17583 100644 --- a/crates/graphql-orm/src/graphql/filters.rs +++ b/crates/graphql-orm/src/graphql/filters.rs @@ -233,10 +233,21 @@ pub struct BoolFilter { #[cfg_attr(feature = "field-case-lower", graphql(rename_fields = "lowercase"))] #[cfg_attr(feature = "field-case-upper", graphql(rename_fields = "UPPERCASE"))] pub struct DateRangeInput { - pub start: Option, - pub end: Option, + /// Inclusive ISO-8601 lower bound. + pub start: String, + /// Inclusive ISO-8601 upper bound. + pub end: String, } +/// Maximum positive calendar span accepted by `recentDays` and `withinDays`. +/// +/// The 100-year ceiling is backend-neutral and remains within the supported +/// date range of SQLite, PostgreSQL, and SQL Server for contemporary clocks. +pub const MAX_CALENDAR_DAY_SPAN: i32 = 36_600; + +/// Maximum absolute calendar offset accepted by relative-date predicates. +pub const MAX_RELATIVE_DAY_OFFSET: i32 = 36_600; + #[derive(async_graphql::InputObject, Clone, Debug, Default)] #[cfg_attr(feature = "field-case-pascal", graphql(rename_fields = "PascalCase"))] #[cfg_attr(feature = "field-case-snake", graphql(rename_fields = "snake_case"))] @@ -247,6 +258,8 @@ pub struct DateRangeInput { #[cfg_attr(feature = "field-case-lower", graphql(rename_fields = "lowercase"))] #[cfg_attr(feature = "field-case-upper", graphql(rename_fields = "UPPERCASE"))] pub struct RelativeDateInput { + /// Signed calendar-day offset from the start of today. The accepted range + /// is -36,600 through 36,600 days. pub days: i32, } @@ -292,9 +305,13 @@ pub struct DateFilter { pub is_today: Option, #[cfg_attr(feature = "field-case-lower", graphql(name = "recentdays"))] #[cfg_attr(feature = "field-case-upper", graphql(name = "RECENTDAYS"))] + /// Positive number of calendar dates ending with today (maximum 36,600). + #[graphql(validator(minimum = 1, maximum = 36600))] pub recent_days: Option, #[cfg_attr(feature = "field-case-lower", graphql(name = "withindays"))] #[cfg_attr(feature = "field-case-upper", graphql(name = "WITHINDAYS"))] + /// Positive number of calendar dates beginning with today (maximum 36,600). + #[graphql(validator(minimum = 1, maximum = 36600))] pub within_days: Option, #[cfg_attr(feature = "field-case-lower", graphql(name = "gterelative"))] #[cfg_attr(feature = "field-case-upper", graphql(name = "GTERELATIVE"))] @@ -303,3 +320,189 @@ pub struct DateFilter { #[cfg_attr(feature = "field-case-upper", graphql(name = "LTERELATIVE"))] pub lte_relative: Option, } + +impl DateFilter { + /// Validate bounds shared by GraphQL-decoded and programmatically built filters. + pub fn validate(&self) -> crate::Result<()> { + if let Some(days) = self.recent_days { + validate_positive_calendar_span("recentDays", days)?; + } + if let Some(days) = self.within_days { + validate_positive_calendar_span("withinDays", days)?; + } + if let Some(relative) = &self.gte_relative { + validate_relative_offset("gteRelative", relative.days)?; + } + if let Some(relative) = &self.lte_relative { + validate_relative_offset("lteRelative", relative.days)?; + } + if let Some(range) = &self.between { + let start = parse_comparable_date_value(&range.start).ok_or_else(|| { + invalid_date_filter("between.start must be an ISO-8601 date or timestamp") + })?; + let end = parse_comparable_date_value(&range.end).ok_or_else(|| { + invalid_date_filter("between.end must be an ISO-8601 date or timestamp") + })?; + if start > end { + return Err(invalid_date_filter( + "between.start must not be after between.end", + )); + } + } + Ok(()) + } + + /// Render this filter for macro-generated SQL. + /// + /// This method is public only because procedural-macro expansion occurs in + /// the consuming crate. The column expression is fixed generated metadata; + /// GraphQL clients cannot supply SQL or identifiers. + #[doc(hidden)] + pub fn render_sql( + &self, + backend: crate::graphql::orm::DatabaseBackend, + column: &str, + start_index: usize, + ) -> crate::Result<(Vec, Vec)> { + self.render_sql_with_calendar(backend, column, start_index, true) + } + + /// Render only clock-independent predicates for a residual in-memory path. + #[doc(hidden)] + pub fn render_sql_prefilter( + &self, + backend: crate::graphql::orm::DatabaseBackend, + column: &str, + start_index: usize, + ) -> crate::Result<(Vec, Vec)> { + self.render_sql_with_calendar(backend, column, start_index, false) + } + + fn render_sql_with_calendar( + &self, + backend: crate::graphql::orm::DatabaseBackend, + column: &str, + start_index: usize, + include_calendar: bool, + ) -> crate::Result<(Vec, Vec)> { + use crate::graphql::orm::{SqlDialect, SqlValue}; + + self.validate()?; + let mut conditions = Vec::new(); + let mut values = Vec::new(); + for (value, operator) in [ + (&self.eq, "="), + (&self.ne, "!="), + (&self.lt, "<"), + (&self.lte, "<="), + (&self.gt, ">"), + (&self.gte, ">="), + ] { + if let Some(value) = value { + let placeholder = backend.placeholder(start_index + values.len()); + conditions.push(format!("{column} {operator} {placeholder}")); + values.push(SqlValue::String(value.clone())); + } + } + if let Some(range) = &self.between { + let start_placeholder = backend.placeholder(start_index + values.len()); + let end_placeholder = backend.placeholder(start_index + values.len() + 1); + conditions.push(format!( + "{column} BETWEEN {start_placeholder} AND {end_placeholder}" + )); + values.push(SqlValue::String(range.start.clone())); + values.push(SqlValue::String(range.end.clone())); + } + if let Some(is_null) = self.is_null { + conditions.push(format!( + "{column} IS {}NULL", + if is_null { "" } else { "NOT " } + )); + } + + if include_calendar { + let today = backend.current_date_expr(); + let tomorrow = backend.days_ahead_expr(1); + if self.in_past == Some(true) { + conditions.push(format!("{column} < {today}")); + } + if self.in_future == Some(true) { + conditions.push(format!("{column} >= {tomorrow}")); + } + if self.is_today == Some(true) { + conditions.push(format!("{column} >= {today} AND {column} < {tomorrow}")); + } + if let Some(days) = self.recent_days { + let lower = backend.days_ago_expr(i64::from(days - 1)); + conditions.push(format!("{column} >= {lower} AND {column} < {tomorrow}")); + } + if let Some(days) = self.within_days { + let upper = backend.days_ahead_expr(i64::from(days)); + conditions.push(format!("{column} >= {today} AND {column} < {upper}")); + } + if let Some(relative) = &self.gte_relative { + conditions.push(format!("{column} >= {}", relative.to_sql_expr(backend))); + } + if let Some(relative) = &self.lte_relative { + let exclusive_offset = i64::from(relative.days) + 1; + conditions.push(format!( + "{column} < {}", + relative_day_expr(backend, exclusive_offset) + )); + } + } + + Ok((conditions, values)) + } +} + +fn validate_positive_calendar_span(name: &str, days: i32) -> crate::Result<()> { + if !(1..=MAX_CALENDAR_DAY_SPAN).contains(&days) { + return Err(invalid_date_filter(format!( + "{name} must be between 1 and {MAX_CALENDAR_DAY_SPAN} days" + ))); + } + Ok(()) +} + +fn validate_relative_offset(name: &str, days: i32) -> crate::Result<()> { + if !(-MAX_RELATIVE_DAY_OFFSET..=MAX_RELATIVE_DAY_OFFSET).contains(&days) { + return Err(invalid_date_filter(format!( + "{name}.days must be between -{MAX_RELATIVE_DAY_OFFSET} and {MAX_RELATIVE_DAY_OFFSET}" + ))); + } + Ok(()) +} + +fn invalid_date_filter(message: impl Into) -> sqlx::Error { + crate::graphql::errors::sqlx_error_from_public( + crate::graphql::errors::OrmPublicError::new( + crate::graphql::errors::OrmErrorCode::InvalidInput, + ) + .with_internal(message.into()), + ) +} + +fn relative_day_expr(backend: crate::graphql::orm::DatabaseBackend, days: i64) -> String { + use crate::graphql::orm::SqlDialect; + + if days < 0 { + backend.days_ago_expr(days.unsigned_abs() as i64) + } else { + backend.days_ahead_expr(days) + } +} + +fn parse_comparable_date_value(value: &str) -> Option { + if let Ok(value) = chrono::DateTime::parse_from_rfc3339(value) { + return Some(value.naive_utc()); + } + for format in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"] { + if let Ok(value) = chrono::NaiveDateTime::parse_from_str(value, format) { + return Some(value); + } + } + chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") + .ok() + .and_then(|value| value.and_hms_opt(0, 0, 0)) +} diff --git a/crates/graphql-orm/src/graphql/orm/core.rs b/crates/graphql-orm/src/graphql/orm/core.rs index 9d664ec8..5f6827d6 100644 --- a/crates/graphql-orm/src/graphql/orm/core.rs +++ b/crates/graphql-orm/src/graphql/orm/core.rs @@ -1953,7 +1953,7 @@ where B::set_retention_context(&mut self.mutation.tx, T::TABLE_NAME).await?; let (sql, values) = EntityQuery::::new() .filter(&filter) - .build_delete_sql(); + .try_build_delete_sql()?; let execution = self.mutation.execute(&sql, &values).await; let cleared = B::clear_retention_context(&mut self.mutation.tx).await; let result = execution?; diff --git a/crates/graphql-orm/src/graphql/orm/query.rs b/crates/graphql-orm/src/graphql/orm/query.rs index 24c3e66c..e770eed9 100644 --- a/crates/graphql-orm/src/graphql/orm/query.rs +++ b/crates/graphql-orm/src/graphql/orm/query.rs @@ -146,6 +146,14 @@ pub trait ReadProjection: } pub trait DatabaseFilter { + /// Validate a filter before any database work. + /// + /// Generated filters use this checked seam for constraints that cannot be + /// represented by the historical infallible SQL-rendering interface. + fn validate(&self) -> crate::Result<()> { + Ok(()) + } + fn to_sql_conditions(&self) -> (Vec, Vec); fn is_empty(&self) -> bool; @@ -186,10 +194,35 @@ pub trait DatabaseFilter { Ok(true) } + /// Evaluate an in-memory predicate using one query-scoped calendar date. + #[doc(hidden)] + fn matches_entity_at( + &self, + entity: &(dyn Any + Send + Sync), + _calendar_today: chrono::NaiveDate, + ) -> crate::Result { + self.matches_entity(entity) + } + fn to_filter_expression(&self) -> Option { let (conditions, values) = self.to_sql_conditions(); filter_expression_from_raw_parts(&conditions, &values) } + + /// Validate and convert this filter to the backend-neutral query IR. + fn to_filter_expression_checked(&self) -> crate::Result> { + self.validate()?; + Ok(self.to_filter_expression()) + } +} + +fn invalid_filter_input() -> sqlx::Error { + crate::graphql::errors::sqlx_error_from_public( + crate::graphql::errors::OrmPublicError::new( + crate::graphql::errors::OrmErrorCode::InvalidInput, + ) + .with_internal("generated filter validation failed"), + ) } pub trait DatabaseOrderBy { @@ -2185,6 +2218,9 @@ where "at least one aggregate expression is required", )); } + if let Some(filter) = &self.filter { + filter.validate()?; + } self.authorize(context).await?; let filter = self .filter @@ -2565,7 +2601,7 @@ pub fn build_upsert_sql( update_updated_at, ) } -type EntityMatcher = Arc crate::Result + Send + Sync>; +type EntityMatcher = Arc crate::Result + Send + Sync>; struct PoolRef<'a, B: OrmBackend> { pool: &'a B::Pool, @@ -2587,6 +2623,7 @@ where order_clauses: Vec, limit: Option, requires_in_memory_filtering: bool, + filter_is_valid: bool, _marker: PhantomData<(P, B)>, } @@ -2602,11 +2639,13 @@ where order_clauses: Vec::new(), limit: None, requires_in_memory_filtering: false, + filter_is_valid: true, _marker: PhantomData, } } fn filter(mut self, filter: P::Filter) -> Self { + self.filter_is_valid &= filter.validate().is_ok(); if filter.requires_in_memory_filtering(B::DIALECT) { self.requires_in_memory_filtering = true; } else { @@ -2693,6 +2732,9 @@ where } fn validate(&self) -> crate::Result<()> { + if !self.filter_is_valid { + return Err(invalid_filter_input()); + } if self.requires_in_memory_filtering { return Err(sqlx::Error::Protocol( "projection filter requires full-entity in-memory evaluation and was rejected" @@ -2959,6 +3001,7 @@ pub struct EntityQuery { order_values: Vec>, pub page: Option, entity_matchers: Vec>, + filter_is_valid: bool, _marker: PhantomData<(T, B)>, } @@ -2971,6 +3014,7 @@ impl Clone for EntityQuery { order_values: self.order_values.clone(), page: self.page.clone(), entity_matchers: self.entity_matchers.clone(), + filter_is_valid: self.filter_is_valid, _marker: PhantomData, } } @@ -2989,6 +3033,7 @@ where order_values: Vec::new(), page: None, entity_matchers: Vec::new(), + filter_is_valid: true, _marker: PhantomData, } } @@ -3009,6 +3054,7 @@ where where F: DatabaseFilter, { + self.filter_is_valid &= filter.validate().is_ok(); let (conds, values) = filter.to_sql_conditions(); self.where_clauses.extend(conds); self.values.extend(values); @@ -3019,13 +3065,16 @@ where where F: DatabaseFilter + Clone + Send + Sync + 'static, { + self.filter_is_valid &= filter.validate().is_ok(); if filter.requires_in_memory_filtering(B::DIALECT) { let (conds, values) = filter.to_sql_prefilter_conditions(B::DIALECT); self.where_clauses.extend(conds); self.values.extend(values); let filter = filter.clone(); self.entity_matchers - .push(Arc::new(move |entity| filter.matches_entity(entity))); + .push(Arc::new(move |entity, calendar_today| { + filter.matches_entity_at(entity, calendar_today) + })); } else { let (conds, values) = filter.to_sql_conditions(); self.where_clauses.extend(conds); @@ -3077,7 +3126,8 @@ where &self, pagination_config: PaginationConfig, apply_default_limit: bool, - ) -> SelectQuery { + ) -> crate::Result { + self.ensure_filter_valid()?; let page = pagination_config.resolve_page(self.page.as_ref(), apply_default_limit); let mut sorts = self .order_clauses @@ -3096,7 +3146,7 @@ where sorts.push(SortExpression::unbound(format!("{primary_key} ASC"))); } } - SelectQuery { + Ok(SelectQuery { table: T::TABLE_NAME, columns: T::column_names() .iter() @@ -3112,13 +3162,21 @@ where None }, count_only: false, - } + }) } - fn build_select_query(&self) -> SelectQuery { + fn build_select_query(&self) -> crate::Result { self.build_select_query_with_config(PaginationConfig::default(), false) } + fn ensure_filter_valid(&self) -> crate::Result<()> { + if self.filter_is_valid { + Ok(()) + } else { + Err(invalid_filter_input()) + } + } + fn aggregate_column_sql(column: &str) -> crate::Result where T: DatabaseSchema, @@ -3138,6 +3196,7 @@ where where T: DatabaseSchema, { + self.ensure_filter_valid()?; if self.requires_in_memory_filtering() { return Err(sqlx::Error::Protocol( "aggregate queries require filters that can be rendered to SQL".to_string(), @@ -3156,15 +3215,16 @@ where !self.entity_matchers.is_empty() } - fn matches_entity(&self, entity: &T) -> crate::Result { + fn matches_entity(&self, entity: &T, calendar_today: chrono::NaiveDate) -> crate::Result { self.entity_matchers .iter() - .try_fold( - true, - |matches, matcher| { - if matches { matcher(entity) } else { Ok(false) } - }, - ) + .try_fold(true, |matches, matcher| { + if matches { + matcher(entity, calendar_today) + } else { + Ok(false) + } + }) } fn apply_in_memory_filtering( @@ -3179,13 +3239,17 @@ where .collect::, _>>()?; if self.requires_in_memory_filtering() { + let calendar_today = + chrono::DateTime::::from(std::time::SystemTime::now()).date_naive(); entities = entities .into_iter() - .filter_map(|entity| match self.matches_entity(&entity) { - Ok(true) => Some(Ok(entity)), - Ok(false) => None, - Err(error) => Some(Err(error)), - }) + .filter_map( + |entity| match self.matches_entity(&entity, calendar_today) { + Ok(true) => Some(Ok(entity)), + Ok(false) => None, + Err(error) => Some(Err(error)), + }, + ) .collect::, _>>()?; let page = pagination_config.resolve_page(self.page.as_ref(), apply_default_limit); @@ -3232,7 +3296,7 @@ where let pagination_config = provider.pagination_config(); let rendered = render_select_query( B::DIALECT, - &self.build_select_query_with_config(pagination_config, false), + &self.build_select_query_with_config(pagination_config, false)?, ); let rows = B::fetch_rows(provider.pool(), &rendered.sql, &rendered.values).await?; self.apply_in_memory_filtering(rows, pagination_config, false) @@ -3249,7 +3313,7 @@ where let pagination_config = provider.pagination_config(); let rendered = render_select_query( B::DIALECT, - &self.build_select_query_with_config(pagination_config, false), + &self.build_select_query_with_config(pagination_config, false)?, ); let rows = B::fetch_rows_with_auth(provider.pool(), &rendered.sql, &rendered.values, auth).await?; @@ -3267,7 +3331,7 @@ where { let rendered = render_select_query( B::DIALECT, - &self.build_select_query_with_config(pagination_config, false), + &self.build_select_query_with_config(pagination_config, false)?, ); let rows = B::fetch_rows_with_auth(provider.pool(), &rendered.sql, &rendered.values, auth).await?; @@ -3279,7 +3343,7 @@ where B: SqlxBackend, E: sqlx::Executor<'e, Database = ::Database> + Send + 'e, { - let rendered = render_select_query(B::DIALECT, &self.build_select_query()); + let rendered = render_select_query(B::DIALECT, &self.build_select_query()?); let rows = B::fetch_rows_on(executor, rendered.sql, rendered.values).await?; self.apply_in_memory_filtering(rows, PaginationConfig::default(), false) } @@ -3293,7 +3357,7 @@ where where B: WriteBackend, { - let rendered = render_select_query(B::DIALECT, &self.build_select_query()); + let rendered = render_select_query(B::DIALECT, &self.build_select_query()?); let rows = context.fetch_rows(&rendered.sql, &rendered.values).await?; self.apply_in_memory_filtering(rows, PaginationConfig::default(), false) } @@ -3309,7 +3373,7 @@ where where B: WriteBackend, { - let rendered = render_write_decision_select_query(B::DIALECT, &self.build_select_query()); + let rendered = render_write_decision_select_query(B::DIALECT, &self.build_select_query()?); let rows = context.fetch_rows(&rendered.sql, &rendered.values).await?; self.apply_in_memory_filtering(rows, PaginationConfig::default(), false) } @@ -3375,7 +3439,7 @@ where let pagination = PaginationConfig::unbounded(); let rendered = render_write_decision_select_query( B::DIALECT, - &query.build_select_query_with_config(pagination, false), + &query.build_select_query_with_config(pagination, false)?, ); let rows = context.fetch_rows(&rendered.sql, &rendered.values).await?; query.apply_in_memory_filtering(rows, pagination, false) @@ -3434,7 +3498,7 @@ where if self.requires_in_memory_filtering() { return Ok(self.fetch_unpaged_filtered(provider).await?.len() as i64); } - let mut query = self.build_select_query(); + let mut query = self.build_select_query()?; query.count_only = true; query.pagination = None; query.sorts.clear(); @@ -3546,7 +3610,7 @@ where .await? .len() as i64); } - let mut query = self.build_select_query(); + let mut query = self.build_select_query()?; query.count_only = true; query.pagination = None; query.sorts.clear(); @@ -3636,7 +3700,7 @@ where query.page = None; return Ok(query.fetch_all_on(executor).await?.len() as i64); } - let mut query = self.build_select_query(); + let mut query = self.build_select_query()?; query.count_only = true; query.pagination = None; query.sorts.clear(); @@ -3660,7 +3724,7 @@ where query.page = None; return Ok(query.fetch_all_in_transaction(context).await?.len() as i64); } - let mut query = self.build_select_query(); + let mut query = self.build_select_query()?; query.count_only = true; query.pagination = None; query.sorts.clear(); @@ -3670,7 +3734,24 @@ where B::try_get_i64(row, "count") } + /// Build a delete statement, rendering an invalid filter as a predicate + /// that cannot match any row. pub fn build_delete_sql(&self) -> (String, Vec) { + self.try_build_delete_sql().unwrap_or_else(|_| { + let rendered = render_delete_query( + B::DIALECT, + &DeleteQuery { + table: T::TABLE_NAME, + filter: Some(FilterExpression::trusted_fragment("1 = 0", Vec::new())), + }, + ); + (rendered.sql, rendered.values) + }) + } + + /// Validate the accumulated filter and build a delete statement. + pub fn try_build_delete_sql(&self) -> crate::Result<(String, Vec)> { + self.ensure_filter_valid()?; let rendered = render_delete_query( B::DIALECT, &DeleteQuery { @@ -3678,7 +3759,7 @@ where filter: filter_expression_from_raw_parts(&self.where_clauses, &self.values), }, ); - (rendered.sql, rendered.values) + Ok((rendered.sql, rendered.values)) } pub async fn fetch_connection

(&self, provider: &P) -> crate::Result> @@ -3725,14 +3806,14 @@ where let pagination_config = provider.pagination_config(); let page = pagination_config.resolve_page(self.page.as_ref(), true); let offset = page.offset.max(0) as usize; - let mut count_query = self.build_select_query(); + let mut count_query = self.build_select_query()?; count_query.count_only = true; count_query.pagination = None; count_query.sorts.clear(); let count_rendered = render_select_query(B::DIALECT, &count_query); let row_rendered = render_select_query( B::DIALECT, - &self.build_select_query_with_config(pagination_config, true), + &self.build_select_query_with_config(pagination_config, true)?, ); let (count_rows, rows) = B::fetch_rows_pair_with_auth( provider.pool(), @@ -3817,7 +3898,7 @@ where edges, }); } - let mut count_query = self.build_select_query(); + let mut count_query = self.build_select_query()?; count_query.count_only = true; count_query.pagination = None; count_query.sorts.clear(); @@ -3825,7 +3906,7 @@ where let pagination_config = provider.pagination_config(); let row_rendered = render_select_query( B::DIALECT, - &self.build_select_query_with_config(pagination_config, true), + &self.build_select_query_with_config(pagination_config, true)?, ); let (count_rows, rows) = B::fetch_rows_pair_with_auth( provider.pool(), @@ -4056,6 +4137,7 @@ pub struct CountQuery<'a, W, B: OrmBackend = DefaultBackend> { table: &'static str, filters: Vec, values: Vec, + filter_is_valid: bool, _marker: PhantomData<(W, B)>, } @@ -4070,11 +4152,13 @@ where table, filters: Vec::new(), values: Vec::new(), + filter_is_valid: true, _marker: PhantomData, } } pub fn filter(mut self, filter: &W) -> Self { + self.filter_is_valid &= filter.validate().is_ok(); let (conds, values) = filter.to_sql_conditions(); self.filters.extend(conds); self.values.extend(values); @@ -4082,6 +4166,9 @@ where } pub async fn count(self) -> crate::Result { + if !self.filter_is_valid { + return Err(invalid_filter_input()); + } let rendered = render_select_query( B::DIALECT, &SelectQuery { @@ -4103,6 +4190,9 @@ where B: SqlxBackend, E: sqlx::Executor<'e, Database = ::Database> + Send + 'e, { + if !self.filter_is_valid { + return Err(invalid_filter_input()); + } let rendered = render_select_query( B::DIALECT, &SelectQuery { diff --git a/crates/graphql-orm/src/graphql/orm/spatial.rs b/crates/graphql-orm/src/graphql/orm/spatial.rs index 8179aa1a..cbcae8e2 100644 --- a/crates/graphql-orm/src/graphql/orm/spatial.rs +++ b/crates/graphql-orm/src/graphql/orm/spatial.rs @@ -415,19 +415,42 @@ pub fn date_filter_matches( value: Option<&str>, filter: &crate::graphql::filters::DateFilter, ) -> bool { + date_filter_matches_at(value, filter, sqlite_calendar_today()) +} + +/// Evaluate a date filter at one deterministic calendar anchor. +/// +/// This is public only for generated SQLite spatial-fallback code and tests. +#[doc(hidden)] +pub fn date_filter_matches_at( + value: Option<&str>, + filter: &crate::graphql::filters::DateFilter, + today: chrono::NaiveDate, +) -> bool { + date_filter_truth_at(value, filter, today) == Some(true) +} + +/// Evaluate a date filter with SQL's true/false/unknown semantics. +#[doc(hidden)] +pub fn date_filter_truth_at( + value: Option<&str>, + filter: &crate::graphql::filters::DateFilter, + today: chrono::NaiveDate, +) -> Option { + if filter.validate().is_err() { + return Some(false); + } if let Some(is_null) = filter.is_null { if value.is_none() != is_null { - return false; + return Some(false); } } let Some(value) = value else { - return filter.eq.is_none() - && filter.ne.is_none() - && filter.lt.is_none() - && filter.lte.is_none() - && filter.gt.is_none() - && filter.gte.is_none() - && filter.between.is_none(); + return if date_filter_has_value_predicate(filter) { + None + } else { + Some(true) + }; }; if filter @@ -435,50 +458,154 @@ pub fn date_filter_matches( .as_deref() .is_some_and(|expected| value != expected) { - return false; + return Some(false); } if filter .ne .as_deref() .is_some_and(|expected| value == expected) { - return false; + return Some(false); } if filter .lt .as_deref() .is_some_and(|expected| value >= expected) { - return false; + return Some(false); } if filter .lte .as_deref() .is_some_and(|expected| value > expected) { - return false; + return Some(false); } if filter .gt .as_deref() .is_some_and(|expected| value <= expected) { - return false; + return Some(false); } if filter .gte .as_deref() .is_some_and(|expected| value < expected) { - return false; + return Some(false); } if let Some(range) = &filter.between { - if let (Some(start), Some(end)) = (&range.start, &range.end) { - if value < start.as_str() || value > end.as_str() { - return false; + if value < range.start.as_str() || value > range.end.as_str() { + return Some(false); + } + } + + if date_filter_has_calendar_predicate(filter) { + let Some(value) = parse_calendar_value(value) else { + return Some(false); + }; + let Some(today_start) = today.and_hms_opt(0, 0, 0) else { + return Some(false); + }; + let Some(tomorrow_start) = today_start.checked_add_days(chrono::Days::new(1)) else { + return Some(false); + }; + + if filter.in_past == Some(true) && value >= today_start { + return Some(false); + } + if filter.in_future == Some(true) && value < tomorrow_start { + return Some(false); + } + if filter.is_today == Some(true) && !(today_start..tomorrow_start).contains(&value) { + return Some(false); + } + if let Some(days) = filter.recent_days { + let Some(lower) = today_start.checked_sub_days(chrono::Days::new((days - 1) as u64)) + else { + return Some(false); + }; + if value < lower || value >= tomorrow_start { + return Some(false); + } + } + if let Some(days) = filter.within_days { + let Some(upper) = today_start.checked_add_days(chrono::Days::new(days as u64)) else { + return Some(false); + }; + if value < today_start || value >= upper { + return Some(false); + } + } + if let Some(relative) = &filter.gte_relative { + let Some(lower) = checked_relative_date(today_start, relative.days) else { + return Some(false); + }; + if value < lower { + return Some(false); + } + } + if let Some(relative) = &filter.lte_relative { + let Some(upper) = checked_relative_date(today_start, relative.days.saturating_add(1)) + else { + return Some(false); + }; + if value >= upper { + return Some(false); } } } - true + Some(true) +} + +fn date_filter_has_value_predicate(filter: &crate::graphql::filters::DateFilter) -> bool { + filter.eq.is_some() + || filter.ne.is_some() + || filter.lt.is_some() + || filter.lte.is_some() + || filter.gt.is_some() + || filter.gte.is_some() + || filter.between.is_some() + || date_filter_has_calendar_predicate(filter) +} + +fn date_filter_has_calendar_predicate(filter: &crate::graphql::filters::DateFilter) -> bool { + filter.in_past == Some(true) + || filter.in_future == Some(true) + || filter.is_today == Some(true) + || filter.recent_days.is_some() + || filter.within_days.is_some() + || filter.gte_relative.is_some() + || filter.lte_relative.is_some() +} + +fn parse_calendar_value(value: &str) -> Option { + if let Ok(value) = chrono::DateTime::parse_from_rfc3339(value) { + return Some(value.naive_utc()); + } + for format in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"] { + if let Ok(value) = chrono::NaiveDateTime::parse_from_str(value, format) { + return Some(value); + } + } + chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") + .ok() + .and_then(|value| value.and_hms_opt(0, 0, 0)) +} + +fn checked_relative_date( + today_start: chrono::NaiveDateTime, + days: i32, +) -> Option { + if days < 0 { + today_start.checked_sub_days(chrono::Days::new(i64::from(days).unsigned_abs())) + } else { + today_start.checked_add_days(chrono::Days::new(days as u64)) + } +} + +fn sqlite_calendar_today() -> chrono::NaiveDate { + chrono::DateTime::::from(std::time::SystemTime::now()).date_naive() } diff --git a/crates/graphql-orm/src/lib.rs b/crates/graphql-orm/src/lib.rs index 849c2aae..7cdaeed7 100644 --- a/crates/graphql-orm/src/lib.rs +++ b/crates/graphql-orm/src/lib.rs @@ -305,6 +305,8 @@ //! backend notes, relation batching, schema policies, and migration guidance. pub use async_graphql; +#[doc(hidden)] +pub use chrono; pub use futures; pub use graphql_orm_macros::*; pub use rust_decimal; diff --git a/crates/graphql-orm/tests/date_filters.rs b/crates/graphql-orm/tests/date_filters.rs new file mode 100644 index 00000000..dad319d4 --- /dev/null +++ b/crates/graphql-orm/tests/date_filters.rs @@ -0,0 +1,647 @@ +#![cfg(feature = "sqlite")] + +use graphql_orm::async_graphql::{EmptyMutation, EmptySubscription, Schema}; +use graphql_orm::graphql::filters::{ + DateFilter, DateRangeInput, MAX_CALENDAR_DAY_SPAN, MAX_RELATIVE_DAY_OFFSET, RelativeDateInput, + SpatialFilter, StringFilter, +}; +use graphql_orm::graphql::orm::spatial::date_filter_matches_at; +use graphql_orm::prelude::*; + +#[derive(GraphQLEntity, GraphQLOperations, Clone, Debug, serde::Deserialize, serde::Serialize)] +#[graphql_entity( + table = "calendar_spatial_records", + plural = "CalendarSpatialRecords", + backend = "sqlite", + auth = "none" +)] +struct CalendarSpatialRecord { + #[primary_key] + #[filterable(type = "string")] + #[sortable] + id: String, + + #[filterable(type = "date")] + occurred_at: Option, + + #[graphql_orm(spatial(kind = "geometry", geometry_type = "Point", srid = 4326))] + #[filterable(type = "spatial")] + location: graphql_orm::serde_json::Value, +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct DateTime(String); + +#[graphql_orm::async_graphql::Scalar] +impl graphql_orm::async_graphql::ScalarType for DateTime { + fn parse( + value: graphql_orm::async_graphql::Value, + ) -> graphql_orm::async_graphql::InputValueResult { + let graphql_orm::async_graphql::Value::String(value) = value else { + return Err(graphql_orm::async_graphql::InputValueError::expected_type( + value, + )); + }; + Ok(Self(value)) + } + + fn to_value(&self) -> graphql_orm::async_graphql::Value { + graphql_orm::async_graphql::Value::String(self.0.clone()) + } +} + +#[derive(GraphQLSchemaEntity, Clone, Debug, serde::Deserialize, serde::Serialize)] +#[graphql_entity(table = "string_date_semantics", plural = "StringDateSemantics")] +struct StringDateSemantics { + #[primary_key] + id: String, + #[date_field] + #[filterable(type = "date")] + observed_at: String, +} + +#[derive(GraphQLSchemaEntity, Clone, Debug, serde::Deserialize, serde::Serialize)] +#[graphql_entity(table = "datetime_date_semantics", plural = "DateTimeDateSemantics")] +struct DateTimeDateSemantics { + #[primary_key] + id: String, + #[date_field] + #[filterable(type = "date")] + observed_at: Option, +} + +fn anchor() -> graphql_orm::chrono::NaiveDate { + graphql_orm::chrono::NaiveDate::from_ymd_opt(2026, 8, 31).expect("valid fixed date") +} + +fn point(x: f64, y: f64) -> graphql_orm::serde_json::Value { + graphql_orm::serde_json::json!({ "type": "Point", "coordinates": [x, y] }) +} + +fn point_filter(x: f64, y: f64) -> SpatialFilter { + SpatialFilter { + equals: Some(graphql_orm::async_graphql::Json(point(x, y))), + ..Default::default() + } +} + +fn record(occurred_at: Option<&str>) -> CalendarSpatialRecord { + CalendarSpatialRecord { + id: "record-1".to_owned(), + occurred_at: occurred_at.map(str::to_owned), + location: point(1.0, 2.0), + } +} + +fn matches(filter: &CalendarSpatialRecordWhereInput, entity: &CalendarSpatialRecord) -> bool { + DatabaseFilter::matches_entity_at(filter, entity, anchor()).expect("matcher succeeds") +} + +#[test] +fn calendar_operators_use_half_open_fixed_anchor_ranges() { + let today_midday = "2026-08-31T14:45:00"; + let yesterday_last = "2026-08-30T23:59:59.999999"; + let yesterday_start = "2026-08-30T00:00:00"; + let two_days_ago = "2026-08-29T23:59:59.999999"; + let tomorrow_start = "2026-09-01T00:00:00"; + let tomorrow_midday = "2026-09-01T14:45:00"; + let day_after_start = "2026-09-02T00:00:00"; + + let today_filter = DateFilter { + is_today: Some(true), + ..Default::default() + }; + assert!(!date_filter_matches_at( + Some(yesterday_last), + &today_filter, + anchor(), + )); + assert!(date_filter_matches_at( + Some("2026-08-31T00:00:00"), + &today_filter, + anchor(), + )); + assert!(!date_filter_matches_at( + Some(tomorrow_start), + &today_filter, + anchor(), + )); + + assert!(date_filter_matches_at( + Some(today_midday), + &today_filter, + anchor(), + )); + assert!(!date_filter_matches_at( + Some(today_midday), + &DateFilter { + in_future: Some(true), + ..Default::default() + }, + anchor(), + )); + assert!(date_filter_matches_at( + Some(tomorrow_start), + &DateFilter { + in_future: Some(true), + ..Default::default() + }, + anchor(), + )); + + for (days, included, excluded) in [ + (1, vec![today_midday], vec![yesterday_last, tomorrow_start]), + ( + 2, + vec![yesterday_start, yesterday_last, today_midday], + vec![two_days_ago, tomorrow_start], + ), + ] { + let filter = DateFilter { + recent_days: Some(days), + ..Default::default() + }; + for value in included { + assert!(date_filter_matches_at(Some(value), &filter, anchor())); + } + for value in excluded { + assert!(!date_filter_matches_at(Some(value), &filter, anchor())); + } + } + + for (days, included, excluded) in [ + (1, vec![today_midday], vec![yesterday_last, tomorrow_start]), + ( + 2, + vec![today_midday, tomorrow_start, tomorrow_midday], + vec![yesterday_last, day_after_start], + ), + ] { + let filter = DateFilter { + within_days: Some(days), + ..Default::default() + }; + for value in included { + assert!(date_filter_matches_at(Some(value), &filter, anchor())); + } + for value in excluded { + assert!(!date_filter_matches_at(Some(value), &filter, anchor())); + } + } + + let lower = DateFilter { + gte_relative: Some(RelativeDateInput { days: 1 }), + ..Default::default() + }; + assert!(!date_filter_matches_at( + Some(today_midday), + &lower, + anchor() + )); + assert!(date_filter_matches_at( + Some(tomorrow_start), + &lower, + anchor() + )); + + let upper = DateFilter { + lte_relative: Some(RelativeDateInput { days: 0 }), + ..Default::default() + }; + assert!(date_filter_matches_at(Some(today_midday), &upper, anchor())); + assert!(!date_filter_matches_at( + Some(tomorrow_start), + &upper, + anchor() + )); +} + +#[test] +fn invalid_date_filter_inputs_fail_validation_and_rendering() { + let invalid = [ + DateFilter { + recent_days: Some(-1), + ..Default::default() + }, + DateFilter { + recent_days: Some(0), + ..Default::default() + }, + DateFilter { + recent_days: Some(MAX_CALENDAR_DAY_SPAN + 1), + ..Default::default() + }, + DateFilter { + within_days: Some(-1), + ..Default::default() + }, + DateFilter { + within_days: Some(0), + ..Default::default() + }, + DateFilter { + within_days: Some(MAX_CALENDAR_DAY_SPAN + 1), + ..Default::default() + }, + DateFilter { + gte_relative: Some(RelativeDateInput { + days: MAX_RELATIVE_DAY_OFFSET + 1, + }), + ..Default::default() + }, + DateFilter { + lte_relative: Some(RelativeDateInput { + days: -MAX_RELATIVE_DAY_OFFSET - 1, + }), + ..Default::default() + }, + DateFilter { + between: Some(DateRangeInput { + start: "not-a-date".to_owned(), + end: "2026-08-31".to_owned(), + }), + ..Default::default() + }, + DateFilter { + between: Some(DateRangeInput { + start: "2026-09-01".to_owned(), + end: "2026-08-31".to_owned(), + }), + ..Default::default() + }, + ]; + + for filter in invalid { + let error = filter.validate().expect_err("invalid filter must fail"); + assert_eq!( + OrmPublicError::from_sqlx(&error).code, + OrmErrorCode::InvalidInput + ); + assert!( + filter + .render_sql(DatabaseBackend::Mssql, "[occurred_at]", 1) + .is_err() + ); + assert!(!date_filter_matches_at( + Some("2026-08-31T14:45:00"), + &filter, + anchor() + )); + } +} + +#[test] +fn calendar_sql_is_exact_for_each_backend() { + let filter = DateFilter { + is_today: Some(true), + in_past: Some(true), + in_future: Some(true), + recent_days: Some(2), + within_days: Some(2), + gte_relative: Some(RelativeDateInput { days: -2 }), + lte_relative: Some(RelativeDateInput { days: 2 }), + ..Default::default() + }; + + let cases = [ + ( + DatabaseBackend::Sqlite, + "occurred_at", + vec![ + "occurred_at < date('now')", + "occurred_at >= date('now', '+1 days')", + "occurred_at >= date('now') AND occurred_at < date('now', '+1 days')", + "occurred_at >= date('now', '-1 days') AND occurred_at < date('now', '+1 days')", + "occurred_at >= date('now') AND occurred_at < date('now', '+2 days')", + "occurred_at >= date('now', '-2 days')", + "occurred_at < date('now', '+3 days')", + ], + ), + ( + DatabaseBackend::Postgres, + "\"occurred_at\"", + vec![ + "\"occurred_at\" < CURRENT_DATE", + "\"occurred_at\" >= CURRENT_DATE + INTERVAL '1 days'", + "\"occurred_at\" >= CURRENT_DATE AND \"occurred_at\" < CURRENT_DATE + INTERVAL '1 days'", + "\"occurred_at\" >= CURRENT_DATE - INTERVAL '1 days' AND \"occurred_at\" < CURRENT_DATE + INTERVAL '1 days'", + "\"occurred_at\" >= CURRENT_DATE AND \"occurred_at\" < CURRENT_DATE + INTERVAL '2 days'", + "\"occurred_at\" >= CURRENT_DATE - INTERVAL '2 days'", + "\"occurred_at\" < CURRENT_DATE + INTERVAL '3 days'", + ], + ), + ( + DatabaseBackend::Mssql, + "[occurred_at]", + vec![ + "[occurred_at] < CAST(GETDATE() AS date)", + "[occurred_at] >= DATEADD(day, 1, CAST(GETDATE() AS date))", + "[occurred_at] >= CAST(GETDATE() AS date) AND [occurred_at] < DATEADD(day, 1, CAST(GETDATE() AS date))", + "[occurred_at] >= DATEADD(day, -1, CAST(GETDATE() AS date)) AND [occurred_at] < DATEADD(day, 1, CAST(GETDATE() AS date))", + "[occurred_at] >= CAST(GETDATE() AS date) AND [occurred_at] < DATEADD(day, 2, CAST(GETDATE() AS date))", + "[occurred_at] >= DATEADD(day, -2, CAST(GETDATE() AS date))", + "[occurred_at] < DATEADD(day, 3, CAST(GETDATE() AS date))", + ], + ), + ]; + + for (backend, column, expected) in cases { + let (conditions, values) = filter + .render_sql(backend, column, 1) + .expect("valid filter renders"); + assert_eq!(conditions, expected); + assert!(values.is_empty()); + } + + let exact = DateFilter { + eq: Some("2026-08-31T14:45:00".to_owned()), + between: Some(DateRangeInput { + start: "2026-08-30".to_owned(), + end: "2026-09-01".to_owned(), + }), + ..Default::default() + }; + for (backend, expected) in [ + ( + DatabaseBackend::Sqlite, + vec!["occurred_at = ?", "occurred_at BETWEEN ? AND ?"], + ), + ( + DatabaseBackend::Postgres, + vec!["occurred_at = $4", "occurred_at BETWEEN $5 AND $6"], + ), + ( + DatabaseBackend::Mssql, + vec!["occurred_at = @P4", "occurred_at BETWEEN @P5 AND @P6"], + ), + ] { + let (conditions, values) = exact + .render_sql(backend, "occurred_at", 4) + .expect("exact comparisons render"); + assert_eq!(conditions, expected); + assert_eq!(values.len(), 3); + } +} + +#[test] +fn sqlite_spatial_fallback_matches_date_predicates_in_boolean_trees() { + let today = record(Some("2026-08-31T14:45:00")); + let null_date = record(None); + let calendar = DateFilter { + is_today: Some(true), + ..Default::default() + }; + + let direct = CalendarSpatialRecordWhereInput { + occurred_at: Some(calendar.clone()), + location: Some(point_filter(1.0, 2.0)), + ..Default::default() + }; + assert!(direct.requires_in_memory_filtering(DatabaseBackend::Sqlite)); + let (prefilter, prefilter_values) = direct.to_sql_prefilter_conditions(DatabaseBackend::Sqlite); + assert!(prefilter.is_empty()); + assert!(prefilter_values.is_empty()); + assert!(matches(&direct, &today)); + assert!(!matches(&direct, &null_date)); + + let and = CalendarSpatialRecordWhereInput { + location: Some(point_filter(1.0, 2.0)), + and: Some(vec![CalendarSpatialRecordWhereInput { + occurred_at: Some(calendar.clone()), + ..Default::default() + }]), + ..Default::default() + }; + assert!(matches(&and, &today)); + assert!(!matches(&and, &null_date)); + + let or = CalendarSpatialRecordWhereInput { + or: Some(vec![ + CalendarSpatialRecordWhereInput { + location: Some(point_filter(9.0, 9.0)), + ..Default::default() + }, + CalendarSpatialRecordWhereInput { + occurred_at: Some(calendar.clone()), + ..Default::default() + }, + ]), + ..Default::default() + }; + assert!(matches(&or, &today)); + assert!(!matches(&or, &null_date)); + + let not = CalendarSpatialRecordWhereInput { + location: Some(point_filter(1.0, 2.0)), + not: Some(Box::new(CalendarSpatialRecordWhereInput { + occurred_at: Some(DateFilter { + in_future: Some(true), + ..Default::default() + }), + ..Default::default() + })), + ..Default::default() + }; + assert!(matches(¬, &today)); + assert!(!matches(¬, &null_date)); +} + +#[test] +fn date_fields_advertise_the_exact_filter_grammar() { + let expected = vec![ + GraphqlSemanticFilterOperator::Equal, + GraphqlSemanticFilterOperator::NotEqual, + GraphqlSemanticFilterOperator::LessThan, + GraphqlSemanticFilterOperator::LessThanOrEqual, + GraphqlSemanticFilterOperator::GreaterThan, + GraphqlSemanticFilterOperator::GreaterThanOrEqual, + GraphqlSemanticFilterOperator::Between, + GraphqlSemanticFilterOperator::IsNull, + GraphqlSemanticFilterOperator::InPast, + GraphqlSemanticFilterOperator::InFuture, + GraphqlSemanticFilterOperator::IsToday, + GraphqlSemanticFilterOperator::RecentDays, + GraphqlSemanticFilterOperator::WithinDays, + GraphqlSemanticFilterOperator::GteRelative, + GraphqlSemanticFilterOperator::LteRelative, + ]; + + for metadata in [ + StringDateSemantics::graphql_semantic_metadata(), + DateTimeDateSemantics::graphql_semantic_metadata(), + ] { + let metadata = metadata.expect("semantic metadata exists"); + let field = metadata + .fields + .iter() + .find(|field| field.field_name == "observedAt") + .expect("date field exists"); + assert_eq!(field.filter_operators, expected); + assert!( + !field + .filter_operators + .contains(&GraphqlSemanticFilterOperator::In) + ); + assert!( + !field + .filter_operators + .contains(&GraphqlSemanticFilterOperator::Contains) + ); + } +} + +#[derive(Default)] +struct DateSchemaQuery; + +#[graphql_orm::async_graphql::Object] +impl DateSchemaQuery { + async fn accepts_date_filter( + &self, + filter: DateFilter, + ) -> graphql_orm::async_graphql::Result { + filter + .validate() + .map_err(graphql_orm::graphql::errors::graphql_error_from_sqlx)?; + Ok(true) + } + + async fn checks_generated_filter( + &self, + ctx: &graphql_orm::async_graphql::Context<'_>, + filter: CalendarSpatialRecordWhereInput, + ) -> graphql_orm::async_graphql::Result { + let pool = ctx.data_unchecked::(); + CalendarSpatialRecord::query(pool) + .filter(filter) + .count() + .await + .map_err(graphql_orm::graphql::errors::graphql_error_from_sqlx) + } +} + +#[tokio::test] +async fn graphql_schema_requires_between_bounds_and_documents_day_limits() { + let pool = graphql_orm::sqlx::SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory SQLite pool"); + let schema = Schema::build(DateSchemaQuery, EmptyMutation, EmptySubscription) + .data(pool) + .finish(); + let sdl = schema.sdl(); + assert!(sdl.contains("start: String!")); + assert!(sdl.contains("end: String!")); + assert!(sdl.contains("maximum 36,600")); + assert!(sdl.contains("-36,600 through 36,600")); + + let incomplete = schema + .execute("{ acceptsDateFilter(filter: { between: { start: \"2026-08-31\" } }) }") + .await; + assert!(!incomplete.errors.is_empty()); + + for input in [ + "{ acceptsDateFilter(filter: { recentDays: 0 }) }", + "{ acceptsDateFilter(filter: { recentDays: 36601 }) }", + "{ acceptsDateFilter(filter: { withinDays: -1 }) }", + "{ acceptsDateFilter(filter: { gteRelative: { days: 36601 } }) }", + "{ acceptsDateFilter(filter: { between: { start: \"invalid\", end: \"2026-08-31\" } }) }", + "{ acceptsDateFilter(filter: { between: { start: \"2026-09-01\", end: \"2026-08-31\" } }) }", + ] { + let response = schema.execute(input).await; + assert!( + !response.errors.is_empty(), + "input unexpectedly accepted: {input}" + ); + } + + let generated = schema + .execute( + "{ checksGeneratedFilter(filter: { occurredAt: { gteRelative: { days: 36601 } } }) }", + ) + .await; + let error = generated + .errors + .first() + .expect("generated execution rejects invalid filter"); + assert_eq!( + error + .extensions + .as_ref() + .and_then(|extensions| extensions.get("code")) + .and_then(|value| match value { + graphql_orm::async_graphql::Value::String(value) => Some(value.as_str()), + _ => None, + }), + Some("INVALID_INPUT") + ); +} + +#[tokio::test] +async fn programmatic_generated_filters_fail_before_database_work() { + let pool = graphql_orm::sqlx::SqlitePool::connect("sqlite::memory:") + .await + .expect("in-memory SQLite pool"); + let filter = CalendarSpatialRecordWhereInput { + occurred_at: Some(DateFilter { + within_days: Some(0), + ..Default::default() + }), + ..Default::default() + }; + let (conditions, values) = filter.to_sql_conditions(); + assert_eq!(conditions, vec!["1 = 0"]); + assert!(values.is_empty()); + + let error = CalendarSpatialRecord::query(&pool) + .filter(filter) + .fetch_all() + .await + .expect_err("invalid programmatic filter fails before querying a missing table"); + assert_eq!( + OrmPublicError::from_sqlx(&error).code, + OrmErrorCode::InvalidInput + ); +} + +#[test] +fn invalid_nested_filters_render_globally_false() { + let invalid_date = CalendarSpatialRecordWhereInput { + occurred_at: Some(DateFilter { + recent_days: Some(0), + ..Default::default() + }), + ..Default::default() + }; + let valid_id = CalendarSpatialRecordWhereInput { + id: Some(StringFilter { + eq: Some("record-1".to_owned()), + ..Default::default() + }), + ..Default::default() + }; + + for filter in [ + CalendarSpatialRecordWhereInput { + or: Some(vec![invalid_date.clone(), valid_id]), + ..Default::default() + }, + CalendarSpatialRecordWhereInput { + not: Some(Box::new(invalid_date.clone())), + ..Default::default() + }, + ] { + assert!(DatabaseFilter::validate(&filter).is_err()); + let (conditions, values) = filter.to_sql_conditions(); + assert_eq!(conditions, vec!["1 = 0"]); + assert!(values.is_empty()); + } + + let spatial_not = CalendarSpatialRecordWhereInput { + location: Some(point_filter(1.0, 2.0)), + not: Some(Box::new(invalid_date)), + ..Default::default() + }; + assert!(spatial_not.requires_in_memory_filtering(DatabaseBackend::Sqlite)); + let (conditions, values) = spatial_not.to_sql_prefilter_conditions(DatabaseBackend::Sqlite); + assert_eq!(conditions, vec!["1 = 0"]); + assert!(values.is_empty()); +} diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 3f9a0f89..41ba6af0 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1304,7 +1304,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.29.0" +version = "0.30.0" dependencies = [ "agql-auth", "async-graphql", @@ -1372,7 +1372,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.29.0" +version = "0.30.0" dependencies = [ "convert_case", "proc-macro2", @@ -1383,7 +1383,7 @@ dependencies = [ [[package]] name = "graphql-orm-operation-catalog" -version = "0.3.0" +version = "0.4.0" dependencies = [ "serde", "serde_json", diff --git a/docs/reference/graphql-orm/backends.md b/docs/reference/graphql-orm/backends.md index e2ba4aba..3be1a2cb 100644 --- a/docs/reference/graphql-orm/backends.md +++ b/docs/reference/graphql-orm/backends.md @@ -17,7 +17,7 @@ database schema. Schema ownership and migration behavior are controlled by runti ## Features ```toml -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.26.0", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.30.0", default-features = false, features = ["sqlite"] } ``` Available backend features: @@ -50,6 +50,14 @@ Optional non-backend features: The `mssql` feature activates optional `tiberius`, `tokio-util`, and Tokio TCP support. Projects that do not select `mssql` do not build the SQL Server driver path. +Generated calendar-date filters use the selected database's date clock: +PostgreSQL session `CURRENT_DATE`, SQL Server's server-local `GETDATE()`, and +SQLite UTC `date('now')`. These clocks are not silently unified. Configure the +PostgreSQL session or SQL Server deployment clock according to application +policy; the ORM does not hard-code an application timezone. SQLite spatial +fallback evaluates its residual predicates with one UTC date anchor matching +SQLite's date basis. + Normal application setup can stay on `graphql-orm` types: ```rust diff --git a/docs/reference/graphql-orm/macros-and-attributes.md b/docs/reference/graphql-orm/macros-and-attributes.md index 598140fe..1811daa5 100644 --- a/docs/reference/graphql-orm/macros-and-attributes.md +++ b/docs/reference/graphql-orm/macros-and-attributes.md @@ -123,6 +123,36 @@ Validation metadata accepted inside `graphql_orm` is `min`, `max`, `max_length`, `one_of = ["…"]`, and `gte_field`, `gt_field`, `lte_field`, `lt_field` (field-name strings). +### Generated date-filter contract + +`#[filterable(type = "date")]` generates exact `Eq`, `Ne`, `Lt`, `Lte`, `Gt`, +and `Gte` value comparisons plus structured date predicates. Exact values are +bound unchanged. Calendar predicates are sargable half-open ranges and never +wrap the persisted column in a cast or date function: + +| Predicate | Calendar range | +| --- | --- | +| `IsToday` | `[today, tomorrow)` | +| `InPast` | before today | +| `InFuture` | at or after tomorrow | +| `RecentDays(N)` | `[today - (N - 1 days), tomorrow)` | +| `WithinDays(N)` | `[today, today + N days)` | +| `GteRelative(days: d)` | at or after the start of today plus `d` days | +| `LteRelative(days: d)` | before the start of today plus `d + 1` days | + +`DateRangeInput.start` and `.end` are required and inclusive. Both must be +parseable date/timestamp values and the range cannot be reversed. +`RecentDays` and `WithinDays` accept 1 through 36,600. Relative offsets accept +-36,600 through 36,600. Generated filters validate recursively before database +work, including filters constructed directly in Rust; invalid direct SQL +rendering produces a false predicate and execution returns `INVALID_INPUT`. + +PostgreSQL obtains today from session `CURRENT_DATE`, SQL Server from the +server-local date of `GETDATE()`, and SQLite from UTC `date('now')`. SQLite +spatial fallback uses one UTC anchor for its complete in-memory boolean tree +and preserves SQL NULL/unknown behavior. The runtime does not normalize exact +comparison strings or choose an application timezone. + Entity-level expression ordering is intended for computed fields whose SQL is owned by the server declaration rather than accepted from a GraphQL request: diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 14345d4d..7f7c7a88 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -18,12 +18,12 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | -| `graphql-orm` | `0.29.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.30.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.95.10` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.10.2` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.2` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | -| `graphql-orm-macros` | `0.29.0` | `crates/graphql-orm-macros` | `sqlite` | none | -| `graphql-orm-operation-catalog` | `0.3.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | +| `graphql-orm-macros` | `0.30.0` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-operation-catalog` | `0.4.0` | `crates/graphql-orm-operation-catalog` | none | `graphql-orm-router-protocol` (optional) | | `graphql-orm-router` | `0.5.0` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none | | `graphql-orm-storage` | `0.6.2` | `crates/graphql-orm-storage` | `local` | none |