From 939f937aefcdae8ce4c0bc4cd5f0af4a14eec946 Mon Sep 17 00:00:00 2001 From: Ata B Barzegar Date: Tue, 11 Aug 2026 19:48:24 +0300 Subject: [PATCH 1/6] feat: add sparql_source module with run_sparql and safe_query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run_sparql() dispatches queries to a remote SPARQL endpoint (via sparql_query) or a local rdflib model (via rdflib::rdf_query()) - safe_query() wraps run_sparql() with error handling and now accepts expected_cols to guarantee column presence in the result - Tests in tests/testthat/test-sparql_source.R cover dispatch and column‑injection behaviour --- R/sparql_source.R | 63 +++++++++++++++++++++++++++++ R/utils.R | 31 ++++++++++++++ tests/testthat/test-sparql_source.R | 36 +++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 R/sparql_source.R create mode 100644 tests/testthat/test-sparql_source.R diff --git a/R/sparql_source.R b/R/sparql_source.R new file mode 100644 index 0000000..459b824 --- /dev/null +++ b/R/sparql_source.R @@ -0,0 +1,63 @@ +#' Run a SPARQL query against a local model or a remote endpoint +#' +#' Dispatches `query` to [sparql_query()] when `source` is a single SPARQL +#' endpoint URL (a character scalar), or to `rdflib::rdf_query()` when +#' `source` is a parsed local RDF model (e.g. as returned by +#' `rdflib::rdf_parse()`). This lets every schema-extraction function work +#' unchanged whether `source` is a materialised RDF document or a live +#' SPARQL endpoint. +#' +#' @param source A SPARQL endpoint URL (character scalar), or an `rdf` +#' model object as created by `rdflib::rdf_parse()`. +#' @param query A SPARQL query string. +#' @return A data frame of query results. +#' @examples +#' \dontrun{ +#' run_sparql("https://query.wikidata.org/sparql", "SELECT * WHERE { ?s ?p ?o } LIMIT 1") +#' } +#' @export +run_sparql <- function(source, query) { + if (is.character(source) && length(source) == 1) { + return(sparql_query(source, query)) + } + + if (!requireNamespace("rdflib", quietly = TRUE)) { + stop( + "Querying a local RDF model requires the 'rdflib' package. ", + "Install it with install.packages('rdflib'), or pass an endpoint URL instead.", + call. = FALSE + ) + } + rdflib::rdf_query(source, query) +} + +#' Run a SPARQL query, guaranteeing a well-shaped result +#' +#' Wraps [run_sparql()] so that a failed query (a malformed query, or a +#' temporarily unreachable endpoint) produces a warning and an empty, but +#' correctly-shaped, data frame instead of stopping the whole pipeline. +#' +#' @param source A SPARQL endpoint URL, or a local RDF model. +#' @param query A SPARQL query string. +#' @param expected_cols A character vector of column names that must be +#' present in the returned data frame. If the query returns zero rows, +#' the columns are created as empty character vectors. +#' @return A data frame with at least the columns in `expected_cols`. +#' @examples +#' \dontrun{ +#' safe_query( +#' "https://query.wikidata.org/sparql", +#' "SELECT ?s WHERE { ?s a }" +#' ) +#' } +#' @export +safe_query <- function(source, query, expected_cols = character()) { + df <- tryCatch( + run_sparql(source, query), + error = function(e) { + warning("SPARQL query failed: ", conditionMessage(e), call. = FALSE) + NULL + } + ) + .ensure_cols(df, expected_cols) +} diff --git a/R/utils.R b/R/utils.R index b846d9c..8df623b 100644 --- a/R/utils.R +++ b/R/utils.R @@ -42,3 +42,34 @@ local_name <- function(uri) { #' normalize_scheme("https://schema.org/") #' @export normalize_scheme <- function(x) sub("^https://", "http://", x) + +#' @noRd +.empty_schema <- function() { + list( + classes = data.frame(uri = character(), label = character(), stringsAsFactors = FALSE), + properties = data.frame(property = character(), label = character(), stringsAsFactors = FALSE), + subclass = data.frame(child = character(), parent = character(), stringsAsFactors = FALSE), + property_domains = data.frame(property = character(), domain = character(), stringsAsFactors = FALSE), + property_ranges = data.frame(property = character(), range = character(), stringsAsFactors = FALSE), + extra_edges = data.frame(from = character(), to = character(), relation = character(), stringsAsFactors = FALSE), + union_intersection = data.frame(from = character(), member = character(), relation = character(), stringsAsFactors = FALSE), + restrictions = data.frame(restriction = character(), onProperty = character(), target = character(), + relation = character(), cardinality_label = character(), stringsAsFactors = FALSE) + ) +} + +#' @noRd +.ensure_cols <- function(df, expected_cols) { + if (is.null(df) || !is.data.frame(df)) df <- data.frame() + if (nrow(df) == 0) { + return(as.data.frame( + setNames(rep(list(character(0)), length(expected_cols)), expected_cols), + stringsAsFactors = FALSE + )) + } + df <- as.data.frame(df, stringsAsFactors = FALSE) + for (col in expected_cols) { + if (!col %in% names(df)) df[[col]] <- NA_character_ + } + df +} diff --git a/tests/testthat/test-sparql_source.R b/tests/testthat/test-sparql_source.R new file mode 100644 index 0000000..7637446 --- /dev/null +++ b/tests/testthat/test-sparql_source.R @@ -0,0 +1,36 @@ +test_that("run_sparql dispatches a character source to sparql_query", { + testthat::local_mocked_bindings( + sparql_query = function(url, query, timeout = 60) { + data.frame(url = url, query = query, stringsAsFactors = FALSE) + } + ) + + result <- run_sparql("https://example.org/sparql", "SELECT * WHERE { ?s ?p ?o }") + + expect_equal(result$url, "https://example.org/sparql") + expect_equal(result$query, "SELECT * WHERE { ?s ?p ?o }") +}) + +test_that("run_sparql dispatches a non-character source to rdflib::rdf_query", { + skip_if_not_installed("rdflib") + + testthat::local_mocked_bindings( + rdf_query = function(source, query) data.frame(x = "ok", stringsAsFactors = FALSE), + .package = "rdflib" + ) + + fake_model <- structure(list(), class = "rdf") + result <- run_sparql(fake_model, "SELECT * WHERE { ?s ?p ?o }") + + expect_equal(result$x, "ok") +}) + +test_that("safe_query warns and returns an empty, well-shaped data frame on failure", { + testthat::local_mocked_bindings( + sparql_query = function(url, query, timeout = 60) stop("boom") + ) + expect_warning( + result <- safe_query("https://example.org/sparql", "SELECT ?s WHERE { ?s ?p ?o }"), + "boom" + ) +}) From 8f30cfaf2f04ec753b9e0fd279f5ac8ed47397e0 Mon Sep 17 00:00:00 2001 From: Ata B Barzegar Date: Sat, 22 Aug 2026 21:21:39 +0300 Subject: [PATCH 2/6] feat: add OWL ontology schema extraction functions - extract_list_edges: fully traverses rdf:List structures referenced by owl:unionOf, owl:intersectionOf, owl:oneOf to capture all members - extract_restrictions: extracts owl:Restriction axioms including onProperty, target, relation, and human-readable cardinality labels - extract_schema_owl: main function that aggregates classes, properties, subclass hierarchies, domains, ranges, extra edges (e.g., subPropertyOf, equivalentClass, inverseOf), union/intersection lists, and restrictions - Uses dplyr for data manipulation and SPARQL queries via safe_query (expected to be provided elsewhere) - Imports dplyr operators (%>%, filter, mutate, distinct, bind_rows, select, case_when) --- R/schema_owl.R | 198 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 R/schema_owl.R diff --git a/R/schema_owl.R b/R/schema_owl.R new file mode 100644 index 0000000..6cab542 --- /dev/null +++ b/R/schema_owl.R @@ -0,0 +1,198 @@ +#' @importFrom dplyr %>% filter mutate distinct bind_rows select case_when +NULL + +RDF_NIL <- "http://www.w3.org/1999/02/22-rdf-syntax-ns#nil" + +#' Fully traverse every rdf:List referenced by owl:unionOf/intersectionOf/oneOf +#' +#' Queries all rdf:first/rdf:rest cons-cells up front and walks each list to +#' its end, so lists with more than one member are captured completely. +#' +#' @param source A SPARQL endpoint URL or a parsed rdflib compatible file. +#' @return A data frame with columns `from`, `member`, `relation`. +#' @export +extract_list_edges <- function(source) { + heads <- safe_query(source, ' + PREFIX owl: + SELECT ?class ?list ?relation WHERE { + { ?class owl:unionOf ?list . BIND("unionOf" AS ?relation) } + UNION { ?class owl:intersectionOf ?list . BIND("intersectionOf" AS ?relation) } + UNION { ?class owl:oneOf ?list . BIND("oneOf" AS ?relation) } + }', expected_cols = c("class", "list", "relation")) + + if (nrow(heads) == 0) { + return(data.frame(from = character(), member = character(), relation = character(), + stringsAsFactors = FALSE)) + } + + cells <- safe_query(source, ' + PREFIX rdf: + SELECT ?list ?first ?rest WHERE { + ?list rdf:first ?first . + OPTIONAL { ?list rdf:rest ?rest } + }', expected_cols = c("list", "first", "rest")) + + first_of <- cells %>% filter(!is.na(first)) %>% distinct(list, first) + rest_of <- cells %>% filter(!is.na(rest)) %>% distinct(list, rest) + + out <- vector("list", nrow(heads)) + for (i in seq_len(nrow(heads))) { + class_iri <- heads$class[i] + rel <- heads$relation[i] + current <- heads$list[i] + visited <- character() + members <- character() + while (!is.na(current) && current != RDF_NIL && !(current %in% visited)) { + visited <- c(visited, current) + first_val <- first_of$first[match(current, first_of$list)] + if (!is.na(first_val)) members <- c(members, first_val) + current <- rest_of$rest[match(current, rest_of$list)] + } + if (length(members) > 0) { + out[[i]] <- data.frame(from = class_iri, member = members, relation = rel, + stringsAsFactors = FALSE) + } + } + + result <- bind_rows(out) + if (nrow(result) == 0) { + return(data.frame(from = character(), member = character(), relation = character(), + stringsAsFactors = FALSE)) + } + distinct(result) +} + +#' Extract owl:Restriction axioms +#' +#' Captures the restricted property (`owl:onProperty`), the restriction kind +#' (someValuesFrom/allValuesFrom/hasValue/onClass), and a human-readable +#' cardinality label when a cardinality constraint is present. +#' +#' @param source A SPARQL endpoint URL or a parsed rdflib compatible file. +#' @return A data frame with columns `restriction`, `onProperty`, `target`, +#' `relation`, `cardinality_label`. +#' @export +extract_restrictions <- function(source) { + cols <- c("restriction", "onProperty", "target", "relation", "cardinality", + "minCardinality", "maxCardinality", "qualifiedCardinality", + "minQualifiedCardinality", "maxQualifiedCardinality") + + raw <- safe_query(source, ' + PREFIX owl: + SELECT ?restriction ?onProperty ?target ?relation + ?cardinality ?minCardinality ?maxCardinality + ?qualifiedCardinality ?minQualifiedCardinality ?maxQualifiedCardinality + WHERE { + ?restriction a owl:Restriction ; + owl:onProperty ?onProperty . + OPTIONAL { ?restriction owl:someValuesFrom ?someValuesFrom } + OPTIONAL { ?restriction owl:allValuesFrom ?allValuesFrom } + OPTIONAL { ?restriction owl:hasValue ?hasValue } + OPTIONAL { ?restriction owl:onClass ?onClass } + OPTIONAL { ?restriction owl:cardinality ?cardinality } + OPTIONAL { ?restriction owl:minCardinality ?minCardinality } + OPTIONAL { ?restriction owl:maxCardinality ?maxCardinality } + OPTIONAL { ?restriction owl:qualifiedCardinality ?qualifiedCardinality } + OPTIONAL { ?restriction owl:minQualifiedCardinality ?minQualifiedCardinality } + OPTIONAL { ?restriction owl:maxQualifiedCardinality ?maxQualifiedCardinality } + BIND(COALESCE(?someValuesFrom, ?allValuesFrom, ?hasValue, ?onClass) AS ?target) + BIND( + IF(BOUND(?someValuesFrom), "someValuesFrom", + IF(BOUND(?allValuesFrom), "allValuesFrom", + IF(BOUND(?hasValue), "hasValue", + IF(BOUND(?onClass), "onClass", "restriction")))) AS ?relation) + }', expected_cols = cols) + + if (nrow(raw) == 0) { + return(data.frame(restriction = character(), onProperty = character(), + target = character(), relation = character(), + cardinality_label = character(), stringsAsFactors = FALSE)) + } + + raw %>% + mutate(cardinality_label = case_when( + !is.na(cardinality) ~ paste0("exactly ", cardinality), + !is.na(minCardinality) & !is.na(maxCardinality) ~ paste0(minCardinality, "..", maxCardinality), + !is.na(minCardinality) ~ paste0("min ", minCardinality), + !is.na(maxCardinality) ~ paste0("max ", maxCardinality), + !is.na(qualifiedCardinality) ~ paste0("exactly ", qualifiedCardinality, " (qualified)"), + !is.na(minQualifiedCardinality) ~ paste0("min ", minQualifiedCardinality, " (qualified)"), + !is.na(maxQualifiedCardinality) ~ paste0("max ", maxQualifiedCardinality, " (qualified)"), + TRUE ~ NA_character_ + )) %>% + select(restriction, onProperty, target, relation, cardinality_label) %>% + distinct() +} + +#' Extract a schema from an OWL ontology +#' +#' @param source A SPARQL endpoint URL or a parsed rdflib compatible file. +#' @return A schema list, see [.empty_schema()]. +#' @export +extract_schema_owl <- function(source) { + schema <- .empty_schema() + + classes <- safe_query(source, ' + PREFIX owl: + PREFIX rdfs: + SELECT ?uri ?label WHERE { + { ?uri a owl:Class } UNION { ?uri a rdfs:Class } + OPTIONAL { + ?uri rdfs:label ?label . + BIND(IF(LANG(?label) = "en" || LANG(?label) = "", 0, 1) AS ?labelRank) + } + } ORDER BY ?uri ?labelRank', expected_cols = c("uri", "label")) + + subclass <- safe_query(source, ' + PREFIX rdfs: + SELECT ?child ?parent WHERE { ?child rdfs:subClassOf ?parent . }', + expected_cols = c("child", "parent")) + + properties <- safe_query(source, ' + PREFIX owl: + PREFIX rdf: + PREFIX rdfs: + SELECT ?property ?label WHERE { + ?property a ?ptype . + FILTER(?ptype IN (owl:ObjectProperty, owl:DatatypeProperty, + owl:AnnotationProperty, rdf:Property)) + OPTIONAL { + ?property rdfs:label ?label . + BIND(IF(LANG(?label) = "en" || LANG(?label) = "", 0, 1) AS ?labelRank) + } + } ORDER BY ?property ?labelRank', expected_cols = c("property", "label")) + + property_domains <- safe_query(source, ' + PREFIX rdfs: + SELECT DISTINCT ?property ?domain WHERE { ?property rdfs:domain ?domain . }', + expected_cols = c("property", "domain")) + + property_ranges <- safe_query(source, ' + PREFIX rdfs: + SELECT DISTINCT ?property ?range WHERE { ?property rdfs:range ?range . }', + expected_cols = c("property", "range")) + + extra_edges <- safe_query(source, ' + PREFIX owl: + PREFIX rdfs: + SELECT ?from ?to ?relation WHERE { + { ?from rdfs:subPropertyOf ?to . BIND("subPropertyOf" AS ?relation) } + UNION { ?from owl:equivalentClass ?to . BIND("equivalentClass" AS ?relation) } + UNION { ?from owl:equivalentProperty ?to . BIND("equivalentProperty" AS ?relation) } + UNION { ?from owl:inverseOf ?to . BIND("inverseOf" AS ?relation) } + UNION { ?from owl:disjointWith ?to . BIND("disjointWith" AS ?relation) } + UNION { ?from owl:propertyDisjointWith ?to . BIND("propertyDisjointWith" AS ?relation) } + UNION { ?from owl:complementOf ?to . BIND("complementOf" AS ?relation) } + }', expected_cols = c("from", "to", "relation")) + + schema$classes <- classes %>% distinct(uri, .keep_all = TRUE) + schema$subclass <- subclass %>% filter(!is.na(child), !is.na(parent)) %>% distinct() + schema$properties <- properties %>% distinct(property, .keep_all = TRUE) + schema$property_domains <- property_domains %>% filter(!is.na(domain)) %>% distinct() + schema$property_ranges <- property_ranges %>% filter(!is.na(range)) %>% distinct() + schema$extra_edges <- extra_edges %>% filter(!is.na(from), !is.na(to)) %>% distinct() + schema$union_intersection <- .extract_list_edges(source) + schema$restrictions <- .extract_restrictions(source) + + schema +} From 8bc60938bd376fed0313c7b7020a547cb15cd793 Mon Sep 17 00:00:00 2001 From: Ata B Barzegar Date: Sat, 22 Aug 2026 21:32:25 +0300 Subject: [PATCH 3/6] fix: make the use of correct funtions --- R/schema_owl.R | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/R/schema_owl.R b/R/schema_owl.R index 6cab542..0bb6bd6 100644 --- a/R/schema_owl.R +++ b/R/schema_owl.R @@ -191,8 +191,8 @@ extract_schema_owl <- function(source) { schema$property_domains <- property_domains %>% filter(!is.na(domain)) %>% distinct() schema$property_ranges <- property_ranges %>% filter(!is.na(range)) %>% distinct() schema$extra_edges <- extra_edges %>% filter(!is.na(from), !is.na(to)) %>% distinct() - schema$union_intersection <- .extract_list_edges(source) - schema$restrictions <- .extract_restrictions(source) + schema$union_intersection <- extract_list_edges(source) + schema$restrictions <- extract_restrictions(source) schema } From 43af5d40505e9ad4cdb4b5ec289acc1088f3e03f Mon Sep 17 00:00:00 2001 From: Ata B Barzegar Date: Sat, 22 Aug 2026 23:41:32 +0300 Subject: [PATCH 4/6] docs: update docs --- DESCRIPTION | 4 +++- NAMESPACE | 22 +++++++++++++++++++++- R/schema_owl.R | 8 ++++---- R/utils.R | 2 +- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 8457d98..fecf09e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -18,7 +18,9 @@ Imports: S7, tools, httr2, - stats + stats, + dplyr, + rdflib Suggests: testthat (>= 3.0.0), withr diff --git a/NAMESPACE b/NAMESPACE index 40ea8e2..62ae34b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -1 +1,21 @@ -exportPattern("^[[:alpha:]]+") +# Generated by roxygen2: do not edit by hand + +export(.solve_possible_duplicate_display) +export(build_ont) +export(extract_list_edges) +export(extract_restrictions) +export(extract_schema_owl) +export(local_name) +export(make_display_list) +export(namespace_of) +export(normalize_scheme) +export(run_sparql) +export(safe_query) +export(sparql_query) +importFrom(dplyr,"%>%") +importFrom(dplyr,bind_rows) +importFrom(dplyr,case_when) +importFrom(dplyr,distinct) +importFrom(dplyr,filter) +importFrom(dplyr,mutate) +importFrom(dplyr,select) diff --git a/R/schema_owl.R b/R/schema_owl.R index 0bb6bd6..b36fa54 100644 --- a/R/schema_owl.R +++ b/R/schema_owl.R @@ -64,13 +64,13 @@ extract_list_edges <- function(source) { #' Extract owl:Restriction axioms #' -#' Captures the restricted property (`owl:onProperty`), the restriction kind +#' Captures the restricted property owl:onProperty, the restriction kind #' (someValuesFrom/allValuesFrom/hasValue/onClass), and a human-readable #' cardinality label when a cardinality constraint is present. #' #' @param source A SPARQL endpoint URL or a parsed rdflib compatible file. -#' @return A data frame with columns `restriction`, `onProperty`, `target`, -#' `relation`, `cardinality_label`. +#' @return A data frame with columns 'restriction', 'onProperty', 'target', +#' 'relation', 'cardinality_label'. #' @export extract_restrictions <- function(source) { cols <- c("restriction", "onProperty", "target", "relation", "cardinality", @@ -127,7 +127,7 @@ extract_restrictions <- function(source) { #' Extract a schema from an OWL ontology #' #' @param source A SPARQL endpoint URL or a parsed rdflib compatible file. -#' @return A schema list, see [.empty_schema()]. +#' @return A schema list, see empty_schema(). #' @export extract_schema_owl <- function(source) { schema <- .empty_schema() diff --git a/R/utils.R b/R/utils.R index 8df623b..bd2abac 100644 --- a/R/utils.R +++ b/R/utils.R @@ -43,7 +43,7 @@ local_name <- function(uri) { #' @export normalize_scheme <- function(x) sub("^https://", "http://", x) -#' @noRd + .empty_schema <- function() { list( classes = data.frame(uri = character(), label = character(), stringsAsFactors = FALSE), From f760d492c7091dda4c2d1ca9d9892e7f735d6d3d Mon Sep 17 00:00:00 2001 From: Ata B Barzegar Date: Sat, 22 Aug 2026 23:42:24 +0300 Subject: [PATCH 5/6] test: add tests for the schema_owl.R file --- tests/testthat/test-schema_owl.R | 173 +++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/testthat/test-schema_owl.R diff --git a/tests/testthat/test-schema_owl.R b/tests/testthat/test-schema_owl.R new file mode 100644 index 0000000..f9245d8 --- /dev/null +++ b/tests/testthat/test-schema_owl.R @@ -0,0 +1,173 @@ +test_that("extract_list_edges fully traverses a multi-member rdf:List", { + fake_sparql <- function(url, query, timeout = 60) { + if (grepl("unionOf", query, fixed = TRUE)) { + data.frame(class = "http://ex.org/A", list = "http://ex.org/list1", + relation = "unionOf", stringsAsFactors = FALSE) + } else if (grepl("rdf:first", query, fixed = TRUE)) { + data.frame( + list = c("http://ex.org/list1", "http://ex.org/list2"), + first = c("http://ex.org/X", "http://ex.org/Y"), + rest = c("http://ex.org/list2", RDF_NIL), + stringsAsFactors = FALSE + ) + } else { + data.frame() + } + } + testthat::local_mocked_bindings(sparql_query = fake_sparql) + + result <- extract_list_edges("https://example.org/sparql") + + expect_equal(nrow(result), 2) + expect_setequal(result$member, c("http://ex.org/X", "http://ex.org/Y")) + expect_true(all(result$relation == "unionOf")) +}) + +test_that("extract_list_edges guards against a cyclic list without hanging", { + fake_sparql <- function(url, query, timeout = 60) { + if (grepl("unionOf", query, fixed = TRUE)) { + data.frame(class = "http://ex.org/A", list = "http://ex.org/loop", + relation = "unionOf", stringsAsFactors = FALSE) + } else if (grepl("rdf:first", query, fixed = TRUE)) { + data.frame(list = "http://ex.org/loop", first = "http://ex.org/X", + rest = "http://ex.org/loop", stringsAsFactors = FALSE) + } else { + data.frame() + } + } + testthat::local_mocked_bindings(sparql_query = fake_sparql) + + result <- extract_list_edges("https://example.org/sparql") + + expect_equal(nrow(result), 1) + expect_equal(result$member, "http://ex.org/X") +}) + +test_that("extract_list_edges tolerates a failing endpoint", { + testthat::local_mocked_bindings( + sparql_query = function(url, query, timeout = 60) stop("network down") + ) + + expect_warning(result <- extract_list_edges("https://example.org/sparql"), "network down") + expect_equal(nrow(result), 0) + expect_equal(names(result), c("from", "member", "relation")) +}) + +test_that("extract_restrictions derives a cardinality_label per restriction", { + fake_sparql <- function(url, query, timeout = 60) { + data.frame( + restriction = c("http://ex.org/r1", "http://ex.org/r2"), + onProperty = c("http://ex.org/hasChild", "http://ex.org/hasSpouse"), + target = c("http://ex.org/Person", NA), + relation = c("someValuesFrom", "hasValue"), + cardinality = c(NA, "1"), + minCardinality = NA_character_, maxCardinality = NA_character_, + qualifiedCardinality = NA_character_, minQualifiedCardinality = NA_character_, + maxQualifiedCardinality = NA_character_, + stringsAsFactors = FALSE + ) + } + testthat::local_mocked_bindings(sparql_query = fake_sparql) + + result <- extract_restrictions("https://example.org/sparql") + + expect_equal(nrow(result), 2) + expect_true(is.na(result$cardinality_label[result$restriction == "http://ex.org/r1"])) + expect_equal(result$cardinality_label[result$restriction == "http://ex.org/r2"], "exactly 1") +}) + +test_that("extract_restrictions returns an empty, well-shaped result when there are none", { + testthat::local_mocked_bindings( + sparql_query = function(url, query, timeout = 60) data.frame() + ) + + result <- extract_restrictions("https://example.org/sparql") + + expect_equal(nrow(result), 0) + expect_equal(names(result), c("restriction", "onProperty", "target", "relation", "cardinality_label")) +}) + +test_that("extract_restrictions tolerates a failing endpoint", { + testthat::local_mocked_bindings( + sparql_query = function(url, query, timeout = 60) stop("boom") + ) + + expect_warning(result <- extract_restrictions("https://example.org/sparql"), "boom") + expect_equal(nrow(result), 0) +}) + +test_that("extract_schema_owl assembles classes, properties and axioms", { + fake_sparql <- function(url, query, timeout = 60) { + if (grepl("a owl:Class", query, fixed = TRUE)) { + data.frame(uri = "http://ex.org/Person", label = "Person", stringsAsFactors = FALSE) + } else if (grepl("rdfs:subClassOf ?parent", query, fixed = TRUE)) { + data.frame(child = "http://ex.org/Student", parent = "http://ex.org/Person", + stringsAsFactors = FALSE) + } else if (grepl("?ptype IN", query, fixed = TRUE)) { + data.frame(property = "http://ex.org/name", label = "name", stringsAsFactors = FALSE) + } else if (grepl("rdfs:domain ?domain", query, fixed = TRUE)) { + data.frame(property = "http://ex.org/name", domain = "http://ex.org/Person", + stringsAsFactors = FALSE) + } else if (grepl("rdfs:range ?range", query, fixed = TRUE)) { + data.frame(property = "http://ex.org/name", + range = "http://www.w3.org/2001/XMLSchema#string", stringsAsFactors = FALSE) + } else if (grepl("owl:equivalentClass", query, fixed = TRUE)) { + data.frame(from = "http://ex.org/Person", to = "http://ex.org/Human", + relation = "equivalentClass", stringsAsFactors = FALSE) + } else { + data.frame() + } + } + testthat::local_mocked_bindings( + sparql_query = fake_sparql, + extract_list_edges = function(source) { + data.frame(from = character(), member = character(), relation = character(), + stringsAsFactors = FALSE) + }, + extract_restrictions = function(source) { + data.frame(restriction = character(), onProperty = character(), target = character(), + relation = character(), cardinality_label = character(), stringsAsFactors = FALSE) + } + ) + + schema <- extract_schema_owl("https://example.org/sparql") + + expect_equal(schema$classes$uri, "http://ex.org/Person") + expect_equal(schema$subclass$child, "http://ex.org/Student") + expect_equal(schema$properties$property, "http://ex.org/name") + expect_equal(schema$property_domains$domain, "http://ex.org/Person") + expect_equal(schema$property_ranges$range, "http://www.w3.org/2001/XMLSchema#string") + expect_equal(schema$extra_edges$relation, "equivalentClass") +}) + +test_that("extract_schema_owl returns an empty-but-well-shaped schema for an OWL-free source", { + testthat::local_mocked_bindings( + sparql_query = function(url, query, timeout = 60) data.frame(), + extract_list_edges = function(source) { + data.frame(from = character(), member = character(), relation = character(), + stringsAsFactors = FALSE) + }, + extract_restrictions = function(source) { + data.frame(restriction = character(), onProperty = character(), target = character(), + relation = character(), cardinality_label = character(), stringsAsFactors = FALSE) + } + ) + + schema <- extract_schema_owl("https://example.org/sparql") + + expect_equal(nrow(schema$classes), 0) + expect_equal(nrow(schema$properties), 0) + expect_equal(nrow(schema$subclass), 0) +}) + +test_that("extract_schema_owl degrades gracefully when every query fails", { + testthat::local_mocked_bindings( + sparql_query = function(url, query, timeout = 60) stop("endpoint unreachable") + ) + + schema <- suppressWarnings(extract_schema_owl("https://example.org/sparql")) + + expect_equal(nrow(schema$classes), 0) + expect_equal(nrow(schema$restrictions), 0) + expect_equal(names(schema$classes), c("uri", "label")) +}) From d3530f2faba372aa423d44539ac3f3975ecfe449 Mon Sep 17 00:00:00 2001 From: Ata B Barzegar Date: Sat, 22 Aug 2026 23:43:47 +0300 Subject: [PATCH 6/6] docs: update doc files in man with document command --- man/extract_list_edges.Rd | 18 ++++++++++++++++++ man/extract_restrictions.Rd | 20 ++++++++++++++++++++ man/extract_schema_owl.Rd | 17 +++++++++++++++++ man/run_sparql.Rd | 30 ++++++++++++++++++++++++++++++ man/safe_query.Rd | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+) create mode 100644 man/extract_list_edges.Rd create mode 100644 man/extract_restrictions.Rd create mode 100644 man/extract_schema_owl.Rd create mode 100644 man/run_sparql.Rd create mode 100644 man/safe_query.Rd diff --git a/man/extract_list_edges.Rd b/man/extract_list_edges.Rd new file mode 100644 index 0000000..f83375d --- /dev/null +++ b/man/extract_list_edges.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/schema_owl.R +\name{extract_list_edges} +\alias{extract_list_edges} +\title{Fully traverse every rdf:List referenced by owl:unionOf/intersectionOf/oneOf} +\usage{ +extract_list_edges(source) +} +\arguments{ +\item{source}{A SPARQL endpoint URL or a parsed rdflib compatible file.} +} +\value{ +A data frame with columns \code{from}, \code{member}, \code{relation}. +} +\description{ +Queries all rdf:first/rdf:rest cons-cells up front and walks each list to +its end, so lists with more than one member are captured completely. +} diff --git a/man/extract_restrictions.Rd b/man/extract_restrictions.Rd new file mode 100644 index 0000000..83dcd2b --- /dev/null +++ b/man/extract_restrictions.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/schema_owl.R +\name{extract_restrictions} +\alias{extract_restrictions} +\title{Extract owl:Restriction axioms} +\usage{ +extract_restrictions(source) +} +\arguments{ +\item{source}{A SPARQL endpoint URL or a parsed rdflib compatible file.} +} +\value{ +A data frame with columns 'restriction', 'onProperty', 'target', +'relation', 'cardinality_label'. +} +\description{ +Captures the restricted property owl:onProperty, the restriction kind +(someValuesFrom/allValuesFrom/hasValue/onClass), and a human-readable +cardinality label when a cardinality constraint is present. +} diff --git a/man/extract_schema_owl.Rd b/man/extract_schema_owl.Rd new file mode 100644 index 0000000..3026ef8 --- /dev/null +++ b/man/extract_schema_owl.Rd @@ -0,0 +1,17 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/schema_owl.R +\name{extract_schema_owl} +\alias{extract_schema_owl} +\title{Extract a schema from an OWL ontology} +\usage{ +extract_schema_owl(source) +} +\arguments{ +\item{source}{A SPARQL endpoint URL or a parsed rdflib compatible file.} +} +\value{ +A schema list, see empty_schema(). +} +\description{ +Extract a schema from an OWL ontology +} diff --git a/man/run_sparql.Rd b/man/run_sparql.Rd new file mode 100644 index 0000000..59e860c --- /dev/null +++ b/man/run_sparql.Rd @@ -0,0 +1,30 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/sparql_source.R +\name{run_sparql} +\alias{run_sparql} +\title{Run a SPARQL query against a local model or a remote endpoint} +\usage{ +run_sparql(source, query) +} +\arguments{ +\item{source}{A SPARQL endpoint URL (character scalar), or an \code{rdf} +model object as created by \code{rdflib::rdf_parse()}.} + +\item{query}{A SPARQL query string.} +} +\value{ +A data frame of query results. +} +\description{ +Dispatches \code{query} to \code{\link[=sparql_query]{sparql_query()}} when \code{source} is a single SPARQL +endpoint URL (a character scalar), or to \code{rdflib::rdf_query()} when +\code{source} is a parsed local RDF model (e.g. as returned by +\code{rdflib::rdf_parse()}). This lets every schema-extraction function work +unchanged whether \code{source} is a materialised RDF document or a live +SPARQL endpoint. +} +\examples{ +\dontrun{ +run_sparql("https://query.wikidata.org/sparql", "SELECT * WHERE { ?s ?p ?o } LIMIT 1") +} +} diff --git a/man/safe_query.Rd b/man/safe_query.Rd new file mode 100644 index 0000000..742e1a9 --- /dev/null +++ b/man/safe_query.Rd @@ -0,0 +1,33 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/sparql_source.R +\name{safe_query} +\alias{safe_query} +\title{Run a SPARQL query, guaranteeing a well-shaped result} +\usage{ +safe_query(source, query, expected_cols = character()) +} +\arguments{ +\item{source}{A SPARQL endpoint URL, or a local RDF model.} + +\item{query}{A SPARQL query string.} + +\item{expected_cols}{A character vector of column names that must be +present in the returned data frame. If the query returns zero rows, +the columns are created as empty character vectors.} +} +\value{ +A data frame with at least the columns in \code{expected_cols}. +} +\description{ +Wraps \code{\link[=run_sparql]{run_sparql()}} so that a failed query (a malformed query, or a +temporarily unreachable endpoint) produces a warning and an empty, but +correctly-shaped, data frame instead of stopping the whole pipeline. +} +\examples{ +\dontrun{ +safe_query( + "https://query.wikidata.org/sparql", + "SELECT ?s WHERE { ?s a }" +) +} +}