Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
235 changes: 170 additions & 65 deletions crates/registry-breg/src/compiler.rs

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions crates/registry-breg/src/history_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,7 @@ mod tests {
attachments: Default::default(),
primary_dataset: None,
id: "membership".to_owned(),
source_module: None,
route: "memberships".to_owned(),
mutation_mode: MutationMode::Mutable,
tombstone: true,
Expand Down Expand Up @@ -842,6 +843,7 @@ mod tests {
access_profiles: BTreeMap::new(),
membership_boundaries: BTreeMap::new(),
hooks: BTreeMap::new(),
module_origins: Default::default(),
}
}

Expand Down
46 changes: 46 additions & 0 deletions crates/registry-breg/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,9 @@ pub struct CompiledMembershipBoundary {
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct CompiledEntity {
pub id: String,
/// The module that declared this entity. Absent means the project root.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_module: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub primary_dataset: Option<String>,
pub route: String,
Expand Down Expand Up @@ -783,6 +786,49 @@ pub struct CompiledEntity {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub membership_boundaries: BTreeMap<String, Vec<CompiledMembershipBoundary>>,
pub hooks: BTreeMap<String, HookSource>,
/// Which module contributed each id in this entity's id-keyed
/// collections. An id absent from a map was contributed by the project
/// root. Two modules can never declare the same id (every level is a
/// compile error), so this is exactly one contributing module per id,
/// never a list.
#[serde(default, skip_serializing_if = "CompiledEntityModuleOrigins::is_empty")]
pub module_origins: CompiledEntityModuleOrigins,
}

/// Per-collection module provenance for one [`CompiledEntity`]. See
/// [`CompiledEntity::module_origins`].
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct CompiledEntityModuleOrigins {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub fields: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub constraints: BTreeMap<String, String>,
Comment thread
jeremi marked this conversation as resolved.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub hooks: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub derived_relations: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub indexes: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub access_profiles: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub selector_profiles: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub read_paths: BTreeMap<String, String>,
}

impl CompiledEntityModuleOrigins {
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
&& self.constraints.is_empty()
&& self.hooks.is_empty()
&& self.derived_relations.is_empty()
&& self.indexes.is_empty()
&& self.access_profiles.is_empty()
&& self.selector_profiles.is_empty()
&& self.read_paths.is_empty()
}
}

/// Governed catalogue projection with every resource reference resolved once.
Expand Down
2 changes: 1 addition & 1 deletion crates/registry-breg/tests/compiler_submitter_targets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use registry_breg::model::CompiledQueryFilterOperator;
use serde_json::{json, Value};

fn source() -> Value {
serde_json::from_slice(include_bytes!(
serde_norway::from_slice(include_bytes!(
"../../../products/breg/starters/professional-licences/core/registry.yaml"
))
.unwrap()
Expand Down
206 changes: 206 additions & 0 deletions crates/registry-breg/tests/module_provenance.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: Apache-2.0
//! An entity's `sourceModule`, and the per-collection `moduleOrigins` side
//! block, name the module that contributed each id a compiled entity carries.
//! This extends the precedent `CompiledAction.source_module` already set:
//! two modules can never declare the same id (every level is a compile
//! error), so provenance is exactly one `Option<String>` per id, `Some`
//! naming a module or absent meaning the project root.

use registry_breg::compiler::{compile_project, CompileProfile};
use registry_breg::contract::{parse_module_json, parse_project_json, RegistryModule};
use registry_breg::model::CompiledEntity;

fn provenance_project() -> registry_breg::contract::RegistryProject {
parse_project_json(
br#"{
"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject",
"registry":{"id":"module-provenance","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://module-provenance.example.test"},
"modules":[{"id":"declarer","version":"1"},{"id":"extender","version":"1"}],
"entities":[{
"id":"root-case","primaryDataset":"test-dataset","route":"root-cases","mutationMode":"mutable",
"fields":[{"id":"root-field","type":"string","maxLength":8,"classification":"internal"}]
}],
"accessProfiles":[{
"id":"reader","default":true,"principalClaim":"principal","permissions":[
{"entity":"root-case","operations":["get"],"readableFields":["root-field"],"rowBoundaries":[]},
{"entity":"module-case","operations":["get"],"readableFields":["base-field","extra-field"],"rowBoundaries":[]}
]
}]
}"#,
)
.expect("project parses")
}

fn declarer_module() -> RegistryModule {
parse_module_json(
br#"{"id":"declarer","version":"1","entities":[{
"id":"module-case","primaryDataset":"test-dataset","route":"module-cases","mutationMode":"mutable",
"fields":[{"id":"base-field","type":"string","maxLength":8,"classification":"internal"}],
"hooks":[{"id":"module-case-created","phase":"after","trigger":"created","projection":["base-field"]}],
"constraints":[{"kind":"unique","fields":["base-field"]}]
}]}"#,
)
.expect("module parses")
}

