From 0033cb61534ec874e1e8299fbc64fa6f14692b95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:37:55 +0000 Subject: [PATCH 1/3] Initial plan From 6c40df16aa4a56e53efb7eed48dbd34a9487c216 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 18:44:19 +0000 Subject: [PATCH 2/3] Add Distinct checkbox for EntityNode variables with SPARQL SELECT DISTINCT support Co-authored-by: HerrMotz <20333692+HerrMotz@users.noreply.github.com> --- .../components/EntitySelectorInputControl.vue | 25 +++++++ .../src/components/ProjectionCheckbox.vue | 6 +- query-by-graph/src/lib.rs | 39 +++++++++-- query-by-graph/src/lib/rete/constants.ts | 3 +- query-by-graph/src/lib/types/EntityType.ts | 1 + query-by-graph/tests/logic.rs | 70 +++++++++++++++++++ 6 files changed, 135 insertions(+), 9 deletions(-) diff --git a/query-by-graph/src/components/EntitySelectorInputControl.vue b/query-by-graph/src/components/EntitySelectorInputControl.vue index 6969d96..a6df931 100644 --- a/query-by-graph/src/components/EntitySelectorInputControl.vue +++ b/query-by-graph/src/components/EntitySelectorInputControl.vue @@ -16,6 +16,15 @@ container-classes="px-2" label-classes="text-white" /> + @@ -50,6 +59,19 @@ export default { } } } + }, + includeAsDistinct: { + get() { + return this.data.value?.distinct === true; + }, + set(value) { + if (this.data.value) { + this.data.value.distinct = value; + if (this.data.options?.change) { + this.data.options.change(this.data.value); + } + } + } } }, methods: { @@ -72,6 +94,9 @@ export default { if (this.data.value.selectedForProjection === undefined) { this.data.value.selectedForProjection = true; } + if (this.data.value.distinct === undefined) { + this.data.value.distinct = false; + } } }, } diff --git a/query-by-graph/src/components/ProjectionCheckbox.vue b/query-by-graph/src/components/ProjectionCheckbox.vue index e3ff476..f7d1997 100644 --- a/query-by-graph/src/components/ProjectionCheckbox.vue +++ b/query-by-graph/src/components/ProjectionCheckbox.vue @@ -8,7 +8,7 @@ class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2" /> @@ -27,6 +27,10 @@ defineProps({ type: Boolean, default: false }, + label: { + type: String, + default: 'Select?' + }, containerClasses: { type: String, default: '' diff --git a/query-by-graph/src/lib.rs b/query-by-graph/src/lib.rs index c104646..b60afec 100644 --- a/query-by-graph/src/lib.rs +++ b/query-by-graph/src/lib.rs @@ -21,6 +21,8 @@ pub struct Entity { pub prefix: Prefix, #[serde(default = "default_selected_for_projection")] pub selected_for_projection: bool, + #[serde(default)] + pub distinct: bool, } #[derive(Serialize, Deserialize, Clone)] @@ -167,6 +169,11 @@ fn vqg_to_query( }) .collect::>(); + let has_distinct = connections.iter().any(|connection| { + (connection.source.id.starts_with('?') && connection.source.selected_for_projection && connection.source.distinct) + || (connection.target.id.starts_with('?') && connection.target.selected_for_projection && connection.target.distinct) + }); + let projection_list = if projection_set.is_empty() { String::from("*") } else { @@ -175,6 +182,8 @@ fn vqg_to_query( sorted_projection_set.join(" ") }; + let distinct_keyword = if has_distinct { "DISTINCT " } else { "" }; + let prefix_set = connections .iter() .flat_map(|connection| { @@ -248,13 +257,13 @@ fn vqg_to_query( if add_label_service_prefixes { format!( - "{}\n{}\n{}{}SELECT {} WHERE {{\n{}{}}}", - BD_PREFIX, WIKIBASE_PREFIX, xsd_prefix, prefix_list, projection_list, where_clause, service + "{}\n{}\n{}{}SELECT {}{} WHERE {{\n{}{}}}", + BD_PREFIX, WIKIBASE_PREFIX, xsd_prefix, prefix_list, distinct_keyword, projection_list, where_clause, service ) } else { format!( - "{}{}SELECT {} WHERE {{\n{}{}}}", - xsd_prefix, prefix_list, projection_list, where_clause, service + "{}{}SELECT {}{} WHERE {{\n{}{}}}", + xsd_prefix, prefix_list, distinct_keyword, projection_list, where_clause, service ) } } @@ -399,15 +408,24 @@ fn query_to_vqg(query: &str) -> Vec { // Match on the query type. match parsed_query { Ok(Query::Select { pattern: p, .. }) => { - let (connections, projection_vars) = match p { + let (connections, projection_vars, is_distinct) = match p { + GraphPattern::Distinct { inner } => match *inner { + GraphPattern::Project { variables: v, inner: i } => ( + match_bgp_or_path_to_vqg(*i), + Some(v.iter().map(|var| format!("?{}", var.as_str())).collect::>()), + true, + ), + other => (match_bgp_or_path_to_vqg(other), None, true), + }, GraphPattern::Project { variables: v, inner: i, } => ( match_bgp_or_path_to_vqg(*i), - Some(v.iter().map(|var| format!("?{}", var.as_str())).collect::>()) + Some(v.iter().map(|var| format!("?{}", var.as_str())).collect::>()), + false, ), - _ => (match_bgp_or_path_to_vqg(p), None), + _ => (match_bgp_or_path_to_vqg(p), None, false), }; let mut connections = connections; @@ -417,10 +435,14 @@ fn query_to_vqg(query: &str) -> Vec { if connection.source.id.starts_with('?') { connection.source.selected_for_projection = vars.contains(&connection.source.id); + connection.source.distinct = + is_distinct && vars.contains(&connection.source.id); } if connection.target.id.starts_with('?') { connection.target.selected_for_projection = vars.contains(&connection.target.id); + connection.target.distinct = + is_distinct && vars.contains(&connection.target.id); } for property in &mut connection.properties { if property.id.starts_with('?') { @@ -472,6 +494,7 @@ fn match_bgp_or_path_to_vqg(p: GraphPattern) -> Vec { let r_parsed = match_bgp_or_path_to_vqg(*r); l_parsed.into_iter().chain(r_parsed).collect() } + GraphPattern::Distinct { inner } => match_bgp_or_path_to_vqg(*inner), GraphPattern::Path { subject: s, path: p, @@ -503,6 +526,7 @@ fn connection_constructor( abbreviation: "".to_string(), }, selected_for_projection: true, // Default to true + distinct: false, }, target: Entity { id: object_name.clone(), @@ -512,6 +536,7 @@ fn connection_constructor( abbreviation: "".to_string(), }, selected_for_projection: true, // Default to true + distinct: false, }, properties: vec![Property { id: predicate_name.clone(), diff --git a/query-by-graph/src/lib/rete/constants.ts b/query-by-graph/src/lib/rete/constants.ts index ab07407..6772b7d 100644 --- a/query-by-graph/src/lib/rete/constants.ts +++ b/query-by-graph/src/lib/rete/constants.ts @@ -11,7 +11,8 @@ const variableEntity: EntityType = { // EntityType abbreviation: "", }, dataSource: noDataSource, - selectedForProjection: true + selectedForProjection: true, + distinct: false }; const variableEntityConstructor = (name: string): EntityType => { diff --git a/query-by-graph/src/lib/types/EntityType.ts b/query-by-graph/src/lib/types/EntityType.ts index 11865d5..34e0bdd 100644 --- a/query-by-graph/src/lib/types/EntityType.ts +++ b/query-by-graph/src/lib/types/EntityType.ts @@ -7,6 +7,7 @@ export interface EntityType { prefix: PrefixType, dataSource: WikibaseDataSource; selectedForProjection?: boolean; + distinct?: boolean; isLiteral?: boolean; } diff --git a/query-by-graph/tests/logic.rs b/query-by-graph/tests/logic.rs index 70e940b..707dae6 100644 --- a/query-by-graph/tests/logic.rs +++ b/query-by-graph/tests/logic.rs @@ -324,3 +324,73 @@ SELECT ?o ?s WHERE { }"###; assert_sparql_equivalent(&result, expected); } + +#[test] +fn test_distinct_variable_generates_select_distinct() { + let graph = r###"[{"properties":[{"id":"P69","label":"educated at","prefix":{"iri":"http://www.wikidata.org/prop/direct/","abbreviation":"wdt"},"selectedForProjection":false}],"source":{"id":"Q5879","label":"Johann Wolfgang von Goethe","prefix":{"iri":"http://www.wikidata.org/entity/","abbreviation":"wd"},"selectedForProjection":false,"distinct":false},"target":{"id":"?university","label":"Variable","prefix":{"iri":"","abbreviation":""},"selectedForProjection":true,"distinct":true}}]"###; + + let result = vqg_to_query_wasm(graph, false, false); + let select = select_line(&result); + + assert!(select.contains("DISTINCT"), "Expected SELECT DISTINCT but got: {}", select); + assert!(select.contains("?university")); +} + +#[test] +fn test_no_distinct_generates_plain_select() { + let graph = r###"[{"properties":[{"id":"P69","label":"educated at","prefix":{"iri":"http://www.wikidata.org/prop/direct/","abbreviation":"wdt"},"selectedForProjection":false}],"source":{"id":"Q5879","label":"Johann Wolfgang von Goethe","prefix":{"iri":"http://www.wikidata.org/entity/","abbreviation":"wd"},"selectedForProjection":false,"distinct":false},"target":{"id":"?university","label":"Variable","prefix":{"iri":"","abbreviation":""},"selectedForProjection":true,"distinct":false}}]"###; + + let result = vqg_to_query_wasm(graph, false, false); + let select = select_line(&result); + + assert!(!select.contains("DISTINCT"), "Expected plain SELECT but got: {}", select); + assert!(select.contains("?university")); +} + +#[test] +fn test_parse_select_distinct_query_sets_distinct_flag() { + let query = r###"PREFIX wd: +PREFIX wdt: +SELECT DISTINCT ?university WHERE { + wd:Q5879 wdt:P69 ?university . +}"###; + + let result = query_to_vqg_wasm(query); + let connections = parse_connections_json(&result); + + assert_eq!(connections.len(), 1); + let c = &connections[0]; + + assert_eq!(c["target"]["id"], Value::String("?university".to_string())); + assert_eq!(c["target"]["selectedForProjection"], Value::Bool(true)); + assert_eq!(c["target"]["distinct"], Value::Bool(true)); +} + +#[test] +fn test_parse_non_distinct_query_does_not_set_distinct_flag() { + let query = r###"PREFIX wd: +PREFIX wdt: +SELECT ?university WHERE { + wd:Q5879 wdt:P69 ?university . +}"###; + + let result = query_to_vqg_wasm(query); + let connections = parse_connections_json(&result); + + assert_eq!(connections.len(), 1); + let c = &connections[0]; + + assert_eq!(c["target"]["id"], Value::String("?university".to_string())); + assert_eq!(c["target"]["distinct"], Value::Bool(false)); +} + +#[test] +fn test_distinct_only_applies_when_variable_is_selected_for_projection() { + // distinct=true but selectedForProjection=false → should NOT generate SELECT DISTINCT + let graph = r###"[{"properties":[{"id":"P69","label":"educated at","prefix":{"iri":"http://www.wikidata.org/prop/direct/","abbreviation":"wdt"},"selectedForProjection":false}],"source":{"id":"Q5879","label":"Goethe","prefix":{"iri":"http://www.wikidata.org/entity/","abbreviation":"wd"},"selectedForProjection":false,"distinct":false},"target":{"id":"?university","label":"Variable","prefix":{"iri":"","abbreviation":""},"selectedForProjection":false,"distinct":true}}]"###; + + let result = vqg_to_query_wasm(graph, false, false); + let select = select_line(&result); + + assert!(!select.contains("DISTINCT"), "DISTINCT should not appear when variable is not selected for projection: {}", select); +} From a4be0afc79d19b0a8abc22e5bac93d0f2d310a5c Mon Sep 17 00:00:00 2001 From: Friedrich Answin Daniel Motz Date: Sun, 22 Feb 2026 19:29:12 +0100 Subject: [PATCH 3/3] add: make distinct checkbox global --- query-by-graph/src/App.vue | 29 ++++++++++++++++++- .../components/EntitySelectorInputControl.vue | 10 ++++++- query-by-graph/src/store.ts | 3 ++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/query-by-graph/src/App.vue b/query-by-graph/src/App.vue index 14a499c..c155627 100644 --- a/query-by-graph/src/App.vue +++ b/query-by-graph/src/App.vue @@ -13,7 +13,7 @@ import ConnectionInterfaceType from "./lib/types/ConnectionInterfaceType.ts"; import ClipboardButton from "./components/ClipboardButton.vue"; import QueryButton from './components/QueryButton.vue'; import WikibaseDataService from './lib/wikidata/WikibaseDataService.ts'; -import {selectedDataSource, dataSources, setSelectedDataSource} from './store.ts'; +import {selectedDataSource, dataSources, setSelectedDataSource, globalDistinct} from './store.ts'; import {WikibaseDataSource} from "./lib/types/WikibaseDataSource.ts"; import {debounce} from "./lib/utils"; import DataSourcesPopover from "./components/DataSourcesPopover.vue"; @@ -225,6 +225,19 @@ const deselectAllVariablesForProjection = () => { } }; +const toggleDistinct = () => { + globalDistinct.value = !globalDistinct.value; + // Sync entity.distinct on every node so the Rust backend export picks it up + if (editor.value) { + editor.value.getNodes().forEach((node: any) => { + if (node.entity) { + node.entity.distinct = globalDistinct.value; + editor.value?.updateNode(node.id); + } + }); + } +}; + const gotoLink = (url?: string) => { const link = url || window.location.href; @@ -383,6 +396,20 @@ const gotoLink = (url?: string) => {

+
+

Distinct Query

+
+ +
+

+ Hint: + When Distinct is enabled, the SELECT DISTINCT modifier is applied globally to the entire query. + Toggling this on any node's "Distinct?" checkbox has the same effect. +

+
+

Arrangement

diff --git a/query-by-graph/src/components/EntitySelectorInputControl.vue b/query-by-graph/src/components/EntitySelectorInputControl.vue index a6df931..e94cd8a 100644 --- a/query-by-graph/src/components/EntitySelectorInputControl.vue +++ b/query-by-graph/src/components/EntitySelectorInputControl.vue @@ -31,6 +31,7 @@