Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: scenario field filters #303

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
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
193 changes: 183 additions & 10 deletions core/src/schema/scenario.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
use anyhow::{Context, Result};
use std::path::PathBuf;
use serde::Deserialize;
use std::{collections::BTreeMap, path::PathBuf};

use crate::Namespace;
use crate::{Content, Namespace};

pub struct Scenario {
namespace: Namespace,
scenario: Namespace,
scenario: ScenarioNamespace,
name: String,
}

#[derive(Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
struct ScenarioNamespace {
#[serde(flatten)]
collections: BTreeMap<String, ScenarioCollection>,
}

#[derive(Deserialize)]
#[cfg_attr(test, derive(Debug, PartialEq))]
struct ScenarioCollection {
#[serde(flatten)]
fields: BTreeMap<String, Content>,
}

impl Scenario {
pub fn new(namespace: Namespace, namespace_path: PathBuf, scenario: &str) -> Result<Self> {
let scenario_path = namespace_path
Expand Down Expand Up @@ -41,6 +56,7 @@ impl Scenario {
self.has_extra_collections()
.context(anyhow!("failed to build scenario '{}'", self.name))?;
self.trim_namespace_collections();
self.trim_fields()?;

Ok(self.namespace)
}
Expand All @@ -50,8 +66,9 @@ impl Scenario {

let extra_collections: Vec<_> = self
.scenario
.collections
.keys()
.filter(|c| !collections.contains(c))
.filter(|c| !collections.contains(&c.as_str()))
.collect();

if !extra_collections.is_empty() {
Expand All @@ -71,21 +88,73 @@ impl Scenario {
}

fn trim_namespace_collections(&mut self) {
let scenario_collections: Vec<_> = self.scenario.keys().collect();
let scenario_collections: Vec<_> = self.scenario.collections.keys().collect();

let trim_collections: Vec<_> = self
.namespace
.keys()
.map(ToOwned::to_owned)
.into_iter()
.filter(|c| !scenario_collections.contains(&c.as_str()))
.filter(|c| !scenario_collections.contains(&c))
.collect();

for trim_collection in trim_collections {
debug!("removing collection '{}'", trim_collection);
self.namespace.remove_collection(&trim_collection);
}
}

fn trim_fields(&mut self) -> Result<()> {
for (name, collection) in self.scenario.collections.iter() {
// Nothing to trim
if collection.fields.is_empty() {
continue;
}

let namespace_collection = self.namespace.get_collection_mut(name)?;

Self::trim_collection_fields(namespace_collection, &collection.fields)
.context(anyhow!("failed to trim collection '{}'", name))?;
}

Ok(())
}

fn trim_collection_fields(
collection: &mut Content,
fields: &BTreeMap<String, Content>,
) -> Result<()> {
match collection {
Content::Object(map) => {
let map_keys: Vec<_> = map.fields.keys().collect();

for field in fields.keys() {
if !map_keys.contains(&field) {
return Err(anyhow!(
"'{}' is not a field on the object, therefore it cannot be included",
field
));
}
}
let trim_fields: Vec<_> = map_keys
.into_iter()
.filter(|c| !fields.contains_key(c.as_str()))
.map(ToOwned::to_owned)
.collect();

for trim_field in trim_fields {
debug!("removing field '{}'", trim_field);
map.fields.remove(trim_field.as_str());
}
}
Content::Array(arr) => {
Self::trim_collection_fields(&mut arr.content, fields)?;
}
_ => return Err(anyhow!("cannot select fields to include from a non-object")),
};

Ok(())
}
}

#[cfg(test)]
Expand All @@ -101,12 +170,21 @@ mod tests {

use super::Scenario;

macro_rules! scenario {
macro_rules! namespace {
{
$($inner:tt)*
} => {
serde_json::from_value::<crate::Namespace>(serde_json::json!($($inner)*))
.expect("could not deserialize scenario into a namespace")
.expect("could not deserialize into a namespace")
}
}

macro_rules! scenario {
{
$($inner:tt)*
} => {
serde_json::from_value::<super::ScenarioNamespace>(serde_json::json!($($inner)*))
.expect("could not deserialize into a scenario namespace")
}
}

Expand Down Expand Up @@ -154,14 +232,109 @@ mod tests {
#[test]
fn build_filter_collections() {
let scenario = Scenario {
namespace: scenario!({"collection1": {}, "collection2": {}}),
namespace: namespace!({"collection1": {}, "collection2": {}}),
scenario: scenario!({"collection1": {}}),
name: "test".to_string(),
};

let actual = scenario.build().unwrap();
let expected = scenario!({"collection1": {}});
let expected = namespace!({"collection1": {}});

assert_eq!(actual, expected);
}

#[test]
fn build_filter_fields() {
let scenario = Scenario {
namespace: namespace!({
"collection1": {
"type": "object",
"nully": {"type": "null"},
"stringy": {"type": "string", "pattern": "test"}
},
"collection2": {}
}),
scenario: scenario!({"collection1": {"nully": {}}}),
name: "test".to_string(),
};

let actual = scenario.build().unwrap();
let expected = namespace!({
"collection1": {
"type": "object",
"nully": {"type": "null"}
}
});

assert_eq!(actual, expected);
}

#[test]
fn build_filter_fields_array() {
let scenario = Scenario {
namespace: namespace!({
"collection1": {
"type": "array",
"length": 5,
"content": {
"type": "object",
"nully": {"type": "null"},
"stringy": {"type": "string", "pattern": "test"}
}
},
"collection2": {}
}),
scenario: scenario!({"collection1": {"nully": {}}}),
name: "test".to_string(),
};

let actual = scenario.build().unwrap();
let expected = namespace!({
"collection1": {
"type": "array",
"length": 5,
"content": {
"type": "object",
"nully": {"type": "null"},
}
}
});

assert_eq!(actual, expected);
}

#[test]
#[should_panic(expected = "'null' is not a field on the object")]
fn build_filter_extra_field() {
let scenario = Scenario {
namespace: namespace!({
"collection1": {
"type": "object",
"nully": {"type": "null"},
"stringy": {"type": "string", "pattern": "test"}
},
"collection2": {}
}),
scenario: scenario!({"collection1": {"null": {}}}),
name: "test".to_string(),
};

scenario.build().unwrap();
}

#[test]
#[should_panic(expected = "cannot select fields to include from a non-object")]
fn build_filter_field_scalar() {
let scenario = Scenario {
namespace: namespace!({
"collection1": {
"type": "null"
},
}),
scenario: scenario!({"collection1": {"nully": {}}}),
name: "test".to_string(),
};

scenario.build().unwrap();
}
}
26 changes: 16 additions & 10 deletions docs/docs/getting_started/core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,32 +128,38 @@ is a little involved so there is a section devoted to just the [schema][schema].
## Scenarios
Since [collections](#collections) correspond to closely to a database
collection, we will have numerous use cases which only uses a subset of the
collections in a namespace. This is were we will use scenarios.
collections in a namespace or even only a subset of the fields in the
collections. This is were we will use scenarios.

Scenarios allow us to define a specific use case for the data in a namespace.
So expanding from our `bank` example, we can create a scenario which only
generates data for users by having the following directory structure:
generates data for a users `search-by-name` feature by having the following
directory structure:

```
└── bank/
├── scenarios
│   └── users-only.json
│   └── search-by-name.json
├── transactions.json
└── users.json
```

This creates a scenario called `users-only` by having a `[scenario-name].json`
inside the `scenarios/` directory inside our [namespace](#namespaces).
The definition for this scenario will look as follow:
This creates a scenario called `search-by-name` by having a
`[scenario-name].json` inside the `scenarios/` directory inside our
[namespace](#namespaces). The definition for this scenario will look as
follow:

```json synth-scenario[users-only.json]
```json synth-scenario[search-by-name.json]
{
"users": {}
"users": {
"username": {},
"id": {}
}
}
```

This definition explicitly marks the `users` collection for inclusion inside
this scenario.
This definition explicitly marks the `username` and `id` fields from the
`users` collection for inclusion inside this scenario.

## Importing datasets

Expand Down
6 changes: 6 additions & 0 deletions examples/bank/bank_db/scenarios/search-by-email.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"users": {
"email": {},
"id": {}
}
}
49 changes: 24 additions & 25 deletions synth/tests/examples.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use anyhow::Result;
use paste::paste;
use test_macros::{file_stem, parent, parent2, tmpl_ignore};

// Skipping fmt is needed until this fix is released
// https://github.com/rust-lang/rustfmt/pull/5142
Expand All @@ -21,7 +21,7 @@ macro_rules! test_examples {
))
.await?;

let expected = include_str!(concat!("examples/", stringify!($name), "/output.json"))
let expected = include_str!(concat!("examples/", stringify!($name), "/", stringify!($ns), "/output.json"))
.replace("\r\n", "\n");

assert_eq!(actual, expected);
Expand All @@ -38,30 +38,29 @@ test_examples!(
random_variants / random,
);

macro_rules! test_scenarios {
($($name:ident / $ns:ident,)*) => {
$(
paste!{
#[async_std::test]
async fn [<$name _scenario>]() -> Result<()> {
let actual = generate_scenario(concat!(
"../examples/",
stringify!($name),
"/",
stringify!($ns)
), Some("users-only".to_string()))
.await?;
#[tmpl_ignore(
"examples/bank/bank_db/scenarios",
exclude_dir = true,
filter_extension = "json"
)]
#[async_std::test]
async fn PATH_IDENT() -> Result<()> {
let actual = generate_scenario(
concat!("../", parent2!(PATH)),
Some(file_stem!(PATH).to_string()),
)
.await;

let expected = include_str!(concat!("examples/", stringify!($name), "/scenarios/users-only.json"))
.replace("\r\n", "\n");
assert!(
actual.is_ok(),
"did not expect error: {}",
actual.unwrap_err()
);

assert_eq!(actual, expected);
let expected =
include_str!(concat!(parent!(PATH), "/", file_stem!(PATH), ".json")).replace("\r\n", "\n");

Ok(())
}
}
)*
};
}
assert_eq!(actual.unwrap(), expected);

test_scenarios!(bank / bank_db,);
Ok(())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"users":[{"email":"[email protected]","id":1},{"email":"[email protected]","id":2},{"email":"[email protected]","id":3},{"email":"[email protected]","id":4},{"email":"[email protected]","id":5},{"email":"[email protected]","id":6},{"email":"[email protected]","id":7},{"email":"[email protected]","id":8},{"email":"[email protected]","id":9},{"email":"[email protected]","id":10},{"email":"[email protected]","id":11}]}
Loading