1use {crate::error::BenchError, std::collections::HashSet};
8
9#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
16pub struct BenchmarkDefinition {
17 pub name: String,
19 pub description: String,
21 #[serde(default)]
25 pub relations: Vec<RelationSource>,
26 #[serde(default)]
30 pub queries: Vec<QueryDefinition>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub generator: Option<GeneratorSpec>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
42#[serde(tag = "kind", rename_all = "kebab-case")]
43pub enum GeneratorSpec {
44 Watdiv {
46 scale: u32,
48 #[serde(default)]
51 stress: WatdivStressSpec,
52 },
53 WatdivBasic {
57 scale: u32,
59 },
60 Lubm {
63 scale: u32,
65 #[serde(default = "default_lubm_seed")]
67 seed: u32,
68 #[serde(default = "default_lubm_threads")]
70 threads: u32,
71 #[serde(default)]
73 start_index: u32,
74 #[serde(default = "default_lubm_ontology")]
77 ontology: String,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
81 queries: Option<Vec<String>>,
82 },
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
88pub struct WatdivStressSpec {
89 #[serde(default = "default_watdiv_max_query_size")]
91 pub max_query_size: u32,
92 #[serde(default = "default_watdiv_query_count")]
94 pub query_count: u32,
95 #[serde(default = "default_watdiv_constants_per_query")]
97 pub constants_per_query: u32,
98 #[serde(default)]
100 pub allow_join_vertex: bool,
101}
102
103impl Default for WatdivStressSpec {
104 fn default() -> Self {
105 Self {
106 max_query_size: default_watdiv_max_query_size(),
107 query_count: default_watdiv_query_count(),
108 constants_per_query: default_watdiv_constants_per_query(),
109 allow_join_vertex: false,
110 }
111 }
112}
113
114pub const DEFAULT_LUBM_ONTOLOGY: &str = "http://www.lehigh.edu/~zhp2/2004/0401/univ-bench.owl";
117
118fn default_watdiv_max_query_size() -> u32 { 5 }
119fn default_watdiv_query_count() -> u32 { 20 }
120fn default_watdiv_constants_per_query() -> u32 { 2 }
121fn default_lubm_seed() -> u32 { 0 }
122fn default_lubm_threads() -> u32 { 1 }
123fn default_lubm_ontology() -> String { DEFAULT_LUBM_ONTOLOGY.to_string() }
124
125impl GeneratorSpec {
126 pub fn spec_hash(&self) -> String {
137 use sha2::{Digest, Sha256};
138 let yaml = serde_yaml::to_string(self).expect("GeneratorSpec serializes to YAML");
139 let mut h = Sha256::new();
140 h.update(yaml.as_bytes());
141 format!("{:x}", h.finalize())
142 }
143}
144
145#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
147pub struct RelationSource {
148 pub name: String,
151 pub url: String,
153}
154
155#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
157pub struct QueryDefinition {
158 pub name: String,
160 pub description: String,
162 pub query: String,
164}
165
166impl BenchmarkDefinition {
167 pub fn validate(&self) -> Result<(), BenchError> {
200 if self.name.is_empty() {
201 return Err(BenchError::Invalid {
202 name: self.name.clone(),
203 reason: "name must not be empty".to_string(),
204 });
205 }
206
207 if !is_portable_filename(&self.name) {
213 return Err(BenchError::Invalid {
214 name: self.name.clone(),
215 reason: "name may only contain ASCII alphanumerics, '.', '_', or '-' (no path \
216 separators or '..')"
217 .to_string(),
218 });
219 }
220
221 let has_static = !self.relations.is_empty() || !self.queries.is_empty();
222 match (&self.generator, has_static) {
223 | (Some(_), true) => {
224 return Err(BenchError::Invalid {
225 name: self.name.clone(),
226 reason: "benchmark cannot mix `generator` with `relations`/`queries`; pick one"
227 .to_string(),
228 });
229 },
230 | (None, false) => {
231 return Err(BenchError::Invalid {
232 name: self.name.clone(),
233 reason: "benchmark must declare either `relations`+`queries` or `generator`"
234 .to_string(),
235 });
236 },
237 | (Some(spec), false) => return validate_generator(&self.name, spec),
238 | (None, true) => {},
239 }
240
241 if self.relations.is_empty() {
242 return Err(BenchError::Invalid {
243 name: self.name.clone(),
244 reason: "relations must not be empty".to_string(),
245 });
246 }
247
248 if self.queries.is_empty() {
249 return Err(BenchError::Invalid {
250 name: self.name.clone(),
251 reason: "queries must not be empty".to_string(),
252 });
253 }
254
255 for q in &self.queries {
256 if q.name.is_empty() {
257 return Err(BenchError::Invalid {
258 name: self.name.clone(),
259 reason: "query name must not be empty".to_string(),
260 });
261 }
262 if q.query.is_empty() {
263 return Err(BenchError::Invalid {
264 name: self.name.clone(),
265 reason: format!("query '{}' has empty query string", q.name),
266 });
267 }
268 }
269
270 let mut seen = HashSet::new();
271 for rel in &self.relations {
272 if !seen.insert(&rel.name) {
273 return Err(BenchError::Invalid {
274 name: self.name.clone(),
275 reason: format!("duplicate relation name: {}", rel.name),
276 });
277 }
278 }
279
280 seen.clear();
281 for q in &self.queries {
282 if !seen.insert(&q.name) {
283 return Err(BenchError::Invalid {
284 name: self.name.clone(),
285 reason: format!("duplicate query name: {}", q.name),
286 });
287 }
288 }
289
290 Ok(())
291 }
292}
293
294fn validate_generator(bench_name: &str, spec: &GeneratorSpec) -> Result<(), BenchError> {
295 match spec {
296 | GeneratorSpec::Watdiv {
297 scale, ..
298 } => {
299 if *scale == 0 {
300 return Err(BenchError::Invalid {
301 name: bench_name.to_string(),
302 reason: "watdiv generator scale must be >= 1".to_string(),
303 });
304 }
305 },
306 | GeneratorSpec::WatdivBasic {
307 scale,
308 } => {
309 if *scale == 0 {
310 return Err(BenchError::Invalid {
311 name: bench_name.to_string(),
312 reason: "watdiv-basic generator scale must be >= 1".to_string(),
313 });
314 }
315 },
316 | GeneratorSpec::Lubm {
317 scale,
318 queries,
319 ontology,
320 ..
321 } => {
322 if *scale == 0 {
323 return Err(BenchError::Invalid {
324 name: bench_name.to_string(),
325 reason: "lubm generator scale must be >= 1".to_string(),
326 });
327 }
328 if ontology.is_empty() {
329 return Err(BenchError::Invalid {
330 name: bench_name.to_string(),
331 reason: "lubm generator ontology must not be empty".to_string(),
332 });
333 }
334 if let Some(qs) = queries {
335 if qs.is_empty() {
336 return Err(BenchError::Invalid {
337 name: bench_name.to_string(),
338 reason: "lubm generator queries list must be non-empty if provided (omit \
339 to run all 14)"
340 .to_string(),
341 });
342 }
343 let mut seen = HashSet::new();
344 for q in qs {
345 if !is_valid_lubm_query_name(q) {
346 return Err(BenchError::Invalid {
347 name: bench_name.to_string(),
348 reason: format!("lubm generator query '{q}' is not one of q1..q14"),
349 });
350 }
351 if !seen.insert(q) {
352 return Err(BenchError::Invalid {
353 name: bench_name.to_string(),
354 reason: format!("duplicate lubm query name: {q}"),
355 });
356 }
357 }
358 }
359 },
360 }
361 Ok(())
362}
363
364fn is_valid_lubm_query_name(name: &str) -> bool {
365 let Some(rest) = name.strip_prefix('q') else {
366 return false;
367 };
368 if rest.len() > 1 && rest.starts_with('0') {
373 return false;
374 }
375 matches!(rest.parse::<u32>(), Ok(n) if (1..=14).contains(&n))
376}
377
378fn is_portable_filename(name: &str) -> bool {
382 if name == "." || name == ".." || name.is_empty() {
383 return false;
384 }
385 name.chars()
386 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn make_query(name: &str, query: &str) -> QueryDefinition {
394 QueryDefinition {
395 name: name.to_string(),
396 description: format!("{name} query"),
397 query: query.to_string(),
398 }
399 }
400
401 #[test]
402 fn deserialize_valid_yaml() {
403 let yaml = r#"
404name: triangle
405description: "Triangle query"
406relations:
407 - name: edge
408 url: "https://example.com/edge.parquet"
409queries:
410 - name: triangle
411 description: "Triangle query"
412 query: "T(X, Y, Z) :- edge(X, Y), edge(Y, Z), edge(X, Z)."
413"#;
414 let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
415 assert_eq!(def.name, "triangle");
416 assert_eq!(def.relations.len(), 1);
417 assert_eq!(def.relations[0].name, "edge");
418 assert_eq!(def.queries.len(), 1);
419 assert_eq!(def.queries[0].name, "triangle");
420 assert!(def.validate().is_ok());
421 }
422
423 #[test]
424 fn deserialize_multiple_relations() {
425 let yaml = r#"
426name: path
427description: "Path query"
428relations:
429 - name: edge
430 url: "https://example.com/edge.parquet"
431 - name: node
432 url: "https://example.com/node.parquet"
433queries:
434 - name: path
435 description: "Path query"
436 query: "P(X, Z) :- edge(X, Y), node(Y), edge(Y, Z)."
437"#;
438 let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
439 assert_eq!(def.relations.len(), 2);
440 assert!(def.validate().is_ok());
441 }
442
443 #[test]
444 fn deserialize_multiple_queries() {
445 let yaml = r#"
446name: graph
447description: "Graph queries"
448relations:
449 - name: edge
450 url: "https://example.com/edge.parquet"
451queries:
452 - name: triangle
453 description: "Triangle query"
454 query: "T(X, Y, Z) :- edge(X, Y), edge(Y, Z), edge(X, Z)."
455 - name: two-hop
456 description: "Two-hop path"
457 query: "P(X, Z) :- edge(X, Y), edge(Y, Z)."
458"#;
459 let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
460 assert_eq!(def.name, "graph");
461 assert_eq!(def.queries.len(), 2);
462 assert_eq!(def.queries[0].name, "triangle");
463 assert_eq!(def.queries[1].name, "two-hop");
464 assert!(def.validate().is_ok());
465 }
466
467 #[test]
468 fn validate_empty_name() {
469 let def = BenchmarkDefinition {
470 name: String::new(),
471 description: "test".to_string(),
472 relations: vec![RelationSource {
473 name: "r".to_string(),
474 url: "http://x".to_string(),
475 }],
476 queries: vec![make_query("q", "Q(X) :- r(X).")],
477 generator: None,
478 };
479 assert!(def.validate().is_err());
480 }
481
482 #[test]
483 fn validate_empty_relations() {
484 let def = BenchmarkDefinition {
485 name: "test".to_string(),
486 description: "test".to_string(),
487 relations: vec![],
488 queries: vec![make_query("q", "Q(X) :- r(X).")],
489 generator: None,
490 };
491 assert!(def.validate().is_err());
492 }
493
494 #[test]
495 fn validate_empty_queries() {
496 let def = BenchmarkDefinition {
497 name: "test".to_string(),
498 description: "test".to_string(),
499 relations: vec![RelationSource {
500 name: "r".to_string(),
501 url: "http://x".to_string(),
502 }],
503 queries: vec![],
504 generator: None,
505 };
506 assert!(def.validate().is_err());
507 }
508
509 #[test]
510 fn validate_empty_query_name() {
511 let def = BenchmarkDefinition {
512 name: "test".to_string(),
513 description: "test".to_string(),
514 relations: vec![RelationSource {
515 name: "r".to_string(),
516 url: "http://x".to_string(),
517 }],
518 queries: vec![make_query("", "Q(X) :- r(X).")],
519 generator: None,
520 };
521 assert!(def.validate().is_err());
522 }
523
524 #[test]
525 fn validate_empty_query_string() {
526 let def = BenchmarkDefinition {
527 name: "test".to_string(),
528 description: "test".to_string(),
529 relations: vec![RelationSource {
530 name: "r".to_string(),
531 url: "http://x".to_string(),
532 }],
533 queries: vec![make_query("q", "")],
534 generator: None,
535 };
536 assert!(def.validate().is_err());
537 }
538
539 #[test]
540 fn validate_duplicate_relation_names() {
541 let def = BenchmarkDefinition {
542 name: "test".to_string(),
543 description: "test".to_string(),
544 relations: vec![
545 RelationSource {
546 name: "edge".to_string(),
547 url: "http://x".to_string(),
548 },
549 RelationSource {
550 name: "edge".to_string(),
551 url: "http://y".to_string(),
552 },
553 ],
554 queries: vec![make_query("q", "Q(X) :- edge(X).")],
555 generator: None,
556 };
557 assert!(def.validate().is_err());
558 }
559
560 #[test]
561 fn validate_duplicate_query_names() {
562 let def = BenchmarkDefinition {
563 name: "test".to_string(),
564 description: "test".to_string(),
565 relations: vec![RelationSource {
566 name: "r".to_string(),
567 url: "http://x".to_string(),
568 }],
569 queries: vec![
570 make_query("q", "Q(X) :- r(X)."),
571 make_query("q", "Q(Y) :- r(Y)."),
572 ],
573 generator: None,
574 };
575 assert!(def.validate().is_err());
576 }
577
578 #[test]
579 fn missing_required_fields_fails_validation() {
580 let yaml = r#"
581name: triangle
582description: "Triangle query"
583relations:
584 - name: edge
585 url: "https://example.com/edge.parquet"
586"#;
587 let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
588 assert!(
589 def.validate().is_err(),
590 "queries-less static benchmark must fail validation"
591 );
592 }
593
594 #[test]
595 fn deserialize_watdiv_generator() {
596 let yaml = r#"
597name: watdiv-100
598description: "watdiv at scale 100"
599generator:
600 kind: watdiv
601 scale: 100
602 stress:
603 max_query_size: 5
604 query_count: 20
605 constants_per_query: 2
606 allow_join_vertex: false
607"#;
608 let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
609 assert!(def.relations.is_empty());
610 assert!(def.queries.is_empty());
611 match def.generator.as_ref().unwrap() {
612 | GeneratorSpec::Watdiv {
613 scale,
614 stress,
615 } => {
616 assert_eq!(*scale, 100);
617 assert_eq!(stress.query_count, 20);
618 },
619 | other => panic!("expected watdiv, got {other:?}"),
620 }
621 assert!(def.validate().is_ok());
622 }
623
624 #[test]
625 fn deserialize_watdiv_generator_with_default_stress() {
626 let yaml = r#"
627name: watdiv-1
628description: "watdiv default stress"
629generator:
630 kind: watdiv
631 scale: 1
632"#;
633 let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
634 match def.generator.as_ref().unwrap() {
635 | GeneratorSpec::Watdiv {
636 stress, ..
637 } => {
638 assert_eq!(stress, &WatdivStressSpec::default());
639 },
640 | _ => panic!("expected watdiv"),
641 }
642 assert!(def.validate().is_ok());
643 }
644
645 #[test]
646 fn deserialize_lubm_generator_full() {
647 let yaml = r#"
648name: lubm-2
649description: "lubm scale 2"
650generator:
651 kind: lubm
652 scale: 2
653 seed: 7
654 threads: 4
655 start_index: 1
656 ontology: "http://example.com/onto"
657 queries: [q1, q3, q14]
658"#;
659 let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
660 match def.generator.as_ref().unwrap() {
661 | GeneratorSpec::Lubm {
662 scale,
663 seed,
664 threads,
665 start_index,
666 ontology,
667 queries,
668 } => {
669 assert_eq!(*scale, 2);
670 assert_eq!(*seed, 7);
671 assert_eq!(*threads, 4);
672 assert_eq!(*start_index, 1);
673 assert_eq!(ontology, "http://example.com/onto");
674 assert_eq!(queries.as_ref().unwrap(), &vec!["q1", "q3", "q14"]);
675 },
676 | _ => panic!("expected lubm"),
677 }
678 assert!(def.validate().is_ok());
679 }
680
681 #[test]
682 fn deserialize_lubm_generator_minimal() {
683 let yaml = r#"
684name: lubm-1
685description: "lubm minimal"
686generator:
687 kind: lubm
688 scale: 1
689"#;
690 let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
691 match def.generator.as_ref().unwrap() {
692 | GeneratorSpec::Lubm {
693 scale,
694 seed,
695 threads,
696 start_index,
697 ontology,
698 queries,
699 } => {
700 assert_eq!(*scale, 1);
701 assert_eq!(*seed, 0);
702 assert_eq!(*threads, 1);
703 assert_eq!(*start_index, 0);
704 assert_eq!(ontology, DEFAULT_LUBM_ONTOLOGY);
705 assert!(queries.is_none());
706 },
707 | _ => panic!("expected lubm"),
708 }
709 assert!(def.validate().is_ok());
710 }
711
712 #[test]
713 fn xor_rejects_generator_with_relations_and_queries() {
714 let yaml = r#"
715name: hybrid
716description: "both"
717relations:
718 - name: r
719 url: "http://x"
720queries:
721 - name: q
722 description: "default"
723 query: "Q(X) :- r(X)."
724generator:
725 kind: watdiv
726 scale: 1
727"#;
728 let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
729 let err = def.validate().unwrap_err();
730 let msg = err.to_string();
731 assert!(msg.contains("cannot mix"), "expected XOR error, got: {msg}");
732 }
733
734 #[test]
735 fn xor_rejects_neither_generator_nor_relations() {
736 let yaml = r#"
737name: empty
738description: "nothing"
739"#;
740 let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
741 let err = def.validate().unwrap_err();
742 let msg = err.to_string();
743 assert!(
744 msg.contains("must declare"),
745 "expected XOR error, got: {msg}"
746 );
747 }
748
749 #[test]
750 fn lubm_invalid_query_name_rejected() {
751 let def = BenchmarkDefinition {
752 name: "lubm-bad".to_string(),
753 description: "bad query".to_string(),
754 relations: vec![],
755 queries: vec![],
756 generator: Some(GeneratorSpec::Lubm {
757 scale: 1,
758 seed: 0,
759 threads: 1,
760 start_index: 0,
761 ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
762 queries: Some(vec!["q15".to_string()]),
763 }),
764 };
765 assert!(def.validate().is_err());
766 }
767
768 #[test]
769 fn lubm_zero_scale_rejected() {
770 let def = BenchmarkDefinition {
771 name: "lubm-zero".to_string(),
772 description: "zero scale".to_string(),
773 relations: vec![],
774 queries: vec![],
775 generator: Some(GeneratorSpec::Lubm {
776 scale: 0,
777 seed: 0,
778 threads: 1,
779 start_index: 0,
780 ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
781 queries: None,
782 }),
783 };
784 assert!(def.validate().is_err());
785 }
786
787 #[test]
788 fn watdiv_zero_scale_rejected() {
789 let def = BenchmarkDefinition {
790 name: "watdiv-zero".to_string(),
791 description: "zero scale".to_string(),
792 relations: vec![],
793 queries: vec![],
794 generator: Some(GeneratorSpec::Watdiv {
795 scale: 0,
796 stress: WatdivStressSpec::default(),
797 }),
798 };
799 assert!(def.validate().is_err());
800 }
801
802 #[test]
803 fn spec_hash_is_deterministic() {
804 let a = GeneratorSpec::Watdiv {
805 scale: 10,
806 stress: WatdivStressSpec::default(),
807 };
808 let b = GeneratorSpec::Watdiv {
809 scale: 10,
810 stress: WatdivStressSpec::default(),
811 };
812 assert_eq!(a.spec_hash(), b.spec_hash());
813 }
814
815 #[test]
816 fn spec_hash_differs_on_param_change() {
817 let a = GeneratorSpec::Watdiv {
818 scale: 10,
819 stress: WatdivStressSpec::default(),
820 };
821 let b = GeneratorSpec::Watdiv {
822 scale: 20,
823 stress: WatdivStressSpec::default(),
824 };
825 assert_ne!(a.spec_hash(), b.spec_hash());
826 }
827
828 #[test]
829 fn name_with_path_traversal_rejected() {
830 for evil in [
831 "../escape",
832 "..",
833 ".",
834 "with/slash",
835 "with\\backslash",
836 "has space",
837 "has:colon",
838 ] {
839 let def = BenchmarkDefinition {
840 name: evil.to_string(),
841 description: "x".to_string(),
842 relations: vec![RelationSource {
843 name: "r".to_string(),
844 url: "http://x".to_string(),
845 }],
846 queries: vec![make_query("q", "Q(X) :- r(X).")],
847 generator: None,
848 };
849 assert!(
850 def.validate().is_err(),
851 "expected rejection for name = {evil:?}"
852 );
853 }
854 }
855
856 #[test]
857 fn lubm_zero_padded_query_name_rejected() {
858 for bad in ["q01", "q014", "q00", "q001"] {
859 let def = BenchmarkDefinition {
860 name: "lubm-zero-pad".to_string(),
861 description: "x".to_string(),
862 relations: vec![],
863 queries: vec![],
864 generator: Some(GeneratorSpec::Lubm {
865 scale: 1,
866 seed: 0,
867 threads: 1,
868 start_index: 0,
869 ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
870 queries: Some(vec![bad.to_string()]),
871 }),
872 };
873 assert!(
874 def.validate().is_err(),
875 "expected rejection for query name = {bad:?}"
876 );
877 }
878 }
879
880 #[test]
881 fn deserialize_watdiv_basic_generator() {
882 let yaml = r#"
883name: watdiv-basic
884description: "watdiv basic"
885generator:
886 kind: watdiv-basic
887 scale: 10
888"#;
889 let def: BenchmarkDefinition = serde_yaml::from_str(yaml).unwrap();
890 def.validate().unwrap();
891 match def.generator.as_ref().unwrap() {
892 | GeneratorSpec::WatdivBasic {
893 scale,
894 } => assert_eq!(*scale, 10),
895 | other => panic!("expected WatdivBasic, got {other:?}"),
896 }
897 }
898
899 #[test]
900 fn watdiv_basic_scale_zero_is_invalid() {
901 let spec = GeneratorSpec::WatdivBasic {
902 scale: 0,
903 };
904 assert!(validate_generator("b", &spec).is_err());
905 }
906
907 #[test]
908 fn watdiv_basic_spec_hash_differs_from_watdiv() {
909 let basic = GeneratorSpec::WatdivBasic {
910 scale: 10,
911 };
912 let stress = GeneratorSpec::Watdiv {
913 scale: 10,
914 stress: WatdivStressSpec::default(),
915 };
916 assert_ne!(basic.spec_hash(), stress.spec_hash());
917 }
918
919 #[test]
920 fn spec_hash_distinguishes_lubm_query_subset() {
921 let a = GeneratorSpec::Lubm {
922 scale: 1,
923 seed: 0,
924 threads: 1,
925 start_index: 0,
926 ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
927 queries: None,
928 };
929 let b = GeneratorSpec::Lubm {
930 scale: 1,
931 seed: 0,
932 threads: 1,
933 start_index: 0,
934 ontology: DEFAULT_LUBM_ONTOLOGY.to_string(),
935 queries: Some(vec!["q1".to_string(), "q2".to_string()]),
936 };
937 assert_ne!(a.spec_hash(), b.spec_hash());
938 }
939}