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}