Skip to main content

sqlite_graphrag/
entity_type.rs

1//! Entity type vocabulary: open by default, with a canonical set as guidance.
2//!
3//! Until v1.2.8 this module owned a closed `EntityType` enum of thirteen kinds
4//! and folded every other label onto the nearest one, terminating at `concept`.
5//! The fold was lossy in the strict sense: the string the caller wrote was
6//! consumed inside `Deserialize` and never existed as a value again, so no
7//! layer above could report it, store it, or decide policy about it. On the
8//! database of this workspace that put 69% of all entities in a single bucket,
9//! which makes filtering by `concept` indistinguishable from not filtering.
10//!
11//! v1.2.8 opens the vocabulary instead of widening it, mirroring what
12//! `V010__open_relation_vocabulary.sql` did for relations back in v1.0.49: the
13//! SQL `CHECK` is gone (V017), the label travels as a plain `String`, and
14//! [`crate::entity_type::CANONICAL_ENTITY_TYPES`] is advice rather than a gate.
15//! The path is fully qualified on purpose: a `//!` header is concatenated with
16//! the `///` written above `pub mod` in `lib.rs` and RESOLVES in that outer
17//! scope, so a bare name here fails to resolve and `cargo doc` exits 101.
18//! Because nothing is
19//! folded any more, the caller's label survives by construction — it needs no
20//! second column to be preserved, only the absence of something destroying it.
21//!
22//! What remains enforced is *shape*, never membership. See
23//! [`crate::entity_type::normalize_entity_type`].
24
25use crate::constants::MAX_ENTITY_TYPE_LEN;
26use crate::errors::AppError;
27use crate::i18n::validation;
28
29/// The thirteen kinds that were canonical while the vocabulary was closed.
30///
31/// They stay as the recommended vocabulary — surfaced in help text, offered as
32/// completion candidates, and enforced under `--strict-entity-types` — but they
33/// no longer bound what can be stored. Deliberately mirrors
34/// [`crate::parsers::CANONICAL_RELATIONS`], which has played exactly this role
35/// for the relation vocabulary since v1.0.49.
36///
37/// Kept sorted so emitted diagnostics are stable.
38pub const CANONICAL_ENTITY_TYPES: &[&str] = &[
39    "concept",
40    "dashboard",
41    "date",
42    "decision",
43    "file",
44    "incident",
45    "issue_tracker",
46    "location",
47    "memory",
48    "organization",
49    "person",
50    "project",
51    "tool",
52];
53
54/// Kind assigned when a caller supplies no type at all.
55///
56/// This is the one place `concept` still wins by default. It is a default, not
57/// a destination: a label that simply differs from the canonical set is now
58/// stored as written, and only an *absent* label lands here.
59pub const DEFAULT_ENTITY_TYPE: &str = "concept";
60
61/// Reports whether `s` is one of [`CANONICAL_ENTITY_TYPES`].
62///
63/// Compares the already-normalised form, so callers should pass the output of
64/// [`normalize_entity_type`]. Mirrors `parsers::is_canonical_relation`.
65#[must_use]
66pub fn is_canonical_entity_type(s: &str) -> bool {
67    CANONICAL_ENTITY_TYPES.contains(&s)
68}
69
70/// Normalises an entity type label's *shape*, never its meaning.
71///
72/// Applies exactly three transformations — trim, lowercase, and hyphen to
73/// underscore — so `"Issue-Tracker"` and `"issue_tracker"` remain the same
74/// row rather than two, and so does `"Crate"` versus `"crate"`. It performs no
75/// mapping whatsoever: an unrecognised label comes back as itself, which is
76/// the whole point of the change.
77///
78/// Rejection is limited to labels that could not be a word in any vocabulary:
79/// empty, digits only, containing a line break, or longer than
80/// [`MAX_ENTITY_TYPE_LEN`]. Membership is never a reason to reject here;
81/// that decision belongs to `--strict-entity-types`, one layer up, where the
82/// caller has asked for it.
83///
84/// # Errors
85/// Returns [`AppError::Validation`] when the label is blank, digits only,
86/// contains a line break, or exceeds [`MAX_ENTITY_TYPE_LEN`] characters.
87pub fn normalize_entity_type(s: &str) -> Result<String, AppError> {
88    let normalized = s.trim().to_lowercase().replace('-', "_");
89
90    if normalized.is_empty() {
91        return Err(AppError::Validation(validation::entity_type_blank()));
92    }
93    if normalized.contains('\n') || normalized.contains('\r') {
94        return Err(AppError::Validation(validation::entity_type_has_newline(
95            &normalized,
96        )));
97    }
98    if normalized.chars().all(|c| c.is_ascii_digit()) {
99        return Err(AppError::Validation(validation::entity_type_digits_only(
100            &normalized,
101        )));
102    }
103    if normalized.chars().count() > MAX_ENTITY_TYPE_LEN {
104        return Err(AppError::Validation(validation::entity_type_too_long(
105            &normalized,
106            MAX_ENTITY_TYPE_LEN,
107        )));
108    }
109
110    Ok(normalized)
111}
112
113/// Normalises `s`, falling back to [`DEFAULT_ENTITY_TYPE`] when it is unusable.
114///
115/// For the read paths that materialise a label already stored in SQLite, where
116/// refusing is not an option because the row exists either way. Write paths
117/// must call [`normalize_entity_type`] and surface the error instead.
118#[must_use]
119pub fn normalize_entity_type_or_default(s: &str) -> String {
120    normalize_entity_type(s).unwrap_or_else(|_| DEFAULT_ENTITY_TYPE.to_string())
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn canonical_set_has_thirteen_sorted_members() {
129        assert_eq!(CANONICAL_ENTITY_TYPES.len(), 13);
130        let mut sorted = CANONICAL_ENTITY_TYPES.to_vec();
131        sorted.sort_unstable();
132        assert_eq!(
133            sorted.as_slice(),
134            CANONICAL_ENTITY_TYPES,
135            "kept sorted so diagnostics are stable"
136        );
137    }
138
139    #[test]
140    fn canonical_labels_are_recognised() {
141        for kind in CANONICAL_ENTITY_TYPES {
142            assert!(is_canonical_entity_type(kind), "{kind} must be canonical");
143        }
144    }
145
146    #[test]
147    fn shape_normalisation_is_case_and_hyphen_insensitive() {
148        assert_eq!(
149            normalize_entity_type("  Issue-Tracker ").unwrap(),
150            "issue_tracker"
151        );
152        assert_eq!(normalize_entity_type("PERSON").unwrap(), "person");
153    }
154
155    /// The regression this whole change exists to prevent: a label outside the
156    /// canonical set must come back as itself, not as `concept`.
157    #[test]
158    fn non_canonical_labels_survive_verbatim() {
159        for label in ["crate", "gap", "flag", "migration", "schema", "framework"] {
160            let normalized = normalize_entity_type(label).unwrap();
161            assert_eq!(normalized, label, "{label} must not be folded");
162            assert!(
163                !is_canonical_entity_type(&normalized),
164                "{label} is not canonical, but is still storable"
165            );
166        }
167    }
168
169    /// `framework` was on the deliberate fold list until v1.2.8. Pinned
170    /// separately because reintroducing that map would pass every other test.
171    #[test]
172    fn previously_folded_labels_are_no_longer_folded() {
173        for label in [
174            "framework",
175            "library",
176            "method",
177            "metric",
178            "platform",
179            "protocol",
180        ] {
181            assert_eq!(normalize_entity_type(label).unwrap(), label);
182        }
183    }
184
185    #[test]
186    fn blank_and_digit_only_labels_are_refused() {
187        assert!(normalize_entity_type("").is_err());
188        assert!(normalize_entity_type("   ").is_err());
189        assert!(normalize_entity_type("42").is_err());
190    }
191
192    #[test]
193    fn line_breaks_are_refused() {
194        assert!(normalize_entity_type("person\nrole").is_err());
195        assert!(normalize_entity_type("person\rrole").is_err());
196    }
197
198    #[test]
199    fn overlong_labels_are_refused_by_characters_not_bytes() {
200        let long = "a".repeat(MAX_ENTITY_TYPE_LEN + 1);
201        assert!(normalize_entity_type(&long).is_err());
202
203        let at_limit = "a".repeat(MAX_ENTITY_TYPE_LEN);
204        assert!(normalize_entity_type(&at_limit).is_ok());
205
206        // Multi-byte characters count once each, never by their UTF-8 width.
207        let accented = "á".repeat(MAX_ENTITY_TYPE_LEN);
208        assert!(normalize_entity_type(&accented).is_ok());
209    }
210
211    #[test]
212    fn default_is_used_only_when_normalisation_fails() {
213        assert_eq!(normalize_entity_type_or_default("crate"), "crate");
214        assert_eq!(normalize_entity_type_or_default(""), DEFAULT_ENTITY_TYPE);
215    }
216}