Skip to main content

type_bridge_contract/
id.rs

1//! Validated typed identifiers that do not depend on schema envelopes.
2
3use std::fmt;
4
5use serde::de::Error as _;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use unicode_ident::{is_xid_continue, is_xid_start};
8
9use crate::diagnostic::{Diagnostic, DiagnosticCategory};
10
11/// Maximum UTF-8 byte length of one canonical label.
12pub const MAX_LABEL_BYTES: usize = 255;
13
14/// Maximum hexadecimal digit count accepted for one provider Thing IID.
15pub const MAX_THING_IID_HEX_DIGITS: usize = 256;
16
17/// Return whether `value` is one canonical provider Thing IID.
18///
19/// This preserves the released V1 evidence grammar: a lowercase `0x`
20/// prefix followed by between one and 256 ASCII hexadecimal digits.
21#[must_use]
22pub fn is_canonical_thing_iid(value: &str) -> bool {
23    value.strip_prefix("0x").is_some_and(|digits| {
24        !digits.is_empty()
25            && digits.len() <= MAX_THING_IID_HEX_DIGITS
26            && digits.bytes().all(|byte| byte.is_ascii_hexdigit())
27    })
28}
29
30// TypeDB 3.12.1 pins the TypeQL 3.12.0 grammar. Keep this vocabulary
31// explicit at the identifier boundary so every schema, plan, and local
32// function projection rejects spellings the target semantic validator reserves.
33const TYPEQL_3_12_1_RESERVED_LABELS: [&str; 42] = [
34    "with",
35    "given",
36    "match",
37    "fetch",
38    "update",
39    "define",
40    "undefine",
41    "redefine",
42    "insert",
43    "put",
44    "delete",
45    "end",
46    "entity",
47    "relation",
48    "attribute",
49    "role",
50    "asc",
51    "desc",
52    "struct",
53    "fun",
54    "return",
55    "alias",
56    "sub",
57    "owns",
58    "as",
59    "plays",
60    "relates",
61    "iid",
62    "isa",
63    "links",
64    "has",
65    "is",
66    "or",
67    "not",
68    "try",
69    "in",
70    "true",
71    "false",
72    "of",
73    "from",
74    "first",
75    "last",
76];
77
78// TypeQL parses these exact spellings as built-in expression functions before
79// considering the ordinary user-function identifier alternative. Keep this
80// separate from the reserved-label vocabulary: except for `iid`, these remain
81// legitimate identifiers in contexts that do not emit a user-function call.
82const TYPEQL_3_12_BUILTIN_FUNCTION_NAMES: [&str; 9] = [
83    "abs", "ceil", "floor", "iid", "label", "len", "max", "min", "round",
84];
85
86fn is_typeql_3_12_1_reserved_label(value: &str) -> bool {
87    TYPEQL_3_12_1_RESERVED_LABELS.contains(&value)
88}
89
90/// Return whether TypeQL 3.12 parses `value` as a built-in function call name.
91///
92/// Most of these spellings remain valid labels and function identities in the
93/// binding-neutral contract. A semantic surface that emits a user-defined
94/// function call or definition must reject them to avoid TypeQL resolving the
95/// call as a built-in instead.
96#[must_use]
97pub fn is_typeql_3_12_builtin_function_name(value: &str) -> bool {
98    TYPEQL_3_12_BUILTIN_FUNCTION_NAMES.contains(&value)
99}
100
101/// A validated TypeQL-facing label.
102#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
103pub struct Label(String);
104
105impl Label {
106    /// Validate and construct a label.
107    pub fn new(value: impl Into<String>) -> Result<Self, Diagnostic> {
108        let value = value.into();
109        let mut chars = value.chars();
110        let valid = value.len() <= MAX_LABEL_BYTES
111            && chars.next().is_some_and(|ch| ch == '_' || is_xid_start(ch))
112            && chars.all(|ch| ch == '-' || is_xid_continue(ch))
113            && !is_typeql_3_12_1_reserved_label(&value);
114        if valid {
115            Ok(Self(value))
116        } else {
117            Err(Diagnostic::stable(
118                DiagnosticCategory::InvalidContract,
119                "malformed_id",
120                "identifier label is empty, oversized, reserved, or contains invalid characters",
121            )
122            .with_detail(
123                "maximum_bytes",
124                i64::try_from(MAX_LABEL_BYTES).unwrap_or(i64::MAX),
125            ))
126        }
127    }
128    /// Return the canonical label spelling.
129    pub fn as_str(&self) -> &str {
130        &self.0
131    }
132}
133
134impl fmt::Display for Label {
135    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
136        formatter.write_str(self.as_str())
137    }
138}
139impl Serialize for Label {
140    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
141    where
142        S: Serializer,
143    {
144        serializer.serialize_str(self.as_str())
145    }
146}
147impl<'de> Deserialize<'de> for Label {
148    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149    where
150        D: Deserializer<'de>,
151    {
152        Self::new(String::deserialize(deserializer)?).map_err(D::Error::custom)
153    }
154}
155
156/// The closed kind component of a schema type identity.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum TypeKind {
160    /// Entity type.
161    Entity,
162    /// Relation type.
163    Relation,
164    /// Attribute type.
165    Attribute,
166    /// Struct type.
167    Struct,
168}
169
170/// A type identity containing both kind and label.
171#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
172pub struct TypeId {
173    kind: TypeKind,
174    label: Label,
175}
176
177impl TypeId {
178    /// Construct a typed identity from a validated label spelling.
179    pub fn new(kind: TypeKind, label: impl Into<String>) -> Result<Self, Diagnostic> {
180        Ok(Self {
181            kind,
182            label: Label::new(label)?,
183        })
184    }
185    /// Return the type kind.
186    pub const fn kind(&self) -> TypeKind {
187        self.kind
188    }
189    /// Return the type label.
190    pub fn label(&self) -> &Label {
191        &self.label
192    }
193}
194
195/// A relation-qualified role identity.
196#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
197pub struct RoleId {
198    declaring_relation: Label,
199    label: Label,
200}
201
202impl RoleId {
203    /// Construct a role identity. Equal role labels under different relations remain unequal.
204    pub fn new(
205        declaring_relation: impl Into<String>,
206        label: impl Into<String>,
207    ) -> Result<Self, Diagnostic> {
208        Ok(Self {
209            declaring_relation: Label::new(declaring_relation)?,
210            label: Label::new(label)?,
211        })
212    }
213    /// Return the declaring relation label.
214    pub fn declaring_relation(&self) -> &Label {
215        &self.declaring_relation
216    }
217    /// Return the role label.
218    pub fn label(&self) -> &Label {
219        &self.label
220    }
221}
222
223macro_rules! label_id {
224    ($name:ident, $doc:literal) => {
225        #[doc = $doc]
226        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
227        #[serde(transparent)]
228        pub struct $name(Label);
229        impl $name {
230            /// Validate and construct this identity.
231            pub fn new(label: impl Into<String>) -> Result<Self, Diagnostic> {
232                Ok(Self(Label::new(label)?))
233            }
234            /// Return the validated label.
235            pub fn label(&self) -> &Label {
236                &self.0
237            }
238        }
239        impl fmt::Display for $name {
240            fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241                self.0.fmt(formatter)
242            }
243        }
244    };
245}
246
247label_id!(AttributeId, "A typed attribute identity.");
248label_id!(FunctionId, "A typed function identity.");
249label_id!(StructId, "A typed struct identity.");
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    #[test]
256    fn labels_reject_malformed_input_during_deserialization() {
257        for value in ["", "9person", "person name", "person.", "a²"] {
258            assert_eq!(
259                Label::new(value).unwrap_err().code().as_str(),
260                "malformed_id"
261            );
262        }
263        assert!(serde_json::from_str::<Label>(r#""person name""#).is_err());
264    }
265
266    #[test]
267    fn labels_follow_typeql_unicode_xid_grammar() {
268        for value in ["_", "type-with-hyphens", "a·b", "a\u{301}", "℘x"] {
269            assert_eq!(Label::new(value).unwrap().as_str(), value);
270        }
271    }
272
273    #[test]
274    fn labels_reject_the_typeql_3_12_1_reserved_vocabulary() {
275        for value in TYPEQL_3_12_1_RESERVED_LABELS {
276            assert_eq!(
277                Label::new(value).unwrap_err().code().as_str(),
278                "malformed_id",
279                "reserved TypeQL word {value:?} must not cross the identifier boundary",
280            );
281        }
282        assert!(Label::new("matching").is_ok());
283        assert!(Label::new("entity-type").is_ok());
284    }
285
286    #[test]
287    fn builtin_function_names_remain_contextual_identifiers() {
288        for value in TYPEQL_3_12_BUILTIN_FUNCTION_NAMES {
289            assert!(
290                is_typeql_3_12_builtin_function_name(value),
291                "missing TypeQL built-in function {value:?}",
292            );
293        }
294        for value in ["absolute", "length", "person_name_length"] {
295            assert!(!is_typeql_3_12_builtin_function_name(value));
296        }
297
298        assert!(FunctionId::new("abs").is_ok());
299        assert!(FunctionId::new("label").is_ok());
300        assert!(FunctionId::new("iid").is_err());
301    }
302
303    #[test]
304    fn thing_iids_preserve_the_released_bounded_hexadecimal_grammar() {
305        assert!(is_canonical_thing_iid("0x0"));
306        assert!(is_canonical_thing_iid("0xAbCdEf"));
307        assert!(is_canonical_thing_iid(&format!(
308            "0x{}",
309            "a".repeat(MAX_THING_IID_HEX_DIGITS)
310        )));
311
312        for malformed in ["", "0x", "0X1", "01", "0x1g", "0x1; delete $x;"] {
313            assert!(!is_canonical_thing_iid(malformed), "{malformed:?}");
314        }
315        assert!(!is_canonical_thing_iid(&format!(
316            "0x{}",
317            "a".repeat(MAX_THING_IID_HEX_DIGITS + 1)
318        )));
319    }
320
321    #[test]
322    fn malformed_typed_id_wires_fail_closed() {
323        assert!(serde_json::from_str::<TypeId>(r#"{"kind":"entity","label":"9person"}"#).is_err());
324        assert!(serde_json::from_str::<TypeId>(r#"{"kind":"future","label":"person"}"#).is_err());
325        assert!(
326            serde_json::from_str::<RoleId>(
327                r#"{"declaring_relation":"9employment","label":"employee"}"#
328            )
329            .is_err()
330        );
331    }
332
333    #[test]
334    fn role_identity_includes_the_declaring_relation() {
335        let employee = RoleId::new("employment", "employee").unwrap();
336        let membership = RoleId::new("membership", "employee").unwrap();
337        assert_ne!(employee, membership);
338    }
339}