use crate::{
db::{
relation::{
RelationDescriptor, RelationDescriptorCardinality, relation_descriptors_for_model_iter,
},
schema::{
AcceptedSchemaSnapshot, PersistedFieldKind, PersistedIndexKeyItemSnapshot,
PersistedIndexKeySnapshot, PersistedNestedLeafSnapshot, PersistedRelationStrength,
SchemaFieldDefault, SchemaFieldSlot, field_type_from_persisted_kind,
},
},
model::{
entity::EntityModel,
field::{FieldDatabaseDefault, FieldKind, FieldModel, RelationStrength},
},
};
use candid::CandidType;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::fmt::Write;
const ENTITY_FIELD_DESCRIPTION_NO_SLOT: u16 = u16::MAX;
#[cfg_attr(
doc,
doc = "EntitySchemaDescription\n\nStable describe payload for one entity model."
)]
#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct EntitySchemaDescription {
pub(crate) entity_path: String,
pub(crate) entity_name: String,
pub(crate) primary_key: String,
pub(crate) primary_key_fields: Vec<String>,
pub(crate) fields: Vec<EntityFieldDescription>,
pub(crate) indexes: Vec<EntityIndexDescription>,
pub(crate) relations: Vec<EntityRelationDescription>,
}
#[cfg_attr(
doc,
doc = "EntitySchemaCheckDescription\n\nGenerated-vs-accepted schema description payload for one entity."
)]
#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct EntitySchemaCheckDescription {
pub(crate) generated: EntitySchemaDescription,
pub(crate) accepted: EntitySchemaDescription,
}
impl EntitySchemaCheckDescription {
#[must_use]
pub const fn new(
generated: EntitySchemaDescription,
accepted: EntitySchemaDescription,
) -> Self {
Self {
generated,
accepted,
}
}
#[must_use]
pub const fn generated(&self) -> &EntitySchemaDescription {
&self.generated
}
#[must_use]
pub const fn accepted(&self) -> &EntitySchemaDescription {
&self.accepted
}
}
impl EntitySchemaDescription {
#[must_use]
pub fn new(
entity_path: String,
entity_name: String,
primary_key: String,
fields: Vec<EntityFieldDescription>,
indexes: Vec<EntityIndexDescription>,
relations: Vec<EntityRelationDescription>,
) -> Self {
Self::new_with_primary_key_fields(
entity_path,
entity_name,
primary_key.clone(),
vec![primary_key],
fields,
indexes,
relations,
)
}
#[must_use]
pub const fn new_with_primary_key_fields(
entity_path: String,
entity_name: String,
primary_key: String,
primary_key_fields: Vec<String>,
fields: Vec<EntityFieldDescription>,
indexes: Vec<EntityIndexDescription>,
relations: Vec<EntityRelationDescription>,
) -> Self {
Self {
entity_path,
entity_name,
primary_key,
primary_key_fields,
fields,
indexes,
relations,
}
}
#[must_use]
pub const fn entity_path(&self) -> &str {
self.entity_path.as_str()
}
#[must_use]
pub const fn entity_name(&self) -> &str {
self.entity_name.as_str()
}
#[must_use]
pub const fn primary_key(&self) -> &str {
self.primary_key.as_str()
}
#[must_use]
pub const fn primary_key_fields(&self) -> &[String] {
self.primary_key_fields.as_slice()
}
#[must_use]
pub const fn fields(&self) -> &[EntityFieldDescription] {
self.fields.as_slice()
}
#[must_use]
pub const fn indexes(&self) -> &[EntityIndexDescription] {
self.indexes.as_slice()
}
#[must_use]
pub const fn relations(&self) -> &[EntityRelationDescription] {
self.relations.as_slice()
}
}
#[cfg_attr(
doc,
doc = "EntityFieldDescription\n\nOne field entry in a describe payload."
)]
#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct EntityFieldDescription {
pub(crate) name: String,
pub(crate) slot: u16,
pub(crate) kind: String,
pub(crate) nullable: bool,
pub(crate) primary_key: bool,
pub(crate) queryable: bool,
pub(crate) origin: String,
}
impl EntityFieldDescription {
#[must_use]
pub const fn new(
name: String,
slot: Option<u16>,
kind: String,
nullable: bool,
primary_key: bool,
queryable: bool,
origin: String,
) -> Self {
let slot = match slot {
Some(slot) => slot,
None => ENTITY_FIELD_DESCRIPTION_NO_SLOT,
};
Self {
name,
slot,
kind,
nullable,
primary_key,
queryable,
origin,
}
}
#[must_use]
pub const fn name(&self) -> &str {
self.name.as_str()
}
#[must_use]
pub const fn slot(&self) -> Option<u16> {
if self.slot == ENTITY_FIELD_DESCRIPTION_NO_SLOT {
None
} else {
Some(self.slot)
}
}
#[must_use]
pub const fn kind(&self) -> &str {
self.kind.as_str()
}
#[must_use]
pub const fn nullable(&self) -> bool {
self.nullable
}
#[must_use]
pub const fn primary_key(&self) -> bool {
self.primary_key
}
#[must_use]
pub const fn queryable(&self) -> bool {
self.queryable
}
#[must_use]
pub const fn origin(&self) -> &str {
self.origin.as_str()
}
}
#[cfg_attr(
doc,
doc = "EntityIndexDescription\n\nOne index entry in a describe payload."
)]
#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct EntityIndexDescription {
pub(crate) name: String,
pub(crate) unique: bool,
pub(crate) fields: Vec<String>,
pub(crate) origin: String,
}
impl EntityIndexDescription {
#[must_use]
pub const fn new(name: String, unique: bool, fields: Vec<String>, origin: String) -> Self {
Self {
name,
unique,
fields,
origin,
}
}
#[must_use]
pub const fn name(&self) -> &str {
self.name.as_str()
}
#[must_use]
pub const fn unique(&self) -> bool {
self.unique
}
#[must_use]
pub const fn fields(&self) -> &[String] {
self.fields.as_slice()
}
#[must_use]
pub const fn origin(&self) -> &str {
self.origin.as_str()
}
}
#[cfg_attr(
doc,
doc = "EntityRelationDescription\n\nOne relation entry in a describe payload."
)]
#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
pub struct EntityRelationDescription {
pub(crate) field: String,
pub(crate) target_path: String,
pub(crate) target_entity_name: String,
pub(crate) target_store_path: String,
pub(crate) strength: EntityRelationStrength,
pub(crate) cardinality: EntityRelationCardinality,
}
impl EntityRelationDescription {
#[must_use]
pub const fn new(
field: String,
target_path: String,
target_entity_name: String,
target_store_path: String,
strength: EntityRelationStrength,
cardinality: EntityRelationCardinality,
) -> Self {
Self {
field,
target_path,
target_entity_name,
target_store_path,
strength,
cardinality,
}
}
#[must_use]
pub const fn field(&self) -> &str {
self.field.as_str()
}
#[must_use]
pub const fn target_path(&self) -> &str {
self.target_path.as_str()
}
#[must_use]
pub const fn target_entity_name(&self) -> &str {
self.target_entity_name.as_str()
}
#[must_use]
pub const fn target_store_path(&self) -> &str {
self.target_store_path.as_str()
}
#[must_use]
pub const fn strength(&self) -> EntityRelationStrength {
self.strength
}
#[must_use]
pub const fn cardinality(&self) -> EntityRelationCardinality {
self.cardinality
}
}
#[cfg_attr(doc, doc = "EntityRelationStrength\n\nDescribe relation strength.")]
#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
pub enum EntityRelationStrength {
Strong,
Weak,
}
#[cfg_attr(
doc,
doc = "EntityRelationCardinality\n\nDescribe relation cardinality."
)]
#[derive(CandidType, Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
pub enum EntityRelationCardinality {
Single,
List,
Set,
}
#[cfg_attr(
doc,
doc = "Build one stable entity-schema description from one runtime `EntityModel`."
)]
#[must_use]
pub(in crate::db) fn describe_entity_model(model: &EntityModel) -> EntitySchemaDescription {
let fields = describe_entity_fields(model);
let primary_key_fields = primary_key_field_names_from_model(model);
let primary_key = render_primary_key_fields(primary_key_fields.as_slice());
describe_entity_model_with_parts(
model.path,
model.entity_name,
primary_key.as_str(),
primary_key_fields,
fields,
describe_entity_indexes_from_model(model),
describe_entity_relations_from_model(model),
)
}
#[cfg_attr(
doc,
doc = "Build one entity-schema description using accepted persisted schema slot metadata."
)]
#[must_use]
pub(in crate::db) fn describe_entity_model_with_persisted_schema(
model: &EntityModel,
schema: &AcceptedSchemaSnapshot,
) -> EntitySchemaDescription {
let fields = describe_entity_fields_with_persisted_schema(schema);
let primary_key_fields = schema.primary_key_field_names();
let primary_key_fields = if primary_key_fields.is_empty() {
vec![model.primary_key.name.to_string()]
} else {
primary_key_fields
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>()
};
let primary_key = render_primary_key_fields(primary_key_fields.as_slice());
describe_entity_model_with_parts(
schema.entity_path(),
schema.entity_name(),
primary_key.as_str(),
primary_key_fields,
fields,
describe_entity_indexes_with_persisted_schema(schema),
describe_entity_relations_with_persisted_schema(schema),
)
}
fn describe_entity_model_with_parts(
entity_path: &str,
entity_name: &str,
primary_key: &str,
primary_key_fields: Vec<String>,
fields: Vec<EntityFieldDescription>,
indexes: Vec<EntityIndexDescription>,
relations: Vec<EntityRelationDescription>,
) -> EntitySchemaDescription {
EntitySchemaDescription::new_with_primary_key_fields(
entity_path.to_string(),
entity_name.to_string(),
primary_key.to_string(),
primary_key_fields,
fields,
indexes,
relations,
)
}
fn describe_entity_relations_from_model(model: &EntityModel) -> Vec<EntityRelationDescription> {
relation_descriptors_for_model_iter(model)
.map(relation_description_from_descriptor)
.collect()
}
fn primary_key_field_names_from_model(model: &EntityModel) -> Vec<String> {
model
.primary_key_model()
.fields()
.iter()
.map(|field| field.name.to_string())
.collect()
}
fn render_primary_key_fields(fields: &[String]) -> String {
fields.join(", ")
}
fn describe_entity_indexes_from_model(model: &EntityModel) -> Vec<EntityIndexDescription> {
let mut indexes = Vec::with_capacity(model.indexes.len());
for index in model.indexes {
indexes.push(EntityIndexDescription::new(
index.name().to_string(),
index.is_unique(),
index
.fields()
.iter()
.map(|field| (*field).to_string())
.collect(),
"generated".to_string(),
));
}
indexes
}
fn describe_entity_indexes_with_persisted_schema(
schema: &AcceptedSchemaSnapshot,
) -> Vec<EntityIndexDescription> {
schema
.persisted_snapshot()
.indexes()
.iter()
.map(|index| {
EntityIndexDescription::new(
index.name().to_string(),
index.unique(),
describe_persisted_index_fields(index.key()),
if index.generated() {
"generated".to_string()
} else {
"ddl".to_string()
},
)
})
.collect()
}
fn describe_persisted_index_fields(key: &PersistedIndexKeySnapshot) -> Vec<String> {
match key {
PersistedIndexKeySnapshot::FieldPath(paths) => paths
.iter()
.map(|field_path| field_path.path().join("."))
.collect(),
PersistedIndexKeySnapshot::Items(items) => items
.iter()
.map(|item| match item {
PersistedIndexKeyItemSnapshot::FieldPath(field_path) => field_path.path().join("."),
PersistedIndexKeyItemSnapshot::Expression(expression) => {
expression.canonical_text().to_string()
}
})
.collect(),
}
}
#[must_use]
pub(in crate::db) fn describe_entity_fields(model: &EntityModel) -> Vec<EntityFieldDescription> {
describe_entity_fields_with_slot_lookup(model, |slot, _field| {
Some(u16::try_from(slot).expect("generated field slot should fit in u16"))
})
}
#[cfg_attr(
doc,
doc = "Build field descriptors using accepted persisted schema slot metadata."
)]
#[must_use]
pub(in crate::db) fn describe_entity_fields_with_persisted_schema(
schema: &AcceptedSchemaSnapshot,
) -> Vec<EntityFieldDescription> {
let snapshot = schema.persisted_snapshot();
let mut fields = Vec::with_capacity(snapshot.fields().len());
for field in snapshot.fields() {
let primary_key = snapshot.primary_key_field_ids().contains(&field.id());
let slot = snapshot
.row_layout()
.slot_for_field(field.id())
.map(SchemaFieldSlot::get);
let mut kind = summarize_persisted_field_kind(field.kind());
write_schema_default_summary(&mut kind, field.default());
let metadata = DescribeFieldMetadata::new(
kind,
field.nullable(),
field_type_from_persisted_kind(field.kind())
.value_kind()
.is_queryable(),
field_origin_label(field.generated()),
);
push_described_field_row(&mut fields, field.name(), slot, primary_key, None, metadata);
if !field.nested_leaves().is_empty() {
describe_persisted_nested_leaves(
&mut fields,
field.nested_leaves(),
field_origin_label(field.generated()),
);
}
}
fields
}
fn describe_entity_fields_with_slot_lookup(
model: &EntityModel,
mut slot_for_field: impl FnMut(usize, &FieldModel) -> Option<u16>,
) -> Vec<EntityFieldDescription> {
let mut fields = Vec::with_capacity(model.fields.len());
let primary_key_fields = primary_key_field_names_from_model(model);
for (slot, field) in model.fields.iter().enumerate() {
let primary_key = primary_key_fields
.iter()
.any(|primary_key_field| primary_key_field == field.name);
describe_field_recursive(
&mut fields,
field.name,
slot_for_field(slot, field),
field,
primary_key,
None,
None,
);
}
fields
}
struct DescribeFieldMetadata {
kind: String,
nullable: bool,
queryable: bool,
origin: String,
}
impl DescribeFieldMetadata {
const fn new(kind: String, nullable: bool, queryable: bool, origin: String) -> Self {
Self {
kind,
nullable,
queryable,
origin,
}
}
}
fn describe_field_recursive(
fields: &mut Vec<EntityFieldDescription>,
name: &str,
slot: Option<u16>,
field: &FieldModel,
primary_key: bool,
tree_prefix: Option<&'static str>,
metadata_override: Option<DescribeFieldMetadata>,
) {
let metadata = metadata_override.unwrap_or_else(|| {
let mut kind = summarize_field_kind(&field.kind);
write_model_default_summary(&mut kind, field.database_default());
DescribeFieldMetadata::new(
kind,
field.nullable(),
field.kind.value_kind().is_queryable(),
"generated".to_string(),
)
});
push_described_field_row(fields, name, slot, primary_key, tree_prefix, metadata);
describe_generated_nested_fields(fields, field.nested_fields());
}
fn push_described_field_row(
fields: &mut Vec<EntityFieldDescription>,
name: &str,
slot: Option<u16>,
primary_key: bool,
tree_prefix: Option<&'static str>,
metadata: DescribeFieldMetadata,
) {
let display_name = if let Some(prefix) = tree_prefix {
format!("{prefix}{name}")
} else {
name.to_string()
};
fields.push(EntityFieldDescription::new(
display_name,
slot,
metadata.kind,
metadata.nullable,
primary_key,
metadata.queryable,
metadata.origin,
));
}
fn describe_generated_nested_fields(
fields: &mut Vec<EntityFieldDescription>,
nested_fields: &[FieldModel],
) {
for (index, nested) in nested_fields.iter().enumerate() {
let prefix = if index + 1 == nested_fields.len() {
"└─ "
} else {
"├─ "
};
describe_field_recursive(
fields,
nested.name(),
None,
nested,
false,
Some(prefix),
None,
);
}
}
fn describe_persisted_nested_leaves(
fields: &mut Vec<EntityFieldDescription>,
nested_leaves: &[PersistedNestedLeafSnapshot],
origin: String,
) {
for (index, leaf) in nested_leaves.iter().enumerate() {
let prefix = if index + 1 == nested_leaves.len() {
"└─ "
} else {
"├─ "
};
let name = leaf.path().last().map_or("", String::as_str);
let metadata = DescribeFieldMetadata::new(
summarize_persisted_field_kind(leaf.kind()),
leaf.nullable(),
field_type_from_persisted_kind(leaf.kind())
.value_kind()
.is_queryable(),
origin.clone(),
);
push_described_field_row(fields, name, None, false, Some(prefix), metadata);
}
}
fn field_origin_label(generated: bool) -> String {
if generated {
"generated".to_string()
} else {
"ddl".to_string()
}
}
fn describe_entity_relations_with_persisted_schema(
schema: &AcceptedSchemaSnapshot,
) -> Vec<EntityRelationDescription> {
schema
.persisted_snapshot()
.fields()
.iter()
.filter_map(relation_description_from_persisted_field)
.collect()
}
fn relation_description_from_persisted_field(
field: &crate::db::schema::PersistedFieldSnapshot,
) -> Option<EntityRelationDescription> {
let relation = persisted_relation_description_metadata(field.kind())?;
Some(EntityRelationDescription::new(
field.name().to_string(),
relation.target_path.to_string(),
relation.target_entity_name.to_string(),
relation.target_store_path.to_string(),
relation.strength,
relation.cardinality,
))
}
struct PersistedRelationDescriptionMetadata<'a> {
target_path: &'a str,
target_entity_name: &'a str,
target_store_path: &'a str,
strength: EntityRelationStrength,
cardinality: EntityRelationCardinality,
}
fn persisted_relation_description_metadata(
kind: &PersistedFieldKind,
) -> Option<PersistedRelationDescriptionMetadata<'_>> {
const fn from_relation_kind(
kind: &PersistedFieldKind,
cardinality: EntityRelationCardinality,
) -> Option<PersistedRelationDescriptionMetadata<'_>> {
let PersistedFieldKind::Relation {
target_path,
target_entity_name,
target_store_path,
strength,
..
} = kind
else {
return None;
};
Some(PersistedRelationDescriptionMetadata {
target_path: target_path.as_str(),
target_entity_name: target_entity_name.as_str(),
target_store_path: target_store_path.as_str(),
strength: entity_relation_strength_from_persisted(*strength),
cardinality,
})
}
match kind {
PersistedFieldKind::Relation { .. } => {
from_relation_kind(kind, EntityRelationCardinality::Single)
}
PersistedFieldKind::List(inner) => {
from_relation_kind(inner, EntityRelationCardinality::List)
}
PersistedFieldKind::Set(inner) => from_relation_kind(inner, EntityRelationCardinality::Set),
_ => None,
}
}
const fn entity_relation_strength_from_persisted(
strength: PersistedRelationStrength,
) -> EntityRelationStrength {
match strength {
PersistedRelationStrength::Strong => EntityRelationStrength::Strong,
PersistedRelationStrength::Weak => EntityRelationStrength::Weak,
}
}
fn relation_description_from_descriptor(
descriptor: RelationDescriptor,
) -> EntityRelationDescription {
let strength = match descriptor.strength() {
RelationStrength::Strong => EntityRelationStrength::Strong,
RelationStrength::Weak => EntityRelationStrength::Weak,
};
let cardinality = match descriptor.cardinality() {
RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
RelationDescriptorCardinality::List => EntityRelationCardinality::List,
RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
};
EntityRelationDescription::new(
descriptor.field_name().to_string(),
descriptor.target_path().to_string(),
descriptor.target_entity_name().to_string(),
descriptor.target_store_path().to_string(),
strength,
cardinality,
)
}
#[cfg_attr(doc, doc = "Render one stable field-kind label for describe output.")]
fn summarize_field_kind(kind: &FieldKind) -> String {
let mut out = String::new();
write_field_kind_summary(&mut out, kind);
out
}
fn write_field_kind_summary(out: &mut String, kind: &FieldKind) {
if let Some(name) = kind.describe_kind_name() {
out.push_str(name);
return;
}
match kind {
FieldKind::Blob { max_len } => {
write_length_bounded_field_kind_summary(out, "blob", *max_len);
}
FieldKind::Decimal { scale } => {
let _ = write!(out, "decimal(scale={scale})");
}
FieldKind::IntBig { max_bytes } => {
write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
}
FieldKind::Enum { path, .. } => {
out.push_str("enum(");
out.push_str(path);
out.push(')');
}
FieldKind::Text { max_len } => {
write_length_bounded_field_kind_summary(out, "text", *max_len);
}
FieldKind::Relation {
target_entity_name,
key_kind,
strength,
..
} => {
out.push_str("relation(target=");
out.push_str(target_entity_name);
out.push_str(", key=");
write_field_kind_summary(out, key_kind);
out.push_str(", strength=");
out.push_str(summarize_relation_strength(*strength));
out.push(')');
}
FieldKind::List(inner) => {
out.push_str("list<");
write_field_kind_summary(out, inner);
out.push('>');
}
FieldKind::Set(inner) => {
out.push_str("set<");
write_field_kind_summary(out, inner);
out.push('>');
}
FieldKind::Map { key, value } => {
out.push_str("map<");
write_field_kind_summary(out, key);
out.push_str(", ");
write_field_kind_summary(out, value);
out.push('>');
}
FieldKind::Structured { .. } => {
out.push_str("structured");
}
FieldKind::Account
| FieldKind::Bool
| FieldKind::Date
| FieldKind::Duration
| FieldKind::Float32
| FieldKind::Float64
| FieldKind::Int8
| FieldKind::Int16
| FieldKind::Int32
| FieldKind::Int64
| FieldKind::Int128
| FieldKind::Principal
| FieldKind::Subaccount
| FieldKind::Timestamp
| FieldKind::Nat8
| FieldKind::Nat16
| FieldKind::Nat32
| FieldKind::Nat64
| FieldKind::Nat128
| FieldKind::Ulid
| FieldKind::Unit => unreachable!("plain field kind labels return before recursive render"),
FieldKind::NatBig { max_bytes } => {
write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
}
}
}
trait DescribeKindName {
fn describe_kind_name(&self) -> Option<&'static str>;
}
impl DescribeKindName for FieldKind {
fn describe_kind_name(&self) -> Option<&'static str> {
Some(match self {
Self::Account => "account",
Self::Bool => "bool",
Self::Date => "date",
Self::Duration => "duration",
Self::Float32 => "float32",
Self::Float64 => "float64",
Self::Int8 => "int8",
Self::Int16 => "int16",
Self::Int32 => "int32",
Self::Int64 => "int64",
Self::Int128 => "int128",
Self::Principal => "principal",
Self::Subaccount => "subaccount",
Self::Timestamp => "timestamp",
Self::Nat8 => "nat8",
Self::Nat16 => "nat16",
Self::Nat32 => "nat32",
Self::Nat64 => "nat64",
Self::Nat128 => "nat128",
Self::Ulid => "ulid",
Self::Unit => "unit",
Self::Blob { .. }
| Self::Decimal { .. }
| Self::Enum { .. }
| Self::IntBig { .. }
| Self::NatBig { .. }
| Self::Text { .. }
| Self::Relation { .. }
| Self::List(_)
| Self::Set(_)
| Self::Map { .. }
| Self::Structured { .. } => return None,
})
}
}
fn write_length_bounded_field_kind_summary(
out: &mut String,
kind_name: &str,
max_len: Option<u32>,
) {
out.push_str(kind_name);
if let Some(max_len) = max_len {
out.push_str("(max_len=");
out.push_str(&max_len.to_string());
out.push(')');
} else {
out.push_str("(unbounded)");
}
}
fn write_byte_bounded_field_kind_summary(out: &mut String, kind_name: &str, max_bytes: u32) {
out.push_str(kind_name);
out.push_str("(max_bytes=");
out.push_str(&max_bytes.to_string());
out.push(')');
}
fn write_model_default_summary(out: &mut String, default: FieldDatabaseDefault) {
match default {
FieldDatabaseDefault::None => {}
FieldDatabaseDefault::EncodedSlotPayload(payload) => {
write_encoded_default_payload_summary(out, payload);
}
}
}
fn write_schema_default_summary(out: &mut String, default: &SchemaFieldDefault) {
if let Some(payload) = default.slot_payload() {
write_encoded_default_payload_summary(out, payload);
}
}
fn write_encoded_default_payload_summary(out: &mut String, payload: &[u8]) {
let _ = write!(
out,
" default=slot_payload(bytes={}, sha256={})",
payload.len(),
short_default_payload_fingerprint(payload),
);
}
fn short_default_payload_fingerprint(payload: &[u8]) -> String {
let digest = Sha256::digest(payload);
let mut out = String::with_capacity(16);
for byte in &digest[..8] {
let _ = write!(out, "{byte:02x}");
}
out
}
#[cfg_attr(
doc,
doc = "Render one stable field-kind label from accepted persisted schema metadata."
)]
fn summarize_persisted_field_kind(kind: &PersistedFieldKind) -> String {
let mut out = String::new();
write_persisted_field_kind_summary(&mut out, kind);
out
}
fn write_persisted_field_kind_summary(out: &mut String, kind: &PersistedFieldKind) {
if let Some(name) = kind.describe_kind_name() {
out.push_str(name);
return;
}
match kind {
PersistedFieldKind::Blob { max_len } => {
write_length_bounded_field_kind_summary(out, "blob", *max_len);
}
PersistedFieldKind::Decimal { scale } => {
let _ = write!(out, "decimal(scale={scale})");
}
PersistedFieldKind::IntBig { max_bytes } => {
write_byte_bounded_field_kind_summary(out, "int_big", *max_bytes);
}
PersistedFieldKind::Enum { path, .. } => {
out.push_str("enum(");
out.push_str(path);
out.push(')');
}
PersistedFieldKind::Text { max_len } => {
write_length_bounded_field_kind_summary(out, "text", *max_len);
}
PersistedFieldKind::Relation {
target_entity_name,
key_kind,
strength,
..
} => {
out.push_str("relation(target=");
out.push_str(target_entity_name);
out.push_str(", key=");
write_persisted_field_kind_summary(out, key_kind);
out.push_str(", strength=");
out.push_str(summarize_persisted_relation_strength(*strength));
out.push(')');
}
PersistedFieldKind::List(inner) => {
out.push_str("list<");
write_persisted_field_kind_summary(out, inner);
out.push('>');
}
PersistedFieldKind::Set(inner) => {
out.push_str("set<");
write_persisted_field_kind_summary(out, inner);
out.push('>');
}
PersistedFieldKind::Map { key, value } => {
out.push_str("map<");
write_persisted_field_kind_summary(out, key);
out.push_str(", ");
write_persisted_field_kind_summary(out, value);
out.push('>');
}
PersistedFieldKind::Structured { .. } => {
out.push_str("structured");
}
PersistedFieldKind::Account
| PersistedFieldKind::Bool
| PersistedFieldKind::Date
| PersistedFieldKind::Duration
| PersistedFieldKind::Float32
| PersistedFieldKind::Float64
| PersistedFieldKind::Int8
| PersistedFieldKind::Int16
| PersistedFieldKind::Int32
| PersistedFieldKind::Int64
| PersistedFieldKind::Int128
| PersistedFieldKind::Principal
| PersistedFieldKind::Subaccount
| PersistedFieldKind::Timestamp
| PersistedFieldKind::Nat8
| PersistedFieldKind::Nat16
| PersistedFieldKind::Nat32
| PersistedFieldKind::Nat64
| PersistedFieldKind::Nat128
| PersistedFieldKind::Ulid
| PersistedFieldKind::Unit => {
unreachable!("plain persisted field kind labels return before recursive render")
}
PersistedFieldKind::NatBig { max_bytes } => {
write_byte_bounded_field_kind_summary(out, "nat_big", *max_bytes);
}
}
}
impl DescribeKindName for PersistedFieldKind {
fn describe_kind_name(&self) -> Option<&'static str> {
Some(match self {
Self::Account => "account",
Self::Bool => "bool",
Self::Date => "date",
Self::Duration => "duration",
Self::Float32 => "float32",
Self::Float64 => "float64",
Self::Int8 => "int8",
Self::Int16 => "int16",
Self::Int32 => "int32",
Self::Int64 => "int64",
Self::Int128 => "int128",
Self::Principal => "principal",
Self::Subaccount => "subaccount",
Self::Timestamp => "timestamp",
Self::Nat8 => "nat8",
Self::Nat16 => "nat16",
Self::Nat32 => "nat32",
Self::Nat64 => "nat64",
Self::Nat128 => "nat128",
Self::Ulid => "ulid",
Self::Unit => "unit",
Self::Blob { .. }
| Self::Decimal { .. }
| Self::Enum { .. }
| Self::IntBig { .. }
| Self::NatBig { .. }
| Self::Text { .. }
| Self::Relation { .. }
| Self::List(_)
| Self::Set(_)
| Self::Map { .. }
| Self::Structured { .. } => return None,
})
}
}
#[cfg_attr(
doc,
doc = "Render one stable relation-strength label from persisted schema metadata."
)]
const fn summarize_persisted_relation_strength(
strength: PersistedRelationStrength,
) -> &'static str {
match strength {
PersistedRelationStrength::Strong => "strong",
PersistedRelationStrength::Weak => "weak",
}
}
#[cfg_attr(
doc,
doc = "Render one stable relation-strength label for field-kind summaries."
)]
const fn summarize_relation_strength(strength: RelationStrength) -> &'static str {
match strength {
RelationStrength::Strong => "strong",
RelationStrength::Weak => "weak",
}
}
#[cfg(test)]
mod tests {
use crate::{
db::{
EntityFieldDescription, EntityIndexDescription, EntityRelationCardinality,
EntityRelationDescription, EntityRelationStrength, EntitySchemaDescription,
relation::{RelationDescriptorCardinality, relation_descriptors_for_model_iter},
schema::{
AcceptedSchemaSnapshot, FieldId, PersistedFieldKind, PersistedFieldSnapshot,
PersistedNestedLeafSnapshot, PersistedRelationStrength, PersistedSchemaSnapshot,
SchemaFieldDefault, SchemaFieldSlot, SchemaRowLayout, SchemaVersion,
describe::{
describe_entity_fields_with_persisted_schema, describe_entity_model,
describe_entity_model_with_persisted_schema,
},
},
},
model::{
entity::{EntityModel, PrimaryKeyModel},
field::{
FieldDatabaseDefault, FieldKind, FieldModel, FieldStorageDecode, LeafCodec,
RelationStrength, ScalarCodec,
},
},
types::EntityTag,
};
use candid::types::{CandidType, Label, Type, TypeInner};
static DESCRIBE_SINGLE_RELATION_KIND: FieldKind = FieldKind::Relation {
target_path: "entities::Target",
target_entity_name: "Target",
target_entity_tag: EntityTag::new(0xD001),
target_store_path: "stores::Target",
key_kind: &FieldKind::Ulid,
strength: RelationStrength::Strong,
};
static DESCRIBE_LIST_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
target_path: "entities::Account",
target_entity_name: "Account",
target_entity_tag: EntityTag::new(0xD002),
target_store_path: "stores::Account",
key_kind: &FieldKind::Nat64,
strength: RelationStrength::Weak,
};
static DESCRIBE_SET_RELATION_INNER_KIND: FieldKind = FieldKind::Relation {
target_path: "entities::Team",
target_entity_name: "Team",
target_entity_tag: EntityTag::new(0xD003),
target_store_path: "stores::Team",
key_kind: &FieldKind::Text { max_len: None },
strength: RelationStrength::Strong,
};
static DESCRIBE_RELATION_FIELDS: [FieldModel; 4] = [
FieldModel::generated("id", FieldKind::Ulid),
FieldModel::generated("target", DESCRIBE_SINGLE_RELATION_KIND),
FieldModel::generated(
"accounts",
FieldKind::List(&DESCRIBE_LIST_RELATION_INNER_KIND),
),
FieldModel::generated("teams", FieldKind::Set(&DESCRIBE_SET_RELATION_INNER_KIND)),
];
static DESCRIBE_RELATION_INDEXES: [&crate::model::index::IndexModel; 0] = [];
static DESCRIBE_RELATION_MODEL: EntityModel = EntityModel::generated(
"entities::Source",
"Source",
&DESCRIBE_RELATION_FIELDS[0],
0,
&DESCRIBE_RELATION_FIELDS,
&DESCRIBE_RELATION_INDEXES,
);
static DESCRIBE_COMPOSITE_PK_FIELDS: [FieldModel; 3] = [
FieldModel::generated("tenant_id", FieldKind::Nat64),
FieldModel::generated("local_id", FieldKind::Nat64),
FieldModel::generated("label", FieldKind::Text { max_len: None }),
];
static DESCRIBE_COMPOSITE_PK_FIELD_REFS: [&FieldModel; 2] = [
&DESCRIBE_COMPOSITE_PK_FIELDS[0],
&DESCRIBE_COMPOSITE_PK_FIELDS[1],
];
static DESCRIBE_COMPOSITE_PK_MODEL: EntityModel = EntityModel::generated_with_primary_key_model(
"entities::Composite",
"Composite",
PrimaryKeyModel::ordered(&DESCRIBE_COMPOSITE_PK_FIELD_REFS),
0,
&DESCRIBE_COMPOSITE_PK_FIELDS,
&DESCRIBE_RELATION_INDEXES,
);
fn expect_record_fields(ty: Type) -> Vec<String> {
match ty.as_ref() {
TypeInner::Record(fields) => fields
.iter()
.map(|field| match field.id.as_ref() {
Label::Named(name) => name.clone(),
other => panic!("expected named record field, got {other:?}"),
})
.collect(),
other => panic!("expected candid record, got {other:?}"),
}
}
fn expect_record_field_type(ty: Type, field_name: &str) -> Type {
match ty.as_ref() {
TypeInner::Record(fields) => fields
.iter()
.find_map(|field| match field.id.as_ref() {
Label::Named(name) if name == field_name => Some(field.ty.clone()),
_ => None,
})
.unwrap_or_else(|| panic!("expected record field `{field_name}`")),
other => panic!("expected candid record, got {other:?}"),
}
}
fn expect_variant_labels(ty: Type) -> Vec<String> {
match ty.as_ref() {
TypeInner::Variant(fields) => fields
.iter()
.map(|field| match field.id.as_ref() {
Label::Named(name) => name.clone(),
other => panic!("expected named variant label, got {other:?}"),
})
.collect(),
other => panic!("expected candid variant, got {other:?}"),
}
}
#[test]
fn entity_schema_description_candid_shape_is_stable() {
let fields = expect_record_fields(EntitySchemaDescription::ty());
for field in [
"entity_path",
"entity_name",
"primary_key",
"primary_key_fields",
"fields",
"indexes",
"relations",
] {
assert!(
fields.iter().any(|candidate| candidate == field),
"EntitySchemaDescription must keep `{field}` field key",
);
}
}
#[test]
fn entity_field_description_candid_shape_is_stable() {
let fields = expect_record_fields(EntityFieldDescription::ty());
for field in ["name", "slot", "kind", "primary_key", "queryable", "origin"] {
assert!(
fields.iter().any(|candidate| candidate == field),
"EntityFieldDescription must keep `{field}` field key",
);
}
assert!(
matches!(
expect_record_field_type(EntityFieldDescription::ty(), "slot").as_ref(),
TypeInner::Nat16
),
"EntityFieldDescription slot must remain plain nat16 for CLI/canister compatibility",
);
}
#[test]
fn entity_index_description_candid_shape_is_stable() {
let fields = expect_record_fields(EntityIndexDescription::ty());
for field in ["name", "unique", "fields", "origin"] {
assert!(
fields.iter().any(|candidate| candidate == field),
"EntityIndexDescription must keep `{field}` field key",
);
}
}
#[test]
fn entity_relation_description_candid_shape_is_stable() {
let fields = expect_record_fields(EntityRelationDescription::ty());
for field in [
"field",
"target_path",
"target_entity_name",
"target_store_path",
"strength",
"cardinality",
] {
assert!(
fields.iter().any(|candidate| candidate == field),
"EntityRelationDescription must keep `{field}` field key",
);
}
}
#[test]
fn relation_enum_variant_labels_are_stable() {
let mut strength_labels = expect_variant_labels(EntityRelationStrength::ty());
strength_labels.sort_unstable();
assert_eq!(
strength_labels,
vec!["Strong".to_string(), "Weak".to_string()]
);
let mut cardinality_labels = expect_variant_labels(EntityRelationCardinality::ty());
cardinality_labels.sort_unstable();
assert_eq!(
cardinality_labels,
vec!["List".to_string(), "Set".to_string(), "Single".to_string()],
);
}
#[test]
fn describe_fixture_constructors_stay_usable() {
let payload = EntitySchemaDescription::new(
"entities::User".to_string(),
"User".to_string(),
"id".to_string(),
vec![EntityFieldDescription::new(
"id".to_string(),
Some(0),
"ulid".to_string(),
false,
true,
true,
"generated".to_string(),
)],
vec![EntityIndexDescription::new(
"idx_email".to_string(),
true,
vec!["email".to_string()],
"generated".to_string(),
)],
vec![EntityRelationDescription::new(
"account_id".to_string(),
"entities::Account".to_string(),
"Account".to_string(),
"accounts".to_string(),
EntityRelationStrength::Strong,
EntityRelationCardinality::Single,
)],
);
assert_eq!(payload.entity_name(), "User");
assert_eq!(payload.primary_key(), "id");
assert_eq!(payload.primary_key_fields(), ["id".to_string()].as_slice());
assert_eq!(payload.fields().len(), 1);
assert_eq!(payload.indexes().len(), 1);
assert_eq!(payload.relations().len(), 1);
}
#[test]
fn describe_entity_model_marks_all_composite_primary_key_fields() {
let described = describe_entity_model(&DESCRIBE_COMPOSITE_PK_MODEL);
let primary_key_fields = described
.fields()
.iter()
.filter(|field| field.primary_key())
.map(EntityFieldDescription::name)
.collect::<Vec<_>>();
assert_eq!(described.primary_key(), "tenant_id, local_id");
assert_eq!(
described.primary_key_fields(),
["tenant_id".to_string(), "local_id".to_string()].as_slice(),
);
assert_eq!(primary_key_fields, ["tenant_id", "local_id"]);
}
#[test]
fn schema_describe_relations_match_relation_descriptors() {
let descriptors =
relation_descriptors_for_model_iter(&DESCRIBE_RELATION_MODEL).collect::<Vec<_>>();
let described = describe_entity_model(&DESCRIBE_RELATION_MODEL);
let relations = described.relations();
assert_eq!(descriptors.len(), relations.len());
for (descriptor, relation) in descriptors.iter().zip(relations) {
assert_eq!(relation.field(), descriptor.field_name());
assert_eq!(relation.target_path(), descriptor.target_path());
assert_eq!(
relation.target_entity_name(),
descriptor.target_entity_name()
);
assert_eq!(relation.target_store_path(), descriptor.target_store_path());
assert_eq!(
relation.strength(),
match descriptor.strength() {
RelationStrength::Strong => EntityRelationStrength::Strong,
RelationStrength::Weak => EntityRelationStrength::Weak,
}
);
assert_eq!(
relation.cardinality(),
match descriptor.cardinality() {
RelationDescriptorCardinality::Single => EntityRelationCardinality::Single,
RelationDescriptorCardinality::List => EntityRelationCardinality::List,
RelationDescriptorCardinality::Set => EntityRelationCardinality::Set,
}
);
}
}
#[test]
fn accepted_schema_describe_relations_use_persisted_relation_authority() {
let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
SchemaVersion::initial(),
"entities::AcceptedSource".to_string(),
"AcceptedSource".to_string(),
FieldId::new(1),
SchemaRowLayout::new(
SchemaVersion::initial(),
vec![
(FieldId::new(1), SchemaFieldSlot::new(0)),
(FieldId::new(2), SchemaFieldSlot::new(1)),
],
),
vec![
PersistedFieldSnapshot::new(
FieldId::new(1),
"id".to_string(),
SchemaFieldSlot::new(0),
PersistedFieldKind::Ulid,
Vec::new(),
false,
SchemaFieldDefault::None,
FieldStorageDecode::ByKind,
LeafCodec::StructuralFallback,
),
PersistedFieldSnapshot::new(
FieldId::new(2),
"accepted_targets".to_string(),
SchemaFieldSlot::new(1),
PersistedFieldKind::Set(Box::new(PersistedFieldKind::Relation {
target_path: "accepted::Target".to_string(),
target_entity_name: "AcceptedTarget".to_string(),
target_entity_tag: EntityTag::new(0xD0A1),
target_store_path: "accepted::TargetStore".to_string(),
key_kind: Box::new(PersistedFieldKind::Nat128),
strength: PersistedRelationStrength::Strong,
})),
Vec::new(),
false,
SchemaFieldDefault::None,
FieldStorageDecode::ByKind,
LeafCodec::StructuralFallback,
),
],
));
let described =
describe_entity_model_with_persisted_schema(&DESCRIBE_RELATION_MODEL, &snapshot);
assert_eq!(described.entity_path(), "entities::AcceptedSource");
assert_eq!(described.entity_name(), "AcceptedSource");
assert_eq!(
described.primary_key_fields(),
["id".to_string()].as_slice()
);
assert_eq!(described.relations().len(), 1);
let relation = &described.relations()[0];
assert_eq!(relation.field(), "accepted_targets");
assert_eq!(relation.target_path(), "accepted::Target");
assert_eq!(relation.target_entity_name(), "AcceptedTarget");
assert_eq!(relation.target_store_path(), "accepted::TargetStore");
assert_eq!(relation.strength(), EntityRelationStrength::Strong);
assert_eq!(relation.cardinality(), EntityRelationCardinality::Set);
}
#[test]
fn schema_describe_includes_text_max_len_contract() {
static FIELDS: [FieldModel; 2] = [
FieldModel::generated("id", FieldKind::Ulid),
FieldModel::generated("name", FieldKind::Text { max_len: Some(16) }),
];
static INDEXES: [&crate::model::index::IndexModel; 0] = [];
static MODEL: EntityModel = EntityModel::generated(
"entities::BoundedName",
"BoundedName",
&FIELDS[0],
0,
&FIELDS,
&INDEXES,
);
let described = describe_entity_model(&MODEL);
let name_field = described
.fields()
.iter()
.find(|field| field.name() == "name")
.expect("bounded text field should be described");
assert_eq!(name_field.kind(), "text(max_len=16)");
}
#[test]
fn schema_describe_preserves_fixed_width_numeric_kind_labels() {
static FIELDS: [FieldModel; 7] = [
FieldModel::generated("id", FieldKind::Ulid),
FieldModel::generated("small_signed", FieldKind::Int8),
FieldModel::generated("cell_x", FieldKind::Nat16),
FieldModel::generated("large_signed", FieldKind::Int64),
FieldModel::generated("large_unsigned", FieldKind::Nat64),
FieldModel::generated("huge_signed", FieldKind::IntBig { max_bytes: 384 }),
FieldModel::generated("huge_unsigned", FieldKind::NatBig { max_bytes: 512 }),
];
static INDEXES: [&crate::model::index::IndexModel; 0] = [];
static MODEL: EntityModel = EntityModel::generated(
"entities::FixedWidthNumbers",
"FixedWidthNumbers",
&FIELDS[0],
0,
&FIELDS,
&INDEXES,
);
let described = describe_entity_model(&MODEL)
.fields()
.iter()
.map(|field| (field.name().to_string(), field.kind().to_string()))
.collect::<Vec<_>>();
assert!(described.contains(&("small_signed".to_string(), "int8".to_string())));
assert!(described.contains(&("cell_x".to_string(), "nat16".to_string())));
assert!(described.contains(&("large_signed".to_string(), "int64".to_string())));
assert!(described.contains(&("large_unsigned".to_string(), "nat64".to_string())));
assert!(described.contains(&(
"huge_signed".to_string(),
"int_big(max_bytes=384)".to_string()
)));
assert!(described.contains(&(
"huge_unsigned".to_string(),
"nat_big(max_bytes=512)".to_string()
)));
}
#[test]
fn schema_describe_includes_generated_database_default_metadata() {
static DEFAULT_PAYLOAD: &[u8] = &[0xFF, 0x01, 7, 0, 0, 0, 0, 0, 0, 0];
static FIELDS: [FieldModel; 2] = [
FieldModel::generated("id", FieldKind::Ulid),
FieldModel::generated_with_storage_decode_nullability_write_policies_database_default_and_nested_fields(
"score",
FieldKind::Nat64,
FieldStorageDecode::ByKind,
false,
None,
None,
FieldDatabaseDefault::EncodedSlotPayload(DEFAULT_PAYLOAD),
&[],
),
];
static INDEXES: [&crate::model::index::IndexModel; 0] = [];
static MODEL: EntityModel = EntityModel::generated(
"entities::DefaultedScore",
"DefaultedScore",
&FIELDS[0],
0,
&FIELDS,
&INDEXES,
);
let described = describe_entity_model(&MODEL);
let score_field = described
.fields()
.iter()
.find(|field| field.name() == "score")
.expect("database-defaulted score field should be described");
assert_eq!(
score_field.kind(),
"nat64 default=slot_payload(bytes=10, sha256=37746b8fe16bb6b4)"
);
}
#[test]
fn schema_describe_uses_accepted_top_level_field_metadata() {
let id_slot = SchemaFieldSlot::new(0);
let payload_slot = SchemaFieldSlot::new(7);
let stale_payload_field_slot = SchemaFieldSlot::new(3);
let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
SchemaVersion::initial(),
"entities::BlobEvent".to_string(),
"BlobEvent".to_string(),
FieldId::new(1),
SchemaRowLayout::new(
SchemaVersion::initial(),
vec![(FieldId::new(1), id_slot), (FieldId::new(2), payload_slot)],
),
vec![
PersistedFieldSnapshot::new(
FieldId::new(1),
"id".to_string(),
id_slot,
PersistedFieldKind::Ulid,
Vec::new(),
false,
SchemaFieldDefault::None,
FieldStorageDecode::ByKind,
LeafCodec::StructuralFallback,
),
PersistedFieldSnapshot::new(
FieldId::new(2),
"payload".to_string(),
stale_payload_field_slot,
PersistedFieldKind::Blob { max_len: None },
Vec::new(),
false,
SchemaFieldDefault::SlotPayload(vec![0x10, 0x20, 0x30]),
FieldStorageDecode::ByKind,
LeafCodec::StructuralFallback,
),
],
));
let described = describe_entity_fields_with_persisted_schema(&snapshot)
.into_iter()
.map(|field| {
(
field.name().to_string(),
field.slot(),
field.kind().to_string(),
)
})
.collect::<Vec<_>>();
assert_eq!(
described,
vec![
("id".to_string(), Some(0), "ulid".to_string()),
(
"payload".to_string(),
Some(7),
"blob(unbounded) default=slot_payload(bytes=3, sha256=8e1336ab78ebe687)"
.to_string()
),
],
);
}
#[test]
fn schema_describe_preserves_accepted_fixed_width_numeric_kind_labels() {
let id_slot = SchemaFieldSlot::new(0);
let x_slot = SchemaFieldSlot::new(1);
let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
SchemaVersion::initial(),
"entities::Grid".to_string(),
"Grid".to_string(),
FieldId::new(1),
SchemaRowLayout::new(
SchemaVersion::initial(),
vec![(FieldId::new(1), id_slot), (FieldId::new(2), x_slot)],
),
vec![
PersistedFieldSnapshot::new(
FieldId::new(1),
"id".to_string(),
id_slot,
PersistedFieldKind::Ulid,
Vec::new(),
false,
SchemaFieldDefault::None,
FieldStorageDecode::ByKind,
LeafCodec::StructuralFallback,
),
PersistedFieldSnapshot::new(
FieldId::new(2),
"x".to_string(),
x_slot,
PersistedFieldKind::Nat16,
Vec::new(),
false,
SchemaFieldDefault::None,
FieldStorageDecode::ByKind,
LeafCodec::Scalar(ScalarCodec::Nat64),
),
],
));
let described = describe_entity_fields_with_persisted_schema(&snapshot);
let x = described
.iter()
.find(|field| field.name() == "x")
.expect("accepted fixed-width field should be described");
assert_eq!(x.kind(), "nat16");
}
#[test]
fn schema_describe_uses_accepted_nested_leaf_metadata() {
let snapshot = AcceptedSchemaSnapshot::new(PersistedSchemaSnapshot::new(
SchemaVersion::initial(),
"entities::AcceptedProfile".to_string(),
"AcceptedProfile".to_string(),
FieldId::new(1),
SchemaRowLayout::new(
SchemaVersion::initial(),
vec![
(FieldId::new(1), SchemaFieldSlot::new(0)),
(FieldId::new(2), SchemaFieldSlot::new(1)),
],
),
vec![
PersistedFieldSnapshot::new(
FieldId::new(1),
"id".to_string(),
SchemaFieldSlot::new(0),
PersistedFieldKind::Ulid,
Vec::new(),
false,
SchemaFieldDefault::None,
FieldStorageDecode::ByKind,
LeafCodec::StructuralFallback,
),
PersistedFieldSnapshot::new(
FieldId::new(2),
"profile".to_string(),
SchemaFieldSlot::new(1),
PersistedFieldKind::Structured { queryable: true },
vec![PersistedNestedLeafSnapshot::new(
vec!["rank".to_string()],
PersistedFieldKind::Blob { max_len: None },
false,
FieldStorageDecode::ByKind,
LeafCodec::Scalar(ScalarCodec::Blob),
)],
false,
SchemaFieldDefault::None,
FieldStorageDecode::Value,
LeafCodec::StructuralFallback,
),
],
));
let described = describe_entity_fields_with_persisted_schema(&snapshot);
let rank = described
.iter()
.find(|field| field.name() == "└─ rank")
.expect("accepted nested leaf should be described");
assert_eq!(rank.slot(), None);
assert_eq!(rank.kind(), "blob(unbounded)");
assert!(rank.queryable());
}
#[test]
fn schema_describe_expands_generated_structured_field_leaves() {
static NESTED_FIELDS: [FieldModel; 3] = [
FieldModel::generated("name", FieldKind::Text { max_len: None }),
FieldModel::generated("level", FieldKind::Nat64),
FieldModel::generated("pid", FieldKind::Principal),
];
static FIELDS: [FieldModel; 2] = [
FieldModel::generated("id", FieldKind::Ulid),
FieldModel::generated_with_storage_decode_nullability_write_policies_and_nested_fields(
"mentor",
FieldKind::Structured { queryable: false },
FieldStorageDecode::Value,
false,
None,
None,
&NESTED_FIELDS,
),
];
static INDEXES: [&crate::model::index::IndexModel; 0] = [];
static MODEL: EntityModel = EntityModel::generated(
"entities::Character",
"Character",
&FIELDS[0],
0,
&FIELDS,
&INDEXES,
);
let described = describe_entity_model(&MODEL);
let described_fields = described
.fields()
.iter()
.map(|field| (field.name(), field.slot(), field.kind(), field.queryable()))
.collect::<Vec<_>>();
assert_eq!(
described_fields,
vec![
("id", Some(0), "ulid", true),
("mentor", Some(1), "structured", false),
("├─ name", None, "text(unbounded)", true),
("├─ level", None, "nat64", true),
("└─ pid", None, "principal", true),
],
);
}
}