Skip to main content

triblespace_core/
attribute.rs

1//! Typed attribute references with carried identity-determining facts.
2//!
3//! An [`Attribute<S>`] is a rooted [`Fragment`] plus a phantom value-
4//! schema marker. The fragment's `root()` IS the attribute id; its
5//! facts are the identity-determining data (e.g.
6//! `metadata::iri: <handle>` or `metadata::name: <handle>` together
7//! with `metadata::value_encoding: <schema id>`). The attribute is the
8//! *abstract shared thing* multiple parties agree on; codebase-local
9//! annotations (the rust identifier, source location, doc comment)
10//! are emitted at the [`attributes!`] call site as usage facts —
11//! there is no [`AttributeUsage`] type, the macro inlines them.
12//!
13//! Construct via [`From<Fragment>`]:
14//!
15//! ```ignore
16//! // Display-name origin (JSON fields, config keys, column headers):
17//! Attribute::<S>::from(entity! {
18//!     metadata::name:         name.to_blob().get_handle(),
19//!     metadata::value_encoding: <S as MetaDescribe>::id(),
20//! })
21//!
22//! // RDF / JSON-LD predicate (IRI as canonical identifier):
23//! Attribute::<S>::from(entity! {
24//!     metadata::iri:          iri.to_blob().get_handle(),
25//!     metadata::value_encoding: <S as MetaDescribe>::id(),
26//! })
27//!
28//! // Explicit hex id (pinned attribute namespace):
29//! Attribute::<S>::from(entity! {
30//!     ExclusiveId::force_ref(&id) @
31//!         metadata::value_encoding: <S as MetaDescribe>::id(),
32//! })
33//! ```
34
35use crate::id::Id;
36use crate::id::RawId;
37use crate::trible::Fragment;
38use crate::inline::InlineEncoding;
39use core::marker::PhantomData;
40
41/// A typed reference to an attribute: a rooted [`Fragment`] carrying
42/// the identity-determining facts, tagged with a phantom value-schema
43/// marker.
44///
45/// The root id is cached alongside the fragment so `.id()` is a field
46/// read — `entity!{}` codegen calls it once per attribute per fact,
47/// and walking the fragment's exports PATCH each time dominated the
48/// pre-0.40 entities/union benches.
49#[derive(Debug, PartialEq, Eq)]
50pub struct Attribute<S: InlineEncoding> {
51    id: Id,
52    fragment: Fragment,
53    _schema: PhantomData<S>,
54}
55
56impl<S: InlineEncoding> Clone for Attribute<S> {
57    // Manual impl: `PhantomData<S>` doesn't require `S: Clone`, but
58    // `#[derive(Clone)]` over a `S: InlineEncoding` bound conservatively
59    // adds that constraint. Implementing by hand lets callers clone
60    // `Attribute<Boolean>` etc. without needing `Boolean: Clone`.
61    fn clone(&self) -> Self {
62        Self {
63            id: self.id,
64            fragment: self.fragment.clone(),
65            _schema: PhantomData,
66        }
67    }
68}
69
70impl<S: InlineEncoding> Attribute<S> {
71    /// The attribute's id, equal to the wrapped fragment's root.
72    pub fn id(&self) -> Id {
73        self.id
74    }
75
76    /// Return the underlying raw id bytes.
77    pub fn raw(&self) -> RawId {
78        self.id().into()
79    }
80
81    /// The identity-determining fragment.
82    pub fn fragment(&self) -> &Fragment {
83        &self.fragment
84    }
85
86    /// Convert a host value into a typed `Inline<S>` using the Field's schema.
87    /// This is a small convenience wrapper around the `IntoInline` trait and
88    /// simplifies macro expansion: `af.inline_from(expr)` preserves the
89    /// schema `S` for type inference.
90    pub fn inline_from<T: crate::inline::IntoInline<S>>(&self, v: T) -> crate::inline::Inline<S> {
91        crate::inline::IntoInline::to_inline(v)
92    }
93
94    /// Macro-side entry point: produce the [`Encoded<S>`] the
95    /// `entity!{}` codegen folds into a Fragment.
96    ///
97    /// Dispatches via [`IntoEncoded`], parameterised by the schema's
98    /// [`Encoding`](crate::inline::InlineEncoding::Encoding) — `S`
99    /// itself for inline schemas, the inner `BlobEncoding` for
100    /// `Handle<T>`. The resulting `Output` is lifted into a [`Encoded`]
101    /// via [`ToEncoded`].
102    ///
103    /// [`IntoEncoded`]: crate::inline::IntoEncoded
104    /// [`ToEncoded`]: crate::inline::ToEncoded
105    /// [`Encoded`]: crate::inline::Encoded
106    /// [`Encoded<S>`]: crate::inline::Encoded
107    pub fn encoded_from<V>(&self, v: V) -> crate::inline::Encoded<S>
108    where
109        V: crate::inline::IntoEncoded<<S as crate::inline::InlineEncoding>::Encoding>,
110        <V as crate::inline::IntoEncoded<
111            <S as crate::inline::InlineEncoding>::Encoding,
112        >>::Output: crate::inline::ToEncoded<S>,
113    {
114        use crate::inline::ToEncoded;
115        v.into_encoded().to_encoded()
116    }
117
118    /// Coerce an existing variable of any schema into a variable typed with
119    /// this field's schema. This is a convenience for macros: they can
120    /// allocate an untyped/UnknownInline variable and then annotate it with the
121    /// field's schema using `af.as_variable(raw_var)`.
122    ///
123    /// The operation is a zero-cost conversion as variables are simply small
124    /// integer indexes; the implementation uses an unsafe transmute to change
125    /// the type parameter without moving the underlying data.
126    pub fn as_variable(&self, v: crate::query::Variable<S>) -> crate::query::Variable<S> {
127        v
128    }
129}
130
131/// Wrap a rooted fragment as a typed attribute.
132///
133/// The fragment's `root()` is the attribute id; its facts (typically
134/// `metadata::iri | metadata::name` together with
135/// `metadata::value_encoding`) are carried through to [`Describe`] so the
136/// attribute remains queryable in the metadata registry by its
137/// originating identity attribute.
138///
139/// Pinning a schema's attribute ids (so local renames don't churn the
140/// schema) is what the [`attributes!`] macro is for — declare them with
141/// explicit hex literals there.
142impl<S: InlineEncoding> From<Fragment> for Attribute<S> {
143    fn from(fragment: Fragment) -> Self {
144        let id = fragment
145            .root()
146            .expect("Attribute::from(Fragment) requires a rooted fragment");
147        Self {
148            id,
149            fragment,
150            _schema: PhantomData,
151        }
152    }
153}
154
155impl<S> crate::metadata::Describe for Attribute<S>
156where
157    S: InlineEncoding,
158{
159    fn describe(&self) -> Fragment {
160        // An attribute IS its identity fragment. The wrapped fragment
161        // already carries `metadata::iri` / `metadata::name` and
162        // `metadata::value_encoding: S::id()` from construction —
163        // exactly the facts a registry queries on. The schema's own
164        // facts (the human-readable name, description, hash protocol,
165        // …) belong to the schema, not the attribute; consumers
166        // wanting them ask `<S as MetaDescribe>::describe()`
167        // separately. Pure accessor.
168        self.fragment.clone()
169    }
170}
171
172/// Re-export of [`RawId`] used by generated macro code.
173pub use crate::id::RawId as RawIdAlias;
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use crate::blob::encodings::longstring::LongString;
179    use crate::blob::IntoBlob;
180    use crate::id::Id;
181    use crate::macros::{entity, find, pattern};
182    use crate::metadata::{self, Describe, MetaDescribe};
183    use crate::inline::encodings::hash::Handle;
184    use crate::inline::encodings::shortstring::ShortString;
185    use crate::inline::Inline;
186
187    #[test]
188    fn dynamic_field_is_deterministic() {
189        let h1 = "title".to_blob().get_handle();
190        let h2 = "title".to_blob().get_handle();
191        let a1 = Attribute::<ShortString>::from(entity! {
192            metadata::name:         h1,
193            metadata::value_encoding: <ShortString as MetaDescribe>::id(),
194        });
195        let a2 = Attribute::<ShortString>::from(entity! {
196            metadata::name:         h2,
197            metadata::value_encoding: <ShortString as MetaDescribe>::id(),
198        });
199
200        assert_eq!(a1.raw(), a2.raw());
201        assert_ne!(a1.raw(), [0; crate::id::ID_LEN]);
202    }
203
204    #[test]
205    fn dynamic_field_changes_with_name() {
206        let h_title = "title".to_blob().get_handle();
207        let h_author = "author".to_blob().get_handle();
208        let title = Attribute::<ShortString>::from(entity! {
209            metadata::name:         h_title,
210            metadata::value_encoding: <ShortString as MetaDescribe>::id(),
211        });
212        let author = Attribute::<ShortString>::from(entity! {
213            metadata::name:         h_author,
214            metadata::value_encoding: <ShortString as MetaDescribe>::id(),
215        });
216
217        assert_ne!(title.raw(), author.raw());
218    }
219
220    #[test]
221    fn dynamic_field_changes_with_schema() {
222        let h = "title".to_blob().get_handle();
223        let short = Attribute::<ShortString>::from(entity! {
224            metadata::name:         h,
225            metadata::value_encoding: <ShortString as MetaDescribe>::id(),
226        });
227        let handle = Attribute::<Handle<LongString>>::from(entity! {
228            metadata::name:         h,
229            metadata::value_encoding: <Handle<LongString> as MetaDescribe>::id(),
230        });
231
232        assert_ne!(short.raw(), handle.raw());
233    }
234
235    #[test]
236    fn describe_preserves_identity_iri() {
237        let iri = "http://example.org/foo".to_string();
238        let iri_handle: Inline<Handle<LongString>> = iri.to_blob().get_handle();
239        let attr = Attribute::<ShortString>::from(entity! {
240            metadata::iri:          iri_handle,
241            metadata::value_encoding: <ShortString as crate::metadata::MetaDescribe>::id(),
242        });
243        let attr_id = attr.id();
244
245        let meta = attr.describe();
246
247        // Discovery-by-IRI: the registry must contain
248        // `<attr_id> @ metadata::iri: <handle>`.
249        let hits: Vec<Id> = find!(
250            (a: Id),
251            pattern!(&meta, [{ ?a @ metadata::iri: iri_handle }])
252        )
253        .map(|(a,)| a)
254        .collect();
255        assert_eq!(hits, vec![attr_id]);
256
257        // The describe output's sole root is the attribute id — the
258        // schema spread's root doesn't bubble up.
259        assert_eq!(meta.root(), Some(attr_id));
260    }
261}