fn extender_module() -> RegistryModule {
parse_module_json(
br#"{"id":"extender","version":"1","extendEntities":[{
"entity":"module-case",
"fields":[{"id":"extra-field","type":"string","maxLength":8,"classification":"internal"}],
"hooks":[{"id":"module-case-extended","phase":"after","trigger":"created","projection":["extra-field"]}],
"constraints":[{"kind":"unique","fields":["extra-field"]}]
}]}"#,
)
.expect("module parses")
}

fn compile_fixture() -> registry_breg::CompiledRegistry {
let project = provenance_project();
let modules = vec![declarer_module(), extender_module()];
compile_project(&project, &modules, CompileProfile::Authoring)
.unwrap_or_else(|failure| panic!("module provenance fixture compiles: {failure:?}"))
}

fn constraint_id_for<'a>(entity: &'a CompiledEntity, field: &str) -> &'a str {
entity
.constraints
.iter()
.find_map(|(id, constraint)| match constraint {
registry_breg::contract::ConstraintSource::Unique { fields, .. }
if fields.len() == 1 && fields[0] == field =>
{
Some(id.as_str())
}
_ => None,
})
.unwrap_or_else(|| panic!("a unique constraint over `{field}` was compiled"))
}

#[test]
fn entity_declared_by_module_carries_source_module() {
let compiled = compile_fixture();
let module_case = compiled
.entities()
.get("module-case")
.expect("module-declared entity compiled");
assert_eq!(module_case.source_module.as_deref(), Some("declarer"));
}

#[test]
fn entity_declared_at_project_root_omits_source_module_key() {
let compiled = compile_fixture();
let root_case = compiled
.entities()
.get("root-case")
.expect("project-root entity compiled");
assert_eq!(root_case.source_module, None);

let serialized = serde_json::to_value(root_case).expect("entity serializes");
assert!(
serialized.get("sourceModule").is_none(),
"a project-root entity must not serialize a sourceModule key, got {serialized}"
);
assert!(
serialized.get("moduleOrigins").is_none(),
"a project-root entity with no module contributions must not serialize a moduleOrigins key, got {serialized}"
);
}

#[test]
fn extension_attributes_appended_ids_to_the_extending_module_only() {
let compiled = compile_fixture();
let module_case = compiled
.entities()
.get("module-case")
.expect("extended entity compiled");

// The entity is owned by the module that declared it, not the module
// that later extended it.
assert_eq!(module_case.source_module.as_deref(), Some("declarer"));

// The field, hook, and constraint the entity declared itself are
// attributed to the declaring module.
assert_eq!(
module_case.module_origins.fields.get("base-field"),
Some(&"declarer".to_owned())
);
assert_eq!(
module_case.module_origins.hooks.get("module-case-created"),
Some(&"declarer".to_owned())
);
let base_constraint_id = constraint_id_for(module_case, "base-field");
assert_eq!(
module_case
.module_origins
.constraints
.get(base_constraint_id),
Some(&"declarer".to_owned())
);

// The field, hook, and constraint the extension appended are
// attributed to the extending module, and only to it.
assert_eq!(
module_case.module_origins.fields.get("extra-field"),
Some(&"extender".to_owned())
);
assert_eq!(
module_case.module_origins.hooks.get("module-case-extended"),
Some(&"extender".to_owned())
);
let extra_constraint_id = constraint_id_for(module_case, "extra-field");
assert_eq!(
module_case
.module_origins
.constraints
.get(extra_constraint_id),
Some(&"extender".to_owned())
);

// Neither the entity nor its originally-declared ids are attributed to
// the extending module.
assert_ne!(
module_case.module_origins.fields.get("base-field"),
Some(&"extender".to_owned())
);
assert_ne!(
module_case.module_origins.hooks.get("module-case-created"),
Some(&"extender".to_owned())
);
}

