Skip to main content

github_graphql_node_count/
lib.rs

1//! Compute the worst-case **node count** GitHub's GraphQL API attributes to a
2//! query — offline, from the query text and its page-size variable bindings
3//! alone, before the query is ever sent.
4//!
5//! # `nodeCount`, not `cost`
6//!
7//! GitHub meters two different numbers against two different limits, and this
8//! crate is about one of them.
9//!
10//! * **`nodeCount`** — the maximum number of nodes **one query may return**,
11//!   limited **per query** at [`NODE_LIMIT`]. That is what [`node_count`]
12//!   computes.
13//! * **`cost`** — the rate-limit **points** a call spends, metered **per hour**
14//!   across everything one credential does.
15//!
16//! Neither the code nor these docs computes or claims anything about `cost`.
17//!
18//! # The rules, and where they come from
19//!
20//! GitHub publishes them at
21//! <https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api>:
22//! node counts **multiply** down a nested path, **sum** across sibling paths,
23//! every connection supplies a `first` or a `last` inside `1..=100`, and the
24//! limit one query may not reach is 500,000. Check this crate against that page
25//! rather than against our confidence.
26//!
27//! # No schema, by design
28//!
29//! [`node_count`] is handed document text and nothing else. It reaches no
30//! network, reads no credential, and consults no GraphQL schema — so it cannot
31//! know which fields are connections by type. Instead:
32//!
33//! * a field carrying a `first:` or a `last:` argument **is** a connection, and
34//!   multiplies the count beneath it;
35//! * every other field contributes no multiplier and no nodes of its own.
36//!
37//! That is what makes this crate runnable in a fork pull request with no secret,
38//! which is the whole reason a consumer can put it in a gate. The cost is one
39//! blind spot, stated plainly: **a connection that supplies neither `first` nor
40//! `last` is invalid to GitHub and invisible here.** Such a field is counted as
41//! an ordinary field, so the answer is an undercount rather than an error. A
42//! field supplying *both* is also invalid to GitHub; the larger of the two is
43//! used, so the answer stays a worst case.
44//!
45//! # Example
46//!
47//! ```
48//! use github_graphql_node_count::{node_count, NodeCountError, Variables, NODE_LIMIT};
49//!
50//! let document = r#"
51//!     query($repos: Int!) {
52//!       viewer {
53//!         repositories(first: $repos) {
54//!           edges { node { name issues(first: 10) { edges { node { title } } } } }
55//!         }
56//!       }
57//!     }
58//! "#;
59//! let variables = Variables::from([("repos".to_string(), 50)]);
60//!
61//! // 50 repositories + 50 x 10 issues.
62//! assert_eq!(node_count(document, &variables)?, 550);
63//! assert!(node_count(document, &variables)? < NODE_LIMIT);
64//!
65//! // A page size GitHub would reject comes back as an error, not a number.
66//! let over = Variables::from([("repos".to_string(), 500)]);
67//! assert!(matches!(
68//!     node_count(document, &over),
69//!     Err(NodeCountError::PageSizeOutOfRange { .. })
70//! ));
71//! # Ok::<(), NodeCountError>(())
72//! ```
73
74#![deny(missing_docs)]
75#![deny(missing_debug_implementations)]
76
77mod count;
78mod error;
79
80pub use error::{NodeCountError, PageSizeArgument, Position};
81
82/// GitHub's published limit on the number of nodes one query may return.
83///
84/// > Individual calls cannot request more than 500,000 total nodes.
85///
86/// — [Rate limits and node limits for the GraphQL API](https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api).
87///
88/// This is a **per-query** ceiling on `nodeCount`. It is not the hourly `cost`
89/// budget, which this crate says nothing about.
90pub const NODE_LIMIT: u64 = 500_000;
91
92/// The integer bound each page-size variable a document names is given, keyed by
93/// variable name **without** the leading `$`.
94///
95/// A variable a document declares but no `first:`/`last:` argument references
96/// need not appear. A variable a `first:`/`last:` *does* reference must, or
97/// [`node_count`] returns [`NodeCountError::UnboundVariable`] — a declared
98/// default value is not consulted.
99///
100/// A `u32` rather than a validated page-size newtype: this alias is a contract
101/// with the repositories that call this crate, restated verbatim in their own
102/// builds, so it is not ours to narrow. The `1..=100` range GitHub requires is
103/// enforced where the value is *used*, and a binding outside it comes back as
104/// [`NodeCountError::PageSizeOutOfRange`] rather than being silently counted.
105// llmlint: ignore[invalid_states_unrepresentable] the type of this alias is a frozen
106// cross-repository contract (see the paragraph above); narrowing it to a newtype would break
107// the consumer written against it, so the range is validated at the one point of use instead.
108pub type Variables = std::collections::BTreeMap<String, u32>;
109
110/// The worst-case number of nodes the one operation in `document` may return,
111/// under `variables`, computed by GitHub's published rules.
112///
113/// The document must hold exactly one operation. Fragment definitions beside it
114/// are resolved, so the natural consumer shape — one shared fragment
115/// concatenated onto each of several operations, giving several
116/// single-operation documents — is counted correctly, once per document.
117///
118/// # Errors
119///
120/// Returns [`NodeCountError`] rather than panicking, returning zero, or
121/// returning a wrong count, when:
122///
123/// * the text does not parse as GraphQL ([`NodeCountError::Parse`]);
124/// * the document declares no operation ([`NodeCountError::NoOperation`]);
125/// * it declares more than one, which this signature cannot disambiguate
126///   ([`NodeCountError::MultipleOperations`]);
127/// * a `first:`/`last:` names a variable `variables` does not bind
128///   ([`NodeCountError::UnboundVariable`]);
129/// * a `first:`/`last:` value falls outside `1..=100`
130///   ([`NodeCountError::PageSizeOutOfRange`]) or is not an integer at all
131///   ([`NodeCountError::PageSizeNotAnInteger`]);
132/// * a spread names a fragment the document does not define
133///   ([`NodeCountError::UndefinedFragment`]), or the spreads form a cycle
134///   ([`NodeCountError::FragmentCycle`]);
135/// * the count grows past `u64` ([`NodeCountError::Overflow`]), which only a
136///   document far above [`NODE_LIMIT`] can do.
137pub fn node_count(document: &str, variables: &Variables) -> Result<u64, NodeCountError> {
138    count::count(document, variables)
139}