1use serde::{Deserialize, Serialize};
18use std::collections::HashMap;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct QueryBuilderConfig {
23 pub enabled: bool,
25 pub endpoint: String,
27 pub title: String,
29 pub show_preview: bool,
31 pub enable_execution: bool,
33 pub enable_export: bool,
35 pub max_depth: usize,
37 pub custom_css: Option<String>,
39}
40
41impl Default for QueryBuilderConfig {
42 fn default() -> Self {
43 Self {
44 enabled: true,
45 endpoint: "/query-builder".to_string(),
46 title: "OxiRS GraphQL Query Builder".to_string(),
47 show_preview: true,
48 enable_execution: true,
49 enable_export: true,
50 max_depth: 5,
51 custom_css: None,
52 }
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct FieldSelection {
59 pub name: String,
61 pub field_type: String,
63 pub selected: bool,
65 pub arguments: HashMap<String, String>,
67 pub nested_fields: Vec<FieldSelection>,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub enum FilterOp {
74 Equals,
75 NotEquals,
76 GreaterThan,
77 LessThan,
78 GreaterThanOrEqual,
79 LessThanOrEqual,
80 Contains,
81 StartsWith,
82 EndsWith,
83 In,
84 NotIn,
85}
86
87impl std::fmt::Display for FilterOp {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 FilterOp::Equals => write!(f, "="),
91 FilterOp::NotEquals => write!(f, "!="),
92 FilterOp::GreaterThan => write!(f, ">"),
93 FilterOp::LessThan => write!(f, "<"),
94 FilterOp::GreaterThanOrEqual => write!(f, ">="),
95 FilterOp::LessThanOrEqual => write!(f, "<="),
96 FilterOp::Contains => write!(f, "contains"),
97 FilterOp::StartsWith => write!(f, "startsWith"),
98 FilterOp::EndsWith => write!(f, "endsWith"),
99 FilterOp::In => write!(f, "in"),
100 FilterOp::NotIn => write!(f, "notIn"),
101 }
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct FilterCondition {
108 pub field: String,
110 pub operation: FilterOp,
112 pub value: String,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118pub enum SortDirection {
119 Ascending,
120 Descending,
121}
122
123impl std::fmt::Display for SortDirection {
124 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125 match self {
126 SortDirection::Ascending => write!(f, "ASC"),
127 SortDirection::Descending => write!(f, "DESC"),
128 }
129 }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct SortField {
135 pub field: String,
137 pub direction: SortDirection,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct QueryBuilderState {
144 pub operation_type: String,
146 pub root_field: String,
148 pub fields: Vec<FieldSelection>,
150 pub filters: Vec<FilterCondition>,
152 pub sorting: Vec<SortField>,
154 pub limit: Option<usize>,
156 pub offset: Option<usize>,
158 pub variables: HashMap<String, String>,
160}
161
162impl Default for QueryBuilderState {
163 fn default() -> Self {
164 Self {
165 operation_type: "query".to_string(),
166 root_field: String::new(),
167 fields: Vec::new(),
168 filters: Vec::new(),
169 sorting: Vec::new(),
170 limit: None,
171 offset: None,
172 variables: HashMap::new(),
173 }
174 }
175}
176
177pub fn generate_query_builder_html(config: &QueryBuilderConfig, graphql_endpoint: &str) -> String {
179 let custom_css = config
180 .custom_css
181 .as_ref()
182 .map(|url| format!(r#"<link rel="stylesheet" href="{}">"#, url))
183 .unwrap_or_default();
184
185 format!(
186 r#"<!DOCTYPE html>
187<html lang="en">
188<head>
189 <meta charset="UTF-8">
190 <meta name="viewport" content="width=device-width, initial-scale=1.0">
191 <title>{title}</title>
192 <style>
193 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
194 body {{
195 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
196 background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
197 min-height: 100vh;
198 padding: 20px;
199 }}
200 .container {{
201 max-width: 1600px;
202 margin: 0 auto;
203 background: white;
204 border-radius: 12px;
205 box-shadow: 0 20px 60px rgba(0,0,0,0.3);
206 overflow: hidden;
207 display: grid;
208 grid-template-columns: 400px 1fr 500px;
209 min-height: calc(100vh - 40px);
210 }}
211 .panel {{
212 overflow-y: auto;
213 }}
214 .panel-header {{
215 padding: 20px;
216 background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
217 color: white;
218 font-size: 18px;
219 font-weight: 600;
220 }}
221 .panel-content {{
222 padding: 20px;
223 }}
224 .section {{
225 margin-bottom: 24px;
226 }}
227 .section-title {{
228 font-size: 13px;
229 font-weight: 600;
230 color: #4a5568;
231 margin-bottom: 12px;
232 text-transform: uppercase;
233 letter-spacing: 0.5px;
234 }}
235 .field-tree {{
236 border: 1px solid #e2e8f0;
237 border-radius: 6px;
238 overflow: hidden;
239 }}
240 .field-item {{
241 padding: 10px 12px;
242 border-bottom: 1px solid #f7fafc;
243 display: flex;
244 align-items: center;
245 gap: 8px;
246 cursor: pointer;
247 transition: background 0.2s;
248 }}
249 .field-item:hover {{
250 background: #f7fafc;
251 }}
252 .field-item input[type="checkbox"] {{
253 cursor: pointer;
254 }}
255 .field-name {{
256 flex: 1;
257 font-size: 14px;
258 color: #2d3748;
259 }}
260 .field-type {{
261 font-size: 12px;
262 color: #718096;
263 font-family: monospace;
264 }}
265 .filter-list, .sort-list {{
266 display: flex;
267 flex-direction: column;
268 gap: 8px;
269 }}
270 .filter-item, .sort-item {{
271 display: grid;
272 grid-template-columns: 1fr auto 1fr auto;
273 gap: 8px;
274 align-items: center;
275 padding: 10px;
276 background: #f7fafc;
277 border-radius: 6px;
278 }}
279 select, input[type="text"], input[type="number"] {{
280 padding: 8px 12px;
281 border: 1px solid #cbd5e0;
282 border-radius: 4px;
283 font-size: 14px;
284 }}
285 select:focus, input:focus {{
286 outline: none;
287 border-color: #f093fb;
288 box-shadow: 0 0 0 3px rgba(240, 147, 251, 0.1);
289 }}
290 .btn {{
291 padding: 8px 16px;
292 border: none;
293 border-radius: 6px;
294 font-size: 14px;
295 font-weight: 500;
296 cursor: pointer;
297 transition: all 0.2s;
298 }}
299 .btn-primary {{
300 background: #f093fb;
301 color: white;
302 }}
303 .btn-primary:hover {{
304 background: #e081ea;
305 transform: translateY(-1px);
306 box-shadow: 0 4px 12px rgba(240, 147, 251, 0.4);
307 }}
308 .btn-secondary {{
309 background: #e2e8f0;
310 color: #2d3748;
311 }}
312 .btn-secondary:hover {{
313 background: #cbd5e0;
314 }}
315 .btn-small {{
316 padding: 4px 12px;
317 font-size: 12px;
318 }}
319 .btn-danger {{
320 background: #fc8181;
321 color: white;
322 }}
323 .code-preview {{
324 background: #1a202c;
325 color: #e2e8f0;
326 padding: 16px;
327 border-radius: 6px;
328 font-family: 'Monaco', 'Menlo', monospace;
329 font-size: 13px;
330 line-height: 1.6;
331 overflow-x: auto;
332 margin-bottom: 16px;
333 }}
334 .toolbar {{
335 display: flex;
336 gap: 8px;
337 flex-wrap: wrap;
338 }}
339 .response-area {{
340 background: #f7fafc;
341 border: 1px solid #e2e8f0;
342 border-radius: 6px;
343 padding: 16px;
344 min-height: 200px;
345 overflow-x: auto;
346 }}
347 .loading {{
348 text-align: center;
349 padding: 40px;
350 color: #718096;
351 }}
352 .error {{
353 background: #fed7d7;
354 color: #c53030;
355 padding: 16px;
356 border-radius: 6px;
357 }}
358 .middle-panel {{
359 border-left: 1px solid #e2e8f0;
360 border-right: 1px solid #e2e8f0;
361 }}
362 .pagination-grid {{
363 display: grid;
364 grid-template-columns: 1fr 1fr;
365 gap: 12px;
366 }}
367 .form-group {{
368 display: flex;
369 flex-direction: column;
370 gap: 6px;
371 }}
372 .form-label {{
373 font-size: 12px;
374 font-weight: 500;
375 color: #4a5568;
376 }}
377 </style>
378 {custom_css}
379</head>
380<body>
381 <div class="container">
382 <!-- Left Panel: Field Selection -->
383 <div class="panel">
384 <div class="panel-header">๐ฏ Field Selection</div>
385 <div class="panel-content">
386 <div class="section">
387 <div class="section-title">Available Fields</div>
388 <div class="field-tree" id="fieldTree">
389 <div class="loading">Loading schema...</div>
390 </div>
391 </div>
392 </div>
393 </div>
394
395 <!-- Middle Panel: Filters & Options -->
396 <div class="panel middle-panel">
397 <div class="panel-header">โ๏ธ Query Configuration</div>
398 <div class="panel-content">
399 <div class="section">
400 <div class="section-title">Filters</div>
401 <div class="filter-list" id="filterList"></div>
402 <button class="btn btn-secondary btn-small" onclick="addFilter()">+ Add Filter</button>
403 </div>
404
405 <div class="section">
406 <div class="section-title">Sorting</div>
407 <div class="sort-list" id="sortList"></div>
408 <button class="btn btn-secondary btn-small" onclick="addSort()">+ Add Sort</button>
409 </div>
410
411 <div class="section">
412 <div class="section-title">Pagination</div>
413 <div class="pagination-grid">
414 <div class="form-group">
415 <label class="form-label" for="limitInput">Limit</label>
416 <input type="number" id="limitInput" placeholder="10" min="1">
417 </div>
418 <div class="form-group">
419 <label class="form-label" for="offsetInput">Offset</label>
420 <input type="number" id="offsetInput" placeholder="0" min="0">
421 </div>
422 </div>
423 </div>
424 </div>
425 </div>
426
427 <!-- Right Panel: Preview & Execute -->
428 <div class="panel">
429 <div class="panel-header">๐ Query Preview</div>
430 <div class="panel-content">
431 <div class="section">
432 <div class="section-title">Generated Query</div>
433 <div class="code-preview" id="queryPreview">
434 Select fields to build your query
435 </div>
436 <div class="toolbar">
437 <button class="btn btn-primary" onclick="executeQuery()">โถ Run Query</button>
438 <button class="btn btn-secondary" onclick="copyQuery()">๐ Copy</button>
439 <button class="btn btn-secondary" onclick="exportQuery()">๐พ Export</button>
440 <button class="btn btn-secondary" onclick="resetBuilder()">๐ Reset</button>
441 </div>
442 </div>
443
444 <div class="section">
445 <div class="section-title">Response</div>
446 <div class="response-area" id="responseArea">
447 <div class="loading">Build and execute a query to see results</div>
448 </div>
449 </div>
450 </div>
451 </div>
452 </div>
453
454 <script>
455 const graphqlEndpoint = '{graphql_endpoint}';
456 let selectedFields = new Set();
457 let filters = [];
458 let sorting = [];
459
460 // Mock schema (in production, fetch from introspection)
461 const mockSchema = {{
462 fields: [
463 {{ name: 'hello', type: 'String!', description: 'Greeting message' }},
464 {{ name: 'version', type: 'String!', description: 'API version' }},
465 {{ name: 'triples', type: 'Int!', description: 'Triple count' }},
466 {{ name: 'subjects', type: '[String!]!', description: 'Subject IRIs', hasArgs: true }},
467 {{ name: 'predicates', type: '[String!]!', description: 'Predicate IRIs', hasArgs: true }},
468 {{ name: 'objects', type: '[String!]!', description: 'Object values', hasArgs: true }},
469 {{ name: 'sparql', type: 'String', description: 'Execute SPARQL', hasArgs: true }},
470 ]
471 }};
472
473 function renderFieldTree() {{
474 const html = mockSchema.fields.map(field => `
475 <div class="field-item">
476 <input type="checkbox" id="field-${{field.name}}" onchange="toggleField('${{field.name}}')">
477 <span class="field-name">${{field.name}}</span>
478 <span class="field-type">${{field.type}}</span>
479 </div>
480 `).join('');
481 document.getElementById('fieldTree').innerHTML = html;
482 }}
483
484 function toggleField(fieldName) {{
485 if (selectedFields.has(fieldName)) {{
486 selectedFields.delete(fieldName);
487 }} else {{
488 selectedFields.add(fieldName);
489 }}
490 updateQueryPreview();
491 }}
492
493 function addFilter() {{
494 filters.push({{ field: '', op: 'Equals', value: '' }});
495 renderFilters();
496 updateQueryPreview();
497 }}
498
499 function removeFilter(index) {{
500 filters.splice(index, 1);
501 renderFilters();
502 updateQueryPreview();
503 }}
504
505 function renderFilters() {{
506 const html = filters.map((filter, i) => `
507 <div class="filter-item">
508 <select onchange="updateFilter(${{i}}, 'field', this.value)">
509 <option value="">Select field...</option>
510 ${{Array.from(selectedFields).map(f =>
511 `<option value="${{f}}" ${{filter.field === f ? 'selected' : ''}}>${{f}}</option>`
512 ).join('')}}
513 </select>
514 <select onchange="updateFilter(${{i}}, 'op', this.value)">
515 <option value="Equals" ${{filter.op === 'Equals' ? 'selected' : ''}}>equals</option>
516 <option value="Contains" ${{filter.op === 'Contains' ? 'selected' : ''}}>contains</option>
517 <option value="GreaterThan" ${{filter.op === 'GreaterThan' ? 'selected' : ''}}>></option>
518 <option value="LessThan" ${{filter.op === 'LessThan' ? 'selected' : ''}}><</option>
519 </select>
520 <input type="text" placeholder="Value" value="${{filter.value}}"
521 onchange="updateFilter(${{i}}, 'value', this.value)">
522 <button class="btn btn-danger btn-small" onclick="removeFilter(${{i}})">ร</button>
523 </div>
524 `).join('');
525 document.getElementById('filterList').innerHTML = html;
526 }}
527
528 function updateFilter(index, key, value) {{
529 filters[index][key] = value;
530 updateQueryPreview();
531 }}
532
533 function addSort() {{
534 sorting.push({{ field: '', direction: 'Ascending' }});
535 renderSorting();
536 updateQueryPreview();
537 }}
538
539 function removeSort(index) {{
540 sorting.splice(index, 1);
541 renderSorting();
542 updateQueryPreview();
543 }}
544
545 function renderSorting() {{
546 const html = sorting.map((sort, i) => `
547 <div class="sort-item">
548 <select onchange="updateSort(${{i}}, 'field', this.value)">
549 <option value="">Select field...</option>
550 ${{Array.from(selectedFields).map(f =>
551 `<option value="${{f}}" ${{sort.field === f ? 'selected' : ''}}>${{f}}</option>`
552 ).join('')}}
553 </select>
554 <select onchange="updateSort(${{i}}, 'direction', this.value)">
555 <option value="Ascending" ${{sort.direction === 'Ascending' ? 'selected' : ''}}>ASC</option>
556 <option value="Descending" ${{sort.direction === 'Descending' ? 'selected' : ''}}>DESC</option>
557 </select>
558 <span></span>
559 <button class="btn btn-danger btn-small" onclick="removeSort(${{i}})">ร</button>
560 </div>
561 `).join('');
562 document.getElementById('sortList').innerHTML = html;
563 }}
564
565 function updateSort(index, key, value) {{
566 sorting[index][key] = value;
567 updateQueryPreview();
568 }}
569
570 function updateQueryPreview() {{
571 if (selectedFields.size === 0) {{
572 document.getElementById('queryPreview').textContent = 'Select fields to build your query';
573 return;
574 }}
575
576 const fields = Array.from(selectedFields).join('\\n ');
577 let query = `query {{\\n ${{fields}}\\n}}`;
578
579 document.getElementById('queryPreview').textContent = query;
580 }}
581
582 async function executeQuery() {{
583 const queryText = document.getElementById('queryPreview').textContent;
584 if (queryText === 'Select fields to build your query') {{
585 alert('Please select fields first');
586 return;
587 }}
588
589 document.getElementById('responseArea').innerHTML = '<div class="loading">Executing...</div>';
590
591 try {{
592 const response = await fetch(graphqlEndpoint, {{
593 method: 'POST',
594 headers: {{ 'Content-Type': 'application/json' }},
595 body: JSON.stringify({{ query: queryText }})
596 }});
597
598 const result = await response.json();
599 document.getElementById('responseArea').innerHTML =
600 `<pre class="code-preview">${{JSON.stringify(result, null, 2)}}</pre>`;
601 }} catch (error) {{
602 document.getElementById('responseArea').innerHTML =
603 `<div class="error">Error: ${{error.message}}</div>`;
604 }}
605 }}
606
607 function copyQuery() {{
608 const query = document.getElementById('queryPreview').textContent;
609 navigator.clipboard.writeText(query);
610 alert('Query copied to clipboard!');
611 }}
612
613 function exportQuery() {{
614 const query = document.getElementById('queryPreview').textContent;
615 const blob = new Blob([query], {{ type: 'text/plain' }});
616 const url = URL.createObjectURL(blob);
617 const a = document.createElement('a');
618 a.href = url;
619 a.download = 'query.graphql';
620 a.click();
621 }}
622
623 function resetBuilder() {{
624 selectedFields.clear();
625 filters = [];
626 sorting = [];
627 document.getElementById('limitInput').value = '';
628 document.getElementById('offsetInput').value = '';
629 renderFieldTree();
630 renderFilters();
631 renderSorting();
632 updateQueryPreview();
633 }}
634
635 // Initialize
636 renderFieldTree();
637 renderFilters();
638 renderSorting();
639 </script>
640</body>
641</html>"#,
642 title = config.title,
643 graphql_endpoint = graphql_endpoint,
644 custom_css = custom_css
645 )
646}
647
648#[derive(Debug)]
650pub struct QueryBuilder {
651 config: QueryBuilderConfig,
652}
653
654impl QueryBuilder {
655 pub fn new(config: QueryBuilderConfig) -> Self {
657 Self { config }
658 }
659
660 pub fn html(&self, graphql_endpoint: &str) -> String {
662 generate_query_builder_html(&self.config, graphql_endpoint)
663 }
664
665 pub fn config(&self) -> &QueryBuilderConfig {
667 &self.config
668 }
669}
670
671impl Default for QueryBuilder {
672 fn default() -> Self {
673 Self::new(QueryBuilderConfig::default())
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 #[test]
682 fn test_query_builder_config_default() {
683 let config = QueryBuilderConfig::default();
684 assert!(config.enabled);
685 assert_eq!(config.endpoint, "/query-builder");
686 assert!(config.show_preview);
687 assert!(config.enable_execution);
688 assert!(config.enable_export);
689 assert_eq!(config.max_depth, 5);
690 }
691
692 #[test]
693 fn test_filter_op_display() {
694 assert_eq!(FilterOp::Equals.to_string(), "=");
695 assert_eq!(FilterOp::NotEquals.to_string(), "!=");
696 assert_eq!(FilterOp::GreaterThan.to_string(), ">");
697 assert_eq!(FilterOp::LessThan.to_string(), "<");
698 assert_eq!(FilterOp::Contains.to_string(), "contains");
699 assert_eq!(FilterOp::In.to_string(), "in");
700 }
701
702 #[test]
703 fn test_sort_direction_display() {
704 assert_eq!(SortDirection::Ascending.to_string(), "ASC");
705 assert_eq!(SortDirection::Descending.to_string(), "DESC");
706 }
707
708 #[test]
709 fn test_query_builder_state_default() {
710 let state = QueryBuilderState::default();
711 assert_eq!(state.operation_type, "query");
712 assert!(state.root_field.is_empty());
713 assert!(state.fields.is_empty());
714 assert!(state.filters.is_empty());
715 assert!(state.sorting.is_empty());
716 assert!(state.limit.is_none());
717 assert!(state.offset.is_none());
718 }
719
720 #[test]
721 fn test_field_selection() {
722 let field = FieldSelection {
723 name: "test".to_string(),
724 field_type: "String".to_string(),
725 selected: true,
726 arguments: HashMap::new(),
727 nested_fields: Vec::new(),
728 };
729
730 assert_eq!(field.name, "test");
731 assert_eq!(field.field_type, "String");
732 assert!(field.selected);
733 }
734
735 #[test]
736 fn test_filter_condition() {
737 let filter = FilterCondition {
738 field: "name".to_string(),
739 operation: FilterOp::Contains,
740 value: "test".to_string(),
741 };
742
743 assert_eq!(filter.field, "name");
744 assert_eq!(filter.operation, FilterOp::Contains);
745 assert_eq!(filter.value, "test");
746 }
747
748 #[test]
749 fn test_sort_field() {
750 let sort = SortField {
751 field: "createdAt".to_string(),
752 direction: SortDirection::Descending,
753 };
754
755 assert_eq!(sort.field, "createdAt");
756 assert_eq!(sort.direction, SortDirection::Descending);
757 }
758
759 #[test]
760 fn test_query_builder_creation() {
761 let builder = QueryBuilder::default();
762 assert!(builder.config().enabled);
763 assert!(builder.config().show_preview);
764 }
765
766 #[test]
767 fn test_generate_html() {
768 let config = QueryBuilderConfig::default();
769 let html = generate_query_builder_html(&config, "/graphql");
770
771 assert!(html.contains("<!DOCTYPE html>"));
772 assert!(html.contains("Query Builder"));
773 assert!(html.contains("/graphql"));
774 assert!(html.contains("Field Selection"));
775 assert!(html.contains("Query Configuration"));
776 }
777
778 #[test]
779 fn test_html_includes_features() {
780 let builder = QueryBuilder::default();
781 let html = builder.html("/graphql");
782
783 assert!(html.contains("Filters"));
784 assert!(html.contains("Sorting"));
785 assert!(html.contains("Pagination"));
786 assert!(html.contains("Generated Query"));
787 }
788}