#[test]
fn module_free_project_omits_source_module_and_module_origins() {
let project = parse_project_json(
br#"{
"apiVersion":"registry.registrystack.org/v1alpha1","kind":"RegistryProject",
"registry":{"id":"module-free","version":"1","defaultLanguage":"en","canonicalBaseIri":"https://module-free.example.test"},
"entities":[{
"id":"case","primaryDataset":"test-dataset","route":"cases","mutationMode":"mutable",
"fields":[{"id":"label","type":"string","maxLength":8,"classification":"internal"}],
"hooks":[{"id":"case-created","phase":"after","trigger":"created","projection":["label"]}],
"constraints":[{"kind":"unique","fields":["label"]}]
}],
"accessProfiles":[{
"id":"reader","default":true,"principalClaim":"principal","permissions":[
{"entity":"case","operations":["get"],"readableFields":["label"],"rowBoundaries":[]}
]
}]
}"#,
)
.expect("project parses");

let compiled = compile_project(&project, &[], CompileProfile::Authoring)
.unwrap_or_else(|failure| panic!("module-free fixture compiles: {failure:?}"));
let case = compiled
.entities()
.get("case")
.expect("module-free entity compiled");

assert_eq!(case.source_module, None);
assert!(case.module_origins.is_empty());

let serialized = serde_json::to_value(case).expect("entity serializes");
assert!(serialized.get("sourceModule").is_none());
assert!(serialized.get("moduleOrigins").is_none());
}
6 changes: 3 additions & 3 deletions crates/registry-breg/tests/starter_projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ fn published_starters_compile_with_reviewed_update_policy() {
for (source, _) in STARTERS {
let project = parse_project_yaml(source).expect("starter source parses");
compile_project(&project, &[], CompileProfile::Production).expect("starter compiles");
let mut changed: serde_json::Value = serde_json::from_slice(source).unwrap();
let mut changed: serde_norway::Value = serde_norway::from_slice(source).unwrap();
let editor = changed["accessProfiles"]
.as_array_mut()
.as_sequence_mut()
.unwrap()
.iter_mut()
.find(|profile| profile["id"] == "editor")
.unwrap();
editor["permissions"][0]["operations"]
.as_array_mut()
.as_sequence_mut()
.unwrap()
.push("patch".into());
let changed = parse_project_yaml(&serde_json::to_vec(&changed).unwrap()).unwrap();
Expand Down
4 changes: 2 additions & 2 deletions crates/registry-breg/tests/support/submitter_targets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ fn quote(value: &str) -> String {
}

fn starter_source() -> Value {
let mut source: Value = serde_json::from_slice(include_bytes!(
let mut source: Value = serde_norway::from_slice(include_bytes!(
"../../../../products/breg/starters/professional-licences/core/registry.yaml"
))
.expect("starter parses");
Expand Down Expand Up @@ -35,7 +35,7 @@ fn starter() -> registry_breg::CompiledRegistry {
}

fn create_only_guard_starter() -> registry_breg::CompiledRegistry {
let mut source: Value = serde_json::from_slice(include_bytes!(
let mut source: Value = serde_norway::from_slice(include_bytes!(
"../../../../products/breg/starters/professional-licences/core/registry.yaml"
))
.expect("starter source parses");
Expand Down
Loading
Loading