diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8f54e715..8a9a38b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,33 @@ jobs: - run: cargo clippy -p graphql-orm --all-targets -- -D warnings - run: RUSTDOCFLAGS="-D warnings" cargo doc -p graphql-orm -p graphql-orm-macros --no-deps + orm-text-group-pages: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: Execute complete SQLite groups and unchanged aggregate behavior + run: cargo test -p graphql-orm --locked --no-default-features --features sqlite --lib --test complete_group_pages --test grouped_aggregates + - name: Execute the dependency-minimal private repository example + run: cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example complete_events + - name: Verify unsupported profiles before pool I/O + run: | + cargo test -p graphql-orm --locked --no-default-features --features postgres --lib --test complete_group_pages_unsupported + cargo test -p graphql-orm --locked --no-default-features --features mssql --lib --test complete_group_pages_unsupported + - name: Execute existing aggregates on a test-owned PostgreSQL container + run: cargo test -p graphql-orm --locked --no-default-features --features postgres --test grouped_aggregates_postgres -- --ignored --test-threads=1 + - name: Check explicit backend Clippy and Rustdoc lanes + run: | + for backend in sqlite postgres mssql; do + cargo clippy -p graphql-orm -p graphql-orm-macros --locked --no-default-features --features "$backend" --lib --test complete_group_pages --test complete_group_pages_unsupported -- -D warnings + RUSTDOCFLAGS="-D warnings" cargo doc -p graphql-orm -p graphql-orm-macros --locked --no-default-features --features "$backend" --no-deps + done + cargo clippy --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --all-targets -- -D warnings + companions: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 13ec58a3..194ff840 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ title: "Changelog" kind: reference status: active owner: workspace-maintainers -last_reviewed: 2026-09-30 +last_reviewed: 2026-10-01 review_by: 2027-02-01 supersedes: [] --- @@ -14,6 +14,24 @@ 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.35.0 - 2026-10-01 + +Companion macros crate: `graphql-orm-macros` **0.35.0** (alignment only). + +- Add complete bounded SQLite text-group enumeration through the existing + aggregate builder, with native group equality, stable NOCASE/BINARY ordering, + typed host-context cursors and continuation after grouping. +- Apply current complete SQL row visibility before grouping, retaining field/entity + authorization and fail-closed residual policies; bound pages to 1–1,000 plus + one lookahead. Keep ordinary aggregate APIs/GraphQL SDL/cursors unchanged. +- Add a standalone private repository consumer example without query SQL/direct + async-graphql and SQLite execution/unsupported-backend regressions. Other scalar + groups/backends and computed SQL Server summaries remain separate capabilities. + +This static feature remains independent of runtime A–D. The 0.35.0 package +identity preserves the independently reviewed 0.33.4/0.33.5 compatibility fixes; +release coordination does not imply completion of the broader static query plan. + ## 0.33.5 - 2026-10-01 Companion macros crate: `graphql-orm-macros` **0.33.5**. diff --git a/Cargo.lock b/Cargo.lock index 6d84eeb3..f9e3195e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3146,7 +3146,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.5" +version = "0.35.0" dependencies = [ "agql-auth", "async-graphql", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.5" +version = "0.35.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 03f19125..cda7d0eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ 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.33.5", default-features = false } +graphql-orm = { path = "crates/graphql-orm", version = "0.35.0", default-features = false } graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.15.0" } 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.4.0" } diff --git a/MIGRATION.md b/MIGRATION.md index fda8ff0b..5828a33e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -3,7 +3,7 @@ title: "Migration Guide" kind: reference status: active owner: workspace-maintainers -last_reviewed: 2026-09-30 +last_reviewed: 2026-10-01 review_by: 2027-02-01 supersedes: [] --- @@ -13,6 +13,27 @@ 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. +## Complete text-group pages (0.35.0) + +Adopt aligned ORM/macros 0.35.0 after owner review/publication. This is an additive +static aggregate API with no stored-data, existing aggregate SDL or cursor-format +migration. Loop over `fetch_group_page` with a bounded `group_limit`, one text +grouping field and trusted host context. Existing bounded `fetch` remains unchanged; +use pages when the complete distinct set exceeds the group cap. + +The initial page profile is SQLite only, with native group equality and explicit +BINARY/NOCASE ordering. Complete SQL row policies are reapplied before grouping; +residual callbacks cannot authorize groups after aggregation. Include verified +identity/public revision in host context and protect the bounded cursor JSON in +an application-owned envelope when required. This fingerprint is not authority. +See [typed aggregates](docs/reference/graphql-orm/typed-aggregates.md) and its +compiled private consumer example for exact null, collation and continuation rules. + +Other backends/scalar/multi-key groups fail before query I/O for this new method. +Existing PostgreSQL/MSSQL aggregate behavior remains unchanged. Computed summaries, +joined/computed source reads and private generated GraphQL views remain independent +work; no runtime-schema/dynamic GraphQL adoption is required for this API. + ## 0.33.4 to 0.33.5: authoritative generated relationship access Adopt runtime and macros 0.33.5 together. Generated relationship resolvers no diff --git a/crates/graphql-orm-macros/Cargo.toml b/crates/graphql-orm-macros/Cargo.toml index 90451168..29c4e1d4 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.33.5" +version = "0.35.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 bc19ad21..c58a01ea 100644 --- a/crates/graphql-orm-macros/README.md +++ b/crates/graphql-orm-macros/README.md @@ -3,7 +3,7 @@ title: "graphql-orm-macros" kind: reference status: active owner: graphql-orm-macros-maintainers -last_reviewed: 2026-09-30 +last_reviewed: 2026-10-01 review_by: 2027-02-01 supersedes: [] --- @@ -16,13 +16,13 @@ macro/runtime versions aligned: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.33.5", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.35.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.33.5", default-features = false, features = ["sqlite"] } +graphql-orm-macros = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.35.0", default-features = false, features = ["sqlite"] } ``` The direct dependency still requires a compatible `graphql-orm` runtime in the @@ -169,3 +169,7 @@ for compiled plain Rust aggregate calls without a direct async-graphql dependenc Version 0.33.3 gives the repository aggregate compatibility fix a distinct package identity after the independent 0.33.2 fixture maintenance. Generated behavior is unchanged from the reviewed aggregate fix. + +Version 0.35.0 aligns with the additive core text-group paging API. Existing generated +aggregate enums/builders and ordinary GraphQL aggregate SDL remain unchanged; +repository-only consumers still need no direct async-graphql dependency. diff --git a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock index 580c89b6..f65eb9db 100644 --- a/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock +++ b/crates/graphql-orm-macros/fixtures/cross-crate-relations/Cargo.lock @@ -1028,7 +1028,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.5" +version = "0.35.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1054,7 +1054,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.5" +version = "0.35.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/Cargo.toml b/crates/graphql-orm/Cargo.toml index b86ba1b3..b71ec7a8 100644 --- a/crates/graphql-orm/Cargo.toml +++ b/crates/graphql-orm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm" -version = "0.33.5" +version = "0.35.0" edition = "2024" description = "Runtime support crate for graphql-orm-macros" license = "MIT" @@ -68,7 +68,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.33.5", default-features = false } +graphql-orm-macros = { path = "../graphql-orm-macros", version = "0.35.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 7fce0175..5404741a 100644 --- a/crates/graphql-orm/README.md +++ b/crates/graphql-orm/README.md @@ -3,7 +3,7 @@ title: "graphql-orm" kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-09-30 +last_reviewed: 2026-10-01 review_by: 2027-02-01 supersedes: [] --- @@ -29,7 +29,7 @@ backend: ```toml [dependencies] -graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.33.5", default-features = false, features = ["sqlite"] } +graphql-orm = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.35.0", default-features = false, features = ["sqlite"] } ``` This unpublished package has no docs.rs release. Use this Git README and the @@ -187,3 +187,8 @@ compiles scalar and composite keys on SQLite/PostgreSQL/MSSQL; its [owned-backend tests](tests/fixtures/repository-aggregate-consumer/tests/owned_backend.rs) execute grouped aggregates and denial before SQL on disposable SQLite/PostgreSQL. Ordinary `GraphQLEntity` aggregate SDL remains unchanged. + +Complete bounded SQLite text-group pages reuse generated aggregate enums and the +existing aggregate builder. See [typed aggregates](../../docs/reference/graphql-orm/typed-aggregates.md) +and the SQL-free private consumer example for native distinct identities, current +SQL visibility and typed continuation. Existing aggregate SDL/cursors are unchanged. diff --git a/crates/graphql-orm/src/db.rs b/crates/graphql-orm/src/db.rs index 5a7a23c1..99c85d60 100644 --- a/crates/graphql-orm/src/db.rs +++ b/crates/graphql-orm/src/db.rs @@ -975,7 +975,7 @@ impl Database { } /// Observe SQL text (without bind values) and database rows fetched by - /// EntityQuery reads. Intended for diagnostics and query-behavior tests. + /// EntityQuery and aggregate-page reads. Intended for diagnostics and query-behavior tests. pub fn with_read_query_observer( mut self, observer: impl crate::graphql::orm::ReadQueryObserver + 'static, diff --git a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs index e9dbd36d..ba3f2c07 100644 --- a/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs +++ b/crates/graphql-orm/src/graphql/orm/authorized_pagination.rs @@ -143,7 +143,6 @@ impl ReadVisibility { _ => None, } } - pub fn requires_residual_checks(&self) -> bool { matches!(self, Self::CallbackOnly | Self::Prefilter(_)) } diff --git a/crates/graphql-orm/src/graphql/orm/query.rs b/crates/graphql-orm/src/graphql/orm/query.rs index 9c702946..274d5f1f 100644 --- a/crates/graphql-orm/src/graphql/orm/query.rs +++ b/crates/graphql-orm/src/graphql/orm/query.rs @@ -1928,13 +1928,31 @@ fn aggregate_operator_sql(operator: super::GraphqlAggregateOperator) -> &'static fn render_grouped_aggregate_query( dialect: DatabaseBackend, query: &GroupedAggregateSqlQuery, +) -> RenderedQuery { + render_grouped_aggregate_query_page(dialect, query, None) +} + +fn render_grouped_aggregate_query_page( + dialect: DatabaseBackend, + query: &GroupedAggregateSqlQuery, + page: Option<(AggregateGroupOrder, Option<&AggregateGroupCursor>)>, ) -> RenderedQuery { let quoted_groups = query .groups .iter() .map(|field| dialect.quote_identifier_path(field.column_name)) .collect::>(); - let mut projections = quoted_groups + // Preserve native GROUP BY equality. A deterministic original representative + // makes continuation stable even if native equality folds text variants. + let ordered_groups = if page.is_some() { + quoted_groups + .iter() + .map(|column| format!("MIN({column} COLLATE BINARY)")) + .collect::>() + } else { + quoted_groups.clone() + }; + let mut projections = ordered_groups .iter() .enumerate() .map(|(index, column)| { @@ -1958,15 +1976,47 @@ fn render_grouped_aggregate_query( if !quoted_groups.is_empty() { sql.push_str(" GROUP BY "); sql.push_str("ed_groups.join(", ")); + if let Some((order, Some(cursor))) = page { + let column = &ordered_groups[0]; + let boundary = match &cursor.key { + None => FilterExpression::trusted_fragment(format!("{column} IS NOT NULL"), vec![]), + Some(key) => match order { + AggregateGroupOrder::Binary => FilterExpression::trusted_fragment( + format!("{column} COLLATE BINARY > ?"), + vec![SqlValue::String(key.clone())], + ), + AggregateGroupOrder::SqliteNoCase => FilterExpression::trusted_fragment( + format!( + "({column} COLLATE NOCASE > ? OR ({column} COLLATE NOCASE = ? AND {column} COLLATE BINARY > ?))" + ), + vec![SqlValue::String(key.clone()); 3], + ), + }, + }; + sql.push_str(" HAVING "); + sql.push_str(&render_filter_expression( + dialect, + &boundary, + &mut next_index, + &mut values, + )); + } sql.push_str(" ORDER BY "); sql.push_str( - "ed_groups + &ordered_groups .iter() .flat_map(|column| { - [ - format!("CASE WHEN {column} IS NULL THEN 0 ELSE 1 END ASC"), - format!("{column} ASC"), - ] + let mut order = + vec![format!("CASE WHEN {column} IS NULL THEN 0 ELSE 1 END ASC")]; + if let Some((mode, _)) = page { + if mode == AggregateGroupOrder::SqliteNoCase { + order.push(format!("{column} COLLATE NOCASE ASC")); + } + order.push(format!("{column} COLLATE BINARY ASC")); + } else { + order.push(format!("{column} ASC")); + } + order }) .collect::>() .join(", "), @@ -1976,6 +2026,102 @@ fn render_grouped_aggregate_query( RenderedQuery { sql, values } } +/// Ascending SQLite text-group comparison. Group equality remains native. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AggregateGroupOrder { + /// SQLite BINARY byte comparison, with SQL NULL first. + #[default] + Binary, + /// SQLite NOCASE comparison (ASCII folding), then BINARY for deterministic ties. + SqliteNoCase, +} + +/// Request-local settings for complete bounded string-group enumeration. +#[derive(Clone, Debug)] +pub struct AggregateGroupPageOptions { + /// Comparison applied to grouping representatives; not to group equality. + pub order: AggregateGroupOrder, + /// Apply SQL TRIM(key) <> '' before grouping. Original nonblank keys are retained. + pub exclude_blank: bool, + /// Trusted host authorization partition/public revision, never an authority grant. + pub context: String, +} + +/// Framework-neutral typed boundary. Hosts may wrap/protect its bounded JSON. +/// No existing static or runtime cursor format is changed. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct AggregateGroupCursor { + format_version: u8, + fingerprint: String, + key: Option, +} +impl AggregateGroupCursor { + /// Parse bounded JSON with strict shape checking before allocating an unbounded cursor. + pub fn from_json(text: &str) -> crate::Result { + if text.len() > 256 * 1024 { + return Err(invalid_aggregate("invalid aggregate group cursor")); + } + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + format_version: u8, + fingerprint: String, + key: serde_json::Value, + } + let wire: Wire = serde_json::from_str(text) + .map_err(|_| invalid_aggregate("invalid aggregate group cursor"))?; + let cursor = Self { + format_version: wire.format_version, + fingerprint: wire.fingerprint, + key: match wire.key { + serde_json::Value::Null => None, + serde_json::Value::String(key) => Some(key), + _ => return Err(invalid_aggregate("invalid aggregate group cursor")), + }, + }; + cursor.validate()?; + Ok(cursor) + } + /// Serialize the complete boundary for a host-owned cursor envelope. + pub fn to_json(&self) -> crate::Result { + self.validate()?; + let text = serde_json::to_string(self) + .map_err(|_| invalid_aggregate("invalid aggregate group cursor"))?; + if text.len() > 256 * 1024 { + return Err(invalid_aggregate("invalid aggregate group cursor")); + } + Ok(text) + } + /// Exact original grouping representative; None means SQL NULL. + pub fn key(&self) -> Option<&str> { + self.key.as_deref() + } + fn validate(&self) -> crate::Result<()> { + if self.format_version != 1 + || self.fingerprint.len() != 64 + || !self + .fingerprint + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) + || self.key.as_ref().is_some_and(|key| key.len() > 65536) + { + return Err(invalid_aggregate("invalid aggregate group cursor")); + } + Ok(()) + } +} + +/// At most group_limit result groups, with a database lookahead and typed continuation. +#[derive(Clone, Debug)] +pub struct AggregateGroupPage { + /// Ordered groups with exact original representatives and optional metric values. + pub rows: Vec, + /// True only when the database returned one additional authorized group. + pub has_next_page: bool, + /// Last returned boundary, including on the final nonempty page. + pub end_cursor: Option, +} + /// Policy-aware typed multi-expression aggregate builder. pub struct GroupedAggregateQuery<'a, T, F, A, B: OrmBackend = DefaultBackend> where @@ -2124,6 +2270,7 @@ where async fn authorize( &self, graphql_context: Option<&async_graphql::Context<'_>>, + sql_visibility: bool, ) -> crate::Result<()> { let metadata = T::metadata(); let surface = if graphql_context.is_some() { @@ -2141,7 +2288,7 @@ where ) .await .map_err(crate::graphql::errors::sqlx_error_from_graphql)?; - if self.db.row_policy().is_some() { + if self.db.row_policy().is_some() && !sql_visibility { return Err(invalid_aggregate( "aggregate queries require database row security or a typed SQL-renderable filter", )); @@ -2204,6 +2351,160 @@ where Ok(()) } + /// Enumerate all distinct string groups through bounded SQLite pages. + /// + /// Initial capability: exactly one TEXT-affinity group, ascending BINARY/NOCASE, + /// optional existing metrics and fully SQL-authorized rows. Other backends, + /// scalar groups and residual policies fail before the aggregate SELECT. + /// The existing group_limit bounds each page (1..=1000 plus one lookahead). + pub async fn fetch_group_page( + self, + options: AggregateGroupPageOptions, + after: Option<&AggregateGroupCursor>, + ) -> crate::Result + where + T: 'static, + { + if B::DIALECT != DatabaseBackend::Sqlite || self.groups.len() != 1 { + return Err(invalid_aggregate( + "unsupported aggregate group pagination capability", + )); + } + let field = self.groups[0]; + let text_storage = T::columns() + .iter() + .find(|c| c.name == field.column_name) + .is_some_and(|c| { + let ty = c.sql_type.to_ascii_uppercase(); + ty.contains("TEXT") || ty.contains("CHAR") || ty.contains("CLOB") + }); + if field.kind != AggregateFieldKind::Text || !text_storage { + return Err(invalid_aggregate( + "aggregate group pages require text storage", + )); + } + if !(1..=1000).contains(&self.group_limit) + || self + .db + .pagination_config() + .max_limit + .is_some_and(|max| self.group_limit > max) + || options.context.is_empty() + || options.context.len() > 4096 + { + return Err(invalid_aggregate( + "invalid aggregate group page bounds or context", + )); + } + if let Some(cursor) = after { + cursor.validate()?; + if cursor.key.is_none() && !field.nullable { + return Err(invalid_aggregate("invalid aggregate group cursor")); + } + } + if let Some(filter) = &self.filter { + filter.validate()?; + } + self.authorize(None, true).await?; + let visibility = self + .db + .read_visibility::(None, super::EntityAccessSurface::Repository) + .await + .map_err(crate::graphql::errors::sqlx_error_from_graphql)?; + if visibility.requires_residual_checks() { + return Err(invalid_aggregate( + "aggregate group pages require complete SQL row authorization", + )); + } + let mut filters: Vec = self + .filter + .as_ref() + .and_then(DatabaseFilter::to_filter_expression) + .into_iter() + .collect(); + if let Some(predicate) = visibility.predicate_expression() { + filters.push(predicate.clone()); + } + if options.exclude_blank { + filters.push(FilterExpression::trusted_fragment( + format!( + "TRIM({}) <> ?", + B::DIALECT.quote_identifier(field.column_name) + ), + vec![SqlValue::String(String::new())], + )); + } + let query = GroupedAggregateSqlQuery { + table: T::TABLE_NAME, + groups: self.groups.clone(), + metrics: self.metrics.clone(), + filter: if filters.is_empty() { + None + } else { + Some(FilterExpression::And(filters)) + }, + group_limit: self.group_limit + 1, + }; + use sha2::Digest; + let identity = format!( + "aggregate-groups-v1:{:?}", + ( + B::DIALECT, + T::metadata().entity_name, + query.table, + &query.groups, + &query.metrics, + &query.filter, + options.order, + options.exclude_blank, + &options.context, + self.auth.as_ref().map(DbAuthContext::canonical_key) + ) + ); + let fingerprint = format!("{:x}", sha2::Sha256::digest(identity.as_bytes())); + if after.is_some_and(|cursor| cursor.fingerprint != fingerprint) { + return Err(invalid_aggregate("invalid aggregate group cursor")); + } + let rendered = + render_grouped_aggregate_query_page(B::DIALECT, &query, Some((options.order, after))); + let fetched = B::fetch_rows_with_auth( + self.db.pool(), + &rendered.sql, + &rendered.values, + self.auth.as_ref(), + ) + .await?; + self.db.observe_read(&rendered.sql, fetched.len()); + let has_next_page = fetched.len() > self.group_limit as usize; + let rows: Vec = fetched + .iter() + .take(self.group_limit as usize) + .map(|row| decode_aggregate_row::(row, &query.groups, &query.metrics)) + .collect::>()?; + let end_cursor = rows + .last() + .map(|row| { + let key = match &row.groups[0].value { + AggregateValue::Null => None, + AggregateValue::Text(key) => Some(key.clone()), + _ => return Err(invalid_aggregate("invalid aggregate group value")), + }; + let cursor = AggregateGroupCursor { + format_version: 1, + fingerprint, + key, + }; + cursor.to_json()?; + Ok(cursor) + }) + .transpose()?; + Ok(AggregateGroupPage { + rows, + has_next_page, + end_cursor, + }) + } + /// Executes aggregation in the database and decodes bounded result groups. pub async fn fetch(self) -> crate::Result> { self.fetch_authorized(None).await @@ -2230,7 +2531,7 @@ where if let Some(filter) = &self.filter { filter.validate()?; } - self.authorize(context).await?; + self.authorize(context, false).await?; let filter = self .filter .as_ref() diff --git a/crates/graphql-orm/tests/complete_group_pages.rs b/crates/graphql-orm/tests/complete_group_pages.rs new file mode 100644 index 00000000..b0b2b0ec --- /dev/null +++ b/crates/graphql-orm/tests/complete_group_pages.rs @@ -0,0 +1,578 @@ +#![cfg(feature = "sqlite")] + +use graphql_orm::prelude::*; +use std::sync::{Arc, Mutex}; + +#[derive(RepositoryEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[repository_entity( + backend = "sqlite", + table = "private_history", + plural = "PrivateHistory" +)] +struct PrivateHistory { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + #[filterable(type = "string")] + tenant: String, + #[filterable(type = "string")] + event: String, + units: i64, + #[graphql_orm(private, sensitive)] + custody: Option, +} + +#[derive(RepositoryEntity, Clone, Debug, serde::Serialize, serde::Deserialize)] +#[repository_entity( + backend = "sqlite", + table = "nullable_groups", + plural = "NullableGroups" +)] +struct NullableGroup { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + category: Option, + units: i64, +} + +#[derive(Clone)] +struct Reads(Arc>>); +impl ReadQueryObserver for Reads { + fn on_read(&self, sql: &str, fetched_rows: usize) { + self.0.lock().unwrap().push((sql.into(), fetched_rows)); + } +} + +fn options(context: &str, exclude_blank: bool) -> AggregateGroupPageOptions { + AggregateGroupPageOptions { + order: AggregateGroupOrder::SqliteNoCase, + exclude_blank, + context: context.into(), + } +} +fn tenant(value: &str) -> PrivateHistoryWhereInput { + PrivateHistoryWhereInput { + tenant: Some(StringFilter { + eq: Some(value.into()), + ..Default::default() + }), + ..Default::default() + } +} +async fn database() -> graphql_orm::Result> { + let db = Database::::connect_sqlite("sqlite::memory:").await?; + let plan = db + .schema() + .plan_migration_to_entities( + "groups", + "private fixture", + &[PrivateHistory::metadata(), NullableGroup::metadata()], + ) + .await?; + db.schema() + .apply_migration(&plan, Default::default()) + .await?; + Ok(db) +} +async fn insert( + db: &Database, + id: &str, + partition: &str, + event: &str, +) -> graphql_orm::Result<()> { + PrivateHistory::insert( + db, + CreatePrivateHistoryInput { + id: id.into(), + tenant: partition.into(), + event: event.into(), + units: 1, + custody: None, + }, + ) + .await?; + Ok(()) +} + +#[tokio::test] +async fn complete_native_distinct_set_beyond_cap_preserves_case_padding_and_tenants() +-> graphql_orm::Result<()> { + let db = database().await?; + for index in 0..230 { + let event = format!("Event-{index:03}"); + insert(&db, &format!("a-{index}"), "alpha", &event).await?; + insert(&db, &format!("duplicate-{index}"), "alpha", &event).await?; + } + for (index, event) in ["Case", "case", " CASE ", "", " ", "'_%\\", "\t"] + .iter() + .enumerate() + { + insert(&db, &format!("special-{index}"), "alpha", event).await?; + } + insert(&db, "other", "beta", "other-tenant").await?; + let observed = Reads(Arc::new(Mutex::new(vec![]))); + let db = db.with_read_query_observer(observed.clone()); + let mut cursor = None; + let mut actual = Vec::new(); + loop { + let page = PrivateHistory::aggregate(&db) + .filter(tenant("alpha")) + .group_by(PrivateHistoryAggregateField::Event)? + .group_limit(17)? + .fetch_group_page(options("tenant-alpha:revision-1", true), cursor.as_ref()) + .await?; + assert!(page.rows.len() <= 17); + for row in &page.rows { + match &row.groups[0].value { + AggregateValue::Text(value) => actual.push(value.clone()), + other => panic!("unexpected group {other:?}"), + } + assert!(row.metrics.is_empty()); + } + cursor = page + .end_cursor + .map(|cursor| AggregateGroupCursor::from_json(&cursor.to_json().unwrap()).unwrap()); + if !page.has_next_page { + break; + } + } + // Test-only SQL oracle: exact original equality and default SQLite TRIM. + // The executable consumer example contains no query SQL. + let expected: Vec = sqlx::query_scalar("SELECT DISTINCT event FROM private_history WHERE tenant=? AND TRIM(event) <> '' ORDER BY event COLLATE NOCASE ASC, event COLLATE BINARY ASC") + .bind("alpha").fetch_all(db.pool()).await?; + assert_eq!(actual, expected); + assert_eq!(actual.len(), 235); + assert!(actual.contains(&" CASE ".into())); + assert!( + actual.contains(&"\t".into()), + "SQL TRIM only removes ordinary spaces" + ); + let reads = observed.0.lock().unwrap(); + assert_eq!(reads.len(), 14); + assert!(reads.iter().all(|(_, count)| *count <= 18)); + assert!( + reads + .iter() + .skip(1) + .all(|(sql, _)| sql.contains(" HAVING ")) + ); + Ok(()) +} + +#[tokio::test] +async fn nullable_and_native_nocase_groups_keep_complete_metrics_after_boundary() +-> graphql_orm::Result<()> { + let db = database().await?; + // This externally supplied synthetic table has native NOCASE group equality. + sqlx::query("DROP TABLE nullable_groups") + .execute(db.pool()) + .await?; + sqlx::query("CREATE TABLE nullable_groups(id TEXT PRIMARY KEY NOT NULL, category TEXT COLLATE NOCASE, units INTEGER NOT NULL)").execute(db.pool()).await?; + for (index, category) in [ + None, + None, + Some(""), + Some("Alpha"), + Some("alpha"), + Some("Beta"), + Some("beta"), + ] + .into_iter() + .enumerate() + { + NullableGroup::insert( + &db, + CreateNullableGroupInput { + id: index.to_string(), + category: category.map(str::to_owned), + units: 1, + }, + ) + .await?; + } + let mut cursor = None; + let mut rows = Vec::new(); + loop { + let page = NullableGroup::aggregate(&db) + .group_by(NullableGroupAggregateField::Category)? + .sum(NullableGroupAggregateField::Units)? + .group_limit(1)? + .fetch_group_page(options("nullable-owner", false), cursor.as_ref()) + .await?; + assert_eq!(page.rows.len(), 1); + if cursor.is_none() { + assert_eq!(page.end_cursor.as_ref().unwrap().key(), None); + } + rows.extend(page.rows); + cursor = page.end_cursor; + if !page.has_next_page { + break; + } + } + assert_eq!( + rows.iter() + .map(|row| row.groups[0].value.clone()) + .collect::>(), + vec![ + AggregateValue::Null, + AggregateValue::Text("".into()), + AggregateValue::Text("Alpha".into()), + AggregateValue::Text("Beta".into()) + ] + ); + assert_eq!( + rows.iter() + .map(|row| row.metrics[0].value.clone()) + .collect::>(), + vec![ + AggregateValue::Integral(2), + AggregateValue::Integral(1), + AggregateValue::Integral(2), + AggregateValue::Integral(2) + ] + ); + Ok(()) +} + +struct SqlVisibility { + tenant: Arc>, + residual: bool, +} +impl RowPolicy for SqlVisibility { + fn read_visibility<'a>( + &'a self, + _ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + _entity: &'static str, + _policy: Option<&'static str>, + surface: EntityAccessSurface, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + assert_eq!(surface, EntityAccessSurface::Repository); + let filter = tenant(&self.tenant.lock().unwrap()); + Box::pin(async move { + let predicate = ReadPredicate::from_filter::(&filter)?; + Ok(if self.residual { + ReadVisibility::Prefilter(predicate) + } else { + ReadVisibility::Complete(predicate) + }) + }) + } + fn can_read_row<'a>( + &'a self, + _ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + _entity: &'static str, + _policy: Option<&'static str>, + _surface: EntityAccessSurface, + _row: &'a (dyn std::any::Any + Send + Sync), + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { panic!("group authorization must be complete before aggregation") }) + } + fn can_write_row<'a>( + &'a self, + _ctx: Option<&'a async_graphql::Context<'_>>, + _db: &'a Database, + _entity: &'static str, + _policy: Option<&'static str>, + _surface: EntityAccessSurface, + _row: &'a (dyn std::any::Any + Send + Sync), + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(true) }) + } +} + +#[tokio::test] +async fn current_complete_visibility_applies_before_groups_and_stale_cursor_fails_before_sql() +-> graphql_orm::Result<()> { + let mut db = database().await?; + for (id, owner, event) in [ + ("a1", "alpha", "A"), + ("a2", "alpha", "B"), + ("a3", "alpha", "B"), + ("b", "beta", "Other"), + ] { + insert(&db, id, owner, event).await?; + } + let owner = Arc::new(Mutex::new("alpha".to_string())); + db.set_row_policy(SqlVisibility { + tenant: owner.clone(), + residual: false, + }); + let observed = Reads(Arc::new(Mutex::new(vec![]))); + let mut db = db.with_read_query_observer(observed.clone()); + let page = PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Event)? + .count_rows()? + .group_limit(1)? + .fetch_group_page(options("current-auth", true), None) + .await?; + assert!(page.has_next_page); + assert_eq!( + page.rows[0].groups[0].value, + AggregateValue::Text("A".into()) + ); + let cursor = page.end_cursor.unwrap(); + let page = PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Event)? + .count_rows()? + .group_limit(1)? + .fetch_group_page(options("current-auth", true), Some(&cursor)) + .await?; + assert!(!page.has_next_page); + assert_eq!(page.rows[0].metrics[0].value, AggregateValue::Count(2)); + *owner.lock().unwrap() = "beta".into(); + assert!( + PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Event)? + .count_rows()? + .group_limit(1)? + .fetch_group_page(options("current-auth", true), Some(&cursor)) + .await + .is_err() + ); + assert_eq!(observed.0.lock().unwrap().len(), 2); + let page = PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Event)? + .fetch_group_page(options("current-auth", true), None) + .await?; + assert_eq!( + page.rows[0].groups[0].value, + AggregateValue::Text("Other".into()) + ); + db.set_row_policy(SqlVisibility { + tenant: owner, + residual: true, + }); + assert!( + PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Event)? + .fetch_group_page(options("current-auth", true), None) + .await + .is_err() + ); + assert_eq!(observed.0.lock().unwrap().len(), 3); + Ok(()) +} + +#[tokio::test] +async fn cursor_shape_context_order_filter_and_limits_are_checked_before_read() +-> graphql_orm::Result<()> { + let db = database().await?; + insert(&db, "a", "alpha", "A").await?; + insert(&db, "b", "alpha", "B").await?; + let observed = Reads(Arc::new(Mutex::new(vec![]))); + let db = db.with_read_query_observer(observed.clone()); + let first = PrivateHistory::aggregate(&db) + .filter(tenant("alpha")) + .group_by(PrivateHistoryAggregateField::Event)? + .group_limit(1)? + .fetch_group_page(options("alpha:r1", true), None) + .await?; + let cursor = first.end_cursor.unwrap(); + for opts in [ + options("beta:r1", true), + options("alpha:r2", true), + options("alpha:r1", false), + AggregateGroupPageOptions { + order: AggregateGroupOrder::Binary, + ..options("alpha:r1", true) + }, + ] { + assert!( + PrivateHistory::aggregate(&db) + .filter(tenant("alpha")) + .group_by(PrivateHistoryAggregateField::Event)? + .fetch_group_page(opts, Some(&cursor)) + .await + .is_err() + ); + } + assert!( + PrivateHistory::aggregate(&db) + .filter(tenant("beta")) + .group_by(PrivateHistoryAggregateField::Event)? + .fetch_group_page(options("alpha:r1", true), Some(&cursor)) + .await + .is_err() + ); + let mut wire: serde_json::Value = serde_json::from_str(&cursor.to_json()?).unwrap(); + for bad in [ + serde_json::json!(3), + serde_json::json!(true), + serde_json::json!(["A"]), + serde_json::json!({"unexpected":"A"}), + serde_json::json!("x".repeat(65537)), + ] { + wire["key"] = bad; + assert!(AggregateGroupCursor::from_json(&wire.to_string()).is_err()); + } + wire["key"] = serde_json::Value::Null; + let wrong_null = AggregateGroupCursor::from_json(&wire.to_string())?; + assert!( + PrivateHistory::aggregate(&db) + .filter(tenant("alpha")) + .group_by(PrivateHistoryAggregateField::Event)? + .fetch_group_page(options("alpha:r1", true), Some(&wrong_null)) + .await + .is_err() + ); + wire.as_object_mut().unwrap().remove("key"); + assert!(AggregateGroupCursor::from_json(&wire.to_string()).is_err()); + assert!(AggregateGroupCursor::from_json(&"x".repeat(256 * 1024 + 1)).is_err()); + assert!( + PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Units)? + .fetch_group_page(options("alpha", true), None) + .await + .is_err() + ); + assert!( + PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Event)? + .group_limit(101) + .is_err() + ); + let db = db.with_pagination_config(PaginationConfig::unbounded()); + assert!( + PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Event)? + .group_limit(1001)? + .fetch_group_page(options("alpha", true), None) + .await + .is_err() + ); + assert_eq!(observed.0.lock().unwrap().len(), 1); + Ok(()) +} + +struct DenyField(&'static str); +impl FieldPolicy for DenyField { + fn can_read_field<'a>( + &'a self, + _ctx: &'a async_graphql::Context<'_>, + _db: &'a Database, + _entity: &'static str, + _field: &'static str, + _policy: Option<&'static str>, + _record: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(false) }) + } + fn can_write_field<'a>( + &'a self, + _ctx: &'a async_graphql::Context<'_>, + _db: &'a Database, + _entity: &'static str, + _field: &'static str, + _policy: Option<&'static str>, + _record: Option<&'a (dyn std::any::Any + Send + Sync)>, + _value: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async { Ok(false) }) + } + fn can_read_repository_field<'a>( + &'a self, + _access: Option>, + _db: &'a Database, + _entity: &'static str, + field: &'static str, + _policy: Option<&'static str>, + _record: Option<&'a (dyn std::any::Any + Send + Sync)>, + ) -> graphql_orm::futures::future::BoxFuture<'a, async_graphql::Result> { + Box::pin(async move { Ok(field != self.0) }) + } +} + +#[tokio::test] +async fn denied_group_metric_filter_and_entity_issue_no_aggregate_query() -> graphql_orm::Result<()> +{ + let mut db = database().await?; + insert(&db, "a", "alpha", "A").await?; + let observed = Reads(Arc::new(Mutex::new(vec![]))); + db = db.with_read_query_observer(observed.clone()); + db.set_field_policy(DenyField("event")); + assert!( + PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Event)? + .fetch_group_page(options("alpha", false), None) + .await + .is_err() + ); + db.set_field_policy(DenyField("units")); + assert!( + PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Event)? + .sum(PrivateHistoryAggregateField::Units)? + .fetch_group_page(options("alpha", false), None) + .await + .is_err() + ); + db.set_field_policy(DenyField("tenant")); + assert!( + PrivateHistory::aggregate(&db) + .filter(tenant("alpha")) + .group_by(PrivateHistoryAggregateField::Event)? + .fetch_group_page(options("alpha", false), None) + .await + .is_err() + ); + db.set_entity_policy(ScopeEntityPolicy::authenticated()); + assert!( + PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Event)? + .fetch_group_page(options("alpha", false), None) + .await + .is_err() + ); + assert!(observed.0.lock().unwrap().is_empty()); + Ok(()) +} + +#[tokio::test] +async fn thousand_group_page_uses_one_bounded_lookahead() -> graphql_orm::Result<()> { + let db = database() + .await? + .with_pagination_config(PaginationConfig::legacy()); + for index in 0..1001 { + insert( + &db, + &index.to_string(), + "alpha", + &format!("Event-{index:04}"), + ) + .await?; + } + let observed = Reads(Arc::new(Mutex::new(vec![]))); + let db = db.with_read_query_observer(observed.clone()); + let first = PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Event)? + .group_limit(1000)? + .fetch_group_page(options("alpha", true), None) + .await?; + assert_eq!(first.rows.len(), 1000); + assert!(first.has_next_page); + let last = PrivateHistory::aggregate(&db) + .group_by(PrivateHistoryAggregateField::Event)? + .group_limit(1000)? + .fetch_group_page(options("alpha", true), first.end_cursor.as_ref()) + .await?; + assert_eq!(last.rows.len(), 1); + assert!(!last.has_next_page); + assert_eq!( + last.rows[0].groups[0].value, + AggregateValue::Text("Event-1000".into()) + ); + assert_eq!( + observed + .0 + .lock() + .unwrap() + .iter() + .map(|(_, rows)| *rows) + .collect::>(), + vec![1001, 1] + ); + Ok(()) +} diff --git a/crates/graphql-orm/tests/complete_group_pages_unsupported.rs b/crates/graphql-orm/tests/complete_group_pages_unsupported.rs new file mode 100644 index 00000000..afb22d2c --- /dev/null +++ b/crates/graphql-orm/tests/complete_group_pages_unsupported.rs @@ -0,0 +1,56 @@ +#![cfg(all(any(feature = "postgres", feature = "mssql"), not(feature = "sqlite")))] +use graphql_orm::prelude::*; + +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[cfg_attr( + feature = "postgres", + repository_entity( + backend = "postgres", + table = "unsupported_group_pages", + plural = "UnsupportedGroupPages" + ) +)] +#[cfg_attr( + all(feature = "mssql", not(feature = "postgres")), + repository_entity( + backend = "mssql", + table = "dbo.unsupported_group_pages", + plural = "UnsupportedGroupPages", + schema_policy = "external_read_only" + ) +)] +struct GroupSource { + #[primary_key] + id: String, + category: String, +} +#[tokio::test] +async fn unsupported_group_pages_fail_before_pool_io() -> graphql_orm::Result<()> { + #[cfg(feature = "postgres")] + let db = Database::::new( + sqlx::postgres::PgPoolOptions::new() + .connect_lazy("postgres://invalid:invalid@127.0.0.1:1/invalid")?, + ); + #[cfg(all(feature = "mssql", not(feature = "postgres")))] + let db = { + let mut config = graphql_orm::tiberius::Config::new(); + config.host("127.0.0.1"); + config.port(1); + Database::::new(graphql_orm::db::mssql::MssqlPool::new(config)) + }; + let result = GroupSource::aggregate(&db) + .group_by(GroupSourceAggregateField::Category)? + .fetch_group_page( + AggregateGroupPageOptions { + order: AggregateGroupOrder::Binary, + exclude_blank: false, + context: "trusted".into(), + }, + None, + ) + .await; + assert!( + matches!(result,Err(sqlx::Error::Protocol(ref message)) if message=="unsupported aggregate group pagination capability") + ); + Ok(()) +} diff --git a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock index 3d38e493..704dd20b 100644 --- a/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/backend-coexistence/Cargo.lock @@ -1350,7 +1350,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.5" +version = "0.35.0" dependencies = [ "agql-auth", "async-graphql", @@ -1420,7 +1420,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.5" +version = "0.35.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock index c39614d4..dc99b852 100644 --- a/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/federation-end-to-end/Cargo.lock @@ -2508,7 +2508,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.5" +version = "0.35.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -2531,7 +2531,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.5" +version = "0.35.0" dependencies = [ "convert_case 0.7.1", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock index 72c01869..bbdec42a 100644 --- a/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/migration-api-compatibility/Cargo.lock @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.5" +version = "0.35.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.5" +version = "0.35.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock index 886c1bc0..baf9dce2 100644 --- a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.lock @@ -1004,7 +1004,7 @@ dependencies = [ [[package]] name = "graphql-orm" -version = "0.33.5" +version = "0.35.0" dependencies = [ "async-graphql", "chacha20poly1305", @@ -1030,7 +1030,7 @@ dependencies = [ [[package]] name = "graphql-orm-macros" -version = "0.33.5" +version = "0.35.0" dependencies = [ "convert_case", "proc-macro2", diff --git a/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/complete_events.rs b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/complete_events.rs new file mode 100644 index 00000000..de21b67d --- /dev/null +++ b/crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/complete_events.rs @@ -0,0 +1,105 @@ +//! Run with --no-default-features --features sqlite --example complete_events. +//! Private models and complete enumeration, without application query SQL or GraphQL roots. +#[cfg(feature = "sqlite")] +use graphql_orm::prelude::*; +#[cfg(feature = "sqlite")] +#[derive(RepositoryEntity, Clone, serde::Serialize, serde::Deserialize)] +#[repository_entity( + backend = "sqlite", + table = "private_event_history", + plural = "PrivateEventHistory" +)] +struct PrivateEvent { + #[primary_key] + #[graphql_orm(auto_generated = false)] + id: String, + #[filterable(type = "string")] + tenant: String, + event: String, + #[graphql_orm(private, sensitive)] + custody: Option, +} +#[cfg(feature = "sqlite")] +#[tokio::main] +async fn main() -> graphql_orm::Result<()> { + let db = Database::::connect_sqlite("sqlite::memory:").await?; + let plan = db + .schema() + .plan_migration_to_entities( + "example", + "owned private history", + &[PrivateEvent::metadata()], + ) + .await?; + db.schema() + .apply_migration(&plan, Default::default()) + .await?; + for index in 0..225 { + PrivateEvent::insert( + &db, + CreatePrivateEventInput { + id: index.to_string(), + tenant: "tenant-a".into(), + event: format!("Event-{index:03}"), + custody: None, + }, + ) + .await?; + } + PrivateEvent::insert( + &db, + CreatePrivateEventInput { + id: "other".into(), + tenant: "tenant-b".into(), + event: "Private-other-tenant".into(), + custody: None, + }, + ) + .await?; + let options = AggregateGroupPageOptions { + order: AggregateGroupOrder::SqliteNoCase, + exclude_blank: true, + context: "verified-tenant-a:public-revision-1".into(), + }; + let mut cursor = None; + let mut events = Vec::new(); + loop { + let page = PrivateEvent::aggregate(&db) + .filter(PrivateEventWhereInput { + tenant: Some(StringFilter { + eq: Some("tenant-a".into()), + ..Default::default() + }), + ..Default::default() + }) + .group_by(PrivateEventAggregateField::Event)? + .group_limit(37)? + .fetch_group_page(options.clone(), cursor.as_ref()) + .await?; + for row in page.rows { + if let AggregateValue::Text(event) = row.groups[0].value.clone() { + events.push(event); + } + } + // Wrap/protect this bounded JSON in the application's existing cursor envelope. + cursor = page + .end_cursor + .map(|cursor| AggregateGroupCursor::from_json(&cursor.to_json()?)) + .transpose()?; + if !page.has_next_page { + break; + } + } + assert_eq!(events.len(), 225); + assert_eq!(events[0], "Event-000"); + assert_eq!(events.last().unwrap(), "Event-224"); + println!( + "Enumerated {} original event types in bounded authorized pages", + events.len() + ); + Ok(()) +} +#[cfg(not(feature = "sqlite"))] +fn main() { + println!("Complete text-group pages currently require the SQLite feature"); +} diff --git a/docs/reference/graphql-orm/typed-aggregates.md b/docs/reference/graphql-orm/typed-aggregates.md index 1c927386..9e1b56bb 100644 --- a/docs/reference/graphql-orm/typed-aggregates.md +++ b/docs/reference/graphql-orm/typed-aggregates.md @@ -3,7 +3,7 @@ title: Typed grouped aggregates kind: reference status: active owner: graphql-orm-maintainers -last_reviewed: 2026-08-13 +last_reviewed: 2026-10-01 review_by: 2027-02-01 supersedes: [] --- @@ -73,7 +73,7 @@ execution keeps the supplied `DbAuthContext`, so transaction-local RLS is applied before aggregation. An application-side `RowPolicy` cannot safely inspect rows after aggregation; -the builder therefore rejects that configuration. Move the restriction into a +ordinary `fetch` therefore retains its rejection of that configuration. Move the restriction into a typed SQL filter or database RLS instead of aggregating unauthorized rows and filtering the result. @@ -121,3 +121,98 @@ and generated decimal filters bind validated values rather than interpolating them into SQL. Logical backup rows retain the exact Decimal value together with its definition, allowing SQLite and PostgreSQL exports to restore without rounding or treating a native numeric as text. + +## Complete SQLite text-group pages + +ORM/macros 0.35.0 adds `fetch_group_page` to the existing generated aggregate +builder. Its initial profile supports exactly one SQLite TEXT-affinity grouping +field, optional existing metrics, and ascending `Binary` or `SqliteNoCase` +comparison. Other scalar families, multi-key groups and other backends reject +before the aggregate query; ordinary `fetch` and GraphQL aggregate SDL/cursors +remain unchanged. Repository-only entities remain private and need no direct +async-graphql dependency. + +```rust,ignore +let page = PrivateEvent::aggregate(&database) + .filter(PrivateEventWhereInput { + tenant: Some(StringFilter { eq: Some(verified_tenant.clone()), ..Default::default() }), + ..Default::default() + }) + .group_by(PrivateEventAggregateField::Event)? + .group_limit(37)? + .fetch_group_page(AggregateGroupPageOptions { + order: AggregateGroupOrder::SqliteNoCase, + exclude_blank: true, + context: trusted_authorization_partition_and_public_revision, + }, previous_cursor.as_ref()) + .await?; +``` + +Metrics may be omitted to enumerate distinct original values. Repeat with the +returned `end_cursor` until `has_next_page` is false. Each query returns at most +`group_limit + 1` groups and the page exposes at most `group_limit`; the limit is +1–1,000 and respects a stricter database maximum. The secure default is unchanged. +Use existing `PaginationConfig::legacy()` or an explicit 1,000-row maximum only +when that larger bound is intended. A complete set larger than a single-page cap +does not require increasing that cap or loading the ledger into Rust. + +Group equality remains the original column's native SQL equality. Ordering does +not case-fold or trim group identities. For deterministic representative values, +paging selects the BINARY minimum original text in each native group. On ordinary +BINARY text storage that is the exact original distinct string. NOCASE storage +can merge text variants under its native equality and returns the original BINARY +minimum variant. `SqliteNoCase` uses SQLite's ASCII folding followed by a BINARY +tie-breaker; SQL NULL sorts first. `exclude_blank` applies native SQL +`TRIM(field) <> ''` before grouping: ordinary spaces/empty strings and SQL NULL +are excluded, padded nonblank strings are retained unchanged, and tabs/newlines +are not removed by SQLite's default TRIM. + +Continuation uses HAVING over the same grouping representative and comparisons +as ordering. It never filters source rows with a group cursor, which could +truncate metrics or split a group. Metrics include every authorized source row +in each returned group before the group limit. The database computes groups on +each request; this API does not promise a cross-request snapshot under concurrent +ledger writes. + +Every request rechecks entity and group/metric/filter field authority. The page +path permits current `ReadVisibility::Complete` SQL predicates or explicit +`Unrestricted`; callback-only or prefilter/residual policies fail closed because +post-aggregate row callbacks cannot produce correct groups/counts. Ordinary +aggregate `fetch` keeps its previous row-policy contract. SQL visibility is +applied before grouping and pagination, and contributes to cursor identity. + +`AggregateGroupCursor::to_json`/`from_json` provide a strict framework-neutral +boundary for host-owned opaque envelopes. The wire object has exactly +`format_version: 1`, a lowercase 64-digit SHA-256 `fingerprint`, and required +`key` (original string or JSON null for SQL NULL). Missing/unknown fields, other +key types and versions are rejected. JSON is at most 256 KiB, text keys at most +64 KiB UTF-8, and the trusted context is 1–4,096 bytes. A last returned grouping +key outside these bounds fails the page rather than emitting an unusable cursor. +These are cursor bounds, not a new record field storage limit. + +The fingerprint binds backend, entity/table/group/metric definitions, active +filters, complete row predicate, ordering, blank exclusion, supplied +`DbAuthContext` identity and trusted host context. Changing page size is allowed; +changing these bindings rejects a cursor before the aggregate SELECT. The host +must include its verified authorization partition and complete public/policy +revision in context and validate its outer envelope. Fingerprints do not grant +authority or provide encryption. Protect the complete envelope when the host +requires confidentiality; existing static/runtime cursor formats are unchanged. + +The [standalone private consumer example](../../../crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/examples/complete_events.rs) +creates its own disposable SQLite database through ORM migrations/typed inserts +and collects 225 original event types in 37-group pages without application query +SQL or public GraphQL roots: + +```sh +cargo run --manifest-path crates/graphql-orm/tests/fixtures/repository-aggregate-consumer/Cargo.toml --locked --no-default-features --features sqlite --example complete_events +``` + +[Execution regressions](../../../crates/graphql-orm/tests/complete_group_pages.rs) +cover native identities, SQL NULL/blank/padding, literal characters, more groups +than the secure maximum, 1,000 groups plus lookahead, tenant/current policy +isolation, denied fields/entities, strict cursors and complete metrics after +continuation. PostgreSQL/MSSQL only compile and reject this new capability before +pool I/O; existing PostgreSQL aggregate behavior is separately executed against +test-owned infrastructure. Computed SQL Server grouping/whole-set totals and +joined/computed record queries remain separate capabilities. diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index 963d57ca..70ec98a7 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -18,11 +18,11 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | -| `graphql-orm` | `0.33.5` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | +| `graphql-orm` | `0.35.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-ai` | `0.106.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.15.0` | `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.33.5` | `crates/graphql-orm-macros` | `sqlite` | none | +| `graphql-orm-macros` | `0.35.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.3` | `crates/graphql-orm-router` | none | `graphql-orm-router-protocol` | | `graphql-orm-router-protocol` | `0.2.1` | `crates/graphql-orm-router-protocol` | none | none |