From 4665eaa4dda528e45bcb828cbf99aca31bc2199d Mon Sep 17 00:00:00 2001 From: Richard Newman Date: Mon, 7 May 2018 20:44:24 -0700 Subject: [PATCH 1/5] Parse and handle aliased pull attributes. --- query-parser/src/parse.rs | 44 ++++++++++++++++++++++++++++++--------- query-pull/src/lib.rs | 34 ++++++++++++++++++++---------- query/src/lib.rs | 33 +++++++++++++++++++++++++---- tests/pull.rs | 6 +++--- 4 files changed, 89 insertions(+), 28 deletions(-) diff --git a/query-parser/src/parse.rs b/query-parser/src/parse.rs index 4ba5aca1d..244a0cca5 100644 --- a/query-parser/src/parse.rs +++ b/query-parser/src/parse.rs @@ -67,6 +67,7 @@ use self::mentat_query::{ Order, OrJoin, OrWhereClause, + NamedPullAttribute, NotJoin, Pattern, PatternNonValuePlace, @@ -191,6 +192,7 @@ def_parser!(Query, order, Order, { def_matches_plain_symbol!(Query, the, "the"); def_matches_plain_symbol!(Query, pull, "pull"); def_matches_plain_symbol!(Query, wildcard, "*"); +def_matches_keyword!(Query, alias_as, "as"); pub struct Where<'a>(std::marker::PhantomData<&'a ()>); @@ -303,11 +305,28 @@ def_parser!(Query, aggregate, Aggregate, { }) }); +def_parser!(Query, pull_concrete_attribute_ident, PullConcreteAttribute, { + forward_keyword().map(|k| PullConcreteAttribute::Ident(::std::rc::Rc::new(k.clone()))) +}); + +def_parser!(Query, pull_aliased_attribute, PullAttributeSpec, { + vector().of_exactly( + (Query::pull_concrete_attribute_ident() + .skip(Query::alias_as()), + forward_keyword().map(|alias| Some(::std::rc::Rc::new(alias.clone())))) + .map(|(attribute, alias)| + PullAttributeSpec::Attribute( + NamedPullAttribute { attribute, alias }))) +}); + def_parser!(Query, pull_concrete_attribute, PullAttributeSpec, { - forward_keyword().map(|k| - PullAttributeSpec::Attribute( - PullConcreteAttribute::Ident( - ::std::rc::Rc::new(k.clone())))) + Query::pull_concrete_attribute_ident() + .map(|attribute| + PullAttributeSpec::Attribute( + NamedPullAttribute { + attribute, + alias: None, + })) }); def_parser!(Query, pull_wildcard_attribute, PullAttributeSpec, { @@ -316,6 +335,7 @@ def_parser!(Query, pull_wildcard_attribute, PullAttributeSpec, { def_parser!(Query, pull_attribute, PullAttributeSpec, { choice([ + try(Query::pull_aliased_attribute()), try(Query::pull_concrete_attribute()), try(Query::pull_wildcard_attribute()), // TODO: reversed keywords, entids (with aliases, presumably…). @@ -1205,23 +1225,27 @@ mod test { let foo_bar = ::std::rc::Rc::new(edn::NamespacedKeyword::new("foo", "bar")); let foo_baz = ::std::rc::Rc::new(edn::NamespacedKeyword::new("foo", "baz")); + let foo_horse = ::std::rc::Rc::new(edn::NamespacedKeyword::new("foo", "horse")); assert_edn_parses_to!(Query::pull_concrete_attribute, ":foo/bar", PullAttributeSpec::Attribute( - PullConcreteAttribute::Ident(foo_bar.clone()))); + PullConcreteAttribute::Ident(foo_bar.clone()).into())); assert_edn_parses_to!(Query::pull_attribute, ":foo/bar", PullAttributeSpec::Attribute( - PullConcreteAttribute::Ident(foo_bar.clone()))); + PullConcreteAttribute::Ident(foo_bar.clone()).into())); assert_edn_parses_to!(Find::elem, - "(pull ?v [:foo/bar :foo/baz])", + "(pull ?v [[:foo/bar :as :foo/horse] :foo/baz])", Element::Pull(Pull { var: Variable::from_valid_name("?v"), patterns: vec![ PullAttributeSpec::Attribute( - PullConcreteAttribute::Ident(foo_bar.clone())), + NamedPullAttribute { + attribute: PullConcreteAttribute::Ident(foo_bar.clone()), + alias: Some(foo_horse), + }), PullAttributeSpec::Attribute( - PullConcreteAttribute::Ident(foo_baz.clone())), + PullConcreteAttribute::Ident(foo_baz.clone()).into()), ], })); assert_parse_failure_contains!(Find::elem, @@ -1242,7 +1266,7 @@ mod test { PullAttributeSpec::Attribute( PullConcreteAttribute::Ident( ::std::rc::Rc::new(edn::NamespacedKeyword::new("foo", "bar")) - ) + ).into() ), ] })]), where_clauses: vec![ diff --git a/query-pull/src/lib.rs b/query-pull/src/lib.rs index a011ac51d..b6f96030d 100644 --- a/query-pull/src/lib.rs +++ b/query-pull/src/lib.rs @@ -91,6 +91,7 @@ use mentat_core::{ use mentat_db::cache; use mentat_query::{ + NamedPullAttribute, PullAttributeSpec, PullConcreteAttribute, }; @@ -110,7 +111,7 @@ pub fn pull_attributes_for_entity(schema: &Schema, attributes: A) -> Result where A: IntoIterator { let attrs = attributes.into_iter() - .map(|e| PullAttributeSpec::Attribute(PullConcreteAttribute::Entid(e))) + .map(|e| PullAttributeSpec::Attribute(PullConcreteAttribute::Entid(e).into())) .collect(); Puller::prepare(schema, attrs)? .pull(schema, db, once(entity)) @@ -130,7 +131,7 @@ pub fn pull_attributes_for_entities(schema: &Schema, where E: IntoIterator, A: IntoIterator { let attrs = attributes.into_iter() - .map(|e| PullAttributeSpec::Attribute(PullConcreteAttribute::Entid(e))) + .map(|e| PullAttributeSpec::Attribute(PullConcreteAttribute::Entid(e).into())) .collect(); Puller::prepare(schema, attrs)? .pull(schema, db, entities) @@ -148,7 +149,7 @@ impl Puller { pub fn prepare_simple_attributes(schema: &Schema, attributes: Vec) -> Result { Puller::prepare(schema, attributes.into_iter() - .map(|e| PullAttributeSpec::Attribute(PullConcreteAttribute::Entid(e))) + .map(|e| PullAttributeSpec::Attribute(PullConcreteAttribute::Entid(e).into())) .collect()) } @@ -175,16 +176,27 @@ impl Puller { } break; }, - &PullAttributeSpec::Attribute(PullConcreteAttribute::Ident(ref i)) => { - if let Some(entid) = schema.get_entid(i) { - names.insert(entid.into(), i.to_value_rc()); - attrs.insert(entid.into()); + &PullAttributeSpec::Attribute(NamedPullAttribute { + ref attribute, + ref alias, + }) => { + let alias = alias.as_ref() + .map(|ref r| r.to_value_rc()); + match attribute { + &PullConcreteAttribute::Ident(ref i) => { + if let Some(entid) = schema.get_entid(i) { + let name = alias.unwrap_or_else(|| i.to_value_rc()); + names.insert(entid.into(), name); + attrs.insert(entid.into()); + } + }, + &PullConcreteAttribute::Entid(ref entid) => { + let name = alias.map(Ok).unwrap_or_else(|| lookup_name(entid))?; + names.insert(*entid, name); + attrs.insert(*entid); + }, } }, - &PullAttributeSpec::Attribute(PullConcreteAttribute::Entid(ref entid)) => { - names.insert(*entid, lookup_name(entid)?); - attrs.insert(*entid); - }, } } diff --git a/query/src/lib.rs b/query/src/lib.rs index 76e4e115e..cae690515 100644 --- a/query/src/lib.rs +++ b/query/src/lib.rs @@ -499,12 +499,26 @@ pub enum PullConcreteAttribute { Entid(i64), } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct NamedPullAttribute { + pub attribute: PullConcreteAttribute, + pub alias: Option>, +} + +impl From for NamedPullAttribute { + fn from(a: PullConcreteAttribute) -> Self { + NamedPullAttribute { + attribute: a, + alias: None, + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum PullAttributeSpec { Wildcard, - Attribute(PullConcreteAttribute), + Attribute(NamedPullAttribute), // PullMapSpec(Vec<…>), - // AttributeWithOpts(PullConcreteAttribute, …), // LimitedAttribute(PullConcreteAttribute, u64), // Limit nil => Attribute instead. // DefaultedAttribute(PullConcreteAttribute, PullDefaultValue), } @@ -522,14 +536,25 @@ impl std::fmt::Display for PullConcreteAttribute { } } +impl std::fmt::Display for NamedPullAttribute { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + if let &Some(ref alias) = &self.alias { + write!(f, "{} :as {}", self.attribute, alias) + } else { + write!(f, "{}", self.attribute) + } + } +} + + impl std::fmt::Display for PullAttributeSpec { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { match self { &PullAttributeSpec::Wildcard => { write!(f, "*") }, - &PullAttributeSpec::Attribute(ref a) => { - write!(f, "{}", a) + &PullAttributeSpec::Attribute(ref attr) => { + write!(f, "{}", attr) }, } } diff --git a/tests/pull.rs b/tests/pull.rs index dda56c101..b33952a30 100644 --- a/tests/pull.rs +++ b/tests/pull.rs @@ -116,7 +116,7 @@ fn test_simple_pull() { assert_eq!(pulled, expected); // Now test pull inside the query itself. - let query = r#"[:find ?hood (pull ?district [:district/name :district/region]) + let query = r#"[:find ?hood (pull ?district [[:district/name :as :district/district] :district/region]) :where (or [?hood :neighborhood/name "Beacon Hill"] [?hood :neighborhood/name "Capitol Hill"]) @@ -128,12 +128,12 @@ fn test_simple_pull() { .expect("results"); let beacon_district: Vec<(NamespacedKeyword, TypedValue)> = vec![ - (kw!(:district/name), "Greater Duwamish".into()), + (kw!(:district/district), "Greater Duwamish".into()), (kw!(:district/region), schema.get_entid(&NamespacedKeyword::new("region", "se")).unwrap().into()) ]; let beacon_district: StructuredMap = beacon_district.into(); let capitol_district: Vec<(NamespacedKeyword, TypedValue)> = vec![ - (kw!(:district/name), "East".into()), + (kw!(:district/district), "East".into()), (kw!(:district/region), schema.get_entid(&NamespacedKeyword::new("region", "e")).unwrap().into()) ]; let capitol_district: StructuredMap = capitol_district.into(); From a114bda46bf7c05fb420e985df69973135876f58 Mon Sep 17 00:00:00 2001 From: Richard Newman Date: Mon, 7 May 2018 21:39:24 -0700 Subject: [PATCH 2/5] Allow :db/id to mentioned as a pull attribute. --- query-pull/src/errors.rs | 5 +++++ query-pull/src/lib.rs | 26 ++++++++++++++++++++++++++ tests/pull.rs | 27 ++++++++++++++++++--------- 3 files changed, 49 insertions(+), 9 deletions(-) diff --git a/query-pull/src/errors.rs b/query-pull/src/errors.rs index f0e747e64..94c38426b 100644 --- a/query-pull/src/errors.rs +++ b/query-pull/src/errors.rs @@ -22,6 +22,11 @@ error_chain! { description("unnamed attribute") display("attribute {:?} has no name", id) } + + RepeatedDbId { + description(":db/id repeated") + display(":db/id repeated") + } } links { diff --git a/query-pull/src/lib.rs b/query-pull/src/lib.rs index b6f96030d..c873bd4fa 100644 --- a/query-pull/src/lib.rs +++ b/query-pull/src/lib.rs @@ -79,12 +79,14 @@ use std::iter::{ }; use mentat_core::{ + Binding, Cloned, Entid, HasSchema, NamespacedKeyword, Schema, StructuredMap, + TypedValue, ValueRc, }; @@ -143,6 +145,7 @@ pub struct Puller { // The range is the set of aliases to use in the output. attributes: BTreeMap>, attribute_spec: cache::AttributeSpec, + db_id_alias: Option>, } impl Puller { @@ -166,6 +169,9 @@ impl Puller { let mut names: BTreeMap> = Default::default(); let mut attrs: BTreeSet = Default::default(); + let db_id = ::std::rc::Rc::new(NamespacedKeyword::new("db", "id")); + let mut db_id_alias = None; + for attr in attributes.iter() { match attr { &PullAttributeSpec::Wildcard => { @@ -183,6 +189,14 @@ impl Puller { let alias = alias.as_ref() .map(|ref r| r.to_value_rc()); match attribute { + // Handle :db/id. + &PullConcreteAttribute::Ident(ref i) if i.as_ref() == db_id.as_ref() => { + // We only allow :db/id once. + if db_id_alias.is_some() { + bail!(ErrorKind::RepeatedDbId); + } + db_id_alias = Some(alias.unwrap_or_else(|| db_id.to_value_rc())); + }, &PullConcreteAttribute::Ident(ref i) => { if let Some(entid) = schema.get_entid(i) { let name = alias.unwrap_or_else(|| i.to_value_rc()); @@ -203,6 +217,7 @@ impl Puller { Ok(Puller { attributes: names, attribute_spec: cache::AttributeSpec::specified(&attrs, schema), + db_id_alias, }) } @@ -234,6 +249,17 @@ impl Puller { // TODO: should we walk `e` then `a`, or `a` then `e`? Possibly the right answer // is just to collect differently! let mut maps = BTreeMap::new(); + + // Collect :db/id if requested. + if let Some(ref alias) = self.db_id_alias { + for e in entities.iter() { + let mut r = maps.entry(*e) + .or_insert(ValueRc::new(StructuredMap::default())); + let mut m = ValueRc::get_mut(r).unwrap(); + m.insert(alias.clone(), Binding::Scalar(TypedValue::Ref(*e))); + } + } + for (name, cache) in self.attributes.iter().filter_map(|(a, name)| caches.forward_attribute_cache_for_attribute(schema, *a) .map(|cache| (name.clone(), cache))) { diff --git a/tests/pull.rs b/tests/pull.rs index b33952a30..4e2d70bad 100644 --- a/tests/pull.rs +++ b/tests/pull.rs @@ -116,7 +116,9 @@ fn test_simple_pull() { assert_eq!(pulled, expected); // Now test pull inside the query itself. - let query = r#"[:find ?hood (pull ?district [[:district/name :as :district/district] :district/region]) + let query = r#"[:find ?hood (pull ?district [:db/id + [:district/name :as :district/district] + :district/region]) :where (or [?hood :neighborhood/name "Beacon Hill"] [?hood :neighborhood/name "Capitol Hill"]) @@ -127,22 +129,24 @@ fn test_simple_pull() { .into_rel_result() .expect("results"); - let beacon_district: Vec<(NamespacedKeyword, TypedValue)> = vec![ + let beacon_district_pull: Vec<(NamespacedKeyword, TypedValue)> = vec![ + (kw!(:db/id), TypedValue::Ref(beacon_district)), (kw!(:district/district), "Greater Duwamish".into()), (kw!(:district/region), schema.get_entid(&NamespacedKeyword::new("region", "se")).unwrap().into()) ]; - let beacon_district: StructuredMap = beacon_district.into(); - let capitol_district: Vec<(NamespacedKeyword, TypedValue)> = vec![ + let beacon_district_pull: StructuredMap = beacon_district_pull.into(); + let capitol_district_pull: Vec<(NamespacedKeyword, TypedValue)> = vec![ + (kw!(:db/id), TypedValue::Ref(capitol_district)), (kw!(:district/district), "East".into()), (kw!(:district/region), schema.get_entid(&NamespacedKeyword::new("region", "e")).unwrap().into()) ]; - let capitol_district: StructuredMap = capitol_district.into(); + let capitol_district_pull: StructuredMap = capitol_district_pull.into(); let expected = RelResult { width: 2, values: vec![ - TypedValue::Ref(capitol).into(), capitol_district.into(), - TypedValue::Ref(beacon).into(), beacon_district.into(), + TypedValue::Ref(capitol).into(), capitol_district_pull.into(), + TypedValue::Ref(beacon).into(), beacon_district_pull.into(), ].into(), }; assert_eq!(results, expected.clone()); @@ -158,14 +162,19 @@ fn test_simple_pull() { // Execute a scalar query where the body is constant. // TODO: we shouldn't require `:where`; that makes this non-constant! - let query = r#"[:find (pull ?hood [:neighborhood/name]) . :in ?hood + let query = r#"[:find (pull ?hood [[:db/id :as :neighborhood/id] + :neighborhood/name]) . + :in ?hood :where [?hood :neighborhood/district _]]"#; let result = reader.q_once(query, QueryInputs::with_value_sequence(vec![(var!(?hood), TypedValue::Ref(beacon))])) .into_scalar_result() .expect("success") .expect("result"); - let expected: StructuredMap = vec![(kw!(:neighborhood/name), TypedValue::from("Beacon Hill"))].into(); + let expected: StructuredMap = vec![ + (kw!(:neighborhood/name), TypedValue::from("Beacon Hill")), + (kw!(:neighborhood/id), TypedValue::Ref(beacon)), + ].into(); assert_eq!(result, expected.into()); // Collect the names and regions of all districts. From 546e142722042d0748d2a873fc509ca890891a3e Mon Sep 17 00:00:00 2001 From: Richard Newman Date: Mon, 7 May 2018 22:10:37 -0700 Subject: [PATCH 3/5] Clean up comment. --- query-pull/src/lib.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/query-pull/src/lib.rs b/query-pull/src/lib.rs index c873bd4fa..24cd62a63 100644 --- a/query-pull/src/lib.rs +++ b/query-pull/src/lib.rs @@ -232,9 +232,7 @@ impl Puller { // - Recursing. (TODO: we'll need AttributeCaches to not overwrite in case of recursion! And // ideally not do excess work when some entity/attribute pairs are known.) // - Building a structure by walking the pull expression with the caches. - // TODO: aliases. // TODO: limits. - // TODO: fts. // Build a cache for these attributes and entities. // TODO: use the store's existing cache! From dc608319b0137e7dae45db485888666904c081b2 Mon Sep 17 00:00:00 2001 From: Richard Newman Date: Mon, 7 May 2018 22:10:51 -0700 Subject: [PATCH 4/5] Remove unused function. --- query-pull/src/lib.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/query-pull/src/lib.rs b/query-pull/src/lib.rs index 24cd62a63..26ee18958 100644 --- a/query-pull/src/lib.rs +++ b/query-pull/src/lib.rs @@ -149,13 +149,6 @@ pub struct Puller { } impl Puller { - pub fn prepare_simple_attributes(schema: &Schema, attributes: Vec) -> Result { - Puller::prepare(schema, - attributes.into_iter() - .map(|e| PullAttributeSpec::Attribute(PullConcreteAttribute::Entid(e).into())) - .collect()) - } - pub fn prepare(schema: &Schema, attributes: Vec) -> Result { // TODO: eventually this entry point will handle aliasing and that kind of // thing. For now it's just a convenience. From 2b12c41095bd31de83b8fe03dddedac507fd6b35 Mon Sep 17 00:00:00 2001 From: Richard Newman Date: Mon, 7 May 2018 22:33:35 -0700 Subject: [PATCH 5/5] WIP: nested pull. --- query-parser/src/parse.rs | 6 +++++- query-pull/src/errors.rs | 5 +++++ query-pull/src/lib.rs | 30 ++++++++++++++++++++++++++++-- query/src/lib.rs | 26 +++++++++++++++++++++++++- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/query-parser/src/parse.rs b/query-parser/src/parse.rs index 244a0cca5..886917e44 100644 --- a/query-parser/src/parse.rs +++ b/query-parser/src/parse.rs @@ -366,7 +366,11 @@ fn validate_attributes<'a, I>(attrs: I) -> std::result::Result<(), &'static str> return Err("wildcard with specified attributes"); } }, - // TODO: map form. + &PullAttributeSpec::Nested(ref _attr, ref patterns) => { + if patterns.is_empty() { + return Err("empty nested pull map"); + } + }, } } Ok(()) diff --git a/query-pull/src/errors.rs b/query-pull/src/errors.rs index 94c38426b..871bb47f6 100644 --- a/query-pull/src/errors.rs +++ b/query-pull/src/errors.rs @@ -27,6 +27,11 @@ error_chain! { description(":db/id repeated") display(":db/id repeated") } + + NonRefNestedPullAttribute { + description("nested pull attribute is non-ref") + display("nested pull attribute is non-ref") + } } links { diff --git a/query-pull/src/lib.rs b/query-pull/src/lib.rs index 26ee18958..2af6fe0e4 100644 --- a/query-pull/src/lib.rs +++ b/query-pull/src/lib.rs @@ -88,6 +88,7 @@ use mentat_core::{ StructuredMap, TypedValue, ValueRc, + ValueType, }; use mentat_db::cache; @@ -144,8 +145,18 @@ pub struct Puller { // The domain of this map is the set of attributes to fetch. // The range is the set of aliases to use in the output. attributes: BTreeMap>, + + // The original spec for this puller. attribute_spec: cache::AttributeSpec, + + // If :db/id is mentioned in the attribute list, its alias is this. db_id_alias: Option>, + + // A pull expression can be arbitrarily nested. We represent this both + // within the `attribute_spec` itself and also as a nested set of `Puller`s. + // When an attribute in the list above returns an entity -- and it should! -- + // it is accumulated and we recurse down into these nested layers. + nested: BTreeMap, } impl Puller { @@ -156,8 +167,8 @@ impl Puller { let lookup_name = |i: &Entid| { // In the unlikely event that we have an attribute with no name, we bail. schema.get_ident(*i) - .map(|ident| ValueRc::new(ident.clone())) - .ok_or_else(|| ErrorKind::UnnamedAttribute(*i)) + .map(|ident| ValueRc::new(ident.clone())) + .ok_or_else(|| ErrorKind::UnnamedAttribute(*i)) }; let mut names: BTreeMap> = Default::default(); @@ -204,6 +215,20 @@ impl Puller { }, } }, + + // An attribute that nests must be ref-typed. + &PullAttributeSpec::Nested(ref attribute, ref patterns) => { + let value_type = attribute.get_attribute(schema) + .map(|(a, _e)| a.value_type); + let is_ref_typed = value_type.map(|v| v == ValueType::Ref) + .unwrap_or(false); + if !is_ref_typed { + bail!(ErrorKind::NonRefNestedPullAttribute); + } + + // TODO + unimplemented!(); + }, } } @@ -211,6 +236,7 @@ impl Puller { attributes: names, attribute_spec: cache::AttributeSpec::specified(&attrs, schema), db_id_alias, + nested: Default::default(), }) } diff --git a/query/src/lib.rs b/query/src/lib.rs index cae690515..2eb2ee1ae 100644 --- a/query/src/lib.rs +++ b/query/src/lib.rs @@ -55,7 +55,11 @@ pub use edn::{ }; use mentat_core::{ + Attribute, FromRc, + HasSchema, + KnownEntid, + Schema, TypedValue, ValueRc, ValueType, @@ -499,6 +503,19 @@ pub enum PullConcreteAttribute { Entid(i64), } +impl PullConcreteAttribute { + pub fn get_attribute<'s>(&self, schema: &'s Schema) -> Option<(&'s Attribute, KnownEntid)> { + match self { + &PullConcreteAttribute::Ident(ref rc) => { + schema.attribute_for_ident(rc.as_ref()) + }, + &PullConcreteAttribute::Entid(e) => { + schema.attribute_for_entid(e).map(|a| (a, KnownEntid(e))) + }, + } + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct NamedPullAttribute { pub attribute: PullConcreteAttribute, @@ -518,7 +535,7 @@ impl From for NamedPullAttribute { pub enum PullAttributeSpec { Wildcard, Attribute(NamedPullAttribute), - // PullMapSpec(Vec<…>), + Nested(PullConcreteAttribute, Vec), // LimitedAttribute(PullConcreteAttribute, u64), // Limit nil => Attribute instead. // DefaultedAttribute(PullConcreteAttribute, PullDefaultValue), } @@ -556,6 +573,13 @@ impl std::fmt::Display for PullAttributeSpec { &PullAttributeSpec::Attribute(ref attr) => { write!(f, "{}", attr) }, + &PullAttributeSpec::Nested(ref attr, ref patterns) => { + write!(f, "{{{} [", attr)?; + for p in patterns { + write!(f, " {}", p)?; + } + write!(f, "]}}") + }, } } }