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.
+