diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..581e605 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,99 @@ +[target.'cfg(all())'] +rustflags = [ + "-Wclippy::all", + "-Wclippy::as_underscore", + "-Wclippy::await_holding_lock", + "-Wclippy::char_lit_as_u8", + "-Wclippy::checked_conversions", + "-Wclippy::clone_on_ref_ptr", + "-Wclippy::create_dir", + "-Wclippy::dbg_macro", + "-Wclippy::debug_assert_with_mut_call", + "-Wclippy::default_union_representation", + "-Wclippy::deref_by_slicing", + "-Wclippy::doc_markdown", + "-Wclippy::empty_enum", + "-Wclippy::empty_structs_with_brackets", + "-Wclippy::enum_glob_use", + "-Wclippy::exit", + "-Wclippy::expl_impl_clone_on_copy", + "-Wclippy::explicit_deref_methods", + "-Wclippy::explicit_into_iter_loop", + "-Wclippy::fallible_impl_from", + "-Wclippy::filetype_is_file", + "-Wclippy::filter_map_next", + "-Wclippy::flat_map_option", + "-Wclippy::float_cmp_const", + "-Wclippy::fn_params_excessive_bools", + "-Wclippy::from_iter_instead_of_collect", + "-Wclippy::get_unwrap", + "-Wclippy::if_let_mutex", + "-Wclippy::if_then_some_else_none", + "-Wclippy::implicit_clone", + "-Wclippy::imprecise_flops", + "-Wclippy::inefficient_to_string", + "-Wclippy::invalid_upcast_comparisons", + "-Wclippy::large_digit_groups", + "-Wclippy::large_stack_arrays", + "-Wclippy::large_types_passed_by_value", + "-Wclippy::let_unit_value", + "-Wclippy::linkedlist", + "-Wclippy::lossy_float_literal", + "-Wclippy::macro_use_imports", + "-Wclippy::manual_ok_or", + "-Wclippy::map_err_ignore", + "-Wclippy::map_flatten", + "-Wclippy::map_unwrap_or", + "-Wclippy::match_on_vec_items", + "-Wclippy::match_same_arms", + "-Wclippy::match_wild_err_arm", + "-Wclippy::match_wildcard_for_single_variants", + "-Wclippy::mem_forget", + "-Wclippy::mismatched_target_os", + "-Wclippy::missing_enforced_import_renames", + "-Wclippy::mod_module_files", + "-Wclippy::mut_mut", + "-Wclippy::mutex_integer", + "-Wclippy::needless_borrow", + "-Wclippy::needless_continue", + "-Wclippy::needless_for_each", + "-Wclippy::nursery", + "-Wclippy::option_option", + "-Wclippy::path_buf_push_overwrite", + "-Wclippy::pedantic", + "-Wclippy::print_stderr", + "-Wclippy::print_stdout", + "-Wclippy::ptr_as_ptr", + "-Wclippy::rc_buffer", + "-Wclippy::rc_mutex", + "-Wclippy::ref_option_ref", + "-Wclippy::rest_pat_in_fully_bound_structs", + "-Wclippy::same_functions_in_if_condition", + "-Wclippy::same_name_method", + "-Wclippy::semicolon_if_nothing_returned", + "-Wclippy::single_match_else", + "-Wclippy::str_to_string", + "-Wclippy::string_add", + "-Wclippy::string_add_assign", + "-Wclippy::string_lit_as_bytes", + "-Wclippy::string_slice", + "-Wclippy::string_to_string", + "-Wclippy::todo", + "-Wclippy::trait_duplication_in_bounds", + "-Wclippy::try_err", + "-Wclippy::undocumented_unsafe_blocks", + "-Wclippy::unnecessary_self_imports", + "-Wclippy::unnested_or_patterns", + "-Wclippy::unused_self", + "-Wclippy::unwrap_used", + "-Wclippy::use_debug", + "-Wclippy::useless_transmute", + "-Wclippy::verbose_file_reads", + "-Wclippy::zero_sized_map_values", + "-Wfuture_incompatible", + "-Wnonstandard_style", + "-Wunreachable_pub", + "-Aclippy::module_name_repetitions", + "-Aclippy::redundant_pub_crate", + "-Amissing_docs", +] diff --git a/.gitignore b/.gitignore index ebf9c4e..f107017 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,20 @@ .config/**/*.log dist/ node_modules/ -target/ test_artifacts/ + +### Rust template +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb diff --git a/README.md b/README.md index f375f0f..b187d46 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,13 @@ The [HASH] app seeks to enable its users to make better decisions by utilizing a - [`sim-core-plugins`](apps/sim-core-plugins) contains an example external plugin developed for hCore, which provides a visual interface for process modeling - [`sim-engine`](apps/sim-engine) contains [HASH Engine], a versatile agent-based simulation engine written in Rust (with support for TypeScript & Python sims) +## Libs + +### Block Protocol Libraries + +- [`turbine`](libs/turbine) +- [`turbine-transformer`](libs/turbine-transformer) + ## POCs The `pocs` folder contains **proof of concepts** and other one-off experiments. diff --git a/libs/turbine-transformer/.DS_Store b/libs/turbine-transformer/.DS_Store new file mode 100644 index 0000000..f00ba4a Binary files /dev/null and b/libs/turbine-transformer/.DS_Store differ diff --git a/libs/turbine-transformer/.cargo/config.toml b/libs/turbine-transformer/.cargo/config.toml new file mode 100644 index 0000000..581e605 --- /dev/null +++ b/libs/turbine-transformer/.cargo/config.toml @@ -0,0 +1,99 @@ +[target.'cfg(all())'] +rustflags = [ + "-Wclippy::all", + "-Wclippy::as_underscore", + "-Wclippy::await_holding_lock", + "-Wclippy::char_lit_as_u8", + "-Wclippy::checked_conversions", + "-Wclippy::clone_on_ref_ptr", + "-Wclippy::create_dir", + "-Wclippy::dbg_macro", + "-Wclippy::debug_assert_with_mut_call", + "-Wclippy::default_union_representation", + "-Wclippy::deref_by_slicing", + "-Wclippy::doc_markdown", + "-Wclippy::empty_enum", + "-Wclippy::empty_structs_with_brackets", + "-Wclippy::enum_glob_use", + "-Wclippy::exit", + "-Wclippy::expl_impl_clone_on_copy", + "-Wclippy::explicit_deref_methods", + "-Wclippy::explicit_into_iter_loop", + "-Wclippy::fallible_impl_from", + "-Wclippy::filetype_is_file", + "-Wclippy::filter_map_next", + "-Wclippy::flat_map_option", + "-Wclippy::float_cmp_const", + "-Wclippy::fn_params_excessive_bools", + "-Wclippy::from_iter_instead_of_collect", + "-Wclippy::get_unwrap", + "-Wclippy::if_let_mutex", + "-Wclippy::if_then_some_else_none", + "-Wclippy::implicit_clone", + "-Wclippy::imprecise_flops", + "-Wclippy::inefficient_to_string", + "-Wclippy::invalid_upcast_comparisons", + "-Wclippy::large_digit_groups", + "-Wclippy::large_stack_arrays", + "-Wclippy::large_types_passed_by_value", + "-Wclippy::let_unit_value", + "-Wclippy::linkedlist", + "-Wclippy::lossy_float_literal", + "-Wclippy::macro_use_imports", + "-Wclippy::manual_ok_or", + "-Wclippy::map_err_ignore", + "-Wclippy::map_flatten", + "-Wclippy::map_unwrap_or", + "-Wclippy::match_on_vec_items", + "-Wclippy::match_same_arms", + "-Wclippy::match_wild_err_arm", + "-Wclippy::match_wildcard_for_single_variants", + "-Wclippy::mem_forget", + "-Wclippy::mismatched_target_os", + "-Wclippy::missing_enforced_import_renames", + "-Wclippy::mod_module_files", + "-Wclippy::mut_mut", + "-Wclippy::mutex_integer", + "-Wclippy::needless_borrow", + "-Wclippy::needless_continue", + "-Wclippy::needless_for_each", + "-Wclippy::nursery", + "-Wclippy::option_option", + "-Wclippy::path_buf_push_overwrite", + "-Wclippy::pedantic", + "-Wclippy::print_stderr", + "-Wclippy::print_stdout", + "-Wclippy::ptr_as_ptr", + "-Wclippy::rc_buffer", + "-Wclippy::rc_mutex", + "-Wclippy::ref_option_ref", + "-Wclippy::rest_pat_in_fully_bound_structs", + "-Wclippy::same_functions_in_if_condition", + "-Wclippy::same_name_method", + "-Wclippy::semicolon_if_nothing_returned", + "-Wclippy::single_match_else", + "-Wclippy::str_to_string", + "-Wclippy::string_add", + "-Wclippy::string_add_assign", + "-Wclippy::string_lit_as_bytes", + "-Wclippy::string_slice", + "-Wclippy::string_to_string", + "-Wclippy::todo", + "-Wclippy::trait_duplication_in_bounds", + "-Wclippy::try_err", + "-Wclippy::undocumented_unsafe_blocks", + "-Wclippy::unnecessary_self_imports", + "-Wclippy::unnested_or_patterns", + "-Wclippy::unused_self", + "-Wclippy::unwrap_used", + "-Wclippy::use_debug", + "-Wclippy::useless_transmute", + "-Wclippy::verbose_file_reads", + "-Wclippy::zero_sized_map_values", + "-Wfuture_incompatible", + "-Wnonstandard_style", + "-Wunreachable_pub", + "-Aclippy::module_name_repetitions", + "-Aclippy::redundant_pub_crate", + "-Amissing_docs", +] diff --git a/libs/turbine-transformer/Cargo.toml b/libs/turbine-transformer/Cargo.toml new file mode 100644 index 0000000..d926999 --- /dev/null +++ b/libs/turbine-transformer/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "turbine-transformer" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +petgraph = "0.6.3" +error-stack = { version = "0.4.1", default-features = false } +funty = "3.0.0-rc2" +serde_json = "1.0.96" +ordered-float = "3.7.0" +paste = "1.0.12" +hashbrown = "0.13.2" + +turbine = { path = "../turbine/lib/turbine" } diff --git a/libs/turbine-transformer/README.md b/libs/turbine-transformer/README.md new file mode 100644 index 0000000..a7a3323 --- /dev/null +++ b/libs/turbine-transformer/README.md @@ -0,0 +1,79 @@ +# `turbine-transformer` + +> Origin of the name: A wind turbine uses wind to generate electricity through rotation. The transformer is used to +> transform the voltage. This project aims to take the input from the turbine and transform it, by applying a set of +> instructions. + + +The goal of the project is to supplement the HASH REST-APIs query abilities with a more powerful query language. This +has some trade-offs, you will still need to load in all entities from HASH, but you can then filter them down to the +ones you want. + +This is _very_ early in development, and is not ready for production use. Tests are missing, and the API is not stable. +Especially the names of the different types are likely to change. Do not expect this to be usable in production, over +the next weeks and months I will be working on this project to make it more stable and usable. + +## Examples + +```rust +use turbine_transformer::View; + +fn main() { + let view = View::new(&mut subgraph.entities); + let always_include = /* entity id */; + + view.select(vec![ + Statement::type_() + .or_id(always_include) + .or_type::() + .or_type::() + .or_inherits_from::() + .and( + PropertyMatch::equals( + JsonPath::new().then::(), + "John Doe" + ) + ) + .with_links() + .with_left( + TypeMatch::new() + .or_type::() + .or_type::() + ) + .with_right( + TypeMatch::new() + .or_type::() + .or_type::() + ) + ]); + + // You can also update selected entities + view.select_properties(vec![ + Select::new(TypeMatch::new().or_type::(), Action::Exclude) + .do_(StaticAction::new::()) + .do_(StaticAction::new::()) + .do_(StaticAction::new::()) + ]); + + // ... or change the value of specific properties + view.update_properties(vec![ + Update::new(TypeMatch::new().or_type::()) + .do_(StaticUpdate::new::("John Doe")) + ]); + + // or remap a specific user name to a different value + view.update_properties(vec![ + Update::new(TypeMatch::new().or_type::().or( + PropertyMatch::equals( + JsonPath::new().then::(), + "John Doe" + ) + )) + .do_(StaticUpdate::new::("Doe John")) + ]); +} +``` + +## Credit + +Developed by [Bilal Mahmoud](https://github.com/indietyp). diff --git a/libs/turbine-transformer/rust-toolchain.toml b/libs/turbine-transformer/rust-toolchain.toml new file mode 100644 index 0000000..8183ca1 --- /dev/null +++ b/libs/turbine-transformer/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly-2023-08-28" +components = ["cargo", "clippy", "rustfmt", "rust-std", "rust-src"] diff --git a/libs/turbine-transformer/rustfmt.toml b/libs/turbine-transformer/rustfmt.toml new file mode 100644 index 0000000..1e35be7 --- /dev/null +++ b/libs/turbine-transformer/rustfmt.toml @@ -0,0 +1,30 @@ +# General +edition = "2021" # Default: "2015" +unstable_features = true # Default: false +version = "Two" # Default: "One" + +# Settings +condense_wildcard_suffixes = true # Default: false +overflow_delimited_expr = true # Default: false +reorder_impl_items = true # Default: false +use_field_init_shorthand = true # Default: false +use_try_shorthand = true # Default: false +wrap_comments = true # Default: false + +# Parameters +comment_width = 100 # Default: 80 +hex_literal_case = "Upper" # Default: "Preserve" + +# Areas +format_code_in_doc_comments = true # Default: false +format_generated_files = true # Default: false +format_macro_matchers = true # Default: false +format_macro_bodies = true # Default: false +format_strings = true # Default: false + +# Imports +imports_granularity = "Crate" # Default: "Preserve" +group_imports = "StdExternalCrate" # Default: "Preserve" + +# Diagnostics +error_on_unformatted = true # Default: false diff --git a/libs/turbine-transformer/src/.DS_Store b/libs/turbine-transformer/src/.DS_Store new file mode 100644 index 0000000..baa29cd Binary files /dev/null and b/libs/turbine-transformer/src/.DS_Store differ diff --git a/libs/turbine-transformer/src/lib.rs b/libs/turbine-transformer/src/lib.rs new file mode 100644 index 0000000..5f16fbc --- /dev/null +++ b/libs/turbine-transformer/src/lib.rs @@ -0,0 +1,182 @@ +#![no_std] +#![feature(error_in_core)] +#![feature(impl_trait_in_assoc_type)] + +mod path; +pub mod property; +mod reachable; +pub mod select; +mod value; + +extern crate alloc; + +use alloc::collections::{BTreeMap, BTreeSet}; + +use petgraph::{graph::NodeIndex, Graph}; +use turbine::{ + entity::{Entity, EntityId, LinkData}, + VersionedUrl, VersionedUrlRef, +}; + +const fn no_lookup(_: VersionedUrlRef) -> BTreeSet> { + BTreeSet::new() +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum LinkEdge { + Left, + Right, +} + +pub struct View<'a> { + graph: Graph, + entities: &'a mut [Entity], + + exclude: BTreeSet, + + lookup: BTreeMap, + lookup_index: BTreeMap, + lookup_inherits_from: fn(VersionedUrlRef) -> BTreeSet>, +} + +impl<'a> View<'a> { + fn empty() -> Self { + Self { + graph: Graph::new(), + entities: &mut [], + + exclude: BTreeSet::new(), + + lookup: BTreeMap::new(), + lookup_index: BTreeMap::new(), + lookup_inherits_from: no_lookup, + } + } + + fn prepare(&mut self, entities: &[Entity]) { + for (index, entity) in entities.iter().enumerate() { + self.lookup_index + .insert(entity.metadata.record_id.entity_id, index); + } + } + + fn get_or_create(&mut self, id: EntityId) -> NodeIndex { + if let Some(node) = self.lookup.get(&id) { + return *node; + } + + let node = self.graph.add_node(id); + self.lookup.insert(id, node); + + node + } + + fn exclude_complement(&mut self, nodes: &BTreeSet) { + let indices: BTreeSet<_> = self.graph.node_indices().collect(); + + let complement = &indices - nodes; + self.exclude = &complement | &self.exclude; + } + + fn exclude(&mut self, nodes: &BTreeSet) { + self.exclude = nodes | &self.exclude; + } + + #[must_use] + pub fn new(entities: &'a mut [Entity]) -> Self { + let mut this = Self::empty(); + this.prepare(entities); + + for (index, entity) in entities.iter().enumerate() { + let node = this.get_or_create(entity.metadata.record_id.entity_id); + this.lookup_index + .insert(entity.metadata.record_id.entity_id, index); + + if let Some(link_data) = entity.link_data { + let lhs = this.get_or_create(link_data.left_entity_id); + let rhs = this.get_or_create(link_data.right_entity_id); + + this.graph.add_edge(lhs, node, LinkEdge::Left); + this.graph.add_edge(node, rhs, LinkEdge::Right); + } + } + + this.entities = entities; + this + } + + #[must_use] + pub const fn entities(&self) -> &[Entity] { + self.entities + } + + #[must_use] + pub fn entity(&self, id: EntityId) -> Option<&Entity> { + let index = *self.lookup_index.get(&id)?; + + self.entities.get(index) + } + + #[must_use] + fn entity_type(&self, id: EntityId) -> Option { + let index = *self.lookup_index.get(&id)?; + let entity = self.entities.get(index)?; + + Some(VersionedUrlRef::from(&entity.metadata.entity_type_id)) + } + + #[must_use] + fn entity_link(&self, id: EntityId) -> Option { + let index = *self.lookup_index.get(&id)?; + let entity = self.entities.get(index)?; + + entity.link_data + } + + #[must_use] + pub const fn graph(&self) -> &Graph { + &self.graph + } + + #[must_use] + pub const fn excluded(&self) -> &BTreeSet { + &self.exclude + } + + #[must_use] + pub const fn lookup(&self) -> &BTreeMap { + &self.lookup + } + + #[must_use] + pub fn with_inherits_from( + mut self, + lookup_inherits_from: fn(VersionedUrlRef) -> BTreeSet>, + ) -> Self { + self.lookup_inherits_from = lookup_inherits_from; + + self + } +} + +impl<'a> IntoIterator for View<'a> { + type Item = &'a Entity; + + type IntoIter = impl Iterator; + + fn into_iter(self) -> Self::IntoIter { + self.entities.iter().filter(move |entity| { + let Some(node) = self.lookup.get(&entity.metadata.record_id.entity_id) else { + return false; + }; + + !self.exclude.contains(node) + }) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn compile() {} +} diff --git a/libs/turbine-transformer/src/path.rs b/libs/turbine-transformer/src/path.rs new file mode 100644 index 0000000..477857f --- /dev/null +++ b/libs/turbine-transformer/src/path.rs @@ -0,0 +1,168 @@ +use alloc::{ + borrow::{Cow, ToOwned}, + vec::Vec, +}; + +use turbine::{entity::Entity, BaseUrl, BaseUrlRef, TypeUrl}; + +use crate::value::{Object, Value}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum Segment<'a> { + Field(Cow<'a, str>), + Index(usize), +} + +impl<'a> From> for Segment<'a> { + fn from(value: BaseUrlRef<'a>) -> Self { + Self::Field(Cow::Borrowed(value.as_str())) + } +} + +impl<'a> From for Segment<'a> { + fn from(value: BaseUrl) -> Self { + Self::Field(Cow::Owned(value.as_str().to_owned())) + } +} + +impl<'a> From for Segment<'a> { + fn from(value: usize) -> Self { + Self::Index(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct JsonPath<'a>(Cow<'a, [Segment<'a>]>); + +impl<'a> JsonPath<'a> { + #[must_use] + pub const fn new() -> Self { + Self(Cow::Owned(Vec::new())) + } + + #[must_use] + pub const fn from_slice(segments: &'a [Segment<'a>]) -> Self { + Self(Cow::Borrowed(segments)) + } + + #[must_use] + pub fn then(mut self) -> Self { + self.0.to_mut().push(T::ID.base().into()); + self + } + + #[must_use] + pub fn then_field(mut self, field: impl Into>) -> Self { + self.0.to_mut().push(Segment::Field(field.into())); + self + } + + #[must_use] + pub fn then_index(mut self, index: usize) -> Self { + self.0.to_mut().push(Segment::Index(index)); + self + } + + pub(crate) fn segments(&self) -> &[Segment] { + &self.0 + } + + pub(crate) fn traverse_entity<'b>(&self, entity: &'b Entity) -> Option> { + let value = entity.properties.properties(); + + if self.0.is_empty() { + return Some( + value + .iter() + .map(|(key, value)| (Value::from(key.as_str()), Value::from(value))) + .collect::() + .into(), + ); + } + + let (first, rest) = self.0.split_first()?; + + let value = match first { + Segment::Field(field) => value.get(field.as_ref())?, + Segment::Index(_) => { + return None; + } + }; + + JsonPath(Cow::Borrowed(rest)).traverse(value) + } + + fn traverse<'b>(&self, value: &'b serde_json::Value) -> Option> { + let mut value = value; + + for segment in self.0.iter() { + match segment { + Segment::Field(field) => { + value = value.get(field.as_ref())?; + } + Segment::Index(index) => { + value = value.get(index)?; + } + } + } + + Some(value.into()) + } + + pub(crate) fn set(&self, target: &mut serde_json::Value, value: Value<'a>) { + if self.0.is_empty() { + *target = value.into(); + return; + } + + let (first, rest) = self.0.split_first().expect("infallible"); + + let target = match first { + Segment::Field(field) => { + if let serde_json::Value::Object(object) = target { + object.get_mut(field.as_ref()) + } else { + return; + } + } + Segment::Index(index) => { + if let serde_json::Value::Array(array) = target { + array.get_mut(*index) + } else { + return; + } + } + }; + + let Some(target) = target else { + return; + }; + + JsonPath(Cow::Borrowed(rest)).set(target, value); + } + + pub(crate) fn set_entity(&self, entity: &mut Entity, value: Value<'a>) { + if self.0.is_empty() { + if let Value::Object(object) = value { + entity.properties = object.into(); + } + + return; + } + + let (first, rest) = self.0.split_first().expect("infallible"); + + let target = match first { + Segment::Field(field) => entity.properties.properties_mut().get_mut(field.as_ref()), + Segment::Index(_) => { + return; + } + }; + + let Some(target) = target else { + return; + }; + + JsonPath(Cow::Borrowed(rest)).set(target, value); + } +} diff --git a/libs/turbine-transformer/src/property.rs b/libs/turbine-transformer/src/property.rs new file mode 100644 index 0000000..fa27920 --- /dev/null +++ b/libs/turbine-transformer/src/property.rs @@ -0,0 +1,5 @@ +mod select; +mod update; + +pub use select::{Action, ActionStatement, DynamicAction, PropertySelect, StaticAction}; +pub use update::{DynamicUpdate, PropertyUpdate, StaticUpdate, Update, UpdateStatement}; diff --git a/libs/turbine-transformer/src/property/select.rs b/libs/turbine-transformer/src/property/select.rs new file mode 100644 index 0000000..d4d5e65 --- /dev/null +++ b/libs/turbine-transformer/src/property/select.rs @@ -0,0 +1,265 @@ +use alloc::{borrow::Cow, boxed::Box, collections::BTreeSet, vec::Vec}; + +use turbine::{ + entity::{Entity, EntityId}, + TypeUrl, +}; + +use crate::{ + select::{Clause, JsonPath, Segment}, + View, +}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum Action { + Exclude, + Include, +} + +impl Action { + const fn reverse(self) -> Self { + match self { + Self::Exclude => Self::Include, + Self::Include => Self::Exclude, + } + } +} + +type DynamicActionFn<'a> = dyn Fn(&Entity) -> Option + 'a; +type BoxedDynamicActionFn = Box>; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +enum Then { + Explicit(Action), + // The reverse of the default action + Implicit, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StaticAction<'a> { + path: JsonPath<'a>, + then: Then, +} + +impl StaticAction<'static> { + #[must_use] + pub fn new() -> Self { + Self { + path: JsonPath::new().then::(), + then: Then::Implicit, + } + } +} + +impl<'a> StaticAction<'a> { + #[must_use] + pub fn new_with_path(path: impl Into>) -> Self { + Self { + path: path.into(), + then: Then::Implicit, + } + } + + #[must_use] + pub const fn then(mut self, action: Action) -> Self { + self.then = Then::Explicit(action); + self + } +} + +pub struct DynamicAction<'a> { + path: JsonPath<'a>, + then: BoxedDynamicActionFn, +} + +impl DynamicAction<'static> { + #[must_use] + pub fn new(then: impl Fn(&Entity) -> Option + 'static) -> Self { + Self { + path: JsonPath::new().then::(), + then: Box::new(then), + } + } +} + +impl<'a> DynamicAction<'a> { + #[must_use] + pub fn new_with_path( + path: impl Into>, + then: impl Fn(&Entity) -> Option + 'static, + ) -> Self { + Self { + path: path.into(), + then: Box::new(then), + } + } +} + +pub enum ActionStatement<'a> { + Static(StaticAction<'a>), + Dynamic(DynamicAction<'a>), +} + +impl<'a> From> for ActionStatement<'a> { + fn from(action: StaticAction<'a>) -> Self { + Self::Static(action) + } +} + +impl<'a> From> for ActionStatement<'a> { + fn from(action: DynamicAction<'a>) -> Self { + Self::Dynamic(action) + } +} + +pub struct Select<'a> { + if_: Clause<'a>, + + actions: Vec>, + default: Action, +} + +impl<'a> Select<'a> { + pub fn new(if_: impl Into>, default: Action) -> Self { + Self { + if_: if_.into(), + actions: Vec::new(), + default, + } + } + + pub fn do_(mut self, action: impl Into>) -> Self { + self.actions.push(action.into()); + self + } + + fn matches(&self, view: &View, id: EntityId) -> bool { + self.if_.matches(view, id) + } + + fn apply(&self, entity: &mut Entity) { + // for every depth, find the matching properties that we need to include + let mut included = BTreeSet::new(); + + // depending on the default action, we either include all properties or exclude all + // properties by default + if self.default == Action::Include { + for keys in entity.properties.properties().keys() { + included.insert(Cow::Owned(keys.clone())); + } + } + + for action in &self.actions { + match action { + ActionStatement::Static(action) => { + assert!( + action.path.segments().len() <= 1, + "PropertySelect does not support nested paths (yet)" + ); + + let [key] = action.path.segments() else { + continue; + }; + + match key { + Segment::Index(_) => continue, + Segment::Field(field) => { + if let Some(action) = match action.then { + Then::Explicit(action) => Some(action), + Then::Implicit => Some(self.default.reverse()), + } { + if action == Action::Include { + included.insert(Cow::Borrowed(field.as_ref())); + } else { + included.remove(field.as_ref()); + } + } + } + } + } + ActionStatement::Dynamic(action) => { + assert!( + action.path.segments().len() <= 1, + "PropertySelect does not support nested paths (yet)" + ); + + let [key] = action.path.segments() else { + continue; + }; + + match key { + Segment::Index(_) => continue, + Segment::Field(field) => { + if let Some(action) = (action.then)(entity) { + if action == Action::Include { + included.insert(Cow::Borrowed(field.as_ref())); + } else { + included.remove(field.as_ref()); + } + } + } + } + } + } + } + + entity + .properties + .properties_mut() + .retain(|key, _| included.contains(key.as_str())); + } +} + +pub struct PropertySelect<'a> { + statements: Vec>, +} + +impl<'a> PropertySelect<'a> { + #[must_use] + pub const fn new() -> Self { + Self { + statements: Vec::new(), + } + } + + #[must_use] + pub fn and(mut self, clause: impl Into>) -> Self { + self.statements.push(clause.into()); + self + } + + fn eval(select: &Select, view: &mut View) { + // We need to precompute the matches because we can't borrow the view immutably while we're + // mutating it. + let matches: BTreeSet<_> = view + .entities + .iter() + .enumerate() + .filter(|(_, entity)| select.matches(view, entity.metadata.record_id.entity_id)) + .map(|(index, _)| index) + .collect(); + + let entities = view + .entities + .iter_mut() + .enumerate() + .filter(|(index, _)| matches.contains(index)) + .map(|(_, entity)| entity); + + for entity in entities { + select.apply(entity); + } + } + + fn run(self, view: &mut View) { + for statement in &self.statements { + Self::eval(statement, view); + } + } +} + +impl<'a> View<'a> { + pub fn select_properties(&mut self, statements: Vec