From 017fde2b6b632691c974d482460850b60b3254f0 Mon Sep 17 00:00:00 2001 From: novica Date: Sat, 12 Sep 2026 17:28:59 +0200 Subject: [PATCH 01/46] feat(maplibr): scaffold R package with savvy-based Model wrapper - RModel wraps maplib::model::Model behind a Mutex, mirroring py_maplib's PyModel - new()/size() verified working end-to-end from R - requires maplib's pyo3 feature and a pinned nightly toolchain Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 52 ++++++++ Cargo.toml | 177 +++++++++++++--------------- maplibr/.Rbuildignore | 4 + maplibr/DESCRIPTION | 12 ++ maplibr/NAMESPACE | 9 ++ maplibr/R/000-wrappers.R | 95 +++++++++++++++ maplibr/cleanup | 1 + maplibr/cleanup.win | 1 + maplibr/configure | 73 ++++++++++++ maplibr/configure.win | 55 +++++++++ maplibr/man/RModel.Rd | 11 ++ maplibr/man/export_test_series.Rd | 16 +++ maplibr/man/import_test_series.Rd | 19 +++ maplibr/src/.gitignore | 7 ++ maplibr/src/Makevars.in | 44 +++++++ maplibr/src/Makevars.win.in | 41 +++++++ maplibr/src/init.c | 77 ++++++++++++ maplibr/src/maplibr-win.def | 2 + maplibr/src/rust-toolchain.toml | 9 ++ maplibr/src/rust/.cargo/config.toml | 10 ++ maplibr/src/rust/Cargo.toml | 26 ++++ maplibr/src/rust/api.h | 6 + maplibr/src/rust/src/lib.rs | 93 +++++++++++++++ 23 files changed, 744 insertions(+), 96 deletions(-) create mode 100644 maplibr/.Rbuildignore create mode 100644 maplibr/DESCRIPTION create mode 100644 maplibr/NAMESPACE create mode 100644 maplibr/R/000-wrappers.R create mode 100644 maplibr/cleanup create mode 100644 maplibr/cleanup.win create mode 100644 maplibr/configure create mode 100644 maplibr/configure.win create mode 100644 maplibr/man/RModel.Rd create mode 100644 maplibr/man/export_test_series.Rd create mode 100644 maplibr/man/import_test_series.Rd create mode 100644 maplibr/src/.gitignore create mode 100644 maplibr/src/Makevars.in create mode 100644 maplibr/src/Makevars.win.in create mode 100644 maplibr/src/init.c create mode 100644 maplibr/src/maplibr-win.def create mode 100644 maplibr/src/rust-toolchain.toml create mode 100644 maplibr/src/rust/.cargo/config.toml create mode 100644 maplibr/src/rust/Cargo.toml create mode 100644 maplibr/src/rust/api.h create mode 100644 maplibr/src/rust/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 5a6e8882..27485eb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2313,6 +2313,17 @@ dependencies = [ "virtualization", ] +[[package]] +name = "maplibr" +version = "0.1.0" +dependencies = [ + "maplib", + "polars", + "polars-arrow", + "representation", + "savvy", +] + [[package]] name = "matchers" version = "0.2.0" @@ -4385,6 +4396,47 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "savvy" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1c95d0e631c3a259af8c2ec2492ad34c30af6399231130d86446691c69ee1f4" +dependencies = [ + "cc", + "rustversion", + "savvy-ffi", + "savvy-macro", +] + +[[package]] +name = "savvy-bindgen" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e909d6698c296c73909e8c46b4124781e9e82f2bda72e0471b7cf8e4481c31" +dependencies = [ + "proc-macro2", + "quote 1.0.46", + "syn 2.0.118", +] + +[[package]] +name = "savvy-ffi" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1b0aeb4e546325c4c207d736d95952f8c589b85d2bf80dfc429a1975a17c74" + +[[package]] +name = "savvy-macro" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8884eebdd646f450ba554e96f32e8904d7d13f573be8d34254374934ab7b619" +dependencies = [ + "proc-macro2", + "quote 1.0.46", + "savvy-bindgen", + "syn 2.0.118", +] + [[package]] name = "schannel" version = "0.1.29" diff --git a/Cargo.toml b/Cargo.toml index a95d5aad..3109bb8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,96 +1,81 @@ -[workspace] -resolver = "2" -members = [ - "lib/in_memory", - "lib/disk", - "lib/utils", - "lib/representation", - "lib/maplib", - "lib/triplestore", - "lib/file_io", - "lib/query_processing", - "lib/spargebra", - "lib/shacl", - "lib/fts", - "lib/datalog", - "lib/cimxml_export", - "lib/cimxml_import", - "lib/chrontext", - "lib/virtualized_query", - "lib/virtualization", - "lib/opc_ua", - "lib/external_sparql", - "py_maplib", -] - -[workspace.package] -rust-version = "1.88.0" -edition = "2021" - -[workspace.dependencies] -pyo3 = { version = "0.29.0", features = ["py-clone", "rust_decimal"] } -#Moment is there to work around a feature toggle bug -polars = { version = "0.54.4", features = [ - "simd", - "nightly", - "performant", - "cse", - "fmt", - "parquet", - "moment", - "timezones", - "lazy", -] } -polars-arrow = { version = "0.54.4" } -polars-core = { version = "0.54.4" } -rayon = "1.10.0" -regex = "1.11.1" -oxrdf = { version = "0.3.3" } -oxrdfio = { version = "0.2.5" } -oxttl = { version = "0.2.3" } -oxjsonld = { version = "0.2.5" } -oxiri = "0.2.11" -sparesults = "0.3.3" -oxsdatatypes = "0.2.2" -chrono = "0.4.41" -chrono-tz = "0.10.3" -uuid = { version = "1.16.0", features = [ - "v4", # Lets you generate random UUIDs - "v5", - "fast-rng", # Use a faster (but still sufficiently random) RNG -] } -thiserror = "2.0.12" -peg = "0.8" -rand = "0.10.1" -oxilangtag = "0.1.5" -fundu = "2.0.1" -hdt = { version = "0.7.2", default-features = false, features = ["nt"] } -memmap2 = "0.9.5" -sprs = "0.11.3" -walkdir = "2.5.0" -aho-corasick = "1.1.4" -simd-json = { version = "0.17.0", features = ["serde_json"] } -serde_json = { version = "1.0.149" } -serde = {version = "1.0.228"} -urlencoding = "2.1.3" -md-5 = "0.11.0" -sha1 = "0.11.0" -hex = "0.4.3" -memchr = "2.8.0" -quick-xml = { version = "0.41.0", features = ["arbitrary"] } -tracing = { version = "0.1", features = ["log"] } -tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -tracing-log = "0.2" -itoa = "1.0.15" -ryu = "1.0.20" -#dev-dependencies -nohash-hasher = "0.2.0" -arrow = "58" -ordered-float = "5.1.0" -rust_decimal = "1.42.1" -range-set-blaze = "0.5.0" -reqwest = { version = "0.13.4", features = ["query", "json", "rustls-no-provider"] } - -[patch.crates-io] -#polars = { git = 'https://github.com/pola-rs/polars', rev="0df0c25d4db895ec8cad773bedc4e98400e3135e" } -#polars-core = { git = 'https://github.com/pola-rs/polars', rev="0df0c25d4db895ec8cad773bedc4e98400e3135e" } +[workspace] +resolver = "2" +members = ["lib/chrontext", "lib/cimxml_export", "lib/cimxml_import", "lib/datalog", "lib/disk", "lib/external_sparql", "lib/file_io", "lib/fts", "lib/in_memory", "lib/maplib", "lib/opc_ua", "lib/query_processing", "lib/representation", "lib/shacl", "lib/spargebra", "lib/triplestore", "lib/utils", "lib/virtualization", "lib/virtualized_query", "py_maplib", "maplibr/src/rust"] + +[workspace.package] +edition = "2021" +rust-version = "1.88.0" + +[workspace.dependencies] +aho-corasick = "1.1.4" +arrow = "58" +chrono = "0.4.41" +chrono-tz = "0.10.3" +fundu = "2.0.1" +hdt = { version = "0.7.2", default-features = false, features = ["nt"] } +hex = "0.4.3" +itoa = "1.0.15" +md-5 = "0.11.0" +memchr = "2.8.0" +memmap2 = "0.9.5" +# dev-dependencies +nohash-hasher = "0.2.0" +ordered-float = "5.1.0" +oxilangtag = "0.1.5" +oxiri = "0.2.11" +oxjsonld = { version = "0.2.5" } +oxrdf = { version = "0.3.3" } +oxrdfio = { version = "0.2.5" } +oxsdatatypes = "0.2.2" +oxttl = { version = "0.2.3" } +peg = "0.8" +# Moment is there to work around a feature toggle bug +polars = { + version = "0.54.4", + features = [ + "cse", + "fmt", + "lazy", + "moment", + "nightly", + "parquet", + "performant", + "simd", + "timezones", + ] +} +polars-arrow = { version = "0.54.4" } +polars-core = { version = "0.54.4" } +pyo3 = { version = "0.29.0", features = ["py-clone", "rust_decimal"] } +quick-xml = { version = "0.41.0", features = ["arbitrary"] } +rand = "0.10.1" +range-set-blaze = "0.5.0" +rayon = "1.10.0" +regex = "1.11.1" +reqwest = { version = "0.13.4", features = ["json", "query", "rustls-no-provider"] } +rust_decimal = "1.42.1" +ryu = "1.0.20" +serde = { version = "1.0.228" } +serde_json = { version = "1.0.149" } +sha1 = "0.11.0" +simd-json = { version = "0.17.0", features = ["serde_json"] } +sparesults = "0.3.3" +sprs = "0.11.3" +thiserror = "2.0.12" +tracing = { version = "0.1", features = ["log"] } +tracing-log = "0.2" +tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +urlencoding = "2.1.3" +uuid = { + version = "1.16.0", + features = [ + "fast-rng", # Use a faster (but still sufficiently random) RNG + "v4", # Lets you generate random UUIDs + "v5", + ] +} +walkdir = "2.5.0" + +[patch.crates-io] +# polars = { git = 'https://github.com/pola-rs/polars', rev="0df0c25d4db895ec8cad773bedc4e98400e3135e" } +# polars-core = { git = 'https://github.com/pola-rs/polars', rev="0df0c25d4db895ec8cad773bedc4e98400e3135e" } diff --git a/maplibr/.Rbuildignore b/maplibr/.Rbuildignore new file mode 100644 index 00000000..5bf4fc92 --- /dev/null +++ b/maplibr/.Rbuildignore @@ -0,0 +1,4 @@ +^src/rust/\.cargo$ +^src/rust/target$ +^src/Makevars$ +^src/Makevars\.win$ diff --git a/maplibr/DESCRIPTION b/maplibr/DESCRIPTION new file mode 100644 index 00000000..9ce13833 --- /dev/null +++ b/maplibr/DESCRIPTION @@ -0,0 +1,12 @@ +Package: maplibr +Title: What the Package Does (One Line, Title Case) +Version: 0.0.0.9000 +Authors@R: + person("First", "Last", , "first.last@example.com", role = c("aut", "cre")) +Description: What the package does (one paragraph). +License: `use_mit_license()`, `use_gpl3_license()` or friends to pick a + license +Encoding: UTF-8 +Roxygen: list(markdown = TRUE) +RoxygenNote: 8.0.0 +SystemRequirements: Cargo (Rust's package manager), rustc diff --git a/maplibr/NAMESPACE b/maplibr/NAMESPACE new file mode 100644 index 00000000..7e264504 --- /dev/null +++ b/maplibr/NAMESPACE @@ -0,0 +1,9 @@ +# Generated by roxygen2: do not edit by hand + +S3method("$<-",savvy_maplibr__sealed) +S3method("[[<-",savvy_maplibr__sealed) +S3method(print,"maplibr::RModel__bundle") +export(RModel) +export(export_test_series) +export(import_test_series) +useDynLib(maplibr, .registration = TRUE) diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R new file mode 100644 index 00000000..ebd2a49f --- /dev/null +++ b/maplibr/R/000-wrappers.R @@ -0,0 +1,95 @@ +# Generated by savvy: do not edit by hand +# +# Note: +# This wrapper file is named as `000-wrappers.R` so that this file is loaded +# first, which allows users to override the functions defined here (e.g., a +# print() method for an enum). + +#' @useDynLib maplibr, .registration = TRUE +#' @keywords internal +NULL + +# Check class and extract the external pointer embedded in the environment +.savvy_extract_ptr <- function(e, class) { + if(is.null(e)) { + return(NULL) + } + + if(inherits(e, class)) { + e$.ptr + } else { + msg <- paste0("Expected ", class, ", got ", class(e)[1]) + stop(msg, call. = FALSE) + } +} + +# Prohibit modifying environments + +#' @export +`$<-.savvy_maplibr__sealed` <- function(x, name, value) { + class <- gsub("__bundle$", "", class(x)[1]) + stop(class, " cannot be modified", call. = FALSE) +} + +#' @export +`[[<-.savvy_maplibr__sealed` <- function(x, i, value) { + class <- gsub("__bundle$", "", class(x)[1]) + stop(class, " cannot be modified", call. = FALSE) +} + +#' Build a small hardcoded i32 Series in Rust and stream it out to R +#' through the Arrow C Stream Interface. +#' +#' @param stream_ptr An external pointer from `nanoarrow::nanoarrow_allocate_array_stream()`. +#' @export +`export_test_series` <- function(`stream_ptr`) { + invisible(.Call(savvy_export_test_series__impl, `stream_ptr`)) +} + +#' Read an Arrow C stream from R back into a Rust Series, then hand back +#' its length and first value as a sanity check (i32 only, for this prototype). +#' +#' @param stream_ptr An external pointer holding a filled ArrowArrayStream. +#' @returns An integer vector: `c(length, first_value)`. +#' @export +`import_test_series` <- function(`stream_ptr`) { + .Call(savvy_import_test_series__impl, `stream_ptr`) +} + +### wrapper functions for RModel + +`RModel_size` <- function(self) { + function() { + .Call(savvy_RModel_size__impl, `self`) + } +} + +`.savvy_wrap_RModel` <- function(ptr) { + e <- new.env(parent = emptyenv()) + e$.ptr <- ptr + e$`size` <- `RModel_size`(ptr) + + class(e) <- c("maplibr::RModel", "RModel", "savvy_maplibr__sealed") + e +} + + +#' A maplib knowledge graph model. +#' +#' @export +`RModel` <- new.env(parent = emptyenv()) + +### associated functions for RModel + +`RModel`$`new` <- function() { + .savvy_wrap_RModel(.Call(savvy_RModel_new__impl)) +} + + +class(`RModel`) <- c("maplibr::RModel__bundle", "savvy_maplibr__sealed") + +#' @export +`print.maplibr::RModel__bundle` <- function(x, ...) { + cat('maplibr::RModel\n') +} + diff --git a/maplibr/cleanup b/maplibr/cleanup new file mode 100644 index 00000000..e346d71d --- /dev/null +++ b/maplibr/cleanup @@ -0,0 +1 @@ +rm -f src/Makevars diff --git a/maplibr/cleanup.win b/maplibr/cleanup.win new file mode 100644 index 00000000..a1821746 --- /dev/null +++ b/maplibr/cleanup.win @@ -0,0 +1 @@ +rm -f src/Makevars.win diff --git a/maplibr/configure b/maplibr/configure new file mode 100644 index 00000000..58793d29 --- /dev/null +++ b/maplibr/configure @@ -0,0 +1,73 @@ +# Even when `cargo` is on `PATH`, `rustc` might not in some cases. This adds +# ~/.cargo/bin to PATH to address such cases. Note that is not always available +# (e.g. or on Ubuntu with Rust installed via APT). +if [ -d "${HOME}/.cargo/bin" ]; then + export PATH="${PATH}:${HOME}/.cargo/bin" +fi + +CARGO_VERSION="$(cargo --version)" + +if [ $? -ne 0 ]; then + echo "-------------- ERROR: CONFIGURATION FAILED --------------------" + echo "" + echo "The cargo command is not available. To install Rust, please refer" + echo "to the official instruction:" + echo "" + echo "https://www.rust-lang.org/tools/install" + echo "" + echo "---------------------------------------------------------------" + + exit 1 +fi + +# There's a little chance that rustc is not available on PATH while cargo is. +# So, just ignore the error case. +RUSTC_VERSION="$(rustc --version || true)" + +# Report the version of Rustc to comply with the CRAN policy +echo "using Rust package manager: '${CARGO_VERSION}'" +echo "using Rust compiler: '${RUSTC_VERSION}'" + +if [ "$(uname)" = "Emscripten" ]; then + TARGET="wasm32-unknown-emscripten" +fi + +# allow overriding profile externally (e.g. on CI) +if [ -n "${SAVVY_PROFILE}" ]; then + PROFILE="${SAVVY_PROFILE}" +# catch DEBUG envvar, which is passed from pkgbuild::compile_dll() +elif [ "${DEBUG}" = "true" ]; then + PROFILE=dev +else + PROFILE=release +fi + +# e.g. SAVVY_FEATURES="a b" --> "--features 'a b'" +if [ -n "${SAVVY_FEATURES}" ]; then + FEATURE_FLAGS="--features '${SAVVY_FEATURES}'" +fi + +# Set macOS deployment target for the Rust cc crate. +# +# The cc crate (used by savvy's build.rs to compile unwind_protect_wrapper.c) +# does not derive the deployment target from CC/CFLAGS. Instead, it looks up +# MACOSX_DEPLOYMENT_TARGET, falling back to `xcrun --show-sdk-version`, and +# passes the result as -mmacosx-version-min=. When the env var is unset, +# the fallback SDK version (e.g. 26.2) can be newer than the version R's +# compiler targets, producing an ld warning about version mismatch. +# +# We default to 11.0, the minimum deployment target for arm64 macOS. This +# should be safe because unwind_protect_wrapper.c is a fairly simple C code +# and uses no version-specific APIs. +if [ "$(uname)" = "Darwin" ]; then + MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-11.0}" + echo "using macOS deployment target: ${MACOSX_DEPLOYMENT_TARGET}" + BEFORE_CARGO_BUILD="${BEFORE_CARGO_BUILD}"' export MACOSX_DEPLOYMENT_TARGET="'"${MACOSX_DEPLOYMENT_TARGET}"'" \&\&' +fi + +sed \ + -e "s/@TARGET@/${TARGET}/" \ + -e "s/@PROFILE@/${PROFILE}/" \ + -e "s/@FEATURE_FLAGS@/${FEATURE_FLAGS}/" \ + -e "s|@BEFORE_CARGO_BUILD@|${BEFORE_CARGO_BUILD}|" \ + src/Makevars.in > src/Makevars diff --git a/maplibr/configure.win b/maplibr/configure.win new file mode 100644 index 00000000..dfff18b2 --- /dev/null +++ b/maplibr/configure.win @@ -0,0 +1,55 @@ +CARGO_VERSION="$(cargo --version)" + +if [ $? -ne 0 ]; then + echo "-------------- ERROR: CONFIGURATION FAILED --------------------" + echo "" + echo "The cargo command is not available. To install Rust, please refer" + echo "to the official instruction:" + echo "" + echo "https://www.rust-lang.org/tools/install" + echo "" + echo "---------------------------------------------------------------" + + exit 1 +fi + +# There's a little chance that rustc is not available on PATH while cargo is. +# So, just ignore the error case. +RUSTC_VERSION="$(rustc --version || true)" + +# Report the version of Rustc to comply with the CRAN policy +echo "using Rust package manager: '${CARGO_VERSION}'" +echo "using Rust compiler: '${RUSTC_VERSION}'" + +# allow overriding profile externally (e.g. on CI) +if [ -n "${SAVVY_PROFILE}" ]; then + PROFILE="${SAVVY_PROFILE}" +# catch DEBUG envvar, which is passed from pkgbuild::compile_dll() +elif [ "${DEBUG}" = "true" ]; then + PROFILE=dev +else + PROFILE=release +fi + +# e.g. SAVVY_FEATURES="a b" --> "--features 'a b'" +if [ -n "${SAVVY_FEATURES}" ]; then + FEATURE_FLAGS="--features '${SAVVY_FEATURES}'" +fi + +# cf. https://github.com/apache/sedona-db/pull/1137 +CC="$("${R_HOME}/bin/R" CMD config CC)" +case "$($CC -dumpmachine)" in + aarch64*|arm64*) + TARGET="aarch64-pc-windows-gnullvm" + ;; + *) + TARGET="x86_64-pc-windows-gnu" + ;; +esac +echo "using Rust target: '${TARGET}'" + +sed \ + -e "s/@TARGET@/${TARGET}/" \ + -e "s/@PROFILE@/${PROFILE}/" \ + -e "s/@FEATURE_FLAGS@/${FEATURE_FLAGS}/" \ + src/Makevars.win.in > src/Makevars.win diff --git a/maplibr/man/RModel.Rd b/maplibr/man/RModel.Rd new file mode 100644 index 00000000..d6609dbf --- /dev/null +++ b/maplibr/man/RModel.Rd @@ -0,0 +1,11 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{RModel} +\alias{RModel} +\title{A maplib knowledge graph model.} +\usage{ +RModel +} +\description{ +A maplib knowledge graph model. +} diff --git a/maplibr/man/export_test_series.Rd b/maplibr/man/export_test_series.Rd new file mode 100644 index 00000000..2106e717 --- /dev/null +++ b/maplibr/man/export_test_series.Rd @@ -0,0 +1,16 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{export_test_series} +\alias{export_test_series} +\title{Build a small hardcoded i32 Series in Rust and stream it out to R +through the Arrow C Stream Interface.} +\usage{ +export_test_series(stream_ptr) +} +\arguments{ +\item{stream_ptr}{An external pointer from \code{nanoarrow::nanoarrow_allocate_array_stream()}.} +} +\description{ +Build a small hardcoded i32 Series in Rust and stream it out to R +through the Arrow C Stream Interface. +} diff --git a/maplibr/man/import_test_series.Rd b/maplibr/man/import_test_series.Rd new file mode 100644 index 00000000..4c3d1af8 --- /dev/null +++ b/maplibr/man/import_test_series.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{import_test_series} +\alias{import_test_series} +\title{Read an Arrow C stream from R back into a Rust Series, then hand back +its length and first value as a sanity check (i32 only, for this prototype).} +\usage{ +import_test_series(stream_ptr) +} +\arguments{ +\item{stream_ptr}{An external pointer holding a filled ArrowArrayStream.} +} +\value{ +An integer vector: \code{c(length, first_value)}. +} +\description{ +Read an Arrow C stream from R back into a Rust Series, then hand back +its length and first value as a sanity check (i32 only, for this prototype). +} diff --git a/maplibr/src/.gitignore b/maplibr/src/.gitignore new file mode 100644 index 00000000..f1265a23 --- /dev/null +++ b/maplibr/src/.gitignore @@ -0,0 +1,7 @@ +*.o +*.so +*.dll +target + +Makevars +Makevars.win diff --git a/maplibr/src/Makevars.in b/maplibr/src/Makevars.in new file mode 100644 index 00000000..75f6df64 --- /dev/null +++ b/maplibr/src/Makevars.in @@ -0,0 +1,44 @@ +TARGET = @TARGET@ + +PROFILE = @PROFILE@ +FEATURE_FLAGS = @FEATURE_FLAGS@ + +# Add flags if necessary +RUSTFLAGS = + +TARGET_DIR = $(CURDIR)/rust/target +LIBDIR = $(TARGET_DIR)/$(TARGET)/$(subst dev,debug,$(PROFILE)) +STATLIB = $(LIBDIR)/libmaplibr.a +PKG_LIBS = -L$(LIBDIR) -lmaplibr + +CARGO_BUILD_ARGS = --lib --profile $(PROFILE) $(FEATURE_FLAGS) --manifest-path=./rust/Cargo.toml --target-dir $(TARGET_DIR) + +all: $(SHLIB) clean_intermediate + +$(SHLIB): $(STATLIB) + +$(STATLIB): + # In some environments, ~/.cargo/bin might not be included in PATH, so we need + # to set it here to ensure cargo can be invoked. It is appended to PATH and + # therefore is only used if cargo is absent from the user's PATH. + export PATH="$(PATH):$(HOME)/.cargo/bin" && \ + export CC="$(CC)" && \ + export CFLAGS="$(CFLAGS)" && \ + export RUSTFLAGS="$(RUSTFLAGS)" && \ + @BEFORE_CARGO_BUILD@ \ + if [ "$(TARGET)" != "wasm32-unknown-emscripten" ]; then \ + cargo build $(CARGO_BUILD_ARGS); \ + else \ + export CARGO_PROFILE_DEV_PANIC="abort" && \ + export CARGO_PROFILE_RELEASE_PANIC="abort" && \ + export RUSTFLAGS="$(RUSTFLAGS) -Zdefault-visibility=hidden" && \ + cargo +nightly build $(CARGO_BUILD_ARGS) --target $(TARGET) -Zbuild-std=panic_abort,std; \ + fi + +clean_intermediate: $(SHLIB) + rm -f $(STATLIB) + +clean: + rm -Rf $(SHLIB) $(OBJECTS) $(STATLIB) ./rust/target + +.PHONY: all clean_intermediate clean diff --git a/maplibr/src/Makevars.win.in b/maplibr/src/Makevars.win.in new file mode 100644 index 00000000..f17aaa8b --- /dev/null +++ b/maplibr/src/Makevars.win.in @@ -0,0 +1,41 @@ +TARGET = @TARGET@ + +PROFILE = @PROFILE@ +FEATURE_FLAGS = @FEATURE_FLAGS@ + +# Add flags if necessary +RUSTFLAGS = + +TARGET_DIR = $(CURDIR)/rust/target +LIBDIR = $(TARGET_DIR)/$(TARGET)/$(subst dev,debug,$(PROFILE)) +STATLIB = $(LIBDIR)/libmaplibr.a +PKG_LIBS = -L$(LIBDIR) -lmaplibr -lws2_32 -ladvapi32 -luserenv -lbcrypt -lntdll + +# Rtools doesn't have the linker in the location that cargo expects, so we need +# to overwrite it via configuration. +CARGO_LINKER = x86_64-w64-mingw32.static.posix-gcc.exe + +all: $(SHLIB) clean_intermediate + +$(SHLIB): $(STATLIB) + +$(STATLIB): + # When the GNU toolchain is used (i.e. on CRAN), -lgcc_eh is specified for + # building proc-macro2, but Rtools doesn't contain libgcc_eh. This isn't used + # in actual, but we need this tweak to please the compiler. + mkdir -p $(LIBDIR)/libgcc_mock && touch $(LIBDIR)/libgcc_mock/libgcc_eh.a + + export CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER="$(CARGO_LINKER)" && \ + export LIBRARY_PATH="$${LIBRARY_PATH};$(LIBDIR)/libgcc_mock" && \ + export CC="$(CC)" && \ + export CFLAGS="$(CFLAGS)" && \ + export RUSTFLAGS="$(RUSTFLAGS)" && \ + cargo build --target $(TARGET) --lib --profile $(PROFILE) $(FEATURE_FLAGS) --manifest-path ./rust/Cargo.toml --target-dir $(TARGET_DIR) + +clean_intermediate: $(SHLIB) + rm -f $(STATLIB) + +clean: + rm -Rf $(SHLIB) $(OBJECTS) $(STATLIB) ./rust/target + +.PHONY: all clean_intermediate clean diff --git a/maplibr/src/init.c b/maplibr/src/init.c new file mode 100644 index 00000000..e7440d12 --- /dev/null +++ b/maplibr/src/init.c @@ -0,0 +1,77 @@ + +// clang-format sorts includes unless SortIncludes: Never. However, the ordering +// does matter here. So, we need to disable clang-format for safety. + +// clang-format off +#include +#include +#include +// clang-format on + +#include "rust/api.h" + +static uintptr_t TAGGED_POINTER_MASK = (uintptr_t)1; + +SEXP handle_result(SEXP res_) { + uintptr_t res = (uintptr_t)res_; + + // An error is indicated by tag. + if ((res & TAGGED_POINTER_MASK) == 1) { + // Remove tag + SEXP res_aligned = (SEXP)(res & ~TAGGED_POINTER_MASK); + + // Currently, there are two types of error cases: + // + // 1. Error from Rust code + // 2. Error from R's C API, which is caught by R_UnwindProtect() + // + if (TYPEOF(res_aligned) == CHARSXP) { + // In case 1, the result is an error message that can be passed to + // Rf_errorcall() directly. + Rf_errorcall(R_NilValue, "%s", CHAR(res_aligned)); + } else { + // In case 2, the result is the token to restart the + // cleanup process on R's side. + R_ContinueUnwind(res_aligned); + } + } + + return (SEXP)res; +} + +SEXP savvy_export_test_series__impl(SEXP c_arg__stream_ptr) { + SEXP res = savvy_export_test_series__ffi(c_arg__stream_ptr); + return handle_result(res); +} + +SEXP savvy_import_test_series__impl(SEXP c_arg__stream_ptr) { + SEXP res = savvy_import_test_series__ffi(c_arg__stream_ptr); + return handle_result(res); +} + +SEXP savvy_RModel_new__impl(void) { + SEXP res = savvy_RModel_new__ffi(); + return handle_result(res); +} + +SEXP savvy_RModel_size__impl(SEXP self__) { + SEXP res = savvy_RModel_size__ffi(self__); + return handle_result(res); +} + + +static const R_CallMethodDef CallEntries[] = { + {"savvy_export_test_series__impl", (DL_FUNC) &savvy_export_test_series__impl, 1}, + {"savvy_import_test_series__impl", (DL_FUNC) &savvy_import_test_series__impl, 1}, + {"savvy_RModel_new__impl", (DL_FUNC) &savvy_RModel_new__impl, 0}, + {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 1}, + {NULL, NULL, 0} +}; + +void R_init_maplibr(DllInfo *dll) { + R_registerRoutines(dll, NULL, CallEntries, NULL, NULL); + R_useDynamicSymbols(dll, FALSE); + + // Functions for initialization, if any. + +} diff --git a/maplibr/src/maplibr-win.def b/maplibr/src/maplibr-win.def new file mode 100644 index 00000000..9bfea364 --- /dev/null +++ b/maplibr/src/maplibr-win.def @@ -0,0 +1,2 @@ +EXPORTS +R_init_maplibr diff --git a/maplibr/src/rust-toolchain.toml b/maplibr/src/rust-toolchain.toml new file mode 100644 index 00000000..002e23e7 --- /dev/null +++ b/maplibr/src/rust-toolchain.toml @@ -0,0 +1,9 @@ +[toolchain] +# The workspace root's polars dependency requests polars' "nightly" cargo +# feature (Cargo.toml's workspace.dependencies.polars), which some of its +# own dependencies (foldhash, argminmax) only compile under a nightly rustc. +# This mirrors flake.nix's `fenix.packages.${system}.complete` (a nightly +# channel), pinned here to whatever nightly toolchain is already installed +# locally. Scoped to this directory (where R's Makevars invokes `cargo +# build` from) so it doesn't force nightly on the rest of the repo. +channel = "nightly-2026-04-28" diff --git a/maplibr/src/rust/.cargo/config.toml b/maplibr/src/rust/.cargo/config.toml new file mode 100644 index 00000000..3d8bdda2 --- /dev/null +++ b/maplibr/src/rust/.cargo/config.toml @@ -0,0 +1,10 @@ +# On Windows, link.exe fails when the artifact contains unresolved symbols +# (i.e., R's API, which cannot be used without a real R session). This option +# makes the linker ignore these problems. +# +# This setting is needed only when you run `cargo test`, not when `R CMD check` +# etc. The `.cargo` directory need to be excluded on building the package (i.e. +# add `^src/rust/\.cargo$` to `.Rbuildignore`) because otherwise you'll get the +# "hidden files and directories" NOTE. +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "link-arg=/FORCE:UNRESOLVED"] diff --git a/maplibr/src/rust/Cargo.toml b/maplibr/src/rust/Cargo.toml new file mode 100644 index 00000000..da0ea0aa --- /dev/null +++ b/maplibr/src/rust/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "maplibr" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["lib", "staticlib"] + +[dependencies] +maplib = { path = "../../../lib/maplib", features = ["pyo3"] } +representation = { path = "../../../lib/representation" } +polars = { version = "0.54.4", default-features = false } +polars-arrow = "0.54.4" +savvy = "0.8" + +[profile.release] +# By default, on release build, savvy terminates the R session when a panic +# occurs. This is the right behavior in that a panic means such a fatal event +# where we can have no hope of recovery. +# +# cf. https://doc.rust-lang.org/book/ch09-03-to-panic-or-not-to-panic.html +# +# However, it's possible that the panic is thrown by some of the dependency +# crate and there's little you can do. In such cases, you can change the +# following line to `panic = "unwind"` to always catch a panic. +panic = "abort" diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h new file mode 100644 index 00000000..cb823f09 --- /dev/null +++ b/maplibr/src/rust/api.h @@ -0,0 +1,6 @@ +SEXP savvy_export_test_series__ffi(SEXP c_arg__stream_ptr); +SEXP savvy_import_test_series__ffi(SEXP c_arg__stream_ptr); + +// methods and associated functions for RModel +SEXP savvy_RModel_new__ffi(void); +SEXP savvy_RModel_size__ffi(SEXP self__); diff --git a/maplibr/src/rust/src/lib.rs b/maplibr/src/rust/src/lib.rs new file mode 100644 index 00000000..0d687898 --- /dev/null +++ b/maplibr/src/rust/src/lib.rs @@ -0,0 +1,93 @@ + +use polars::prelude::*; +use polars_arrow::ffi::{export_iterator, ArrowArrayStream, ArrowArrayStreamReader}; +use representation::dataset::NamedGraph; +use savvy::{savvy, ExternalPointerSexp, Sexp}; +use std::sync::Mutex; + +/// A maplib knowledge graph model. +/// +/// @export +#[savvy] +struct RModel { + inner: Mutex, +} + +#[savvy] +impl RModel { + /// Create a new, empty Model. + /// + /// @export + fn new() -> savvy::Result { + let model = maplib::model::Model::new(None, None, None, None) + .map_err(|e| savvy::Error::new(&e.to_string()))?; + Ok(Self { + inner: Mutex::new(model), + }) + } + + /// Number of triples in the default graph. + /// + /// @export + fn size(&self) -> savvy::Result { + let inner = self.inner.lock().unwrap(); + let graph = NamedGraph::from_maybe_named_node(None); + (inner.graph_size(&graph) as i32).try_into() + } +} + +/// Build a small hardcoded i32 Series in Rust and stream it out to R +/// through the Arrow C Stream Interface. +/// +/// @param stream_ptr An external pointer from `nanoarrow::nanoarrow_allocate_array_stream()`. +/// @export +#[savvy] +fn export_test_series(stream_ptr: Sexp) -> savvy::Result<()> { + let series = Series::new("x".into(), &[1i32, 2, 3, 4, 5]); + + let field = series.field().to_arrow(CompatLevel::newest()); + let chunks = series.chunks().clone(); + let iter = Box::new(chunks.into_iter().map(Ok)); + let stream = export_iterator(iter, field); + + let stream_ptr = unsafe { + ExternalPointerSexp::try_from(stream_ptr)?.cast_mut_unchecked::() + }; + unsafe { std::ptr::replace(stream_ptr, stream) }; + + Ok(()) +} + +/// Read an Arrow C stream from R back into a Rust Series, then hand back +/// its length and first value as a sanity check (i32 only, for this prototype). +/// +/// @param stream_ptr An external pointer holding a filled ArrowArrayStream. +/// @returns An integer vector: `c(length, first_value)`. +/// @export +#[savvy] +fn import_test_series(stream_ptr: Sexp) -> savvy::Result { + let mut stream = unsafe { + let boxed = Box::new(std::ptr::replace( + ExternalPointerSexp::try_from(stream_ptr)?.cast_mut_unchecked::(), + ArrowArrayStream::empty(), + )); + ArrowArrayStreamReader::try_new(boxed).map_err(|e| savvy::Error::new(&e.to_string()))? + }; + + let mut arrays = Vec::new(); + while let Some(arr) = unsafe { stream.next() } { + arrays.push(arr.map_err(|e| savvy::Error::new(&e.to_string()))?); + } + + let series = Series::from_arrow_chunks("x".into(), arrays) + .map_err(|e| savvy::Error::new(&e.to_string()))?; + + let mut out = savvy::OwnedIntegerSexp::new(2)?; + out[0] = series.len() as i32; + out[1] = series + .i32() + .map_err(|e| savvy::Error::new(&e.to_string()))? + .get(0) + .unwrap_or(0); + Ok(out.into()) +} From f04bda4f3ceec28d500de431e3e2d3aea58263b5 Mon Sep 17 00:00:00 2001 From: novica Date: Sat, 12 Sep 2026 17:34:09 +0200 Subject: [PATCH 02/46] feat(maplibr): add RModel reads()/writes() - ports Model::reads/write_triples for ntriples/turtle/rdf-xml - verified round-trip from R Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 3 ++ maplibr/R/000-wrappers.R | 14 +++++++ maplibr/src/init.c | 12 ++++++ maplibr/src/rust/Cargo.toml | 3 ++ maplibr/src/rust/api.h | 2 + maplibr/src/rust/src/lib.rs | 76 +++++++++++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 27485eb6..a99adca3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2318,10 +2318,13 @@ name = "maplibr" version = "0.1.0" dependencies = [ "maplib", + "oxrdf", + "oxrdfio", "polars", "polars-arrow", "representation", "savvy", + "triplestore", ] [[package]] diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index ebd2a49f..bb39de4e 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -58,16 +58,30 @@ NULL ### wrapper functions for RModel +`RModel_reads` <- function(self) { + function(`s`, `format`, `graph` = NULL) { + invisible(.Call(savvy_RModel_reads__impl, `self`, `s`, `format`, `graph`)) + } +} + `RModel_size` <- function(self) { function() { .Call(savvy_RModel_size__impl, `self`) } } +`RModel_writes` <- function(self) { + function(`format` = NULL, `graph` = NULL) { + .Call(savvy_RModel_writes__impl, `self`, `format`, `graph`) + } +} + `.savvy_wrap_RModel` <- function(ptr) { e <- new.env(parent = emptyenv()) e$.ptr <- ptr + e$`reads` <- `RModel_reads`(ptr) e$`size` <- `RModel_size`(ptr) + e$`writes` <- `RModel_writes`(ptr) class(e) <- c("maplibr::RModel", "RModel", "savvy_maplibr__sealed") e diff --git a/maplibr/src/init.c b/maplibr/src/init.c index e7440d12..fe22dd45 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -54,17 +54,29 @@ SEXP savvy_RModel_new__impl(void) { return handle_result(res); } +SEXP savvy_RModel_reads__impl(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEXP c_arg__graph) { + SEXP res = savvy_RModel_reads__ffi(self__, c_arg__s, c_arg__format, c_arg__graph); + return handle_result(res); +} + SEXP savvy_RModel_size__impl(SEXP self__) { SEXP res = savvy_RModel_size__ffi(self__); return handle_result(res); } +SEXP savvy_RModel_writes__impl(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph) { + SEXP res = savvy_RModel_writes__ffi(self__, c_arg__format, c_arg__graph); + return handle_result(res); +} + static const R_CallMethodDef CallEntries[] = { {"savvy_export_test_series__impl", (DL_FUNC) &savvy_export_test_series__impl, 1}, {"savvy_import_test_series__impl", (DL_FUNC) &savvy_import_test_series__impl, 1}, {"savvy_RModel_new__impl", (DL_FUNC) &savvy_RModel_new__impl, 0}, + {"savvy_RModel_reads__impl", (DL_FUNC) &savvy_RModel_reads__impl, 4}, {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 1}, + {"savvy_RModel_writes__impl", (DL_FUNC) &savvy_RModel_writes__impl, 3}, {NULL, NULL, 0} }; diff --git a/maplibr/src/rust/Cargo.toml b/maplibr/src/rust/Cargo.toml index da0ea0aa..a3c9f63d 100644 --- a/maplibr/src/rust/Cargo.toml +++ b/maplibr/src/rust/Cargo.toml @@ -9,6 +9,9 @@ crate-type = ["lib", "staticlib"] [dependencies] maplib = { path = "../../../lib/maplib", features = ["pyo3"] } representation = { path = "../../../lib/representation" } +triplestore = { path = "../../../lib/triplestore" } +oxrdf = { workspace = true } +oxrdfio = { workspace = true } polars = { version = "0.54.4", default-features = false } polars-arrow = "0.54.4" savvy = "0.8" diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index cb823f09..79c687f2 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -3,4 +3,6 @@ SEXP savvy_import_test_series__ffi(SEXP c_arg__stream_ptr); // methods and associated functions for RModel SEXP savvy_RModel_new__ffi(void); +SEXP savvy_RModel_reads__ffi(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEXP c_arg__graph); SEXP savvy_RModel_size__ffi(SEXP self__); +SEXP savvy_RModel_writes__ffi(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph); diff --git a/maplibr/src/rust/src/lib.rs b/maplibr/src/rust/src/lib.rs index 0d687898..29c3d687 100644 --- a/maplibr/src/rust/src/lib.rs +++ b/maplibr/src/rust/src/lib.rs @@ -1,9 +1,38 @@ +use oxrdf::NamedNode; +use oxrdfio::RdfFormat; use polars::prelude::*; use polars_arrow::ffi::{export_iterator, ArrowArrayStream, ArrowArrayStreamReader}; use representation::dataset::NamedGraph; use savvy::{savvy, ExternalPointerSexp, Sexp}; +use std::collections::HashMap; use std::sync::Mutex; +use triplestore::triples_read::ExtendedRdfFormat; + +const DEFAULT_TRIPLES_BATCH_SIZE: usize = 10_000_000; + +/// Mirrors py_maplib's `resolve_normal_format` (py_maplib/src/lib.rs:303-316). +fn resolve_normal_format(format: &str) -> savvy::Result { + match format.to_lowercase().as_str() { + "ntriples" => Ok(RdfFormat::NTriples), + "turtle" => Ok(RdfFormat::Turtle), + "rdf/xml" | "xml" | "rdfxml" => Ok(RdfFormat::RdfXml), + _ => Err(savvy::Error::new(&format!("Unknown format: {}", format))), + } +} + +/// Mirrors py_maplib's `resolve_format` (py_maplib/src/lib.rs:318-327). +/// CIM XML and HDT deliberately unsupported here (out of scope for v1, see plan). +fn resolve_format(format: &str) -> savvy::Result { + resolve_normal_format(format).map(ExtendedRdfFormat::Normal) +} + +fn parse_optional_named_graph(graph: Option<&str>) -> savvy::Result { + let nn = graph + .map(|g| NamedNode::new(g).map_err(|e| savvy::Error::new(&e.to_string()))) + .transpose()?; + Ok(NamedGraph::from_maybe_named_node(nn.as_ref())) +} /// A maplib knowledge graph model. /// @@ -34,6 +63,53 @@ impl RModel { let graph = NamedGraph::from_maybe_named_node(None); (inner.graph_size(&graph) as i32).try_into() } + + /// Parse RDF triples from a string into this Model. + /// + /// @param s RDF data as a string. + /// @param format One of "ntriples", "turtle", "xml" (rdf/xml). + /// @param graph Optional named graph IRI to read into (default graph if NULL). + /// @export + fn reads(&self, s: &str, format: &str, graph: Option<&str>) -> savvy::Result<()> { + let format = resolve_format(format)?; + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.inner.lock().unwrap(); + inner + .reads( + s, + format, + None, + false, + None, + true, + &named_graph, + false, + DEFAULT_TRIPLES_BATCH_SIZE, + HashMap::new(), + ) + .map_err(|e| savvy::Error::new(&e.to_string())) + } + + /// Serialize this Model's triples to a string. + /// + /// @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". + /// @param graph Optional named graph IRI to write (default graph if NULL). + /// @export + fn writes(&self, format: Option<&str>, graph: Option<&str>) -> savvy::Result { + let format = format + .map(resolve_normal_format) + .transpose()? + .unwrap_or(RdfFormat::NTriples); + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.inner.lock().unwrap(); + let mut out = Vec::new(); + inner + .write_triples(&mut out, &named_graph, format, None) + .map_err(|e| savvy::Error::new(&e.to_string()))?; + String::from_utf8(out) + .map_err(|e| savvy::Error::new(&e.to_string()))? + .try_into() + } } /// Build a small hardcoded i32 Series in Rust and stream it out to R From 49fc22f8463e4bda2c7f4375596ccd192338830e Mon Sep 17 00:00:00 2001 From: novica Date: Sat, 12 Sep 2026 17:39:01 +0200 Subject: [PATCH 03/46] refactor(maplibr): split lib.rs into model.rs/arrow_bridge.rs - add create_index/truncate_graph/add_prefixes to RModel - verified from R Co-Authored-By: Claude Sonnet 5 --- maplibr/R/000-wrappers.R | 21 ++++ maplibr/src/init.c | 18 +++ maplibr/src/rust/api.h | 3 + maplibr/src/rust/src/arrow_bridge.rs | 59 +++++++++ maplibr/src/rust/src/lib.rs | 171 +-------------------------- maplibr/src/rust/src/model.rs | 154 ++++++++++++++++++++++++ 6 files changed, 258 insertions(+), 168 deletions(-) create mode 100644 maplibr/src/rust/src/arrow_bridge.rs create mode 100644 maplibr/src/rust/src/model.rs diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index bb39de4e..49594b00 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -58,6 +58,18 @@ NULL ### wrapper functions for RModel +`RModel_add_prefixes` <- function(self) { + function(`prefixes`) { + invisible(.Call(savvy_RModel_add_prefixes__impl, `self`, `prefixes`)) + } +} + +`RModel_create_index` <- function(self) { + function() { + invisible(.Call(savvy_RModel_create_index__impl, `self`)) + } +} + `RModel_reads` <- function(self) { function(`s`, `format`, `graph` = NULL) { invisible(.Call(savvy_RModel_reads__impl, `self`, `s`, `format`, `graph`)) @@ -70,6 +82,12 @@ NULL } } +`RModel_truncate_graph` <- function(self) { + function(`graph` = NULL) { + invisible(.Call(savvy_RModel_truncate_graph__impl, `self`, `graph`)) + } +} + `RModel_writes` <- function(self) { function(`format` = NULL, `graph` = NULL) { .Call(savvy_RModel_writes__impl, `self`, `format`, `graph`) @@ -79,8 +97,11 @@ NULL `.savvy_wrap_RModel` <- function(ptr) { e <- new.env(parent = emptyenv()) e$.ptr <- ptr + e$`add_prefixes` <- `RModel_add_prefixes`(ptr) + e$`create_index` <- `RModel_create_index`(ptr) e$`reads` <- `RModel_reads`(ptr) e$`size` <- `RModel_size`(ptr) + e$`truncate_graph` <- `RModel_truncate_graph`(ptr) e$`writes` <- `RModel_writes`(ptr) class(e) <- c("maplibr::RModel", "RModel", "savvy_maplibr__sealed") diff --git a/maplibr/src/init.c b/maplibr/src/init.c index fe22dd45..1456d435 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -49,6 +49,16 @@ SEXP savvy_import_test_series__impl(SEXP c_arg__stream_ptr) { return handle_result(res); } +SEXP savvy_RModel_add_prefixes__impl(SEXP self__, SEXP c_arg__prefixes) { + SEXP res = savvy_RModel_add_prefixes__ffi(self__, c_arg__prefixes); + return handle_result(res); +} + +SEXP savvy_RModel_create_index__impl(SEXP self__) { + SEXP res = savvy_RModel_create_index__ffi(self__); + return handle_result(res); +} + SEXP savvy_RModel_new__impl(void) { SEXP res = savvy_RModel_new__ffi(); return handle_result(res); @@ -64,6 +74,11 @@ SEXP savvy_RModel_size__impl(SEXP self__) { return handle_result(res); } +SEXP savvy_RModel_truncate_graph__impl(SEXP self__, SEXP c_arg__graph) { + SEXP res = savvy_RModel_truncate_graph__ffi(self__, c_arg__graph); + return handle_result(res); +} + SEXP savvy_RModel_writes__impl(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph) { SEXP res = savvy_RModel_writes__ffi(self__, c_arg__format, c_arg__graph); return handle_result(res); @@ -73,9 +88,12 @@ SEXP savvy_RModel_writes__impl(SEXP self__, SEXP c_arg__format, SEXP c_arg__grap static const R_CallMethodDef CallEntries[] = { {"savvy_export_test_series__impl", (DL_FUNC) &savvy_export_test_series__impl, 1}, {"savvy_import_test_series__impl", (DL_FUNC) &savvy_import_test_series__impl, 1}, + {"savvy_RModel_add_prefixes__impl", (DL_FUNC) &savvy_RModel_add_prefixes__impl, 2}, + {"savvy_RModel_create_index__impl", (DL_FUNC) &savvy_RModel_create_index__impl, 1}, {"savvy_RModel_new__impl", (DL_FUNC) &savvy_RModel_new__impl, 0}, {"savvy_RModel_reads__impl", (DL_FUNC) &savvy_RModel_reads__impl, 4}, {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 1}, + {"savvy_RModel_truncate_graph__impl", (DL_FUNC) &savvy_RModel_truncate_graph__impl, 2}, {"savvy_RModel_writes__impl", (DL_FUNC) &savvy_RModel_writes__impl, 3}, {NULL, NULL, 0} }; diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index 79c687f2..4dbf0743 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -2,7 +2,10 @@ SEXP savvy_export_test_series__ffi(SEXP c_arg__stream_ptr); SEXP savvy_import_test_series__ffi(SEXP c_arg__stream_ptr); // methods and associated functions for RModel +SEXP savvy_RModel_add_prefixes__ffi(SEXP self__, SEXP c_arg__prefixes); +SEXP savvy_RModel_create_index__ffi(SEXP self__); SEXP savvy_RModel_new__ffi(void); SEXP savvy_RModel_reads__ffi(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEXP c_arg__graph); SEXP savvy_RModel_size__ffi(SEXP self__); +SEXP savvy_RModel_truncate_graph__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_writes__ffi(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph); diff --git a/maplibr/src/rust/src/arrow_bridge.rs b/maplibr/src/rust/src/arrow_bridge.rs new file mode 100644 index 00000000..18e4a8f0 --- /dev/null +++ b/maplibr/src/rust/src/arrow_bridge.rs @@ -0,0 +1,59 @@ +use polars::prelude::*; +use polars_arrow::ffi::{export_iterator, ArrowArrayStream, ArrowArrayStreamReader}; +use savvy::{savvy, ExternalPointerSexp, Sexp}; + +/// Build a small hardcoded i32 Series in Rust and stream it out to R +/// through the Arrow C Stream Interface. +/// +/// @param stream_ptr An external pointer from `nanoarrow::nanoarrow_allocate_array_stream()`. +/// @export +#[savvy] +fn export_test_series(stream_ptr: Sexp) -> savvy::Result<()> { + let series = Series::new("x".into(), &[1i32, 2, 3, 4, 5]); + + let field = series.field().to_arrow(CompatLevel::newest()); + let chunks = series.chunks().clone(); + let iter = Box::new(chunks.into_iter().map(Ok)); + let stream = export_iterator(iter, field); + + let stream_ptr = unsafe { + ExternalPointerSexp::try_from(stream_ptr)?.cast_mut_unchecked::() + }; + unsafe { std::ptr::replace(stream_ptr, stream) }; + + Ok(()) +} + +/// Read an Arrow C stream from R back into a Rust Series, then hand back +/// its length and first value as a sanity check (i32 only, for this prototype). +/// +/// @param stream_ptr An external pointer holding a filled ArrowArrayStream. +/// @returns An integer vector: `c(length, first_value)`. +/// @export +#[savvy] +fn import_test_series(stream_ptr: Sexp) -> savvy::Result { + let mut stream = unsafe { + let boxed = Box::new(std::ptr::replace( + ExternalPointerSexp::try_from(stream_ptr)?.cast_mut_unchecked::(), + ArrowArrayStream::empty(), + )); + ArrowArrayStreamReader::try_new(boxed).map_err(|e| savvy::Error::new(&e.to_string()))? + }; + + let mut arrays = Vec::new(); + while let Some(arr) = unsafe { stream.next() } { + arrays.push(arr.map_err(|e| savvy::Error::new(&e.to_string()))?); + } + + let series = Series::from_arrow_chunks("x".into(), arrays) + .map_err(|e| savvy::Error::new(&e.to_string()))?; + + let mut out = savvy::OwnedIntegerSexp::new(2)?; + out[0] = series.len() as i32; + out[1] = series + .i32() + .map_err(|e| savvy::Error::new(&e.to_string()))? + .get(0) + .unwrap_or(0); + Ok(out.into()) +} diff --git a/maplibr/src/rust/src/lib.rs b/maplibr/src/rust/src/lib.rs index 29c3d687..5996df93 100644 --- a/maplibr/src/rust/src/lib.rs +++ b/maplibr/src/rust/src/lib.rs @@ -1,169 +1,4 @@ +mod arrow_bridge; +mod model; -use oxrdf::NamedNode; -use oxrdfio::RdfFormat; -use polars::prelude::*; -use polars_arrow::ffi::{export_iterator, ArrowArrayStream, ArrowArrayStreamReader}; -use representation::dataset::NamedGraph; -use savvy::{savvy, ExternalPointerSexp, Sexp}; -use std::collections::HashMap; -use std::sync::Mutex; -use triplestore::triples_read::ExtendedRdfFormat; - -const DEFAULT_TRIPLES_BATCH_SIZE: usize = 10_000_000; - -/// Mirrors py_maplib's `resolve_normal_format` (py_maplib/src/lib.rs:303-316). -fn resolve_normal_format(format: &str) -> savvy::Result { - match format.to_lowercase().as_str() { - "ntriples" => Ok(RdfFormat::NTriples), - "turtle" => Ok(RdfFormat::Turtle), - "rdf/xml" | "xml" | "rdfxml" => Ok(RdfFormat::RdfXml), - _ => Err(savvy::Error::new(&format!("Unknown format: {}", format))), - } -} - -/// Mirrors py_maplib's `resolve_format` (py_maplib/src/lib.rs:318-327). -/// CIM XML and HDT deliberately unsupported here (out of scope for v1, see plan). -fn resolve_format(format: &str) -> savvy::Result { - resolve_normal_format(format).map(ExtendedRdfFormat::Normal) -} - -fn parse_optional_named_graph(graph: Option<&str>) -> savvy::Result { - let nn = graph - .map(|g| NamedNode::new(g).map_err(|e| savvy::Error::new(&e.to_string()))) - .transpose()?; - Ok(NamedGraph::from_maybe_named_node(nn.as_ref())) -} - -/// A maplib knowledge graph model. -/// -/// @export -#[savvy] -struct RModel { - inner: Mutex, -} - -#[savvy] -impl RModel { - /// Create a new, empty Model. - /// - /// @export - fn new() -> savvy::Result { - let model = maplib::model::Model::new(None, None, None, None) - .map_err(|e| savvy::Error::new(&e.to_string()))?; - Ok(Self { - inner: Mutex::new(model), - }) - } - - /// Number of triples in the default graph. - /// - /// @export - fn size(&self) -> savvy::Result { - let inner = self.inner.lock().unwrap(); - let graph = NamedGraph::from_maybe_named_node(None); - (inner.graph_size(&graph) as i32).try_into() - } - - /// Parse RDF triples from a string into this Model. - /// - /// @param s RDF data as a string. - /// @param format One of "ntriples", "turtle", "xml" (rdf/xml). - /// @param graph Optional named graph IRI to read into (default graph if NULL). - /// @export - fn reads(&self, s: &str, format: &str, graph: Option<&str>) -> savvy::Result<()> { - let format = resolve_format(format)?; - let named_graph = parse_optional_named_graph(graph)?; - let mut inner = self.inner.lock().unwrap(); - inner - .reads( - s, - format, - None, - false, - None, - true, - &named_graph, - false, - DEFAULT_TRIPLES_BATCH_SIZE, - HashMap::new(), - ) - .map_err(|e| savvy::Error::new(&e.to_string())) - } - - /// Serialize this Model's triples to a string. - /// - /// @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". - /// @param graph Optional named graph IRI to write (default graph if NULL). - /// @export - fn writes(&self, format: Option<&str>, graph: Option<&str>) -> savvy::Result { - let format = format - .map(resolve_normal_format) - .transpose()? - .unwrap_or(RdfFormat::NTriples); - let named_graph = parse_optional_named_graph(graph)?; - let mut inner = self.inner.lock().unwrap(); - let mut out = Vec::new(); - inner - .write_triples(&mut out, &named_graph, format, None) - .map_err(|e| savvy::Error::new(&e.to_string()))?; - String::from_utf8(out) - .map_err(|e| savvy::Error::new(&e.to_string()))? - .try_into() - } -} - -/// Build a small hardcoded i32 Series in Rust and stream it out to R -/// through the Arrow C Stream Interface. -/// -/// @param stream_ptr An external pointer from `nanoarrow::nanoarrow_allocate_array_stream()`. -/// @export -#[savvy] -fn export_test_series(stream_ptr: Sexp) -> savvy::Result<()> { - let series = Series::new("x".into(), &[1i32, 2, 3, 4, 5]); - - let field = series.field().to_arrow(CompatLevel::newest()); - let chunks = series.chunks().clone(); - let iter = Box::new(chunks.into_iter().map(Ok)); - let stream = export_iterator(iter, field); - - let stream_ptr = unsafe { - ExternalPointerSexp::try_from(stream_ptr)?.cast_mut_unchecked::() - }; - unsafe { std::ptr::replace(stream_ptr, stream) }; - - Ok(()) -} - -/// Read an Arrow C stream from R back into a Rust Series, then hand back -/// its length and first value as a sanity check (i32 only, for this prototype). -/// -/// @param stream_ptr An external pointer holding a filled ArrowArrayStream. -/// @returns An integer vector: `c(length, first_value)`. -/// @export -#[savvy] -fn import_test_series(stream_ptr: Sexp) -> savvy::Result { - let mut stream = unsafe { - let boxed = Box::new(std::ptr::replace( - ExternalPointerSexp::try_from(stream_ptr)?.cast_mut_unchecked::(), - ArrowArrayStream::empty(), - )); - ArrowArrayStreamReader::try_new(boxed).map_err(|e| savvy::Error::new(&e.to_string()))? - }; - - let mut arrays = Vec::new(); - while let Some(arr) = unsafe { stream.next() } { - arrays.push(arr.map_err(|e| savvy::Error::new(&e.to_string()))?); - } - - let series = Series::from_arrow_chunks("x".into(), arrays) - .map_err(|e| savvy::Error::new(&e.to_string()))?; - - let mut out = savvy::OwnedIntegerSexp::new(2)?; - out[0] = series.len() as i32; - out[1] = series - .i32() - .map_err(|e| savvy::Error::new(&e.to_string()))? - .get(0) - .unwrap_or(0); - Ok(out.into()) -} +pub use model::RModel; diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs new file mode 100644 index 00000000..ccbc55e5 --- /dev/null +++ b/maplibr/src/rust/src/model.rs @@ -0,0 +1,154 @@ +use oxrdf::NamedNode; +use oxrdfio::RdfFormat; +use representation::dataset::NamedGraph; +use savvy::{savvy, ListSexp}; +use std::collections::HashMap; +use std::sync::Mutex; +use triplestore::triples_read::ExtendedRdfFormat; +use triplestore::IndexingOptions; + +const DEFAULT_TRIPLES_BATCH_SIZE: usize = 10_000_000; + +/// Mirrors py_maplib's `resolve_normal_format` (py_maplib/src/lib.rs:303-316). +fn resolve_normal_format(format: &str) -> savvy::Result { + match format.to_lowercase().as_str() { + "ntriples" => Ok(RdfFormat::NTriples), + "turtle" => Ok(RdfFormat::Turtle), + "rdf/xml" | "xml" | "rdfxml" => Ok(RdfFormat::RdfXml), + _ => Err(savvy::Error::new(&format!("Unknown format: {}", format))), + } +} + +/// Mirrors py_maplib's `resolve_format` (py_maplib/src/lib.rs:318-327). +/// CIM XML and HDT deliberately unsupported here (out of scope for v1, see plan). +fn resolve_format(format: &str) -> savvy::Result { + resolve_normal_format(format).map(ExtendedRdfFormat::Normal) +} + +fn parse_optional_named_graph(graph: Option<&str>) -> savvy::Result { + let nn = graph + .map(|g| NamedNode::new(g).map_err(|e| savvy::Error::new(&e.to_string()))) + .transpose()?; + Ok(NamedGraph::from_maybe_named_node(nn.as_ref())) +} + +/// A maplib knowledge graph model. +/// +/// @export +#[savvy] +pub struct RModel { + inner: Mutex, +} + +#[savvy] +impl RModel { + /// Create a new, empty Model. + /// + /// @export + fn new() -> savvy::Result { + let model = maplib::model::Model::new(None, None, None, None) + .map_err(|e| savvy::Error::new(&e.to_string()))?; + Ok(Self { + inner: Mutex::new(model), + }) + } + + /// Number of triples in the default graph. + /// + /// @export + fn size(&self) -> savvy::Result { + let inner = self.inner.lock().unwrap(); + let graph = NamedGraph::from_maybe_named_node(None); + (inner.graph_size(&graph) as i32).try_into() + } + + /// Parse RDF triples from a string into this Model. + /// + /// @param s RDF data as a string. + /// @param format One of "ntriples", "turtle", "xml" (rdf/xml). + /// @param graph Optional named graph IRI to read into (default graph if NULL). + /// @export + fn reads(&self, s: &str, format: &str, graph: Option<&str>) -> savvy::Result<()> { + let format = resolve_format(format)?; + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.inner.lock().unwrap(); + inner + .reads( + s, + format, + None, + false, + None, + true, + &named_graph, + false, + DEFAULT_TRIPLES_BATCH_SIZE, + HashMap::new(), + ) + .map_err(|e| savvy::Error::new(&e.to_string())) + } + + /// Serialize this Model's triples to a string. + /// + /// @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". + /// @param graph Optional named graph IRI to write (default graph if NULL). + /// @export + fn writes(&self, format: Option<&str>, graph: Option<&str>) -> savvy::Result { + let format = format + .map(resolve_normal_format) + .transpose()? + .unwrap_or(RdfFormat::NTriples); + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.inner.lock().unwrap(); + let mut out = Vec::new(); + inner + .write_triples(&mut out, &named_graph, format, None) + .map_err(|e| savvy::Error::new(&e.to_string()))?; + String::from_utf8(out) + .map_err(|e| savvy::Error::new(&e.to_string()))? + .try_into() + } + + /// Build the default (non-FTS) indexes, matching py_maplib's create_index() + /// with no options (create_index_mutex, py_maplib/src/mutexes.rs:402-413). + /// + /// @export + fn create_index(&self) -> savvy::Result<()> { + let mut inner = self.inner.lock().unwrap(); + inner + .create_index(IndexingOptions::default()) + .map_err(|e| savvy::Error::new(&e.to_string())) + } + + /// Remove all triples from a graph (mirrors truncate_graph_mutex, + /// py_maplib/src/mutexes.rs:98-104 -- delegates straight to the + /// triplestore, Model has no dedicated wrapper method). + /// + /// @param graph Optional named graph IRI to truncate (default graph if NULL). + /// @export + fn truncate_graph(&self, graph: Option<&str>) -> savvy::Result<()> { + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.inner.lock().unwrap(); + inner.triplestore.truncate(&named_graph); + Ok(()) + } + + /// Add prefix -> IRI mappings, used when serializing (mirrors + /// add_prefixes_mutex, py_maplib/src/mutexes.rs:90-96 -- direct field + /// extend, no dedicated Model method). + /// + /// @param prefixes A named R list of single strings: names are prefixes, + /// values are IRIs, e.g. `list(ex = "http://example.org/")`. + /// @export + fn add_prefixes(&self, prefixes: ListSexp) -> savvy::Result<()> { + let mut parsed = HashMap::new(); + for (name, value) in prefixes.iter() { + let iri = <&str>::try_from(value)?; + let nn = NamedNode::new(iri).map_err(|e| savvy::Error::new(&e.to_string()))?; + parsed.insert(name.to_string(), nn); + } + let mut inner = self.inner.lock().unwrap(); + inner.prefixes.extend(parsed); + Ok(()) + } +} From 839e5fb5dadb68200dd21a3df989ca669d07b1e2 Mon Sep 17 00:00:00 2001 From: novica Date: Sat, 12 Sep 2026 17:41:49 +0200 Subject: [PATCH 04/46] feat(maplibr): add RModel add_graph()/detach_graph() - copies a graph between RModels, or splits one out into a new RModel - verified: copy across two models, split empties the source Co-Authored-By: Claude Sonnet 5 --- maplibr/R/000-wrappers.R | 15 +++++++++++++ maplibr/src/init.c | 12 ++++++++++ maplibr/src/rust/api.h | 2 ++ maplibr/src/rust/src/model.rs | 41 +++++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+) diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index 49594b00..cc0b3c10 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -58,6 +58,13 @@ NULL ### wrapper functions for RModel +`RModel_add_graph` <- function(self) { + function(`other`, `source_graph` = NULL, `target_graph` = NULL) { + `other` <- .savvy_extract_ptr(`other`, "maplibr::RModel") + invisible(.Call(savvy_RModel_add_graph__impl, `self`, `other`, `source_graph`, `target_graph`)) + } +} + `RModel_add_prefixes` <- function(self) { function(`prefixes`) { invisible(.Call(savvy_RModel_add_prefixes__impl, `self`, `prefixes`)) @@ -70,6 +77,12 @@ NULL } } +`RModel_detach_graph` <- function(self) { + function(`preserve_name`, `graph` = NULL) { + .savvy_wrap_RModel(.Call(savvy_RModel_detach_graph__impl, `self`, `preserve_name`, `graph`)) + } +} + `RModel_reads` <- function(self) { function(`s`, `format`, `graph` = NULL) { invisible(.Call(savvy_RModel_reads__impl, `self`, `s`, `format`, `graph`)) @@ -97,8 +110,10 @@ NULL `.savvy_wrap_RModel` <- function(ptr) { e <- new.env(parent = emptyenv()) e$.ptr <- ptr + e$`add_graph` <- `RModel_add_graph`(ptr) e$`add_prefixes` <- `RModel_add_prefixes`(ptr) e$`create_index` <- `RModel_create_index`(ptr) + e$`detach_graph` <- `RModel_detach_graph`(ptr) e$`reads` <- `RModel_reads`(ptr) e$`size` <- `RModel_size`(ptr) e$`truncate_graph` <- `RModel_truncate_graph`(ptr) diff --git a/maplibr/src/init.c b/maplibr/src/init.c index 1456d435..bdba7c19 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -49,6 +49,11 @@ SEXP savvy_import_test_series__impl(SEXP c_arg__stream_ptr) { return handle_result(res); } +SEXP savvy_RModel_add_graph__impl(SEXP self__, SEXP c_arg__other, SEXP c_arg__source_graph, SEXP c_arg__target_graph) { + SEXP res = savvy_RModel_add_graph__ffi(self__, c_arg__other, c_arg__source_graph, c_arg__target_graph); + return handle_result(res); +} + SEXP savvy_RModel_add_prefixes__impl(SEXP self__, SEXP c_arg__prefixes) { SEXP res = savvy_RModel_add_prefixes__ffi(self__, c_arg__prefixes); return handle_result(res); @@ -59,6 +64,11 @@ SEXP savvy_RModel_create_index__impl(SEXP self__) { return handle_result(res); } +SEXP savvy_RModel_detach_graph__impl(SEXP self__, SEXP c_arg__preserve_name, SEXP c_arg__graph) { + SEXP res = savvy_RModel_detach_graph__ffi(self__, c_arg__preserve_name, c_arg__graph); + return handle_result(res); +} + SEXP savvy_RModel_new__impl(void) { SEXP res = savvy_RModel_new__ffi(); return handle_result(res); @@ -88,8 +98,10 @@ SEXP savvy_RModel_writes__impl(SEXP self__, SEXP c_arg__format, SEXP c_arg__grap static const R_CallMethodDef CallEntries[] = { {"savvy_export_test_series__impl", (DL_FUNC) &savvy_export_test_series__impl, 1}, {"savvy_import_test_series__impl", (DL_FUNC) &savvy_import_test_series__impl, 1}, + {"savvy_RModel_add_graph__impl", (DL_FUNC) &savvy_RModel_add_graph__impl, 4}, {"savvy_RModel_add_prefixes__impl", (DL_FUNC) &savvy_RModel_add_prefixes__impl, 2}, {"savvy_RModel_create_index__impl", (DL_FUNC) &savvy_RModel_create_index__impl, 1}, + {"savvy_RModel_detach_graph__impl", (DL_FUNC) &savvy_RModel_detach_graph__impl, 3}, {"savvy_RModel_new__impl", (DL_FUNC) &savvy_RModel_new__impl, 0}, {"savvy_RModel_reads__impl", (DL_FUNC) &savvy_RModel_reads__impl, 4}, {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 1}, diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index 4dbf0743..b9fe1e60 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -2,8 +2,10 @@ SEXP savvy_export_test_series__ffi(SEXP c_arg__stream_ptr); SEXP savvy_import_test_series__ffi(SEXP c_arg__stream_ptr); // methods and associated functions for RModel +SEXP savvy_RModel_add_graph__ffi(SEXP self__, SEXP c_arg__other, SEXP c_arg__source_graph, SEXP c_arg__target_graph); SEXP savvy_RModel_add_prefixes__ffi(SEXP self__, SEXP c_arg__prefixes); SEXP savvy_RModel_create_index__ffi(SEXP self__); +SEXP savvy_RModel_detach_graph__ffi(SEXP self__, SEXP c_arg__preserve_name, SEXP c_arg__graph); SEXP savvy_RModel_new__ffi(void); SEXP savvy_RModel_reads__ffi(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEXP c_arg__graph); SEXP savvy_RModel_size__ffi(SEXP self__); diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index ccbc55e5..dff21715 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -151,4 +151,45 @@ impl RModel { inner.prefixes.extend(parsed); Ok(()) } + + /// Copy a graph from another Model's triplestore into this one (mirrors + /// PyModel::add_graph, py_maplib/src/py_model.rs:997-1015). + /// + /// @param other Another Model to copy a graph from. + /// @param source_graph Optional named graph IRI in `other` (default graph if NULL). + /// @param target_graph Optional named graph IRI in this Model to copy into (default graph if NULL). + /// @export + fn add_graph( + &self, + other: &RModel, + source_graph: Option<&str>, + target_graph: Option<&str>, + ) -> savvy::Result<()> { + let source_graph = parse_optional_named_graph(source_graph)?; + let target_graph = parse_optional_named_graph(target_graph)?; + let other_inner = other.inner.lock().unwrap(); + let mut inner = self.inner.lock().unwrap(); + inner + .add_graph(&other_inner.triplestore, source_graph, target_graph) + .map_err(|e| savvy::Error::new(&e.to_string())) + } + + /// Split a graph out of this Model into a brand new, standalone Model + /// (mirrors detach_graph_mutex, py_maplib/src/mutexes.rs:106-118). + /// + /// @param preserve_name Keep the graph's own name in the new Model rather + /// than moving it to the default graph there. + /// @param graph Optional named graph IRI to detach (default graph if NULL). + /// @returns A new Model containing only the detached graph. + /// @export + fn detach_graph(&self, preserve_name: bool, graph: Option<&str>) -> savvy::Result { + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.inner.lock().unwrap(); + let sprout = inner + .detach_graph(&named_graph, preserve_name) + .map_err(|e| savvy::Error::new(&e.to_string()))?; + Ok(RModel { + inner: Mutex::new(sprout), + }) + } } From ca2cad7471d17a0d8a019254da71f1fa8ea3830b Mon Sep 17 00:00:00 2001 From: novica Date: Sat, 12 Sep 2026 17:44:23 +0200 Subject: [PATCH 05/46] feat(maplibr): add RModel compact()/serialize()/deserialize() - compact() verified working on an in-memory model - serialize()/deserialize() panic at runtime: lib/disk is a closed-source stub in this checkout, not a port bug Co-Authored-By: Claude Sonnet 5 --- maplibr/R/000-wrappers.R | 18 +++++++++++++++ maplibr/src/init.c | 18 +++++++++++++++ maplibr/src/rust/api.h | 3 +++ maplibr/src/rust/src/model.rs | 41 +++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index cc0b3c10..dfe9a130 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -71,6 +71,12 @@ NULL } } +`RModel_compact` <- function(self) { + function() { + invisible(.Call(savvy_RModel_compact__impl, `self`)) + } +} + `RModel_create_index` <- function(self) { function() { invisible(.Call(savvy_RModel_create_index__impl, `self`)) @@ -89,6 +95,12 @@ NULL } } +`RModel_serialize` <- function(self) { + function(`path`) { + invisible(.Call(savvy_RModel_serialize__impl, `self`, `path`)) + } +} + `RModel_size` <- function(self) { function() { .Call(savvy_RModel_size__impl, `self`) @@ -112,9 +124,11 @@ NULL e$.ptr <- ptr e$`add_graph` <- `RModel_add_graph`(ptr) e$`add_prefixes` <- `RModel_add_prefixes`(ptr) + e$`compact` <- `RModel_compact`(ptr) e$`create_index` <- `RModel_create_index`(ptr) e$`detach_graph` <- `RModel_detach_graph`(ptr) e$`reads` <- `RModel_reads`(ptr) + e$`serialize` <- `RModel_serialize`(ptr) e$`size` <- `RModel_size`(ptr) e$`truncate_graph` <- `RModel_truncate_graph`(ptr) e$`writes` <- `RModel_writes`(ptr) @@ -131,6 +145,10 @@ NULL ### associated functions for RModel +`RModel`$`deserialize` <- function(`path`, `storage_folder` = NULL) { + .savvy_wrap_RModel(.Call(savvy_RModel_deserialize__impl, `path`, `storage_folder`)) +} + `RModel`$`new` <- function() { .savvy_wrap_RModel(.Call(savvy_RModel_new__impl)) } diff --git a/maplibr/src/init.c b/maplibr/src/init.c index bdba7c19..f85cbb2f 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -59,11 +59,21 @@ SEXP savvy_RModel_add_prefixes__impl(SEXP self__, SEXP c_arg__prefixes) { return handle_result(res); } +SEXP savvy_RModel_compact__impl(SEXP self__) { + SEXP res = savvy_RModel_compact__ffi(self__); + return handle_result(res); +} + SEXP savvy_RModel_create_index__impl(SEXP self__) { SEXP res = savvy_RModel_create_index__ffi(self__); return handle_result(res); } +SEXP savvy_RModel_deserialize__impl(SEXP c_arg__path, SEXP c_arg__storage_folder) { + SEXP res = savvy_RModel_deserialize__ffi(c_arg__path, c_arg__storage_folder); + return handle_result(res); +} + SEXP savvy_RModel_detach_graph__impl(SEXP self__, SEXP c_arg__preserve_name, SEXP c_arg__graph) { SEXP res = savvy_RModel_detach_graph__ffi(self__, c_arg__preserve_name, c_arg__graph); return handle_result(res); @@ -79,6 +89,11 @@ SEXP savvy_RModel_reads__impl(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SE return handle_result(res); } +SEXP savvy_RModel_serialize__impl(SEXP self__, SEXP c_arg__path) { + SEXP res = savvy_RModel_serialize__ffi(self__, c_arg__path); + return handle_result(res); +} + SEXP savvy_RModel_size__impl(SEXP self__) { SEXP res = savvy_RModel_size__ffi(self__); return handle_result(res); @@ -100,10 +115,13 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_import_test_series__impl", (DL_FUNC) &savvy_import_test_series__impl, 1}, {"savvy_RModel_add_graph__impl", (DL_FUNC) &savvy_RModel_add_graph__impl, 4}, {"savvy_RModel_add_prefixes__impl", (DL_FUNC) &savvy_RModel_add_prefixes__impl, 2}, + {"savvy_RModel_compact__impl", (DL_FUNC) &savvy_RModel_compact__impl, 1}, {"savvy_RModel_create_index__impl", (DL_FUNC) &savvy_RModel_create_index__impl, 1}, + {"savvy_RModel_deserialize__impl", (DL_FUNC) &savvy_RModel_deserialize__impl, 2}, {"savvy_RModel_detach_graph__impl", (DL_FUNC) &savvy_RModel_detach_graph__impl, 3}, {"savvy_RModel_new__impl", (DL_FUNC) &savvy_RModel_new__impl, 0}, {"savvy_RModel_reads__impl", (DL_FUNC) &savvy_RModel_reads__impl, 4}, + {"savvy_RModel_serialize__impl", (DL_FUNC) &savvy_RModel_serialize__impl, 2}, {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 1}, {"savvy_RModel_truncate_graph__impl", (DL_FUNC) &savvy_RModel_truncate_graph__impl, 2}, {"savvy_RModel_writes__impl", (DL_FUNC) &savvy_RModel_writes__impl, 3}, diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index b9fe1e60..f8a806d5 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -4,10 +4,13 @@ SEXP savvy_import_test_series__ffi(SEXP c_arg__stream_ptr); // methods and associated functions for RModel SEXP savvy_RModel_add_graph__ffi(SEXP self__, SEXP c_arg__other, SEXP c_arg__source_graph, SEXP c_arg__target_graph); SEXP savvy_RModel_add_prefixes__ffi(SEXP self__, SEXP c_arg__prefixes); +SEXP savvy_RModel_compact__ffi(SEXP self__); SEXP savvy_RModel_create_index__ffi(SEXP self__); +SEXP savvy_RModel_deserialize__ffi(SEXP c_arg__path, SEXP c_arg__storage_folder); SEXP savvy_RModel_detach_graph__ffi(SEXP self__, SEXP c_arg__preserve_name, SEXP c_arg__graph); SEXP savvy_RModel_new__ffi(void); SEXP savvy_RModel_reads__ffi(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEXP c_arg__graph); +SEXP savvy_RModel_serialize__ffi(SEXP self__, SEXP c_arg__path); SEXP savvy_RModel_size__ffi(SEXP self__); SEXP savvy_RModel_truncate_graph__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_writes__ffi(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph); diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index dff21715..025e97b8 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -3,6 +3,7 @@ use oxrdfio::RdfFormat; use representation::dataset::NamedGraph; use savvy::{savvy, ListSexp}; use std::collections::HashMap; +use std::path::Path; use std::sync::Mutex; use triplestore::triples_read::ExtendedRdfFormat; use triplestore::IndexingOptions; @@ -192,4 +193,44 @@ impl RModel { inner: Mutex::new(sprout), }) } + + /// Compact on-disk storage (mirrors PyModel::compact, py_maplib/src/py_model.rs:919-924). + /// + /// @export + fn compact(&self) -> savvy::Result<()> { + let mut inner = self.inner.lock().unwrap(); + inner.compact().map_err(|e| savvy::Error::new(&e.to_string())) + } + + /// Serialize this Model's triples to a directory in maplib's own compact + /// on-disk format (not an RDF interchange format -- use write()/writes() + /// for that). Mirrors PyModel::serialize, py_maplib/src/py_model.rs:926-934. + /// + /// @param path Directory to serialize into. + /// @export + fn serialize(&self, path: &str) -> savvy::Result<()> { + let mut inner = self.inner.lock().unwrap(); + inner + .serialize_triples(Path::new(path)) + .map_err(|e| savvy::Error::new(&e.to_string())) + } + + /// Load a Model previously written by serialize() (mirrors + /// PyModel::deserialize, py_maplib/src/py_model.rs:936-954). This is an + /// associated function, not a method -- call as `RModel$deserialize(path)`. + /// + /// @param path Directory previously written by serialize(). + /// @param storage_folder Optional folder for on-disk (rather than + /// in-memory) triplestore storage. + /// @export + fn deserialize(path: &str, storage_folder: Option<&str>) -> savvy::Result { + let model = maplib::model::Model::deserialize_triples( + Path::new(path), + storage_folder.map(String::from), + ) + .map_err(|e| savvy::Error::new(&e.to_string()))?; + Ok(RModel { + inner: Mutex::new(model), + }) + } } From bb13793980392fcee213418bb2379cc36a527ab3 Mon Sep 17 00:00:00 2001 From: novica Date: Sat, 12 Sep 2026 17:53:16 +0200 Subject: [PATCH 06/46] feat(maplibr): add RModel infer_rdfs(), init embedded Python - ports Datalog RDFS inference - fix: pyo3::Python::attach panicked under R on any query-materializing call; call Python::initialize() once in RModel::new() - verified idempotent and that it unblocks infer_rdfs Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 1 + maplibr/R/000-wrappers.R | 7 +++++++ maplibr/src/init.c | 6 ++++++ maplibr/src/rust/Cargo.toml | 1 + maplibr/src/rust/api.h | 1 + maplibr/src/rust/src/model.rs | 26 ++++++++++++++++++++++++++ 6 files changed, 42 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a99adca3..157ef1d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2322,6 +2322,7 @@ dependencies = [ "oxrdfio", "polars", "polars-arrow", + "pyo3", "representation", "savvy", "triplestore", diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index dfe9a130..69a6bc04 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -89,6 +89,12 @@ NULL } } +`RModel_infer_rdfs` <- function(self) { + function(`graph` = NULL) { + .Call(savvy_RModel_infer_rdfs__impl, `self`, `graph`) + } +} + `RModel_reads` <- function(self) { function(`s`, `format`, `graph` = NULL) { invisible(.Call(savvy_RModel_reads__impl, `self`, `s`, `format`, `graph`)) @@ -127,6 +133,7 @@ NULL e$`compact` <- `RModel_compact`(ptr) e$`create_index` <- `RModel_create_index`(ptr) e$`detach_graph` <- `RModel_detach_graph`(ptr) + e$`infer_rdfs` <- `RModel_infer_rdfs`(ptr) e$`reads` <- `RModel_reads`(ptr) e$`serialize` <- `RModel_serialize`(ptr) e$`size` <- `RModel_size`(ptr) diff --git a/maplibr/src/init.c b/maplibr/src/init.c index f85cbb2f..bb4c0033 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -79,6 +79,11 @@ SEXP savvy_RModel_detach_graph__impl(SEXP self__, SEXP c_arg__preserve_name, SEX return handle_result(res); } +SEXP savvy_RModel_infer_rdfs__impl(SEXP self__, SEXP c_arg__graph) { + SEXP res = savvy_RModel_infer_rdfs__ffi(self__, c_arg__graph); + return handle_result(res); +} + SEXP savvy_RModel_new__impl(void) { SEXP res = savvy_RModel_new__ffi(); return handle_result(res); @@ -119,6 +124,7 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_RModel_create_index__impl", (DL_FUNC) &savvy_RModel_create_index__impl, 1}, {"savvy_RModel_deserialize__impl", (DL_FUNC) &savvy_RModel_deserialize__impl, 2}, {"savvy_RModel_detach_graph__impl", (DL_FUNC) &savvy_RModel_detach_graph__impl, 3}, + {"savvy_RModel_infer_rdfs__impl", (DL_FUNC) &savvy_RModel_infer_rdfs__impl, 2}, {"savvy_RModel_new__impl", (DL_FUNC) &savvy_RModel_new__impl, 0}, {"savvy_RModel_reads__impl", (DL_FUNC) &savvy_RModel_reads__impl, 4}, {"savvy_RModel_serialize__impl", (DL_FUNC) &savvy_RModel_serialize__impl, 2}, diff --git a/maplibr/src/rust/Cargo.toml b/maplibr/src/rust/Cargo.toml index a3c9f63d..85218605 100644 --- a/maplibr/src/rust/Cargo.toml +++ b/maplibr/src/rust/Cargo.toml @@ -12,6 +12,7 @@ representation = { path = "../../../lib/representation" } triplestore = { path = "../../../lib/triplestore" } oxrdf = { workspace = true } oxrdfio = { workspace = true } +pyo3 = { workspace = true } polars = { version = "0.54.4", default-features = false } polars-arrow = "0.54.4" savvy = "0.8" diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index f8a806d5..1972c253 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -8,6 +8,7 @@ SEXP savvy_RModel_compact__ffi(SEXP self__); SEXP savvy_RModel_create_index__ffi(SEXP self__); SEXP savvy_RModel_deserialize__ffi(SEXP c_arg__path, SEXP c_arg__storage_folder); SEXP savvy_RModel_detach_graph__ffi(SEXP self__, SEXP c_arg__preserve_name, SEXP c_arg__graph); +SEXP savvy_RModel_infer_rdfs__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_new__ffi(void); SEXP savvy_RModel_reads__ffi(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEXP c_arg__graph); SEXP savvy_RModel_serialize__ffi(SEXP self__, SEXP c_arg__path); diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index 025e97b8..7254a342 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -47,6 +47,17 @@ impl RModel { /// /// @export fn new() -> savvy::Result { + // Required: see CLAUDE.md's pyo3-leak note. lib/utils/src/polars.rs's + // pl_interruptable_collect (used by query/insert/update/map/ + // infer_rdfs -- anything that materializes a LazyFrame) calls + // pyo3::Python::attach unconditionally when built with the pyo3 + // feature, which panics unless some Python interpreter has been + // initialized in this process. Python::initialize() is pyo3's own + // documented mechanism for embedding in a non-Python host; safe to + // call on every RModel::new() (verified idempotent -- a second call + // in the same session doesn't panic or error). + pyo3::Python::initialize(); + let model = maplib::model::Model::new(None, None, None, None) .map_err(|e| savvy::Error::new(&e.to_string()))?; Ok(Self { @@ -194,6 +205,21 @@ impl RModel { }) } + /// Run RDFS inference over a graph in place, returning the number of + /// interesting inference rules applied (mirrors PyModel::infer_rdfs, + /// py_maplib/src/py_model.rs:903-916). + /// + /// @param graph Optional named graph IRI to infer over (default graph if NULL). + /// @export + fn infer_rdfs(&self, graph: Option<&str>) -> savvy::Result { + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.inner.lock().unwrap(); + let n = inner + .infer_rdfs(&named_graph) + .map_err(|e| savvy::Error::new(&e.to_string()))?; + (n as i32).try_into() + } + /// Compact on-disk storage (mirrors PyModel::compact, py_maplib/src/py_model.rs:919-924). /// /// @export From fb4642dc87c6a3a0547f87ed59c19d7548b09313 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 09:31:11 +0200 Subject: [PATCH 07/46] feat(maplibr): wrap RModel in an R6 Model class - user-facing Model$new()$method(...) API matching py_maplib's call syntax - mutating methods return self invisibly so calls chain - requires LD_PRELOAD of libpython for pyo3-ffi symbols to resolve at load Co-Authored-By: Claude Sonnet 5 --- maplibr/DESCRIPTION | 2 + maplibr/NAMESPACE | 1 + maplibr/R/model.R | 132 +++++++++++++++++++ maplibr/man/Model.Rd | 294 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 429 insertions(+) create mode 100644 maplibr/R/model.R create mode 100644 maplibr/man/Model.Rd diff --git a/maplibr/DESCRIPTION b/maplibr/DESCRIPTION index 9ce13833..5d8e8668 100644 --- a/maplibr/DESCRIPTION +++ b/maplibr/DESCRIPTION @@ -7,6 +7,8 @@ Description: What the package does (one paragraph). License: `use_mit_license()`, `use_gpl3_license()` or friends to pick a license Encoding: UTF-8 +Imports: + R6 Roxygen: list(markdown = TRUE) RoxygenNote: 8.0.0 SystemRequirements: Cargo (Rust's package manager), rustc diff --git a/maplibr/NAMESPACE b/maplibr/NAMESPACE index 7e264504..7475a980 100644 --- a/maplibr/NAMESPACE +++ b/maplibr/NAMESPACE @@ -3,6 +3,7 @@ S3method("$<-",savvy_maplibr__sealed) S3method("[[<-",savvy_maplibr__sealed) S3method(print,"maplibr::RModel__bundle") +export(Model) export(RModel) export(export_test_series) export(import_test_series) diff --git a/maplibr/R/model.R b/maplibr/R/model.R new file mode 100644 index 00000000..3794939c --- /dev/null +++ b/maplibr/R/model.R @@ -0,0 +1,132 @@ +#' A maplib knowledge graph model +#' +#' `Model` is the user-facing wrapper around the underlying `RModel` savvy +#' object. It mirrors py_maplib's `Model` class: methods mutate the +#' underlying graph in place and are chainable (each mutating method +#' returns `self` invisibly). +#' +#' @export +Model <- R6::R6Class( + "Model", + public = list( + #' @description + #' Create a new, empty Model. Not used to wrap an existing `RModel` + #' (see `Model$deserialize()` and `$detach_graph()` for that) -- `rmodel` + #' is for internal use only. + #' @param rmodel Internal use only. + initialize = function(rmodel = NULL) { + if (is.null(rmodel)) { + rmodel <- RModel$new() + } else if (!inherits(rmodel, "RModel")) { + stop("`rmodel` must be an RModel object", call. = FALSE) + } + private$rmodel <- rmodel + }, + + #' @description Number of triples in the default graph. + size = function() { + private$rmodel$size() + }, + + #' @description Parse RDF triples from a string into this Model. + #' @param s RDF data as a string. + #' @param format One of "ntriples", "turtle", "xml" (rdf/xml). + #' @param graph Optional named graph IRI to read into (default graph if NULL). + reads = function(s, format, graph = NULL) { + private$rmodel$reads(s, format, graph) + invisible(self) + }, + + #' @description Serialize this Model's triples to a string. + #' @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". + #' @param graph Optional named graph IRI to write (default graph if NULL). + writes = function(format = NULL, graph = NULL) { + private$rmodel$writes(format, graph) + }, + + #' @description Build the default (non-FTS) indexes. + create_index = function() { + private$rmodel$create_index() + invisible(self) + }, + + #' @description Remove all triples from a graph. + #' @param graph Optional named graph IRI to truncate (default graph if NULL). + truncate_graph = function(graph = NULL) { + private$rmodel$truncate_graph(graph) + invisible(self) + }, + + #' @description Add prefix -> IRI mappings, used when serializing. + #' @param prefixes A named R list of single strings: names are prefixes, + #' values are IRIs, e.g. `list(ex = "http://example.org/")`. + add_prefixes = function(prefixes) { + private$rmodel$add_prefixes(prefixes) + invisible(self) + }, + + #' @description Copy a graph from another Model's triplestore into this one. + #' @param other Another Model to copy a graph from. + #' @param source_graph Optional named graph IRI in `other` (default graph if NULL). + #' @param target_graph Optional named graph IRI in this Model to copy into (default graph if NULL). + add_graph = function(other, source_graph = NULL, target_graph = NULL) { + if (!inherits(other, "Model")) { + stop("`other` must be a Model object", call. = FALSE) + } + private$rmodel$add_graph(private$as_rmodel(other), source_graph, target_graph) + invisible(self) + }, + + #' @description Split a graph out of this Model into a brand new, standalone Model. + #' @param preserve_name Keep the graph's own name in the new Model rather + #' than moving it to the default graph there. + #' @param graph Optional named graph IRI to detach (default graph if NULL). + #' @return A new Model containing only the detached graph. + detach_graph = function(preserve_name = FALSE, graph = NULL) { + Model$new(private$rmodel$detach_graph(preserve_name, graph)) + }, + + #' @description Run RDFS inference over a graph in place. + #' @param graph Optional named graph IRI to infer over (default graph if NULL). + #' @return The number of interesting inference rules applied. + infer_rdfs = function(graph = NULL) { + private$rmodel$infer_rdfs(graph) + }, + + #' @description Compact on-disk storage. + compact = function() { + private$rmodel$compact() + invisible(self) + }, + + #' @description Serialize this Model's triples to a directory in + #' maplib's own compact on-disk format (not an RDF interchange format -- + #' use `$writes()` for that). + #' @param path Directory to serialize into. + serialize = function(path) { + private$rmodel$serialize(path) + invisible(self) + }, + + #' @description Print a short summary of this Model. + #' @param ... Unused. + print = function(...) { + cat("", private$rmodel$size(), "triples (default graph)\n") + invisible(self) + } + ), + private = list( + rmodel = NULL, + as_rmodel = function(model) { + model$.__enclos_env__$private$rmodel + } + ) +) + +# Load a Model previously written by `$serialize()`. Attached directly to +# the (already @export'd) Model generator, so users call it as +# `Model$deserialize(path)` -- mirrors RModel's own associated-function +# pattern in 000-wrappers.R. +Model$deserialize <- function(path, storage_folder = NULL) { + Model$new(RModel$deserialize(path, storage_folder)) +} diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd new file mode 100644 index 00000000..3a2387e4 --- /dev/null +++ b/maplibr/man/Model.Rd @@ -0,0 +1,294 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model.R +\name{Model} +\alias{Model} +\title{A maplib knowledge graph model} +\description{ +\code{Model} is the user-facing wrapper around the underlying \code{RModel} savvy +object. It mirrors py_maplib's \code{Model} class: methods mutate the +underlying graph in place and are chainable (each mutating method +returns \code{self} invisibly). +} +\section{Methods}{ +\subsection{Public methods}{ + \itemize{ + \item \href{#method-Model-initialize}{\code{Model$new()}} + \item \href{#method-Model-size}{\code{Model$size()}} + \item \href{#method-Model-reads}{\code{Model$reads()}} + \item \href{#method-Model-writes}{\code{Model$writes()}} + \item \href{#method-Model-create_index}{\code{Model$create_index()}} + \item \href{#method-Model-truncate_graph}{\code{Model$truncate_graph()}} + \item \href{#method-Model-add_prefixes}{\code{Model$add_prefixes()}} + \item \href{#method-Model-add_graph}{\code{Model$add_graph()}} + \item \href{#method-Model-detach_graph}{\code{Model$detach_graph()}} + \item \href{#method-Model-infer_rdfs}{\code{Model$infer_rdfs()}} + \item \href{#method-Model-compact}{\code{Model$compact()}} + \item \href{#method-Model-serialize}{\code{Model$serialize()}} + \item \href{#method-Model-print}{\code{Model$print()}} + \item \href{#method-Model-clone}{\code{Model$clone()}} + } +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-initialize}{}}} +\subsection{\code{Model$new()}}{ + Create a new, empty Model. Not used to wrap an existing \code{RModel} +(see \code{Model$deserialize()} and \verb{$detach_graph()} for that) -- \code{rmodel} +is for internal use only. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$new(rmodel = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{rmodel}}{Internal use only.} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-size}{}}} +\subsection{\code{Model$size()}}{ + Number of triples in the default graph. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$size()} + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-reads}{}}} +\subsection{\code{Model$reads()}}{ + Parse RDF triples from a string into this Model. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$reads(s, format, graph = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{s}}{RDF data as a string.} + \item{\code{format}}{One of "ntriples", "turtle", "xml" (rdf/xml).} + \item{\code{graph}}{Optional named graph IRI to read into (default graph if NULL).} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-writes}{}}} +\subsection{\code{Model$writes()}}{ + Serialize this Model's triples to a string. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$writes(format = NULL, graph = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{format}}{One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples".} + \item{\code{graph}}{Optional named graph IRI to write (default graph if NULL).} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-create_index}{}}} +\subsection{\code{Model$create_index()}}{ + Build the default (non-FTS) indexes. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$create_index()} + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-truncate_graph}{}}} +\subsection{\code{Model$truncate_graph()}}{ + Remove all triples from a graph. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$truncate_graph(graph = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{graph}}{Optional named graph IRI to truncate (default graph if NULL).} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-add_prefixes}{}}} +\subsection{\code{Model$add_prefixes()}}{ + Add prefix -> IRI mappings, used when serializing. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$add_prefixes(prefixes)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{prefixes}}{A named R list of single strings: names are prefixes, +values are IRIs, e.g. \code{list(ex = "http://example.org/")}.} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-add_graph}{}}} +\subsection{\code{Model$add_graph()}}{ + Copy a graph from another Model's triplestore into this one. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$add_graph(other, source_graph = NULL, target_graph = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{other}}{Another Model to copy a graph from.} + \item{\code{source_graph}}{Optional named graph IRI in \code{other} (default graph if NULL).} + \item{\code{target_graph}}{Optional named graph IRI in this Model to copy into (default graph if NULL).} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-detach_graph}{}}} +\subsection{\code{Model$detach_graph()}}{ + Split a graph out of this Model into a brand new, standalone Model. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$detach_graph(preserve_name = FALSE, graph = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{preserve_name}}{Keep the graph's own name in the new Model rather +than moving it to the default graph there.} + \item{\code{graph}}{Optional named graph IRI to detach (default graph if NULL).} + } + \if{html}{\out{
}} + } + \subsection{Returns}{ + A new Model containing only the detached graph. + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-infer_rdfs}{}}} +\subsection{\code{Model$infer_rdfs()}}{ + Run RDFS inference over a graph in place. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$infer_rdfs(graph = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{graph}}{Optional named graph IRI to infer over (default graph if NULL).} + } + \if{html}{\out{
}} + } + \subsection{Returns}{ + The number of interesting inference rules applied. + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-compact}{}}} +\subsection{\code{Model$compact()}}{ + Compact on-disk storage. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$compact()} + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-serialize}{}}} +\subsection{\code{Model$serialize()}}{ + Serialize this Model's triples to a directory in +maplib's own compact on-disk format (not an RDF interchange format -- +use \verb{$writes()} for that). + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$serialize(path)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{path}}{Directory to serialize into.} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-print}{}}} +\subsection{\code{Model$print()}}{ + Print a short summary of this Model. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$print(...)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{...}}{Unused.} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-clone}{}}} +\subsection{\code{Model$clone()}}{ + The objects of this class are cloneable with this method. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$clone(deep = FALSE)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{deep}}{Whether to make a deep clone.} + } + \if{html}{\out{
}} + } +} + +} From 033accda0032e80576f79885f3f3c5217ff3d34f Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 09:42:09 +0200 Subject: [PATCH 08/46] feat(maplibr): prototype DataFrame<->R Arrow bridge - DataFrame -> Struct-typed Series -> existing Arrow C Stream plumbing, reused from the Series-level prototype - rdf_node_types travels as a JSON side-channel keyed by column name - verified end-to-end from R against a real EagerSolutionMappings fixture Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 3 + maplibr/NAMESPACE | 2 + maplibr/R/000-wrappers.R | 29 +++ maplibr/man/export_test_solution_mappings.Rd | 25 +++ maplibr/man/import_test_solution_mappings.Rd | 30 +++ maplibr/src/init.c | 12 ++ maplibr/src/rust/Cargo.toml | 3 + maplibr/src/rust/api.h | 2 + maplibr/src/rust/src/arrow_bridge.rs | 215 +++++++++++++++++++ 9 files changed, 321 insertions(+) create mode 100644 maplibr/man/export_test_solution_mappings.Rd create mode 100644 maplibr/man/import_test_solution_mappings.Rd diff --git a/Cargo.lock b/Cargo.lock index 157ef1d8..bdb78b76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2325,6 +2325,9 @@ dependencies = [ "pyo3", "representation", "savvy", + "serde", + "serde_json", + "sparesults", "triplestore", ] diff --git a/maplibr/NAMESPACE b/maplibr/NAMESPACE index 7475a980..30a3a2b1 100644 --- a/maplibr/NAMESPACE +++ b/maplibr/NAMESPACE @@ -6,5 +6,7 @@ S3method(print,"maplibr::RModel__bundle") export(Model) export(RModel) export(export_test_series) +export(export_test_solution_mappings) export(import_test_series) +export(import_test_solution_mappings) useDynLib(maplibr, .registration = TRUE) diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index 69a6bc04..d3dec6e6 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -46,6 +46,19 @@ NULL invisible(.Call(savvy_export_test_series__impl, `stream_ptr`)) } +#' Build the `build_test_solution_mappings()` fixture, collapse its +#' DataFrame to a Struct-typed Series (`DataFrame::into_struct`), and stream +#' it out to R through the Arrow C Stream Interface exactly like +#' `export_test_series` does -- proving the DataFrame case reuses that +#' mechanism unchanged. +#' +#' @param stream_ptr An external pointer from `nanoarrow::nanoarrow_allocate_array_stream()`. +#' @returns The `rdf_node_types` side-channel, as a JSON string. +#' @export +`export_test_solution_mappings` <- function(`stream_ptr`) { + .Call(savvy_export_test_solution_mappings__impl, `stream_ptr`) +} + #' Read an Arrow C stream from R back into a Rust Series, then hand back #' its length and first value as a sanity check (i32 only, for this prototype). #' @@ -56,6 +69,22 @@ NULL .Call(savvy_import_test_series__impl, `stream_ptr`) } +#' Read an Arrow C stream from R back into a Struct-typed Series, unnest it +#' back into a DataFrame (`StructChunked::unnest`, the reverse of +#' `into_struct`), and return a small human-readable summary combining the +#' recovered DataFrame's shape with the `rdf_node_types` JSON side-channel +#' handed back in -- a sanity check that both halves survive the round trip +#' together, matching `import_test_series`'s role for the Series-only case. +#' +#' @param stream_ptr An external pointer holding a filled ArrowArrayStream. +#' @param rdf_node_types_json The JSON string returned by +#' `export_test_solution_mappings`. +#' @returns A character vector: one line per column, `": []"`. +#' @export +`import_test_solution_mappings` <- function(`stream_ptr`, `rdf_node_types_json`) { + .Call(savvy_import_test_solution_mappings__impl, `stream_ptr`, `rdf_node_types_json`) +} + ### wrapper functions for RModel `RModel_add_graph` <- function(self) { diff --git a/maplibr/man/export_test_solution_mappings.Rd b/maplibr/man/export_test_solution_mappings.Rd new file mode 100644 index 00000000..8e1a0202 --- /dev/null +++ b/maplibr/man/export_test_solution_mappings.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{export_test_solution_mappings} +\alias{export_test_solution_mappings} +\title{Build the \code{build_test_solution_mappings()} fixture, collapse its +DataFrame to a Struct-typed Series (\code{DataFrame::into_struct}), and stream +it out to R through the Arrow C Stream Interface exactly like +\code{export_test_series} does -- proving the DataFrame case reuses that +mechanism unchanged.} +\usage{ +export_test_solution_mappings(stream_ptr) +} +\arguments{ +\item{stream_ptr}{An external pointer from \code{nanoarrow::nanoarrow_allocate_array_stream()}.} +} +\value{ +The \code{rdf_node_types} side-channel, as a JSON string. +} +\description{ +Build the \code{build_test_solution_mappings()} fixture, collapse its +DataFrame to a Struct-typed Series (\code{DataFrame::into_struct}), and stream +it out to R through the Arrow C Stream Interface exactly like +\code{export_test_series} does -- proving the DataFrame case reuses that +mechanism unchanged. +} diff --git a/maplibr/man/import_test_solution_mappings.Rd b/maplibr/man/import_test_solution_mappings.Rd new file mode 100644 index 00000000..bee72b63 --- /dev/null +++ b/maplibr/man/import_test_solution_mappings.Rd @@ -0,0 +1,30 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{import_test_solution_mappings} +\alias{import_test_solution_mappings} +\title{Read an Arrow C stream from R back into a Struct-typed Series, unnest it +back into a DataFrame (\code{StructChunked::unnest}, the reverse of +\code{into_struct}), and return a small human-readable summary combining the +recovered DataFrame's shape with the \code{rdf_node_types} JSON side-channel +handed back in -- a sanity check that both halves survive the round trip +together, matching \code{import_test_series}'s role for the Series-only case.} +\usage{ +import_test_solution_mappings(stream_ptr, rdf_node_types_json) +} +\arguments{ +\item{stream_ptr}{An external pointer holding a filled ArrowArrayStream.} + +\item{rdf_node_types_json}{The JSON string returned by +\code{export_test_solution_mappings}.} +} +\value{ +A character vector: one line per column, \code{": []"}. +} +\description{ +Read an Arrow C stream from R back into a Struct-typed Series, unnest it +back into a DataFrame (\code{StructChunked::unnest}, the reverse of +\code{into_struct}), and return a small human-readable summary combining the +recovered DataFrame's shape with the \code{rdf_node_types} JSON side-channel +handed back in -- a sanity check that both halves survive the round trip +together, matching \code{import_test_series}'s role for the Series-only case. +} diff --git a/maplibr/src/init.c b/maplibr/src/init.c index bb4c0033..23639066 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -44,11 +44,21 @@ SEXP savvy_export_test_series__impl(SEXP c_arg__stream_ptr) { return handle_result(res); } +SEXP savvy_export_test_solution_mappings__impl(SEXP c_arg__stream_ptr) { + SEXP res = savvy_export_test_solution_mappings__ffi(c_arg__stream_ptr); + return handle_result(res); +} + SEXP savvy_import_test_series__impl(SEXP c_arg__stream_ptr) { SEXP res = savvy_import_test_series__ffi(c_arg__stream_ptr); return handle_result(res); } +SEXP savvy_import_test_solution_mappings__impl(SEXP c_arg__stream_ptr, SEXP c_arg__rdf_node_types_json) { + SEXP res = savvy_import_test_solution_mappings__ffi(c_arg__stream_ptr, c_arg__rdf_node_types_json); + return handle_result(res); +} + SEXP savvy_RModel_add_graph__impl(SEXP self__, SEXP c_arg__other, SEXP c_arg__source_graph, SEXP c_arg__target_graph) { SEXP res = savvy_RModel_add_graph__ffi(self__, c_arg__other, c_arg__source_graph, c_arg__target_graph); return handle_result(res); @@ -117,7 +127,9 @@ SEXP savvy_RModel_writes__impl(SEXP self__, SEXP c_arg__format, SEXP c_arg__grap static const R_CallMethodDef CallEntries[] = { {"savvy_export_test_series__impl", (DL_FUNC) &savvy_export_test_series__impl, 1}, + {"savvy_export_test_solution_mappings__impl", (DL_FUNC) &savvy_export_test_solution_mappings__impl, 1}, {"savvy_import_test_series__impl", (DL_FUNC) &savvy_import_test_series__impl, 1}, + {"savvy_import_test_solution_mappings__impl", (DL_FUNC) &savvy_import_test_solution_mappings__impl, 2}, {"savvy_RModel_add_graph__impl", (DL_FUNC) &savvy_RModel_add_graph__impl, 4}, {"savvy_RModel_add_prefixes__impl", (DL_FUNC) &savvy_RModel_add_prefixes__impl, 2}, {"savvy_RModel_compact__impl", (DL_FUNC) &savvy_RModel_compact__impl, 1}, diff --git a/maplibr/src/rust/Cargo.toml b/maplibr/src/rust/Cargo.toml index 85218605..08168822 100644 --- a/maplibr/src/rust/Cargo.toml +++ b/maplibr/src/rust/Cargo.toml @@ -12,10 +12,13 @@ representation = { path = "../../../lib/representation" } triplestore = { path = "../../../lib/triplestore" } oxrdf = { workspace = true } oxrdfio = { workspace = true } +sparesults = { workspace = true } pyo3 = { workspace = true } polars = { version = "0.54.4", default-features = false } polars-arrow = "0.54.4" savvy = "0.8" +serde = { workspace = true } +serde_json = { workspace = true } [profile.release] # By default, on release build, savvy terminates the R session when a panic diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index 1972c253..0c78a847 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -1,5 +1,7 @@ SEXP savvy_export_test_series__ffi(SEXP c_arg__stream_ptr); +SEXP savvy_export_test_solution_mappings__ffi(SEXP c_arg__stream_ptr); SEXP savvy_import_test_series__ffi(SEXP c_arg__stream_ptr); +SEXP savvy_import_test_solution_mappings__ffi(SEXP c_arg__stream_ptr, SEXP c_arg__rdf_node_types_json); // methods and associated functions for RModel SEXP savvy_RModel_add_graph__ffi(SEXP self__, SEXP c_arg__other, SEXP c_arg__source_graph, SEXP c_arg__target_graph); diff --git a/maplibr/src/rust/src/arrow_bridge.rs b/maplibr/src/rust/src/arrow_bridge.rs index 18e4a8f0..c592274a 100644 --- a/maplibr/src/rust/src/arrow_bridge.rs +++ b/maplibr/src/rust/src/arrow_bridge.rs @@ -1,6 +1,12 @@ +use oxrdf::{BlankNode, Literal as OxLiteral, NamedNode, Term, Variable}; use polars::prelude::*; use polars_arrow::ffi::{export_iterator, ArrowArrayStream, ArrowArrayStreamReader}; +use representation::solution_mapping::EagerSolutionMappings; +use representation::{BaseRDFNodeType, RDFNodeState}; use savvy::{savvy, ExternalPointerSexp, Sexp}; +use serde::Serialize; +use sparesults::QuerySolution; +use std::collections::HashMap; /// Build a small hardcoded i32 Series in Rust and stream it out to R /// through the Arrow C Stream Interface. @@ -57,3 +63,212 @@ fn import_test_series(stream_ptr: Sexp) -> savvy::Result { .unwrap_or(0); Ok(out.into()) } + +// --- DataFrame<->R bridge (maplib-fat) --- +// +// Extends the Series-level mechanism above (export_test_series/ +// import_test_series, maplib-qi9) to a full `EagerSolutionMappings +// { mappings: DataFrame, rdf_node_types: HashMap }` +// (lib/representation/src/solution_mapping.rs:43), the shape RModel's future +// query/insert/update methods will need to move across the R/Rust boundary. +// +// Two independent problems, per docs/r-wrapper-plan.md Phase 2: +// +// 1. DataFrame -> single Series -> one Arrow C stream. Mirrors r-polars' +// `as_polars_series(df)` trick: `DataFrame::into_struct(name)` collapses +// every column into one Struct-typed Series, then the already-proven +// Series-level export_iterator/ArrowArrayStreamReader mechanism carries it +// across unchanged (`unnest()` reverses the collapse on the way back). No +// new FFI mechanism needed -- same stream, one more level of Struct +// nesting. This also has to survive genuinely nested Struct-in-Struct data, +// since a "multi" RDFNodeState column (RDFNodeState::polars_data_type, +// lib/representation/src/rdf_state.rs:46-67) is *already* Struct-typed +// before the whole-DataFrame collapse adds another layer -- exercised +// below via the "o" test column. +// +// 2. rdf_node_types has no r-polars equivalent (needed to reconstruct +// IRI/literal/blank-node distinctions from what the DataFrame otherwise +// carries as plain string/list/struct columns). Design chosen here: a +// single JSON string, one flat side-channel value returned alongside the +// Arrow stream, rather than a second stream or an extra struct field +// smuggled into the DataFrame itself -- keeps the Arrow payload exactly +// what Model methods already produce, no need for a schema-aware "does +// this column carry an extra sidecar" convention on the Arrow side. Only +// the semantic type per column travels (`RColumnType`, one entry per +// BaseRDFNodeType a column can hold) -- BaseCatState (RDFNodeState's other +// HashMap value, an internal categorical-encoding cache) does NOT travel: +// it's a Rust-side storage optimization, not something R needs, and the +// physical Arrow encoding (dictionary-typed array vs plain) already +// reflects it structurally. + +/// One RDF node type a solution-mappings column can hold. Mirrors +/// `BaseRDFNodeType` (lib/representation/src/base_rdf_type.rs) but is its own +/// type rather than reusing that enum directly, so the JSON side-channel's +/// shape doesn't change every time `representation`'s internals do. +#[derive(Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum RColumnType { + Iri, + BlankNode, + Literal { datatype: String }, + None, +} + +impl From<&BaseRDFNodeType> for RColumnType { + fn from(t: &BaseRDFNodeType) -> Self { + match t { + BaseRDFNodeType::IRI => RColumnType::Iri, + BaseRDFNodeType::BlankNode => RColumnType::BlankNode, + BaseRDFNodeType::Literal(dt) => RColumnType::Literal { + datatype: dt.as_str().to_string(), + }, + BaseRDFNodeType::None => RColumnType::None, + } + } +} + +/// Serialize `rdf_node_types` (per-column possible RDF node types) to the +/// JSON side-channel string. One entry per column name; each column maps to +/// a list of 1 (single-typed) or more (a "multi" column, RDFNodeState::is_multi) +/// `RColumnType`s. +fn rdf_node_types_to_json(rdf_node_types: &HashMap) -> savvy::Result { + let summary: HashMap<&str, Vec> = rdf_node_types + .iter() + .map(|(col, state)| { + let types = state.get_sorted_types().into_iter().map(RColumnType::from).collect(); + (col.as_str(), types) + }) + .collect(); + serde_json::to_string(&summary).map_err(|e| savvy::Error::new(&e.to_string())) +} + +/// A small, genuinely mixed-type `EagerSolutionMappings` fixture, built the +/// same way real query results are (`EagerSolutionMappings::from_query_solutions`, +/// lib/representation/src/solution_mapping.rs:56-62) rather than hand-rolled, +/// so the DataFrame's column layout matches what a real `Model::query()` will +/// eventually produce. "s" is single-typed (IRI only); "o" is deliberately +/// multi-typed (IRI, then a plain literal, then a blank node across its three +/// rows) to force RDFNodeState::is_multi -- i.e. a Struct-typed column even +/// before the whole-DataFrame collapse. +fn build_test_solution_mappings() -> EagerSolutionMappings { + let vars: std::sync::Arc<[Variable]> = + vec![Variable::new("s").unwrap(), Variable::new("o").unwrap()].into(); + + let sols = vec![ + QuerySolution::from(( + vars.clone(), + vec![ + Some(Term::NamedNode(NamedNode::new("http://example.org/a").unwrap())), + Some(Term::NamedNode(NamedNode::new("http://example.org/b").unwrap())), + ], + )), + QuerySolution::from(( + vars.clone(), + vec![ + Some(Term::NamedNode(NamedNode::new("http://example.org/c").unwrap())), + Some(Term::Literal(OxLiteral::new_simple_literal("hello"))), + ], + )), + QuerySolution::from(( + vars, + vec![ + Some(Term::NamedNode(NamedNode::new("http://example.org/d").unwrap())), + Some(Term::BlankNode(BlankNode::new("x1").unwrap())), + ], + )), + ]; + + EagerSolutionMappings::from_query_solutions(&sols).unwrap() +} + +/// Build the `build_test_solution_mappings()` fixture, collapse its +/// DataFrame to a Struct-typed Series (`DataFrame::into_struct`), and stream +/// it out to R through the Arrow C Stream Interface exactly like +/// `export_test_series` does -- proving the DataFrame case reuses that +/// mechanism unchanged. +/// +/// @param stream_ptr An external pointer from `nanoarrow::nanoarrow_allocate_array_stream()`. +/// @returns The `rdf_node_types` side-channel, as a JSON string. +/// @export +#[savvy] +fn export_test_solution_mappings(stream_ptr: Sexp) -> savvy::Result { + let EagerSolutionMappings { + mappings, + rdf_node_types, + } = build_test_solution_mappings(); + + let struct_series = mappings.into_struct("solution_mappings".into()).into_series(); + let field = struct_series.field().to_arrow(CompatLevel::newest()); + let chunks = struct_series.chunks().clone(); + let iter = Box::new(chunks.into_iter().map(Ok)); + let stream = export_iterator(iter, field); + + let stream_ptr = unsafe { + ExternalPointerSexp::try_from(stream_ptr)?.cast_mut_unchecked::() + }; + unsafe { std::ptr::replace(stream_ptr, stream) }; + + rdf_node_types_to_json(&rdf_node_types)?.try_into() +} + +/// Read an Arrow C stream from R back into a Struct-typed Series, unnest it +/// back into a DataFrame (`StructChunked::unnest`, the reverse of +/// `into_struct`), and return a small human-readable summary combining the +/// recovered DataFrame's shape with the `rdf_node_types` JSON side-channel +/// handed back in -- a sanity check that both halves survive the round trip +/// together, matching `import_test_series`'s role for the Series-only case. +/// +/// @param stream_ptr An external pointer holding a filled ArrowArrayStream. +/// @param rdf_node_types_json The JSON string returned by +/// `export_test_solution_mappings`. +/// @returns A character vector: one line per column, `": []"`. +/// @export +#[savvy] +fn import_test_solution_mappings( + stream_ptr: Sexp, + rdf_node_types_json: &str, +) -> savvy::Result { + let mut stream = unsafe { + let boxed = Box::new(std::ptr::replace( + ExternalPointerSexp::try_from(stream_ptr)?.cast_mut_unchecked::(), + ArrowArrayStream::empty(), + )); + ArrowArrayStreamReader::try_new(boxed).map_err(|e| savvy::Error::new(&e.to_string()))? + }; + + let mut arrays = Vec::new(); + while let Some(arr) = unsafe { stream.next() } { + arrays.push(arr.map_err(|e| savvy::Error::new(&e.to_string()))?); + } + + let struct_series = Series::from_arrow_chunks("solution_mappings".into(), arrays) + .map_err(|e| savvy::Error::new(&e.to_string()))?; + let df = struct_series + .struct_() + .map_err(|e| savvy::Error::new(&e.to_string()))? + .clone() + .unnest(); + + let rdf_node_types: HashMap = + serde_json::from_str(rdf_node_types_json).map_err(|e| savvy::Error::new(&e.to_string()))?; + + let mut lines: Vec = df + .get_column_names() + .into_iter() + .zip(df.dtypes()) + .map(|(name, dtype)| { + let types = rdf_node_types + .get(name.as_str()) + .map(|v| v.to_string()) + .unwrap_or_else(|| "?".to_string()); + format!("{}: {} {}", name, dtype, types) + }) + .collect(); + lines.push(format!("{} rows", df.height())); + + let mut out = savvy::OwnedStringSexp::new(lines.len())?; + for (i, line) in lines.iter().enumerate() { + out.set_elt(i, line)?; + } + Ok(out.into()) +} From 6db30baaad84e83d944127a3c59054a2a3098037 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 10:02:07 +0200 Subject: [PATCH 09/46] feat(maplibr): add S7 RDF term value classes - IRI, Prefix, Variable, BlankNode, Literal as immutable S7 value objects (Model stays R6, it mutates in place) - IRI/variable/blank-node syntax validated via oxrdf parsers, not R regex - remaining OTTR surface (RDFType, Parameter, Argument, Instance, Template, vocab) left for follow-up Co-Authored-By: Claude Sonnet 5 --- maplibr/DESCRIPTION | 3 +- maplibr/NAMESPACE | 9 ++ maplibr/R/000-wrappers.R | 30 ++++ maplibr/R/terms.R | 217 ++++++++++++++++++++++++++ maplibr/R/zzz.R | 3 + maplibr/man/BlankNode.Rd | 14 ++ maplibr/man/IRI.Rd | 14 ++ maplibr/man/Literal.Rd | 22 +++ maplibr/man/Prefix.Rd | 16 ++ maplibr/man/Variable.Rd | 14 ++ maplibr/man/suf.Rd | 19 +++ maplibr/man/validate_blank_node_id.Rd | 18 +++ maplibr/man/validate_iri.Rd | 18 +++ maplibr/man/validate_variable_name.Rd | 18 +++ maplibr/src/init.c | 18 +++ maplibr/src/rust/api.h | 3 + maplibr/src/rust/src/lib.rs | 1 + maplibr/src/rust/src/terms.rs | 38 +++++ 18 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 maplibr/R/terms.R create mode 100644 maplibr/R/zzz.R create mode 100644 maplibr/man/BlankNode.Rd create mode 100644 maplibr/man/IRI.Rd create mode 100644 maplibr/man/Literal.Rd create mode 100644 maplibr/man/Prefix.Rd create mode 100644 maplibr/man/Variable.Rd create mode 100644 maplibr/man/suf.Rd create mode 100644 maplibr/man/validate_blank_node_id.Rd create mode 100644 maplibr/man/validate_iri.Rd create mode 100644 maplibr/man/validate_variable_name.Rd create mode 100644 maplibr/src/rust/src/terms.rs diff --git a/maplibr/DESCRIPTION b/maplibr/DESCRIPTION index 5d8e8668..180e8a19 100644 --- a/maplibr/DESCRIPTION +++ b/maplibr/DESCRIPTION @@ -8,7 +8,8 @@ License: `use_mit_license()`, `use_gpl3_license()` or friends to pick a license Encoding: UTF-8 Imports: - R6 + R6, + S7 Roxygen: list(markdown = TRUE) RoxygenNote: 8.0.0 SystemRequirements: Cargo (Rust's package manager), rustc diff --git a/maplibr/NAMESPACE b/maplibr/NAMESPACE index 30a3a2b1..a809cf0a 100644 --- a/maplibr/NAMESPACE +++ b/maplibr/NAMESPACE @@ -3,10 +3,19 @@ S3method("$<-",savvy_maplibr__sealed) S3method("[[<-",savvy_maplibr__sealed) S3method(print,"maplibr::RModel__bundle") +export(BlankNode) +export(IRI) +export(Literal) export(Model) +export(Prefix) export(RModel) +export(Variable) export(export_test_series) export(export_test_solution_mappings) export(import_test_series) export(import_test_solution_mappings) +export(suf) +export(validate_blank_node_id) +export(validate_iri) +export(validate_variable_name) useDynLib(maplibr, .registration = TRUE) diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index d3dec6e6..5db5522b 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -85,6 +85,36 @@ NULL .Call(savvy_import_test_solution_mappings__impl, `stream_ptr`, `rdf_node_types_json`) } +#' Validate a blank node identifier's syntax (mirrors oxrdf::BlankNode::new, +#' used by py_maplib's PyBlankNode::new, lib/representation/src/python.rs:531-535). +#' Raises an R error on invalid syntax; returns nothing on success. +#' +#' @param id Blank node identifier, without the leading `_:`. +#' @export +`validate_blank_node_id` <- function(`id`) { + invisible(.Call(savvy_validate_blank_node_id__impl, `id`)) +} + +#' Validate an IRI's syntax (mirrors oxrdf::NamedNode::new, the same check +#' py_maplib's PyIRI::new uses, lib/representation/src/python.rs:279-282). +#' Raises an R error on invalid syntax; returns nothing on success. +#' +#' @param iri IRI string to validate. +#' @export +`validate_iri` <- function(`iri`) { + invisible(.Call(savvy_validate_iri__impl, `iri`)) +} + +#' Validate a SPARQL variable name's syntax (mirrors oxrdf::Variable::new, +#' used by py_maplib's PyVariable::new, lib/representation/src/python.rs:340-343). +#' Raises an R error on invalid syntax; returns nothing on success. +#' +#' @param name Variable name, without the leading `?`/`$`. +#' @export +`validate_variable_name` <- function(`name`) { + invisible(.Call(savvy_validate_variable_name__impl, `name`)) +} + ### wrapper functions for RModel `RModel_add_graph` <- function(self) { diff --git a/maplibr/R/terms.R b/maplibr/R/terms.R new file mode 100644 index 00000000..844cdebc --- /dev/null +++ b/maplibr/R/terms.R @@ -0,0 +1,217 @@ +# RDF term value classes (maplib-snz). S7, not R6: these are built once and +# passed into Model$map()/reads() etc., never mutated in place -- see +# CLAUDE.md's "R-side OOP split" note. Mirrors py_maplib's PyIRI/PyPrefix/ +# PyVariable/PyLiteral/PyBlankNode (lib/representation/src/python.rs). +# +# Syntax validation (IRI/variable-name/blank-node-id grammar) is delegated to +# the same oxrdf parsers py_maplib uses, via validate_iri()/ +# validate_variable_name()/validate_blank_node_id() (maplibr/src/rust/src/terms.rs) +# -- not reimplemented as R regexes, to avoid drifting from oxrdf's grammar. + +#' An RDF IRI (Internationalized Resource Identifier) +#' +#' @param iri The IRI string. +#' @export +IRI <- S7::new_class( + "IRI", + package = "maplibr", + properties = list(iri = S7::class_character), + validator = function(self) { + if (length(self@iri) != 1 || is.na(self@iri)) { + return("@iri must be a single, non-NA string") + } + tryCatch( + { + validate_iri(self@iri) + NULL + }, + error = function(e) conditionMessage(e) + ) + } +) + +#' @export +S7::method(format, IRI) <- function(x, ...) paste0("IRI(", x@iri, ")") + +#' @export +S7::method(print, IRI) <- function(x, ...) { + cat(format(x), "\n", sep = "") + invisible(x) +} + +#' An RDF namespace prefix: a short name bound to an IRI. +#' +#' @param iri The IRI the prefix expands to. +#' @param prefix_name Optional short name for the prefix (e.g. "ex"). +#' @export +Prefix <- S7::new_class( + "Prefix", + package = "maplibr", + properties = list( + iri = S7::class_character, + prefix_name = S7::new_union(S7::class_character, NULL) + ), + validator = function(self) { + if (length(self@iri) != 1 || is.na(self@iri)) { + return("@iri must be a single, non-NA string") + } + tryCatch( + { + validate_iri(self@iri) + NULL + }, + error = function(e) conditionMessage(e) + ) + } +) + +#' Suffix a Prefix's IRI, returning a new IRI. +#' +#' Mirrors py_maplib's `Prefix.suf()` (lib/representation/src/python.rs:326-328). +#' +#' @param prefix A Prefix. +#' @param suffix String to append to the prefix's IRI. +#' @return A new IRI. +#' @export +suf <- S7::new_generic("suf", "prefix") + +#' @export +S7::method(suf, Prefix) <- function(prefix, suffix) { + IRI(paste0(prefix@iri, suffix)) +} + +#' @export +S7::method(format, Prefix) <- function(x, ...) { + if (is.null(x@prefix_name)) { + paste0("Prefix(", x@iri, ")") + } else { + paste0("Prefix(", x@prefix_name, ": ", x@iri, ")") + } +} + +#' @export +S7::method(print, Prefix) <- function(x, ...) { + cat(format(x), "\n", sep = "") + invisible(x) +} + +#' A SPARQL/OTTR variable. +#' +#' @param name The variable's name, without the leading `?`/`$`. +#' @export +Variable <- S7::new_class( + "Variable", + package = "maplibr", + properties = list(name = S7::class_character), + validator = function(self) { + if (length(self@name) != 1 || is.na(self@name)) { + return("@name must be a single, non-NA string") + } + tryCatch( + { + validate_variable_name(self@name) + NULL + }, + error = function(e) conditionMessage(e) + ) + } +) + +#' @export +S7::method(format, Variable) <- function(x, ...) paste0("?", x@name) + +#' @export +S7::method(print, Variable) <- function(x, ...) { + cat(format(x), "\n", sep = "") + invisible(x) +} + +#' An RDF blank node. +#' +#' @param name The blank node's identifier, without the leading `_:`. +#' @export +BlankNode <- S7::new_class( + "BlankNode", + package = "maplibr", + properties = list(name = S7::class_character), + validator = function(self) { + if (length(self@name) != 1 || is.na(self@name)) { + return("@name must be a single, non-NA string") + } + tryCatch( + { + validate_blank_node_id(self@name) + NULL + }, + error = function(e) conditionMessage(e) + ) + } +) + +#' @export +S7::method(format, BlankNode) <- function(x, ...) paste0("_:", x@name) + +#' @export +S7::method(print, BlankNode) <- function(x, ...) { + cat(format(x), "\n", sep = "") + invisible(x) +} + +.XSD_STRING <- "http://www.w3.org/2001/XMLSchema#string" +.RDF_LANG_STRING <- "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString" + +#' An RDF literal: a value, with either a datatype IRI or a language tag. +#' +#' Mirrors py_maplib's `Literal` (lib/representation/src/python.rs:359-397). +#' Providing `language` takes priority over `datatype` (matching the Python +#' constructor's precedence); providing neither defaults to `xsd:string`. +#' +#' @param value The literal's lexical value, as a string. +#' @param datatype Optional datatype IRI (an IRI object). Ignored if `language` is given. +#' @param language Optional BCP 47 language tag (not syntax-checked, matching +#' oxrdf::Literal::new_language_tagged_literal_unchecked -- py_maplib does +#' the same). +#' @export +Literal <- S7::new_class( + "Literal", + package = "maplibr", + properties = list( + value = S7::class_character, + datatype = IRI, + language = S7::new_union(S7::class_character, NULL) + ), + validator = function(self) { + if (length(self@value) != 1 || is.na(self@value)) { + return("@value must be a single, non-NA string") + } + if (!is.null(self@language) && self@datatype@iri != .RDF_LANG_STRING) { + return("a language-tagged Literal must have datatype rdf:langString") + } + NULL + }, + constructor = function(value, datatype = NULL, language = NULL) { + if (!is.null(language)) { + datatype <- IRI(.RDF_LANG_STRING) + } else if (is.null(datatype)) { + datatype <- IRI(.XSD_STRING) + } + S7::new_object(S7::S7_object(), value = value, datatype = datatype, language = language) + } +) + +#' @export +S7::method(format, Literal) <- function(x, ...) { + if (!is.null(x@language)) { + paste0("\"", x@value, "\"@", x@language) + } else if (x@datatype@iri == .XSD_STRING) { + paste0("\"", x@value, "\"") + } else { + paste0("\"", x@value, "\"^^", format(x@datatype)) + } +} + +#' @export +S7::method(print, Literal) <- function(x, ...) { + cat(format(x), "\n", sep = "") + invisible(x) +} diff --git a/maplibr/R/zzz.R b/maplibr/R/zzz.R new file mode 100644 index 00000000..511514c4 --- /dev/null +++ b/maplibr/R/zzz.R @@ -0,0 +1,3 @@ +.onLoad <- function(libname, pkgname) { + S7::methods_register() +} diff --git a/maplibr/man/BlankNode.Rd b/maplibr/man/BlankNode.Rd new file mode 100644 index 00000000..f8726f84 --- /dev/null +++ b/maplibr/man/BlankNode.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/terms.R +\name{BlankNode} +\alias{BlankNode} +\title{An RDF blank node.} +\usage{ +BlankNode(name = character(0)) +} +\arguments{ +\item{name}{The blank node's identifier, without the leading \verb{_:}.} +} +\description{ +An RDF blank node. +} diff --git a/maplibr/man/IRI.Rd b/maplibr/man/IRI.Rd new file mode 100644 index 00000000..7deda5bd --- /dev/null +++ b/maplibr/man/IRI.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/terms.R +\name{IRI} +\alias{IRI} +\title{An RDF IRI (Internationalized Resource Identifier)} +\usage{ +IRI(iri = character(0)) +} +\arguments{ +\item{iri}{The IRI string.} +} +\description{ +An RDF IRI (Internationalized Resource Identifier) +} diff --git a/maplibr/man/Literal.Rd b/maplibr/man/Literal.Rd new file mode 100644 index 00000000..f457cafc --- /dev/null +++ b/maplibr/man/Literal.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/terms.R +\name{Literal} +\alias{Literal} +\title{An RDF literal: a value, with either a datatype IRI or a language tag.} +\usage{ +Literal(value, datatype = NULL, language = NULL) +} +\arguments{ +\item{value}{The literal's lexical value, as a string.} + +\item{datatype}{Optional datatype IRI (an IRI object). Ignored if \code{language} is given.} + +\item{language}{Optional BCP 47 language tag (not syntax-checked, matching +oxrdf::Literal::new_language_tagged_literal_unchecked -- py_maplib does +the same).} +} +\description{ +Mirrors py_maplib's \code{Literal} (lib/representation/src/python.rs:359-397). +Providing \code{language} takes priority over \code{datatype} (matching the Python +constructor's precedence); providing neither defaults to \code{xsd:string}. +} diff --git a/maplibr/man/Prefix.Rd b/maplibr/man/Prefix.Rd new file mode 100644 index 00000000..20197ba4 --- /dev/null +++ b/maplibr/man/Prefix.Rd @@ -0,0 +1,16 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/terms.R +\name{Prefix} +\alias{Prefix} +\title{An RDF namespace prefix: a short name bound to an IRI.} +\usage{ +Prefix(iri = character(0), prefix_name = character(0)) +} +\arguments{ +\item{iri}{The IRI the prefix expands to.} + +\item{prefix_name}{Optional short name for the prefix (e.g. "ex").} +} +\description{ +An RDF namespace prefix: a short name bound to an IRI. +} diff --git a/maplibr/man/Variable.Rd b/maplibr/man/Variable.Rd new file mode 100644 index 00000000..ba585cf2 --- /dev/null +++ b/maplibr/man/Variable.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/terms.R +\name{Variable} +\alias{Variable} +\title{A SPARQL/OTTR variable.} +\usage{ +Variable(name = character(0)) +} +\arguments{ +\item{name}{The variable's name, without the leading \verb{?}/\code{$}.} +} +\description{ +A SPARQL/OTTR variable. +} diff --git a/maplibr/man/suf.Rd b/maplibr/man/suf.Rd new file mode 100644 index 00000000..4858fda4 --- /dev/null +++ b/maplibr/man/suf.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/terms.R +\name{suf} +\alias{suf} +\title{Suffix a Prefix's IRI, returning a new IRI.} +\usage{ +suf(prefix, ...) +} +\arguments{ +\item{prefix}{A Prefix.} + +\item{suffix}{String to append to the prefix's IRI.} +} +\value{ +A new IRI. +} +\description{ +Mirrors py_maplib's \code{Prefix.suf()} (lib/representation/src/python.rs:326-328). +} diff --git a/maplibr/man/validate_blank_node_id.Rd b/maplibr/man/validate_blank_node_id.Rd new file mode 100644 index 00000000..f61ff031 --- /dev/null +++ b/maplibr/man/validate_blank_node_id.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{validate_blank_node_id} +\alias{validate_blank_node_id} +\title{Validate a blank node identifier's syntax (mirrors oxrdf::BlankNode::new, +used by py_maplib's PyBlankNode::new, lib/representation/src/python.rs:531-535). +Raises an R error on invalid syntax; returns nothing on success.} +\usage{ +validate_blank_node_id(id) +} +\arguments{ +\item{id}{Blank node identifier, without the leading \verb{_:}.} +} +\description{ +Validate a blank node identifier's syntax (mirrors oxrdf::BlankNode::new, +used by py_maplib's PyBlankNode::new, lib/representation/src/python.rs:531-535). +Raises an R error on invalid syntax; returns nothing on success. +} diff --git a/maplibr/man/validate_iri.Rd b/maplibr/man/validate_iri.Rd new file mode 100644 index 00000000..2cb00c49 --- /dev/null +++ b/maplibr/man/validate_iri.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{validate_iri} +\alias{validate_iri} +\title{Validate an IRI's syntax (mirrors oxrdf::NamedNode::new, the same check +py_maplib's PyIRI::new uses, lib/representation/src/python.rs:279-282). +Raises an R error on invalid syntax; returns nothing on success.} +\usage{ +validate_iri(iri) +} +\arguments{ +\item{iri}{IRI string to validate.} +} +\description{ +Validate an IRI's syntax (mirrors oxrdf::NamedNode::new, the same check +py_maplib's PyIRI::new uses, lib/representation/src/python.rs:279-282). +Raises an R error on invalid syntax; returns nothing on success. +} diff --git a/maplibr/man/validate_variable_name.Rd b/maplibr/man/validate_variable_name.Rd new file mode 100644 index 00000000..3cd1ea8a --- /dev/null +++ b/maplibr/man/validate_variable_name.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{validate_variable_name} +\alias{validate_variable_name} +\title{Validate a SPARQL variable name's syntax (mirrors oxrdf::Variable::new, +used by py_maplib's PyVariable::new, lib/representation/src/python.rs:340-343). +Raises an R error on invalid syntax; returns nothing on success.} +\usage{ +validate_variable_name(name) +} +\arguments{ +\item{name}{Variable name, without the leading \verb{?}/\code{$}.} +} +\description{ +Validate a SPARQL variable name's syntax (mirrors oxrdf::Variable::new, +used by py_maplib's PyVariable::new, lib/representation/src/python.rs:340-343). +Raises an R error on invalid syntax; returns nothing on success. +} diff --git a/maplibr/src/init.c b/maplibr/src/init.c index 23639066..b476eaee 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -59,6 +59,21 @@ SEXP savvy_import_test_solution_mappings__impl(SEXP c_arg__stream_ptr, SEXP c_ar return handle_result(res); } +SEXP savvy_validate_blank_node_id__impl(SEXP c_arg__id) { + SEXP res = savvy_validate_blank_node_id__ffi(c_arg__id); + return handle_result(res); +} + +SEXP savvy_validate_iri__impl(SEXP c_arg__iri) { + SEXP res = savvy_validate_iri__ffi(c_arg__iri); + return handle_result(res); +} + +SEXP savvy_validate_variable_name__impl(SEXP c_arg__name) { + SEXP res = savvy_validate_variable_name__ffi(c_arg__name); + return handle_result(res); +} + SEXP savvy_RModel_add_graph__impl(SEXP self__, SEXP c_arg__other, SEXP c_arg__source_graph, SEXP c_arg__target_graph) { SEXP res = savvy_RModel_add_graph__ffi(self__, c_arg__other, c_arg__source_graph, c_arg__target_graph); return handle_result(res); @@ -130,6 +145,9 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_export_test_solution_mappings__impl", (DL_FUNC) &savvy_export_test_solution_mappings__impl, 1}, {"savvy_import_test_series__impl", (DL_FUNC) &savvy_import_test_series__impl, 1}, {"savvy_import_test_solution_mappings__impl", (DL_FUNC) &savvy_import_test_solution_mappings__impl, 2}, + {"savvy_validate_blank_node_id__impl", (DL_FUNC) &savvy_validate_blank_node_id__impl, 1}, + {"savvy_validate_iri__impl", (DL_FUNC) &savvy_validate_iri__impl, 1}, + {"savvy_validate_variable_name__impl", (DL_FUNC) &savvy_validate_variable_name__impl, 1}, {"savvy_RModel_add_graph__impl", (DL_FUNC) &savvy_RModel_add_graph__impl, 4}, {"savvy_RModel_add_prefixes__impl", (DL_FUNC) &savvy_RModel_add_prefixes__impl, 2}, {"savvy_RModel_compact__impl", (DL_FUNC) &savvy_RModel_compact__impl, 1}, diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index 0c78a847..4bda2c55 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -2,6 +2,9 @@ SEXP savvy_export_test_series__ffi(SEXP c_arg__stream_ptr); SEXP savvy_export_test_solution_mappings__ffi(SEXP c_arg__stream_ptr); SEXP savvy_import_test_series__ffi(SEXP c_arg__stream_ptr); SEXP savvy_import_test_solution_mappings__ffi(SEXP c_arg__stream_ptr, SEXP c_arg__rdf_node_types_json); +SEXP savvy_validate_blank_node_id__ffi(SEXP c_arg__id); +SEXP savvy_validate_iri__ffi(SEXP c_arg__iri); +SEXP savvy_validate_variable_name__ffi(SEXP c_arg__name); // methods and associated functions for RModel SEXP savvy_RModel_add_graph__ffi(SEXP self__, SEXP c_arg__other, SEXP c_arg__source_graph, SEXP c_arg__target_graph); diff --git a/maplibr/src/rust/src/lib.rs b/maplibr/src/rust/src/lib.rs index 5996df93..92cc5072 100644 --- a/maplibr/src/rust/src/lib.rs +++ b/maplibr/src/rust/src/lib.rs @@ -1,4 +1,5 @@ mod arrow_bridge; mod model; +mod terms; pub use model::RModel; diff --git a/maplibr/src/rust/src/terms.rs b/maplibr/src/rust/src/terms.rs new file mode 100644 index 00000000..ec8580bf --- /dev/null +++ b/maplibr/src/rust/src/terms.rs @@ -0,0 +1,38 @@ +use oxrdf::{BlankNode, NamedNode, Variable}; +use savvy::savvy; + +/// Validate an IRI's syntax (mirrors oxrdf::NamedNode::new, the same check +/// py_maplib's PyIRI::new uses, lib/representation/src/python.rs:279-282). +/// Raises an R error on invalid syntax; returns nothing on success. +/// +/// @param iri IRI string to validate. +/// @export +#[savvy] +fn validate_iri(iri: &str) -> savvy::Result<()> { + NamedNode::new(iri).map_err(|e| savvy::Error::new(&e.to_string()))?; + Ok(()) +} + +/// Validate a SPARQL variable name's syntax (mirrors oxrdf::Variable::new, +/// used by py_maplib's PyVariable::new, lib/representation/src/python.rs:340-343). +/// Raises an R error on invalid syntax; returns nothing on success. +/// +/// @param name Variable name, without the leading `?`/`$`. +/// @export +#[savvy] +fn validate_variable_name(name: &str) -> savvy::Result<()> { + Variable::new(name).map_err(|e| savvy::Error::new(&e.to_string()))?; + Ok(()) +} + +/// Validate a blank node identifier's syntax (mirrors oxrdf::BlankNode::new, +/// used by py_maplib's PyBlankNode::new, lib/representation/src/python.rs:531-535). +/// Raises an R error on invalid syntax; returns nothing on success. +/// +/// @param id Blank node identifier, without the leading `_:`. +/// @export +#[savvy] +fn validate_blank_node_id(id: &str) -> savvy::Result<()> { + BlankNode::new(id).map_err(|e| savvy::Error::new(&e.to_string()))?; + Ok(()) +} From f5c3c89de84db4900d7a07161cff21553e5a64d3 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 10:14:51 +0200 Subject: [PATCH 10/46] feat(maplibr): add Template/Instance/Parameter/Argument OTTR classes - thin S7 wrappers over savvy objects, built once and never mutated - bare Variables/terms auto-wrap wherever a Parameter/Argument is expected - verified: build a multi-parameter template, print real OTTR syntax, instantiate it Co-Authored-By: Claude Sonnet 5 --- maplibr/DESCRIPTION | 6 + maplibr/NAMESPACE | 18 ++ maplibr/R/000-wrappers.R | 221 ++++++++++++++++++++++ maplibr/R/templates.R | 196 ++++++++++++++++++++ maplibr/man/Argument.Rd | 18 ++ maplibr/man/Instance.Rd | 24 +++ maplibr/man/Parameter.Rd | 31 ++++ maplibr/man/RArgument.Rd | 13 ++ maplibr/man/RConstantTerm.Rd | 13 ++ maplibr/man/RInstance.Rd | 13 ++ maplibr/man/RParameter.Rd | 19 ++ maplibr/man/RTemplate.Rd | 13 ++ maplibr/man/Template.Rd | 19 ++ maplibr/man/Triple.Rd | 23 +++ maplibr/man/instance_template_iri.Rd | 14 ++ maplibr/man/instantiate.Rd | 21 +++ maplibr/man/make_triple.Rd | 13 ++ maplibr/src/init.c | 84 +++++++++ maplibr/src/rust/Cargo.toml | 1 + maplibr/src/rust/api.h | 24 +++ maplibr/src/rust/src/lib.rs | 1 + maplibr/src/rust/src/templates.rs | 263 +++++++++++++++++++++++++++ 22 files changed, 1048 insertions(+) create mode 100644 maplibr/R/templates.R create mode 100644 maplibr/man/Argument.Rd create mode 100644 maplibr/man/Instance.Rd create mode 100644 maplibr/man/Parameter.Rd create mode 100644 maplibr/man/RArgument.Rd create mode 100644 maplibr/man/RConstantTerm.Rd create mode 100644 maplibr/man/RInstance.Rd create mode 100644 maplibr/man/RParameter.Rd create mode 100644 maplibr/man/RTemplate.Rd create mode 100644 maplibr/man/Template.Rd create mode 100644 maplibr/man/Triple.Rd create mode 100644 maplibr/man/instance_template_iri.Rd create mode 100644 maplibr/man/instantiate.Rd create mode 100644 maplibr/man/make_triple.Rd create mode 100644 maplibr/src/rust/src/templates.rs diff --git a/maplibr/DESCRIPTION b/maplibr/DESCRIPTION index 180e8a19..cb468fde 100644 --- a/maplibr/DESCRIPTION +++ b/maplibr/DESCRIPTION @@ -13,3 +13,9 @@ Imports: Roxygen: list(markdown = TRUE) RoxygenNote: 8.0.0 SystemRequirements: Cargo (Rust's package manager), rustc +Collate: + '000-wrappers.R' + 'terms.R' + 'templates.R' + 'model.R' + 'zzz.R' diff --git a/maplibr/NAMESPACE b/maplibr/NAMESPACE index a809cf0a..34bcc468 100644 --- a/maplibr/NAMESPACE +++ b/maplibr/NAMESPACE @@ -2,18 +2,36 @@ S3method("$<-",savvy_maplibr__sealed) S3method("[[<-",savvy_maplibr__sealed) +S3method(print,"maplibr::RArgument__bundle") +S3method(print,"maplibr::RConstantTerm__bundle") +S3method(print,"maplibr::RInstance__bundle") S3method(print,"maplibr::RModel__bundle") +S3method(print,"maplibr::RParameter__bundle") +S3method(print,"maplibr::RTemplate__bundle") +export(Argument) export(BlankNode) export(IRI) +export(Instance) export(Literal) export(Model) +export(Parameter) export(Prefix) +export(RArgument) +export(RConstantTerm) +export(RInstance) export(RModel) +export(RParameter) +export(RTemplate) +export(Template) +export(Triple) export(Variable) export(export_test_series) export(export_test_solution_mappings) export(import_test_series) export(import_test_solution_mappings) +export(instance_template_iri) +export(instantiate) +export(make_triple) export(suf) export(validate_blank_node_id) export(validate_iri) diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index 5db5522b..2a7cdf89 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -85,6 +85,17 @@ NULL .Call(savvy_import_test_solution_mappings__impl, `stream_ptr`, `rdf_node_types_json`) } +#' Build an rdf:type-style Triple instance (mirrors py_maplib's `Triple()` +#' helper, lib/templates/src/python.rs:404-417). +#' +#' @export +`make_triple` <- function(`subject`, `predicate`, `object`, `list_expander` = NULL) { + `subject` <- .savvy_extract_ptr(`subject`, "maplibr::RArgument") + `predicate` <- .savvy_extract_ptr(`predicate`, "maplibr::RArgument") + `object` <- .savvy_extract_ptr(`object`, "maplibr::RArgument") + .savvy_wrap_RInstance(.Call(savvy_make_triple__impl, `subject`, `predicate`, `object`, `list_expander`)) +} + #' Validate a blank node identifier's syntax (mirrors oxrdf::BlankNode::new, #' used by py_maplib's PyBlankNode::new, lib/representation/src/python.rs:531-535). #' Raises an R error on invalid syntax; returns nothing on success. @@ -115,6 +126,127 @@ NULL invisible(.Call(savvy_validate_variable_name__impl, `name`)) } +### wrapper functions for RArgument + + +`.savvy_wrap_RArgument` <- function(ptr) { + e <- new.env(parent = emptyenv()) + e$.ptr <- ptr + + + class(e) <- c("maplibr::RArgument", "RArgument", "savvy_maplibr__sealed") + e +} + + +#' An OTTR argument: a Variable, or a constant term (IRI/BlankNode/Literal/none). +#' Mirrors py_maplib's `Argument` (lib/templates/src/python.rs:203-236). +#' +#' @export +`RArgument` <- new.env(parent = emptyenv()) + +### associated functions for RArgument + +`RArgument`$`from_constant_term` <- function(`term`, `list_expand`) { + `term` <- .savvy_extract_ptr(`term`, "maplibr::RConstantTerm") + .savvy_wrap_RArgument(.Call(savvy_RArgument_from_constant_term__impl, `term`, `list_expand`)) +} + +`RArgument`$`from_variable` <- function(`name`, `list_expand`) { + .savvy_wrap_RArgument(.Call(savvy_RArgument_from_variable__impl, `name`, `list_expand`)) +} + + +class(`RArgument`) <- c("maplibr::RArgument__bundle", "savvy_maplibr__sealed") + +#' @export +`print.maplibr::RArgument__bundle` <- function(x, ...) { + cat('maplibr::RArgument\n') +} + +### wrapper functions for RConstantTerm + + +`.savvy_wrap_RConstantTerm` <- function(ptr) { + e <- new.env(parent = emptyenv()) + e$.ptr <- ptr + + + class(e) <- c("maplibr::RConstantTerm", "RConstantTerm", "savvy_maplibr__sealed") + e +} + + +#' An OTTR constant term: an IRI, blank node, literal, or explicit "none". +#' Mirrors py_maplib's `extract_constant_term` (lib/templates/src/python.rs:434-446). +#' +#' @export +`RConstantTerm` <- new.env(parent = emptyenv()) + +### associated functions for RConstantTerm + +`RConstantTerm`$`blank_node` <- function(`id`) { + .savvy_wrap_RConstantTerm(.Call(savvy_RConstantTerm_blank_node__impl, `id`)) +} + +`RConstantTerm`$`iri` <- function(`iri`) { + .savvy_wrap_RConstantTerm(.Call(savvy_RConstantTerm_iri__impl, `iri`)) +} + +`RConstantTerm`$`literal` <- function(`value`, `datatype_iri`, `language` = NULL) { + .savvy_wrap_RConstantTerm(.Call(savvy_RConstantTerm_literal__impl, `value`, `datatype_iri`, `language`)) +} + +`RConstantTerm`$`none` <- function() { + .savvy_wrap_RConstantTerm(.Call(savvy_RConstantTerm_none__impl)) +} + + +class(`RConstantTerm`) <- c("maplibr::RConstantTerm__bundle", "savvy_maplibr__sealed") + +#' @export +`print.maplibr::RConstantTerm__bundle` <- function(x, ...) { + cat('maplibr::RConstantTerm\n') +} + +### wrapper functions for RInstance + +`RInstance_iri` <- function(self) { + function() { + .Call(savvy_RInstance_iri__impl, `self`) + } +} + +`.savvy_wrap_RInstance` <- function(ptr) { + e <- new.env(parent = emptyenv()) + e$.ptr <- ptr + e$`iri` <- `RInstance_iri`(ptr) + + class(e) <- c("maplibr::RInstance", "RInstance", "savvy_maplibr__sealed") + e +} + + +#' An OTTR template instance: a call to a template with concrete arguments. +#' Mirrors py_maplib's `Instance` (lib/templates/src/python.rs:245-288). +#' +#' @export +`RInstance` <- new.env(parent = emptyenv()) + +### associated functions for RInstance + +`RInstance`$`new` <- function(`template_iri`, `arguments`, `list_expander` = NULL) { + .savvy_wrap_RInstance(.Call(savvy_RInstance_new__impl, `template_iri`, `arguments`, `list_expander`)) +} + + +class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") + +#' @export +`print.maplibr::RInstance__bundle` <- function(x, ...) { + cat('maplibr::RInstance\n') +} + ### wrapper functions for RModel `RModel_add_graph` <- function(self) { @@ -227,3 +359,92 @@ class(`RModel`) <- c("maplibr::RModel__bundle", "savvy_maplibr__sealed") cat('maplibr::RModel\n') } +### wrapper functions for RParameter + + +`.savvy_wrap_RParameter` <- function(ptr) { + e <- new.env(parent = emptyenv()) + e$.ptr <- ptr + + + class(e) <- c("maplibr::RParameter", "RParameter", "savvy_maplibr__sealed") + e +} + + +#' An OTTR template parameter. Mirrors py_maplib's `Parameter` +#' (lib/templates/src/python.rs:21-167) -- `rdf_type` here only supports a +#' basic (non-nested, non-list) RDF type, given as a datatype/class IRI +#' string; PType::Lub/List/NEList and RDFType.Nested()/Multi() are not yet +#' ported (they need the RDFType port, maplib-snz's remaining scope). +#' +#' @export +`RParameter` <- new.env(parent = emptyenv()) + +### associated functions for RParameter + +`RParameter`$`new` <- function(`variable_name`, `optional`, `allow_blank`, `rdf_type_iri` = NULL, `default_value` = NULL) { + `default_value` <- .savvy_extract_ptr(`default_value`, "maplibr::RConstantTerm") + .savvy_wrap_RParameter(.Call(savvy_RParameter_new__impl, `variable_name`, `optional`, `allow_blank`, `rdf_type_iri`, `default_value`)) +} + + +class(`RParameter`) <- c("maplibr::RParameter__bundle", "savvy_maplibr__sealed") + +#' @export +`print.maplibr::RParameter__bundle` <- function(x, ...) { + cat('maplibr::RParameter\n') +} + +### wrapper functions for RTemplate + +`RTemplate_instance` <- function(self) { + function(`arguments`, `list_expander` = NULL) { + .savvy_wrap_RInstance(.Call(savvy_RTemplate_instance__impl, `self`, `arguments`, `list_expander`)) + } +} + +`RTemplate_iri` <- function(self) { + function() { + .Call(savvy_RTemplate_iri__impl, `self`) + } +} + +`RTemplate_print_string` <- function(self) { + function() { + .Call(savvy_RTemplate_print_string__impl, `self`) + } +} + +`.savvy_wrap_RTemplate` <- function(ptr) { + e <- new.env(parent = emptyenv()) + e$.ptr <- ptr + e$`instance` <- `RTemplate_instance`(ptr) + e$`iri` <- `RTemplate_iri`(ptr) + e$`print_string` <- `RTemplate_print_string`(ptr) + + class(e) <- c("maplibr::RTemplate", "RTemplate", "savvy_maplibr__sealed") + e +} + + +#' An OTTR template: a named, parameterized set of instance patterns. +#' Mirrors py_maplib's `Template` (lib/templates/src/python.rs:291-402). +#' +#' @export +`RTemplate` <- new.env(parent = emptyenv()) + +### associated functions for RTemplate + +`RTemplate`$`new` <- function(`iri`, `parameters`, `instances`) { + .savvy_wrap_RTemplate(.Call(savvy_RTemplate_new__impl, `iri`, `parameters`, `instances`)) +} + + +class(`RTemplate`) <- c("maplibr::RTemplate__bundle", "savvy_maplibr__sealed") + +#' @export +`print.maplibr::RTemplate__bundle` <- function(x, ...) { + cat('maplibr::RTemplate\n') +} + diff --git a/maplibr/R/templates.R b/maplibr/R/templates.R new file mode 100644 index 00000000..d19d35f6 --- /dev/null +++ b/maplibr/R/templates.R @@ -0,0 +1,196 @@ +# OTTR template/instance/parameter/argument value classes (maplib-snz, +# continued). Unlike the pure-R term classes in terms.R, these need real +# Rust-side construction -- they become part of a templates::ast::Template +# eventually passed to Model$map() -- so, like Model/RModel, there is a thin +# S7 wrapper here over a raw savvy object (RConstantTerm/RArgument/ +# RParameter/RInstance/RTemplate, maplibr/src/rust/src/templates.rs). Still +# S7, not R6: built once, never mutated in place. + +.as_raw_constant_term <- function(x) { + if (is.null(x)) { + RConstantTerm$none() + } else if (S7::S7_inherits(x, IRI)) { + RConstantTerm$iri(x@iri) + } else if (S7::S7_inherits(x, BlankNode)) { + RConstantTerm$blank_node(x@name) + } else if (S7::S7_inherits(x, Literal)) { + RConstantTerm$literal(x@value, x@datatype@iri, x@language) + } else { + stop("expected an IRI, BlankNode, Literal, or NULL", call. = FALSE) + } +} + +#' An OTTR template argument: a Variable, or a constant term. +#' +#' Mirrors py_maplib's `Argument` (lib/templates/src/python.rs:203-236). +#' Passing an existing Argument as `term` returns it unchanged (`list_expand` +#' is ignored in that case), matching the Python constructor's behavior. +#' +#' @param term A Variable, IRI, BlankNode, Literal, NULL, or an existing Argument. +#' @param list_expand Whether this argument should be OTTR list-expanded. +#' @export +Argument <- S7::new_class( + "Argument", + package = "maplibr", + properties = list(raw = S7::class_any), + constructor = function(term, list_expand = FALSE) { + if (S7::S7_inherits(term, Argument)) { + return(term) + } + raw <- if (S7::S7_inherits(term, Variable)) { + RArgument$from_variable(term@name, list_expand) + } else { + RArgument$from_constant_term(.as_raw_constant_term(term), list_expand) + } + S7::new_object(S7::S7_object(), raw = raw) + } +) + +.as_argument_ptr <- function(x) { + .savvy_extract_ptr(Argument(x)@raw, "maplibr::RArgument") +} + +#' An OTTR template parameter. +#' +#' Mirrors py_maplib's `Parameter` (lib/templates/src/python.rs:21-167). +#' `rdf_type` only supports a basic (non-nested, non-list) RDF type, given as +#' a datatype/class IRI -- RDFType.Nested()/Multi() equivalents are not yet +#' ported (they need the RDFType port, maplib-snz's remaining scope). +#' +#' @param variable A Variable. +#' @param optional Whether this parameter is optional. +#' @param allow_blank Whether a blank node is an acceptable value (default TRUE). +#' @param rdf_type Optional IRI: the parameter's expected RDF datatype/class. +#' @param default_value Optional default value: an IRI, BlankNode, or Literal. +#' @export +Parameter <- S7::new_class( + "Parameter", + package = "maplibr", + properties = list(raw = S7::class_any, variable = Variable), + constructor = function(variable, + optional = FALSE, + allow_blank = TRUE, + rdf_type = NULL, + default_value = NULL) { + stopifnot(S7::S7_inherits(variable, Variable)) + rdf_type_iri <- if (is.null(rdf_type)) { + NULL + } else { + stopifnot(S7::S7_inherits(rdf_type, IRI)) + rdf_type@iri + } + default_raw <- if (is.null(default_value)) { + NULL + } else { + .as_raw_constant_term(default_value) + } + raw <- RParameter$new(variable@name, optional, allow_blank, rdf_type_iri, default_raw) + S7::new_object(S7::S7_object(), raw = raw, variable = variable) + } +) + +.as_parameter_ptr <- function(x) { + p <- if (S7::S7_inherits(x, Parameter)) x else Parameter(x) + .savvy_extract_ptr(p@raw, "maplibr::RParameter") +} + +#' An OTTR template instance: a call to a template with concrete arguments. +#' +#' Mirrors py_maplib's `Instance` (lib/templates/src/python.rs:245-288). +#' +#' @param template_iri An IRI: the template being instantiated. +#' @param arguments A list of Arguments (or bare Variable/IRI/BlankNode/ +#' Literal/NULL terms, auto-wrapped as non-list-expanded Arguments). +#' @param list_expander Optional: one of "cross", "zipMin", "zipMax". +#' @export +Instance <- S7::new_class( + "Instance", + package = "maplibr", + properties = list(raw = S7::class_any), + constructor = function(template_iri = NULL, arguments = NULL, list_expander = NULL, .raw = NULL) { + if (!is.null(.raw)) { + return(S7::new_object(S7::S7_object(), raw = .raw)) + } + stopifnot(S7::S7_inherits(template_iri, IRI)) + arg_ptrs <- lapply(arguments, .as_argument_ptr) + raw <- RInstance$new(template_iri@iri, arg_ptrs, list_expander) + S7::new_object(S7::S7_object(), raw = raw) + } +) + +#' The IRI of the template an Instance calls. +#' +#' @param x An Instance. +#' @export +instance_template_iri <- S7::new_generic("instance_template_iri", "x") + +#' @export +S7::method(instance_template_iri, Instance) <- function(x) IRI(x@raw$iri()) + +#' An OTTR template: a named, parameterized set of instance patterns. +#' +#' Mirrors py_maplib's `Template` (lib/templates/src/python.rs:291-402). +#' +#' @param iri An IRI. +#' @param parameters A list of Parameters (or bare Variables, auto-wrapped as +#' required, blank-allowed Parameters with no type constraint). +#' @param instances A list of Instances. +#' @export +Template <- S7::new_class( + "Template", + package = "maplibr", + properties = list(raw = S7::class_any, iri = IRI), + constructor = function(iri, parameters, instances) { + stopifnot(S7::S7_inherits(iri, IRI)) + param_ptrs <- lapply(parameters, .as_parameter_ptr) + stopifnot(all(vapply(instances, function(i) S7::S7_inherits(i, Instance), logical(1)))) + instance_ptrs <- lapply(instances, function(i) .savvy_extract_ptr(i@raw, "maplibr::RInstance")) + raw <- RTemplate$new(iri@iri, param_ptrs, instance_ptrs) + S7::new_object(S7::S7_object(), raw = raw, iri = iri) + } +) + +#' @export +S7::method(format, Template) <- function(x, ...) x@raw$print_string() + +#' @export +S7::method(print, Template) <- function(x, ...) { + cat(format(x), "\n", sep = "") + invisible(x) +} + +#' Build a new Instance calling a Template with concrete arguments. +#' +#' Mirrors py_maplib's `Template.instance()` (lib/templates/src/python.rs:335-346). +#' +#' @param template A Template. +#' @param arguments A list of Arguments (or bare terms, auto-wrapped). +#' @param list_expander Optional: one of "cross", "zipMin", "zipMax". +#' @return A new Instance. +#' @export +instantiate <- S7::new_generic("instantiate", "template") + +#' @export +S7::method(instantiate, Template) <- function(template, arguments, list_expander = NULL) { + arg_ptrs <- lapply(arguments, .as_argument_ptr) + raw <- template@raw$instance(arg_ptrs, list_expander) + Instance(.raw = raw) +} + +#' Build an rdf:type-style Triple instance. +#' +#' Mirrors py_maplib's `Triple()` helper (lib/templates/src/python.rs:404-417). +#' +#' @param subject Argument-compatible term. +#' @param predicate Argument-compatible term. +#' @param object Argument-compatible term. +#' @param list_expander Optional: one of "cross", "zipMin", "zipMax". +#' @return A new Instance. +#' @export +Triple <- function(subject, predicate, object, list_expander = NULL) { + s <- Argument(subject) + p <- Argument(predicate) + o <- Argument(object) + raw <- make_triple(s@raw, p@raw, o@raw, list_expander) + Instance(.raw = raw) +} diff --git a/maplibr/man/Argument.Rd b/maplibr/man/Argument.Rd new file mode 100644 index 00000000..d14ced3b --- /dev/null +++ b/maplibr/man/Argument.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/templates.R +\name{Argument} +\alias{Argument} +\title{An OTTR template argument: a Variable, or a constant term.} +\usage{ +Argument(term, list_expand = FALSE) +} +\arguments{ +\item{term}{A Variable, IRI, BlankNode, Literal, NULL, or an existing Argument.} + +\item{list_expand}{Whether this argument should be OTTR list-expanded.} +} +\description{ +Mirrors py_maplib's \code{Argument} (lib/templates/src/python.rs:203-236). +Passing an existing Argument as \code{term} returns it unchanged (\code{list_expand} +is ignored in that case), matching the Python constructor's behavior. +} diff --git a/maplibr/man/Instance.Rd b/maplibr/man/Instance.Rd new file mode 100644 index 00000000..b6af8338 --- /dev/null +++ b/maplibr/man/Instance.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/templates.R +\name{Instance} +\alias{Instance} +\title{An OTTR template instance: a call to a template with concrete arguments.} +\usage{ +Instance( + template_iri = NULL, + arguments = NULL, + list_expander = NULL, + .raw = NULL +) +} +\arguments{ +\item{template_iri}{An IRI: the template being instantiated.} + +\item{arguments}{A list of Arguments (or bare Variable/IRI/BlankNode/ +Literal/NULL terms, auto-wrapped as non-list-expanded Arguments).} + +\item{list_expander}{Optional: one of "cross", "zipMin", "zipMax".} +} +\description{ +Mirrors py_maplib's \code{Instance} (lib/templates/src/python.rs:245-288). +} diff --git a/maplibr/man/Parameter.Rd b/maplibr/man/Parameter.Rd new file mode 100644 index 00000000..1a69d479 --- /dev/null +++ b/maplibr/man/Parameter.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/templates.R +\name{Parameter} +\alias{Parameter} +\title{An OTTR template parameter.} +\usage{ +Parameter( + variable, + optional = FALSE, + allow_blank = TRUE, + rdf_type = NULL, + default_value = NULL +) +} +\arguments{ +\item{variable}{A Variable.} + +\item{optional}{Whether this parameter is optional.} + +\item{allow_blank}{Whether a blank node is an acceptable value (default TRUE).} + +\item{rdf_type}{Optional IRI: the parameter's expected RDF datatype/class.} + +\item{default_value}{Optional default value: an IRI, BlankNode, or Literal.} +} +\description{ +Mirrors py_maplib's \code{Parameter} (lib/templates/src/python.rs:21-167). +\code{rdf_type} only supports a basic (non-nested, non-list) RDF type, given as +a datatype/class IRI -- RDFType.Nested()/Multi() equivalents are not yet +ported (they need the RDFType port, maplib-snz's remaining scope). +} diff --git a/maplibr/man/RArgument.Rd b/maplibr/man/RArgument.Rd new file mode 100644 index 00000000..a5549189 --- /dev/null +++ b/maplibr/man/RArgument.Rd @@ -0,0 +1,13 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{RArgument} +\alias{RArgument} +\title{An OTTR argument: a Variable, or a constant term (IRI/BlankNode/Literal/none). +Mirrors py_maplib's \code{Argument} (lib/templates/src/python.rs:203-236).} +\usage{ +RArgument +} +\description{ +An OTTR argument: a Variable, or a constant term (IRI/BlankNode/Literal/none). +Mirrors py_maplib's \code{Argument} (lib/templates/src/python.rs:203-236). +} diff --git a/maplibr/man/RConstantTerm.Rd b/maplibr/man/RConstantTerm.Rd new file mode 100644 index 00000000..922ce361 --- /dev/null +++ b/maplibr/man/RConstantTerm.Rd @@ -0,0 +1,13 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{RConstantTerm} +\alias{RConstantTerm} +\title{An OTTR constant term: an IRI, blank node, literal, or explicit "none". +Mirrors py_maplib's \code{extract_constant_term} (lib/templates/src/python.rs:434-446).} +\usage{ +RConstantTerm +} +\description{ +An OTTR constant term: an IRI, blank node, literal, or explicit "none". +Mirrors py_maplib's \code{extract_constant_term} (lib/templates/src/python.rs:434-446). +} diff --git a/maplibr/man/RInstance.Rd b/maplibr/man/RInstance.Rd new file mode 100644 index 00000000..b9783bff --- /dev/null +++ b/maplibr/man/RInstance.Rd @@ -0,0 +1,13 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{RInstance} +\alias{RInstance} +\title{An OTTR template instance: a call to a template with concrete arguments. +Mirrors py_maplib's \code{Instance} (lib/templates/src/python.rs:245-288).} +\usage{ +RInstance +} +\description{ +An OTTR template instance: a call to a template with concrete arguments. +Mirrors py_maplib's \code{Instance} (lib/templates/src/python.rs:245-288). +} diff --git a/maplibr/man/RParameter.Rd b/maplibr/man/RParameter.Rd new file mode 100644 index 00000000..de4fbbc3 --- /dev/null +++ b/maplibr/man/RParameter.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{RParameter} +\alias{RParameter} +\title{An OTTR template parameter. Mirrors py_maplib's \code{Parameter} +(lib/templates/src/python.rs:21-167) -- \code{rdf_type} here only supports a +basic (non-nested, non-list) RDF type, given as a datatype/class IRI +string; PType::Lub/List/NEList and RDFType.Nested()/Multi() are not yet +ported (they need the RDFType port, maplib-snz's remaining scope).} +\usage{ +RParameter +} +\description{ +An OTTR template parameter. Mirrors py_maplib's \code{Parameter} +(lib/templates/src/python.rs:21-167) -- \code{rdf_type} here only supports a +basic (non-nested, non-list) RDF type, given as a datatype/class IRI +string; PType::Lub/List/NEList and RDFType.Nested()/Multi() are not yet +ported (they need the RDFType port, maplib-snz's remaining scope). +} diff --git a/maplibr/man/RTemplate.Rd b/maplibr/man/RTemplate.Rd new file mode 100644 index 00000000..859781fa --- /dev/null +++ b/maplibr/man/RTemplate.Rd @@ -0,0 +1,13 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{RTemplate} +\alias{RTemplate} +\title{An OTTR template: a named, parameterized set of instance patterns. +Mirrors py_maplib's \code{Template} (lib/templates/src/python.rs:291-402).} +\usage{ +RTemplate +} +\description{ +An OTTR template: a named, parameterized set of instance patterns. +Mirrors py_maplib's \code{Template} (lib/templates/src/python.rs:291-402). +} diff --git a/maplibr/man/Template.Rd b/maplibr/man/Template.Rd new file mode 100644 index 00000000..f5644a37 --- /dev/null +++ b/maplibr/man/Template.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/templates.R +\name{Template} +\alias{Template} +\title{An OTTR template: a named, parameterized set of instance patterns.} +\usage{ +Template(iri, parameters, instances) +} +\arguments{ +\item{iri}{An IRI.} + +\item{parameters}{A list of Parameters (or bare Variables, auto-wrapped as +required, blank-allowed Parameters with no type constraint).} + +\item{instances}{A list of Instances.} +} +\description{ +Mirrors py_maplib's \code{Template} (lib/templates/src/python.rs:291-402). +} diff --git a/maplibr/man/Triple.Rd b/maplibr/man/Triple.Rd new file mode 100644 index 00000000..1eaa1999 --- /dev/null +++ b/maplibr/man/Triple.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/templates.R +\name{Triple} +\alias{Triple} +\title{Build an rdf:type-style Triple instance.} +\usage{ +Triple(subject, predicate, object, list_expander = NULL) +} +\arguments{ +\item{subject}{Argument-compatible term.} + +\item{predicate}{Argument-compatible term.} + +\item{object}{Argument-compatible term.} + +\item{list_expander}{Optional: one of "cross", "zipMin", "zipMax".} +} +\value{ +A new Instance. +} +\description{ +Mirrors py_maplib's \code{Triple()} helper (lib/templates/src/python.rs:404-417). +} diff --git a/maplibr/man/instance_template_iri.Rd b/maplibr/man/instance_template_iri.Rd new file mode 100644 index 00000000..be3c49d3 --- /dev/null +++ b/maplibr/man/instance_template_iri.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/templates.R +\name{instance_template_iri} +\alias{instance_template_iri} +\title{The IRI of the template an Instance calls.} +\usage{ +instance_template_iri(x, ...) +} +\arguments{ +\item{x}{An Instance.} +} +\description{ +The IRI of the template an Instance calls. +} diff --git a/maplibr/man/instantiate.Rd b/maplibr/man/instantiate.Rd new file mode 100644 index 00000000..a0775362 --- /dev/null +++ b/maplibr/man/instantiate.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/templates.R +\name{instantiate} +\alias{instantiate} +\title{Build a new Instance calling a Template with concrete arguments.} +\usage{ +instantiate(template, ...) +} +\arguments{ +\item{template}{A Template.} + +\item{arguments}{A list of Arguments (or bare terms, auto-wrapped).} + +\item{list_expander}{Optional: one of "cross", "zipMin", "zipMax".} +} +\value{ +A new Instance. +} +\description{ +Mirrors py_maplib's \code{Template.instance()} (lib/templates/src/python.rs:335-346). +} diff --git a/maplibr/man/make_triple.Rd b/maplibr/man/make_triple.Rd new file mode 100644 index 00000000..1f2521a1 --- /dev/null +++ b/maplibr/man/make_triple.Rd @@ -0,0 +1,13 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{make_triple} +\alias{make_triple} +\title{Build an rdf:type-style Triple instance (mirrors py_maplib's \code{Triple()} +helper, lib/templates/src/python.rs:404-417).} +\usage{ +make_triple(subject, predicate, object, list_expander = NULL) +} +\description{ +Build an rdf:type-style Triple instance (mirrors py_maplib's \code{Triple()} +helper, lib/templates/src/python.rs:404-417). +} diff --git a/maplibr/src/init.c b/maplibr/src/init.c index b476eaee..4848991a 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -59,6 +59,11 @@ SEXP savvy_import_test_solution_mappings__impl(SEXP c_arg__stream_ptr, SEXP c_ar return handle_result(res); } +SEXP savvy_make_triple__impl(SEXP c_arg__subject, SEXP c_arg__predicate, SEXP c_arg__object, SEXP c_arg__list_expander) { + SEXP res = savvy_make_triple__ffi(c_arg__subject, c_arg__predicate, c_arg__object, c_arg__list_expander); + return handle_result(res); +} + SEXP savvy_validate_blank_node_id__impl(SEXP c_arg__id) { SEXP res = savvy_validate_blank_node_id__ffi(c_arg__id); return handle_result(res); @@ -74,6 +79,46 @@ SEXP savvy_validate_variable_name__impl(SEXP c_arg__name) { return handle_result(res); } +SEXP savvy_RArgument_from_constant_term__impl(SEXP c_arg__term, SEXP c_arg__list_expand) { + SEXP res = savvy_RArgument_from_constant_term__ffi(c_arg__term, c_arg__list_expand); + return handle_result(res); +} + +SEXP savvy_RArgument_from_variable__impl(SEXP c_arg__name, SEXP c_arg__list_expand) { + SEXP res = savvy_RArgument_from_variable__ffi(c_arg__name, c_arg__list_expand); + return handle_result(res); +} + +SEXP savvy_RConstantTerm_blank_node__impl(SEXP c_arg__id) { + SEXP res = savvy_RConstantTerm_blank_node__ffi(c_arg__id); + return handle_result(res); +} + +SEXP savvy_RConstantTerm_iri__impl(SEXP c_arg__iri) { + SEXP res = savvy_RConstantTerm_iri__ffi(c_arg__iri); + return handle_result(res); +} + +SEXP savvy_RConstantTerm_literal__impl(SEXP c_arg__value, SEXP c_arg__datatype_iri, SEXP c_arg__language) { + SEXP res = savvy_RConstantTerm_literal__ffi(c_arg__value, c_arg__datatype_iri, c_arg__language); + return handle_result(res); +} + +SEXP savvy_RConstantTerm_none__impl(void) { + SEXP res = savvy_RConstantTerm_none__ffi(); + return handle_result(res); +} + +SEXP savvy_RInstance_iri__impl(SEXP self__) { + SEXP res = savvy_RInstance_iri__ffi(self__); + return handle_result(res); +} + +SEXP savvy_RInstance_new__impl(SEXP c_arg__template_iri, SEXP c_arg__arguments, SEXP c_arg__list_expander) { + SEXP res = savvy_RInstance_new__ffi(c_arg__template_iri, c_arg__arguments, c_arg__list_expander); + return handle_result(res); +} + SEXP savvy_RModel_add_graph__impl(SEXP self__, SEXP c_arg__other, SEXP c_arg__source_graph, SEXP c_arg__target_graph) { SEXP res = savvy_RModel_add_graph__ffi(self__, c_arg__other, c_arg__source_graph, c_arg__target_graph); return handle_result(res); @@ -139,15 +184,49 @@ SEXP savvy_RModel_writes__impl(SEXP self__, SEXP c_arg__format, SEXP c_arg__grap return handle_result(res); } +SEXP savvy_RParameter_new__impl(SEXP c_arg__variable_name, SEXP c_arg__optional, SEXP c_arg__allow_blank, SEXP c_arg__rdf_type_iri, SEXP c_arg__default_value) { + SEXP res = savvy_RParameter_new__ffi(c_arg__variable_name, c_arg__optional, c_arg__allow_blank, c_arg__rdf_type_iri, c_arg__default_value); + return handle_result(res); +} + +SEXP savvy_RTemplate_instance__impl(SEXP self__, SEXP c_arg__arguments, SEXP c_arg__list_expander) { + SEXP res = savvy_RTemplate_instance__ffi(self__, c_arg__arguments, c_arg__list_expander); + return handle_result(res); +} + +SEXP savvy_RTemplate_iri__impl(SEXP self__) { + SEXP res = savvy_RTemplate_iri__ffi(self__); + return handle_result(res); +} + +SEXP savvy_RTemplate_new__impl(SEXP c_arg__iri, SEXP c_arg__parameters, SEXP c_arg__instances) { + SEXP res = savvy_RTemplate_new__ffi(c_arg__iri, c_arg__parameters, c_arg__instances); + return handle_result(res); +} + +SEXP savvy_RTemplate_print_string__impl(SEXP self__) { + SEXP res = savvy_RTemplate_print_string__ffi(self__); + return handle_result(res); +} + static const R_CallMethodDef CallEntries[] = { {"savvy_export_test_series__impl", (DL_FUNC) &savvy_export_test_series__impl, 1}, {"savvy_export_test_solution_mappings__impl", (DL_FUNC) &savvy_export_test_solution_mappings__impl, 1}, {"savvy_import_test_series__impl", (DL_FUNC) &savvy_import_test_series__impl, 1}, {"savvy_import_test_solution_mappings__impl", (DL_FUNC) &savvy_import_test_solution_mappings__impl, 2}, + {"savvy_make_triple__impl", (DL_FUNC) &savvy_make_triple__impl, 4}, {"savvy_validate_blank_node_id__impl", (DL_FUNC) &savvy_validate_blank_node_id__impl, 1}, {"savvy_validate_iri__impl", (DL_FUNC) &savvy_validate_iri__impl, 1}, {"savvy_validate_variable_name__impl", (DL_FUNC) &savvy_validate_variable_name__impl, 1}, + {"savvy_RArgument_from_constant_term__impl", (DL_FUNC) &savvy_RArgument_from_constant_term__impl, 2}, + {"savvy_RArgument_from_variable__impl", (DL_FUNC) &savvy_RArgument_from_variable__impl, 2}, + {"savvy_RConstantTerm_blank_node__impl", (DL_FUNC) &savvy_RConstantTerm_blank_node__impl, 1}, + {"savvy_RConstantTerm_iri__impl", (DL_FUNC) &savvy_RConstantTerm_iri__impl, 1}, + {"savvy_RConstantTerm_literal__impl", (DL_FUNC) &savvy_RConstantTerm_literal__impl, 3}, + {"savvy_RConstantTerm_none__impl", (DL_FUNC) &savvy_RConstantTerm_none__impl, 0}, + {"savvy_RInstance_iri__impl", (DL_FUNC) &savvy_RInstance_iri__impl, 1}, + {"savvy_RInstance_new__impl", (DL_FUNC) &savvy_RInstance_new__impl, 3}, {"savvy_RModel_add_graph__impl", (DL_FUNC) &savvy_RModel_add_graph__impl, 4}, {"savvy_RModel_add_prefixes__impl", (DL_FUNC) &savvy_RModel_add_prefixes__impl, 2}, {"savvy_RModel_compact__impl", (DL_FUNC) &savvy_RModel_compact__impl, 1}, @@ -161,6 +240,11 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 1}, {"savvy_RModel_truncate_graph__impl", (DL_FUNC) &savvy_RModel_truncate_graph__impl, 2}, {"savvy_RModel_writes__impl", (DL_FUNC) &savvy_RModel_writes__impl, 3}, + {"savvy_RParameter_new__impl", (DL_FUNC) &savvy_RParameter_new__impl, 5}, + {"savvy_RTemplate_instance__impl", (DL_FUNC) &savvy_RTemplate_instance__impl, 3}, + {"savvy_RTemplate_iri__impl", (DL_FUNC) &savvy_RTemplate_iri__impl, 1}, + {"savvy_RTemplate_new__impl", (DL_FUNC) &savvy_RTemplate_new__impl, 3}, + {"savvy_RTemplate_print_string__impl", (DL_FUNC) &savvy_RTemplate_print_string__impl, 1}, {NULL, NULL, 0} }; diff --git a/maplibr/src/rust/Cargo.toml b/maplibr/src/rust/Cargo.toml index 08168822..773e48a0 100644 --- a/maplibr/src/rust/Cargo.toml +++ b/maplibr/src/rust/Cargo.toml @@ -9,6 +9,7 @@ crate-type = ["lib", "staticlib"] [dependencies] maplib = { path = "../../../lib/maplib", features = ["pyo3"] } representation = { path = "../../../lib/representation" } +templates = { path = "../../../lib/templates" } triplestore = { path = "../../../lib/triplestore" } oxrdf = { workspace = true } oxrdfio = { workspace = true } diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index 4bda2c55..d1863d25 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -2,10 +2,25 @@ SEXP savvy_export_test_series__ffi(SEXP c_arg__stream_ptr); SEXP savvy_export_test_solution_mappings__ffi(SEXP c_arg__stream_ptr); SEXP savvy_import_test_series__ffi(SEXP c_arg__stream_ptr); SEXP savvy_import_test_solution_mappings__ffi(SEXP c_arg__stream_ptr, SEXP c_arg__rdf_node_types_json); +SEXP savvy_make_triple__ffi(SEXP c_arg__subject, SEXP c_arg__predicate, SEXP c_arg__object, SEXP c_arg__list_expander); SEXP savvy_validate_blank_node_id__ffi(SEXP c_arg__id); SEXP savvy_validate_iri__ffi(SEXP c_arg__iri); SEXP savvy_validate_variable_name__ffi(SEXP c_arg__name); +// methods and associated functions for RArgument +SEXP savvy_RArgument_from_constant_term__ffi(SEXP c_arg__term, SEXP c_arg__list_expand); +SEXP savvy_RArgument_from_variable__ffi(SEXP c_arg__name, SEXP c_arg__list_expand); + +// methods and associated functions for RConstantTerm +SEXP savvy_RConstantTerm_blank_node__ffi(SEXP c_arg__id); +SEXP savvy_RConstantTerm_iri__ffi(SEXP c_arg__iri); +SEXP savvy_RConstantTerm_literal__ffi(SEXP c_arg__value, SEXP c_arg__datatype_iri, SEXP c_arg__language); +SEXP savvy_RConstantTerm_none__ffi(void); + +// methods and associated functions for RInstance +SEXP savvy_RInstance_iri__ffi(SEXP self__); +SEXP savvy_RInstance_new__ffi(SEXP c_arg__template_iri, SEXP c_arg__arguments, SEXP c_arg__list_expander); + // methods and associated functions for RModel SEXP savvy_RModel_add_graph__ffi(SEXP self__, SEXP c_arg__other, SEXP c_arg__source_graph, SEXP c_arg__target_graph); SEXP savvy_RModel_add_prefixes__ffi(SEXP self__, SEXP c_arg__prefixes); @@ -20,3 +35,12 @@ SEXP savvy_RModel_serialize__ffi(SEXP self__, SEXP c_arg__path); SEXP savvy_RModel_size__ffi(SEXP self__); SEXP savvy_RModel_truncate_graph__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_writes__ffi(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph); + +// methods and associated functions for RParameter +SEXP savvy_RParameter_new__ffi(SEXP c_arg__variable_name, SEXP c_arg__optional, SEXP c_arg__allow_blank, SEXP c_arg__rdf_type_iri, SEXP c_arg__default_value); + +// methods and associated functions for RTemplate +SEXP savvy_RTemplate_instance__ffi(SEXP self__, SEXP c_arg__arguments, SEXP c_arg__list_expander); +SEXP savvy_RTemplate_iri__ffi(SEXP self__); +SEXP savvy_RTemplate_new__ffi(SEXP c_arg__iri, SEXP c_arg__parameters, SEXP c_arg__instances); +SEXP savvy_RTemplate_print_string__ffi(SEXP self__); diff --git a/maplibr/src/rust/src/lib.rs b/maplibr/src/rust/src/lib.rs index 92cc5072..49b2491e 100644 --- a/maplibr/src/rust/src/lib.rs +++ b/maplibr/src/rust/src/lib.rs @@ -1,5 +1,6 @@ mod arrow_bridge; mod model; +mod templates; mod terms; pub use model::RModel; diff --git a/maplibr/src/rust/src/templates.rs b/maplibr/src/rust/src/templates.rs new file mode 100644 index 00000000..79a3242b --- /dev/null +++ b/maplibr/src/rust/src/templates.rs @@ -0,0 +1,263 @@ +use oxrdf::{BlankNode, Literal as OxLiteral, NamedNode, Variable}; +use representation::constants::OTTR_TRIPLE; +use savvy::{savvy, ListSexp}; +use templates::ast::{ + Argument, ConstantTerm, ConstantTermOrList, Instance, ListExpanderType, PType, Parameter, + Signature, StottrTerm, Template, +}; + +fn parse_list_expander(s: Option<&str>) -> savvy::Result> { + match s { + None => Ok(None), + Some("cross") => Ok(Some(ListExpanderType::Cross)), + Some("zipMin") => Ok(Some(ListExpanderType::ZipMin)), + Some("zipMax") => Ok(Some(ListExpanderType::ZipMax)), + Some(other) => Err(savvy::Error::new(&format!( + "Unknown list_expander \"{other}\", expected one of \"cross\", \"zipMin\", \"zipMax\"" + ))), + } +} + +/// An OTTR constant term: an IRI, blank node, literal, or explicit "none". +/// Mirrors py_maplib's `extract_constant_term` (lib/templates/src/python.rs:434-446). +/// +/// @export +#[savvy] +pub struct RConstantTerm { + pub inner: ConstantTerm, +} + +#[savvy] +impl RConstantTerm { + fn iri(iri: &str) -> savvy::Result { + let nn = NamedNode::new(iri).map_err(|e| savvy::Error::new(&e.to_string()))?; + Ok(RConstantTerm { + inner: ConstantTerm::Iri(nn), + }) + } + + fn blank_node(id: &str) -> savvy::Result { + let bn = BlankNode::new(id).map_err(|e| savvy::Error::new(&e.to_string()))?; + Ok(RConstantTerm { + inner: ConstantTerm::BlankNode(bn), + }) + } + + fn literal(value: &str, datatype_iri: &str, language: Option<&str>) -> savvy::Result { + let literal = if let Some(language) = language { + OxLiteral::new_language_tagged_literal_unchecked(value, language) + } else { + let dt = + NamedNode::new(datatype_iri).map_err(|e| savvy::Error::new(&e.to_string()))?; + OxLiteral::new_typed_literal(value, dt) + }; + Ok(RConstantTerm { + inner: ConstantTerm::Literal(literal), + }) + } + + fn none() -> Self { + RConstantTerm { + inner: ConstantTerm::None, + } + } +} + +/// An OTTR argument: a Variable, or a constant term (IRI/BlankNode/Literal/none). +/// Mirrors py_maplib's `Argument` (lib/templates/src/python.rs:203-236). +/// +/// @export +#[savvy] +pub struct RArgument { + pub inner: Argument, +} + +#[savvy] +impl RArgument { + fn from_variable(name: &str, list_expand: bool) -> savvy::Result { + let v = Variable::new(name).map_err(|e| savvy::Error::new(&e.to_string()))?; + Ok(RArgument { + inner: Argument { + list_expand, + term: StottrTerm::Variable(v), + }, + }) + } + + fn from_constant_term(term: &RConstantTerm, list_expand: bool) -> Self { + RArgument { + inner: Argument { + list_expand, + term: StottrTerm::ConstantTerm(ConstantTermOrList::ConstantTerm( + term.inner.clone(), + )), + }, + } + } +} + +/// An OTTR template parameter. Mirrors py_maplib's `Parameter` +/// (lib/templates/src/python.rs:21-167) -- `rdf_type` here only supports a +/// basic (non-nested, non-list) RDF type, given as a datatype/class IRI +/// string; PType::Lub/List/NEList and RDFType.Nested()/Multi() are not yet +/// ported (they need the RDFType port, maplib-snz's remaining scope). +/// +/// @export +#[savvy] +pub struct RParameter { + pub inner: Parameter, +} + +#[savvy] +impl RParameter { + fn new( + variable_name: &str, + optional: bool, + allow_blank: bool, + rdf_type_iri: Option<&str>, + default_value: Option<&RConstantTerm>, + ) -> savvy::Result { + let variable = + Variable::new(variable_name).map_err(|e| savvy::Error::new(&e.to_string()))?; + let ptype = rdf_type_iri + .map(|iri| { + NamedNode::new(iri) + .map(PType::Basic) + .map_err(|e| savvy::Error::new(&e.to_string())) + }) + .transpose()?; + let default_value = + default_value.map(|d| ConstantTermOrList::ConstantTerm(d.inner.clone())); + Ok(RParameter { + inner: Parameter { + optional, + non_blank: !allow_blank, + ptype, + variable, + default_value, + }, + }) + } +} + +/// An OTTR template instance: a call to a template with concrete arguments. +/// Mirrors py_maplib's `Instance` (lib/templates/src/python.rs:245-288). +/// +/// @export +#[savvy] +pub struct RInstance { + pub inner: Instance, +} + +#[savvy] +impl RInstance { + fn new( + template_iri: &str, + arguments: ListSexp, + list_expander: Option<&str>, + ) -> savvy::Result { + let template_iri = + NamedNode::new(template_iri).map_err(|e| savvy::Error::new(&e.to_string()))?; + let list_expander = parse_list_expander(list_expander)?; + let mut argument_list = Vec::new(); + for (_, value) in arguments.iter() { + let a = <&RArgument>::try_from(value)?; + argument_list.push(a.inner.clone()); + } + Ok(RInstance { + inner: Instance { + list_expander, + template_iri, + argument_list, + }, + }) + } + + fn iri(&self) -> savvy::Result { + self.inner.template_iri.as_str().to_string().try_into() + } +} + +/// An OTTR template: a named, parameterized set of instance patterns. +/// Mirrors py_maplib's `Template` (lib/templates/src/python.rs:291-402). +/// +/// @export +#[savvy] +pub struct RTemplate { + pub inner: Template, +} + +#[savvy] +impl RTemplate { + fn new(iri: &str, parameters: ListSexp, instances: ListSexp) -> savvy::Result { + let iri = NamedNode::new(iri).map_err(|e| savvy::Error::new(&e.to_string()))?; + let mut parameter_list = Vec::new(); + for (_, value) in parameters.iter() { + let p = <&RParameter>::try_from(value)?; + parameter_list.push(p.inner.clone()); + } + let mut pattern_list = Vec::new(); + for (_, value) in instances.iter() { + let i = <&RInstance>::try_from(value)?; + pattern_list.push(i.inner.clone()); + } + Ok(RTemplate { + inner: Template { + signature: Signature { + iri, + parameter_list, + annotation_list: None, + }, + pattern_list, + }, + }) + } + + fn iri(&self) -> savvy::Result { + self.inner.signature.iri.as_str().to_string().try_into() + } + + fn print_string(&self) -> savvy::Result { + self.inner.to_string().try_into() + } + + /// Build a new Instance calling this Template (mirrors + /// `PyTemplate::instance`, lib/templates/src/python.rs:335-346). + fn instance( + &self, + arguments: ListSexp, + list_expander: Option<&str>, + ) -> savvy::Result { + RInstance::new( + self.inner.signature.iri.as_str(), + arguments, + list_expander, + ) + } +} + +/// Build an rdf:type-style Triple instance (mirrors py_maplib's `Triple()` +/// helper, lib/templates/src/python.rs:404-417). +/// +/// @export +#[savvy] +fn make_triple( + subject: &RArgument, + predicate: &RArgument, + object: &RArgument, + list_expander: Option<&str>, +) -> savvy::Result { + let list_expander = parse_list_expander(list_expander)?; + let template_iri = NamedNode::new(OTTR_TRIPLE).unwrap(); + Ok(RInstance { + inner: Instance { + list_expander, + template_iri, + argument_list: vec![ + subject.inner.clone(), + predicate.inner.clone(), + object.inner.clone(), + ], + }, + }) +} From 50dbd794ee45d5520ea267c8085367ea8d02d673 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 10:21:18 +0200 Subject: [PATCH 11/46] feat(maplibr): add xsd/rdf/rdfs/owl vocab IRI constants - names match py_maplib exactly, values cross-checked against oxrdf's vocab.rs - built lazily in .onLoad, since native code isn't registered yet at R-sourcing time Co-Authored-By: Claude Sonnet 5 --- maplibr/DESCRIPTION | 1 + maplibr/NAMESPACE | 4 + maplibr/R/vocab.R | 206 ++++++++++++++++++++++++++++++++++++++++++++ maplibr/R/zzz.R | 9 ++ maplibr/man/owl.Rd | 14 +++ maplibr/man/rdf.Rd | 14 +++ maplibr/man/rdfs.Rd | 14 +++ maplibr/man/xsd.Rd | 14 +++ 8 files changed, 276 insertions(+) create mode 100644 maplibr/R/vocab.R create mode 100644 maplibr/man/owl.Rd create mode 100644 maplibr/man/rdf.Rd create mode 100644 maplibr/man/rdfs.Rd create mode 100644 maplibr/man/xsd.Rd diff --git a/maplibr/DESCRIPTION b/maplibr/DESCRIPTION index cb468fde..997057b0 100644 --- a/maplibr/DESCRIPTION +++ b/maplibr/DESCRIPTION @@ -16,6 +16,7 @@ SystemRequirements: Cargo (Rust's package manager), rustc Collate: '000-wrappers.R' 'terms.R' + 'vocab.R' 'templates.R' 'model.R' 'zzz.R' diff --git a/maplibr/NAMESPACE b/maplibr/NAMESPACE index 34bcc468..1e5b7f43 100644 --- a/maplibr/NAMESPACE +++ b/maplibr/NAMESPACE @@ -32,8 +32,12 @@ export(import_test_solution_mappings) export(instance_template_iri) export(instantiate) export(make_triple) +export(owl) +export(rdf) +export(rdfs) export(suf) export(validate_blank_node_id) export(validate_iri) export(validate_variable_name) +export(xsd) useDynLib(maplibr, .registration = TRUE) diff --git a/maplibr/R/vocab.R b/maplibr/R/vocab.R new file mode 100644 index 00000000..0f980ed0 --- /dev/null +++ b/maplibr/R/vocab.R @@ -0,0 +1,206 @@ +# RDF/XSD vocabulary IRI constants (maplib-snz, continued). Mirrors +# py_maplib's xsd/rdf/rdfs/owl vocab helpers (lib/templates/src/python/ +# {xsd,rdf,rdfs,owl}.rs). Names match py_maplib's exactly (e.g. +# `xsd$dateTime`, `rdf$type`). +# +# Built lazily in zzz.R's .onLoad, not at top-level source time: each entry +# is an IRI(), which validates via a compiled Rust call (validate_iri(), +# terms.rs) -- and devtools::load_all()'s R-sourcing pass does not guarantee +# the package's native library is already registered at the point vocab.R +# itself is sourced (confirmed: calling IRI() here at top level fails with +# "object 'savvy_validate_iri__impl' not found" on a fresh load_all()). +# .onLoad always runs after useDynLib() registration completes, so building +# these there is the standard, safe place for a package constant that needs +# compiled code to construct. + +#' The xsd vocabulary: a named list of IRIs. +#' +#' @format A named list of IRI objects. +#' @export +xsd <- NULL + +.build_xsd_vocab <- function() { + list( + `boolean` = IRI("http://www.w3.org/2001/XMLSchema#boolean"), + `byte` = IRI("http://www.w3.org/2001/XMLSchema#byte"), + `date` = IRI("http://www.w3.org/2001/XMLSchema#date"), + `dateTime` = IRI("http://www.w3.org/2001/XMLSchema#dateTime"), + `dateTimeStamp` = IRI("http://www.w3.org/2001/XMLSchema#dateTimeStamp"), + `decimal` = IRI("http://www.w3.org/2001/XMLSchema#decimal"), + `double` = IRI("http://www.w3.org/2001/XMLSchema#double"), + `duration` = IRI("http://www.w3.org/2001/XMLSchema#duration"), + `float` = IRI("http://www.w3.org/2001/XMLSchema#float"), + `int` = IRI("http://www.w3.org/2001/XMLSchema#int"), + `integer` = IRI("http://www.w3.org/2001/XMLSchema#integer"), + `language` = IRI("http://www.w3.org/2001/XMLSchema#language"), + `long` = IRI("http://www.w3.org/2001/XMLSchema#long"), + `short` = IRI("http://www.w3.org/2001/XMLSchema#short"), + `string` = IRI("http://www.w3.org/2001/XMLSchema#string"), + `anyURI` = IRI("http://www.w3.org/2001/XMLSchema#anyURI"), + `dayTimeDuration` = IRI("http://www.w3.org/2001/XMLSchema#dayTimeDuration"), + `base64Binary` = IRI("http://www.w3.org/2001/XMLSchema#base64Binary"), + `gDay` = IRI("http://www.w3.org/2001/XMLSchema#gDay"), + `gMonthDay` = IRI("http://www.w3.org/2001/XMLSchema#gMonthDay"), + `gMonth` = IRI("http://www.w3.org/2001/XMLSchema#gMonth"), + `gYear` = IRI("http://www.w3.org/2001/XMLSchema#gYear"), + `gYearMonth` = IRI("http://www.w3.org/2001/XMLSchema#gYearMonth"), + `hexBinary` = IRI("http://www.w3.org/2001/XMLSchema#hexBinary"), + `Name` = IRI("http://www.w3.org/2001/XMLSchema#Name"), + `NCName` = IRI("http://www.w3.org/2001/XMLSchema#NCName"), + `NMTOKEN` = IRI("http://www.w3.org/2001/XMLSchema#NMTOKEN"), + `negativeInteger` = IRI("http://www.w3.org/2001/XMLSchema#negativeInteger"), + `nonNegativeInteger` = IRI("http://www.w3.org/2001/XMLSchema#nonNegativeInteger"), + `nonPositiveInteger` = IRI("http://www.w3.org/2001/XMLSchema#nonPositiveInteger"), + `normalizedString` = IRI("http://www.w3.org/2001/XMLSchema#normalizedString"), + `token` = IRI("http://www.w3.org/2001/XMLSchema#token"), + `unsignedByte` = IRI("http://www.w3.org/2001/XMLSchema#unsignedByte"), + `unsignedInt` = IRI("http://www.w3.org/2001/XMLSchema#unsignedInt"), + `unsignedLong` = IRI("http://www.w3.org/2001/XMLSchema#unsignedLong"), + `unsignedShort` = IRI("http://www.w3.org/2001/XMLSchema#unsignedShort"), + `yearMonthDuration` = IRI("http://www.w3.org/2001/XMLSchema#yearMonthDuration") + ) +} + +#' The rdf vocabulary: a named list of IRIs. +#' +#' @format A named list of IRI objects. +#' @export +rdf <- NULL + +.build_rdf_vocab <- function() { + list( + `type` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#type"), + `Alt` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#Alt"), + `Bag` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#Bag"), + `first` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#first"), + `HTML` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#HTML"), + `langString` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#langString"), + `List` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#List"), + `nil` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#nil"), + `object` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#object"), + `predicate` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#predicate"), + `Property` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#Property"), + `rest` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#rest"), + `Seq` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#Seq"), + `Statement` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#Statement"), + `subject` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#subject"), + `value` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#value"), + `XMLLiteral` = IRI("http://www.w3.org/1999/02/22-rdf-syntax-ns#XMLLiteral") + ) +} + +#' The rdfs vocabulary: a named list of IRIs. +#' +#' @format A named list of IRI objects. +#' @export +rdfs <- NULL + +.build_rdfs_vocab <- function() { + list( + `Class` = IRI("http://www.w3.org/2000/01/rdf-schema#Class"), + `comment` = IRI("http://www.w3.org/2000/01/rdf-schema#comment"), + `Container` = IRI("http://www.w3.org/2000/01/rdf-schema#Container"), + `Datatype` = IRI("http://www.w3.org/2000/01/rdf-schema#Datatype"), + `domain` = IRI("http://www.w3.org/2000/01/rdf-schema#domain"), + `ContainerMembershipProperty` = IRI("http://www.w3.org/2000/01/rdf-schema#ContainerMembershipProperty"), + `isDefinedBy` = IRI("http://www.w3.org/2000/01/rdf-schema#isDefinedBy"), + `label` = IRI("http://www.w3.org/2000/01/rdf-schema#label"), + `Literal` = IRI("http://www.w3.org/2000/01/rdf-schema#Literal"), + `member` = IRI("http://www.w3.org/2000/01/rdf-schema#member"), + `range` = IRI("http://www.w3.org/2000/01/rdf-schema#range"), + `seeAlso` = IRI("http://www.w3.org/2000/01/rdf-schema#seeAlso"), + `subClassOf` = IRI("http://www.w3.org/2000/01/rdf-schema#subClassOf"), + `subPropertyOf` = IRI("http://www.w3.org/2000/01/rdf-schema#subPropertyOf"), + `Resource` = IRI("http://www.w3.org/2000/01/rdf-schema#Resource") + ) +} + +#' The owl vocabulary: a named list of IRIs. +#' +#' @format A named list of IRI objects. +#' @export +owl <- NULL + +.build_owl_vocab <- function() { + list( + `allValuesFrom` = IRI("http://www.w3.org/2002/07/owl#allValuesFrom"), + `annotatedProperty` = IRI("http://www.w3.org/2002/07/owl#annotatedProperty"), + `annotatedSource` = IRI("http://www.w3.org/2002/07/owl#annotatedSource"), + `annotatedTarget` = IRI("http://www.w3.org/2002/07/owl#annotatedTarget"), + `assertionProperty` = IRI("http://www.w3.org/2002/07/owl#assertionProperty"), + `cardinality` = IRI("http://www.w3.org/2002/07/owl#cardinality"), + `complementOf` = IRI("http://www.w3.org/2002/07/owl#complementOf"), + `datatypeComplementOf` = IRI("http://www.w3.org/2002/07/owl#datatypeComplementOf"), + `differentFrom` = IRI("http://www.w3.org/2002/07/owl#differentFrom"), + `disjointUnionOf` = IRI("http://www.w3.org/2002/07/owl#disjointUnionOf"), + `disjointWith` = IRI("http://www.w3.org/2002/07/owl#disjointWith"), + `distinctMembers` = IRI("http://www.w3.org/2002/07/owl#distinctMembers"), + `equivalentClass` = IRI("http://www.w3.org/2002/07/owl#equivalentClass"), + `equivalentProperty` = IRI("http://www.w3.org/2002/07/owl#equivalentProperty"), + `hasKey` = IRI("http://www.w3.org/2002/07/owl#hasKey"), + `hasSelf` = IRI("http://www.w3.org/2002/07/owl#hasSelf"), + `hasValue` = IRI("http://www.w3.org/2002/07/owl#hasValue"), + `intersectionOf` = IRI("http://www.w3.org/2002/07/owl#intersectionOf"), + `inverseOf` = IRI("http://www.w3.org/2002/07/owl#inverseOf"), + `maxCardinality` = IRI("http://www.w3.org/2002/07/owl#maxCardinality"), + `maxQualifiedCardinality` = IRI("http://www.w3.org/2002/07/owl#maxQualifiedCardinality"), + `members` = IRI("http://www.w3.org/2002/07/owl#members"), + `minCardinality` = IRI("http://www.w3.org/2002/07/owl#minCardinality"), + `minQualifiedCardinality` = IRI("http://www.w3.org/2002/07/owl#minQualifiedCardinality"), + `onClass` = IRI("http://www.w3.org/2002/07/owl#onClass"), + `onDataRange` = IRI("http://www.w3.org/2002/07/owl#onDataRange"), + `onDatatype` = IRI("http://www.w3.org/2002/07/owl#onDatatype"), + `onProperties` = IRI("http://www.w3.org/2002/07/owl#onProperties"), + `onProperty` = IRI("http://www.w3.org/2002/07/owl#onProperty"), + `oneOf` = IRI("http://www.w3.org/2002/07/owl#oneOf"), + `propertyChainAxiom` = IRI("http://www.w3.org/2002/07/owl#propertyChainAxiom"), + `propertyDisjointWith` = IRI("http://www.w3.org/2002/07/owl#propertyDisjointWith"), + `qualifiedCardinality` = IRI("http://www.w3.org/2002/07/owl#qualifiedCardinality"), + `sameAs` = IRI("http://www.w3.org/2002/07/owl#sameAs"), + `someValuesFrom` = IRI("http://www.w3.org/2002/07/owl#someValuesFrom"), + `sourceIndividual` = IRI("http://www.w3.org/2002/07/owl#sourceIndividual"), + `targetIndividual` = IRI("http://www.w3.org/2002/07/owl#targetIndividual"), + `targetValue` = IRI("http://www.w3.org/2002/07/owl#targetValue"), + `unionOf` = IRI("http://www.w3.org/2002/07/owl#unionOf"), + `withRestrictions` = IRI("http://www.w3.org/2002/07/owl#withRestrictions"), + `AllDifferent` = IRI("http://www.w3.org/2002/07/owl#AllDifferent"), + `AllDisjointClasses` = IRI("http://www.w3.org/2002/07/owl#AllDisjointClasses"), + `AllDisjointProperties` = IRI("http://www.w3.org/2002/07/owl#AllDisjointProperties"), + `Annotation` = IRI("http://www.w3.org/2002/07/owl#Annotation"), + `AnnotationProperty` = IRI("http://www.w3.org/2002/07/owl#AnnotationProperty"), + `Axiom` = IRI("http://www.w3.org/2002/07/owl#Axiom"), + `Class` = IRI("http://www.w3.org/2002/07/owl#Class"), + `DataRange` = IRI("http://www.w3.org/2002/07/owl#DataRange"), + `DatatypeProperty` = IRI("http://www.w3.org/2002/07/owl#DatatypeProperty"), + `DeprecatedClass` = IRI("http://www.w3.org/2002/07/owl#DeprecatedClass"), + `DeprecatedProperty` = IRI("http://www.w3.org/2002/07/owl#DeprecatedProperty"), + `FunctionalProperty` = IRI("http://www.w3.org/2002/07/owl#FunctionalProperty"), + `InverseFunctionalProperty` = IRI("http://www.w3.org/2002/07/owl#InverseFunctionalProperty"), + `IrreflexiveProperty` = IRI("http://www.w3.org/2002/07/owl#IrreflexiveProperty"), + `NamedIndividual` = IRI("http://www.w3.org/2002/07/owl#NamedIndividual"), + `NegativePropertyAssertion` = IRI("http://www.w3.org/2002/07/owl#NegativePropertyAssertion"), + `ObjectProperty` = IRI("http://www.w3.org/2002/07/owl#ObjectProperty"), + `Ontology` = IRI("http://www.w3.org/2002/07/owl#Ontology"), + `OntologyProperty` = IRI("http://www.w3.org/2002/07/owl#OntologyProperty"), + `ReflexiveProperty` = IRI("http://www.w3.org/2002/07/owl#ReflexiveProperty"), + `Restriction` = IRI("http://www.w3.org/2002/07/owl#Restriction"), + `SymmetricProperty` = IRI("http://www.w3.org/2002/07/owl#SymmetricProperty"), + `TransitiveProperty` = IRI("http://www.w3.org/2002/07/owl#TransitiveProperty"), + `backwardCompatibleWith` = IRI("http://www.w3.org/2002/07/owl#backwardCompatibleWith"), + `deprecated` = IRI("http://www.w3.org/2002/07/owl#deprecated"), + `incompatibleWith` = IRI("http://www.w3.org/2002/07/owl#incompatibleWith"), + `priorVersion` = IRI("http://www.w3.org/2002/07/owl#priorVersion"), + `versionInfo` = IRI("http://www.w3.org/2002/07/owl#versionInfo"), + `Nothing` = IRI("http://www.w3.org/2002/07/owl#Nothing"), + `Thing` = IRI("http://www.w3.org/2002/07/owl#Thing"), + `bottomDataProperty` = IRI("http://www.w3.org/2002/07/owl#bottomDataProperty"), + `topDataProperty` = IRI("http://www.w3.org/2002/07/owl#topDataProperty"), + `bottomObjectProperty` = IRI("http://www.w3.org/2002/07/owl#bottomObjectProperty"), + `topObjectProperty` = IRI("http://www.w3.org/2002/07/owl#topObjectProperty"), + `imports` = IRI("http://www.w3.org/2002/07/owl#imports"), + `versionIRI` = IRI("http://www.w3.org/2002/07/owl#versionIRI"), + `rational` = IRI("http://www.w3.org/2002/07/owl#rational"), + `real` = IRI("http://www.w3.org/2002/07/owl#real") + ) +} + diff --git a/maplibr/R/zzz.R b/maplibr/R/zzz.R index 511514c4..907e6a3a 100644 --- a/maplibr/R/zzz.R +++ b/maplibr/R/zzz.R @@ -1,3 +1,12 @@ .onLoad <- function(libname, pkgname) { S7::methods_register() + + # Vocab constants (vocab.R) need a compiled Rust call to build (each is an + # IRI(), validated via validate_iri()) -- built here, not at vocab.R's own + # top level, since .onLoad is guaranteed to run after useDynLib() + # registration completes and top-level R/*.R sourcing is not. + xsd <<- .build_xsd_vocab() + rdf <<- .build_rdf_vocab() + rdfs <<- .build_rdfs_vocab() + owl <<- .build_owl_vocab() } diff --git a/maplibr/man/owl.Rd b/maplibr/man/owl.Rd new file mode 100644 index 00000000..c5f9cfd9 --- /dev/null +++ b/maplibr/man/owl.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/vocab.R +\name{owl} +\alias{owl} +\title{The owl vocabulary: a named list of IRIs.} +\format{ +A named list of IRI objects. +} +\usage{ +owl +} +\description{ +The owl vocabulary: a named list of IRIs. +} diff --git a/maplibr/man/rdf.Rd b/maplibr/man/rdf.Rd new file mode 100644 index 00000000..74b083fe --- /dev/null +++ b/maplibr/man/rdf.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/vocab.R +\name{rdf} +\alias{rdf} +\title{The rdf vocabulary: a named list of IRIs.} +\format{ +A named list of IRI objects. +} +\usage{ +rdf +} +\description{ +The rdf vocabulary: a named list of IRIs. +} diff --git a/maplibr/man/rdfs.Rd b/maplibr/man/rdfs.Rd new file mode 100644 index 00000000..84234187 --- /dev/null +++ b/maplibr/man/rdfs.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/vocab.R +\name{rdfs} +\alias{rdfs} +\title{The rdfs vocabulary: a named list of IRIs.} +\format{ +A named list of IRI objects. +} +\usage{ +rdfs +} +\description{ +The rdfs vocabulary: a named list of IRIs. +} diff --git a/maplibr/man/xsd.Rd b/maplibr/man/xsd.Rd new file mode 100644 index 00000000..e02c57e4 --- /dev/null +++ b/maplibr/man/xsd.Rd @@ -0,0 +1,14 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/vocab.R +\name{xsd} +\alias{xsd} +\title{The xsd vocabulary: a named list of IRIs.} +\format{ +A named list of IRI objects. +} +\usage{ +xsd +} +\description{ +The xsd vocabulary: a named list of IRIs. +} From 3d6003c04d19250a763ef06f647a5b2c189f3b7f Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 10:24:23 +0200 Subject: [PATCH 12/46] feat(maplibr): add RDFType, complete Parameter's rdf_type argument - thin wrapper around the resolved basic-type IRI (Multi collapses to rdfs:Resource) - built via plain functions, since S7 class generators refuse `$<-` assignment - RDFType.Nested() not ported, no current consumer Co-Authored-By: Claude Sonnet 5 --- maplibr/DESCRIPTION | 1 + maplibr/NAMESPACE | 6 +++ maplibr/R/rdf_type.R | 105 +++++++++++++++++++++++++++++++++++++++ maplibr/R/templates.R | 15 ++---- maplibr/man/Parameter.Rd | 8 +-- maplibr/man/RDFType.Rd | 40 +++++++++++++++ 6 files changed, 161 insertions(+), 14 deletions(-) create mode 100644 maplibr/R/rdf_type.R create mode 100644 maplibr/man/RDFType.Rd diff --git a/maplibr/DESCRIPTION b/maplibr/DESCRIPTION index 997057b0..d4294c37 100644 --- a/maplibr/DESCRIPTION +++ b/maplibr/DESCRIPTION @@ -17,6 +17,7 @@ Collate: '000-wrappers.R' 'terms.R' 'vocab.R' + 'rdf_type.R' 'templates.R' 'model.R' 'zzz.R' diff --git a/maplibr/NAMESPACE b/maplibr/NAMESPACE index 1e5b7f43..f667de0e 100644 --- a/maplibr/NAMESPACE +++ b/maplibr/NAMESPACE @@ -18,6 +18,7 @@ export(Parameter) export(Prefix) export(RArgument) export(RConstantTerm) +export(RDFType) export(RInstance) export(RModel) export(RParameter) @@ -34,6 +35,11 @@ export(instantiate) export(make_triple) export(owl) export(rdf) +export(rdf_type_blank_node) +export(rdf_type_iri) +export(rdf_type_literal) +export(rdf_type_multi) +export(rdf_type_unknown) export(rdfs) export(suf) export(validate_blank_node_id) diff --git a/maplibr/R/rdf_type.R b/maplibr/R/rdf_type.R new file mode 100644 index 00000000..70ba5897 --- /dev/null +++ b/maplibr/R/rdf_type.R @@ -0,0 +1,105 @@ +# RDFType (maplib-snz, continued). Mirrors py_maplib's `RDFType` +# (lib/representation/src/python.rs:101-268), used as a Parameter's +# `rdf_type` -- but only its "flat" cases: IRI/BlankNode/Literal/Unknown/ +# Multi. All of these ultimately collapse to a single basic-type IRI once +# they reach the Rust side (templates::ast::PType::from(&RDFNodeState), +# lib/templates/src/ast.rs:211-227): IRI/BlankNode/Literal keep their own +# type IRI, Unknown has no type constraint, and Multi (a parameter that can +# hold more than one RDF node type) collapses all the way down to +# rdfs:Resource, the OTTR top type -- there is no richer "one-of these +# specific types" representation to preserve on the Rust side. Hence +# `RDFType` here is intentionally just a wrapper around that one resulting +# IRI (or NULL for Unknown), not a structured value. +# +# Built via plain functions (rdf_type_iri()/rdf_type_blank_node()/...), not +# `RDFType$IRI()`-style "static methods" the way Model$deserialize works -- +# S7 class generators refuse `$<-` assignment (`Can't set S7 properties with +# $`), unlike R6 generators or savvy's plain-environment classes, so that +# pattern isn't available here. +# +# `RDFType.Nested(...)` is NOT ported -- it represents a parameter whose +# column itself holds nested/list values (templates::MappingColumnType:: +# Nested / PType::List), which needs Parameter's Rust constructor to accept +# a richer type than a single basic-type IRI string. No current consumer +# needs it yet (Model$map() itself isn't ported), so it's left for +# follow-up work alongside the rest of the RDFType port. + +#' An RDF node type, for use as a Parameter's `rdf_type`. +#' +#' Mirrors py_maplib's `RDFType` (lib/representation/src/python.rs:101-268). +#' Only represents a single basic type -- see the note in +#' `maplibr/R/rdf_type.R` for why `rdf_type_multi()` collapses multiple types +#' down to one IRI (`rdfs:Resource`) rather than preserving the set, and why +#' `RDFType.Nested()` isn't ported. Build one with `rdf_type_iri()`, +#' `rdf_type_blank_node()`, `rdf_type_literal()`, `rdf_type_unknown()`, or +#' `rdf_type_multi()`, not by calling `RDFType()` directly. +#' +#' @param basic_type_iri The resolved basic-type IRI, or NULL for +#' `rdf_type_unknown()` (no type constraint). +#' @export +RDFType <- S7::new_class( + "RDFType", + package = "maplibr", + properties = list(basic_type_iri = S7::new_union(IRI, NULL)) +) + +#' @export +S7::method(format, RDFType) <- function(x, ...) { + if (is.null(x@basic_type_iri)) { + "RDFType.Unknown()" + } else { + paste0("RDFType(", x@basic_type_iri@iri, ")") + } +} + +#' @export +S7::method(print, RDFType) <- function(x, ...) { + cat(format(x), "\n", sep = "") + invisible(x) +} + +#' @rdname RDFType +#' @export +rdf_type_iri <- function() { + RDFType(basic_type_iri = IRI("http://ns.ottr.xyz/0.4/IRI")) +} + +#' @rdname RDFType +#' @export +rdf_type_blank_node <- function() { + RDFType(basic_type_iri = IRI("http://ns.ottr.xyz/0.4/BlankNode")) +} + +#' @param iri_or_datatype An IRI (the literal's datatype), or a plain IRI string. +#' @rdname RDFType +#' @export +rdf_type_literal <- function(iri_or_datatype) { + iri <- if (S7::S7_inherits(iri_or_datatype, IRI)) iri_or_datatype else IRI(iri_or_datatype) + RDFType(basic_type_iri = iri) +} + +#' @rdname RDFType +#' @export +rdf_type_unknown <- function() { + RDFType(basic_type_iri = NULL) +} + +#' @param rdf_types A list of RDFTypes. +#' @rdname RDFType +#' @export +rdf_type_multi <- function(rdf_types) { + stopifnot(all(vapply(rdf_types, function(t) S7::S7_inherits(t, RDFType), logical(1)))) + RDFType(basic_type_iri = rdfs$Resource) +} + +.as_rdf_type_iri <- function(x) { + if (is.null(x)) { + NULL + } else if (S7::S7_inherits(x, IRI)) { + x@iri + } else if (S7::S7_inherits(x, RDFType)) { + if (is.null(x@basic_type_iri)) NULL else x@basic_type_iri@iri + } else { + stop("`rdf_type` must be an IRI, an RDFType, or NULL", call. = FALSE) + } +} diff --git a/maplibr/R/templates.R b/maplibr/R/templates.R index d19d35f6..43bf4117 100644 --- a/maplibr/R/templates.R +++ b/maplibr/R/templates.R @@ -53,14 +53,14 @@ Argument <- S7::new_class( #' An OTTR template parameter. #' #' Mirrors py_maplib's `Parameter` (lib/templates/src/python.rs:21-167). -#' `rdf_type` only supports a basic (non-nested, non-list) RDF type, given as -#' a datatype/class IRI -- RDFType.Nested()/Multi() equivalents are not yet -#' ported (they need the RDFType port, maplib-snz's remaining scope). +#' `rdf_type` only supports a basic (non-nested, non-list) RDF type -- +#' `RDFType$Nested()` is not ported (see `maplibr/R/rdf_type.R`). #' #' @param variable A Variable. #' @param optional Whether this parameter is optional. #' @param allow_blank Whether a blank node is an acceptable value (default TRUE). -#' @param rdf_type Optional IRI: the parameter's expected RDF datatype/class. +#' @param rdf_type Optional: an IRI (a datatype/class IRI directly) or an +#' RDFType, giving the parameter's expected RDF type. #' @param default_value Optional default value: an IRI, BlankNode, or Literal. #' @export Parameter <- S7::new_class( @@ -73,12 +73,7 @@ Parameter <- S7::new_class( rdf_type = NULL, default_value = NULL) { stopifnot(S7::S7_inherits(variable, Variable)) - rdf_type_iri <- if (is.null(rdf_type)) { - NULL - } else { - stopifnot(S7::S7_inherits(rdf_type, IRI)) - rdf_type@iri - } + rdf_type_iri <- .as_rdf_type_iri(rdf_type) default_raw <- if (is.null(default_value)) { NULL } else { diff --git a/maplibr/man/Parameter.Rd b/maplibr/man/Parameter.Rd index 1a69d479..5a52d9e7 100644 --- a/maplibr/man/Parameter.Rd +++ b/maplibr/man/Parameter.Rd @@ -19,13 +19,13 @@ Parameter( \item{allow_blank}{Whether a blank node is an acceptable value (default TRUE).} -\item{rdf_type}{Optional IRI: the parameter's expected RDF datatype/class.} +\item{rdf_type}{Optional: an IRI (a datatype/class IRI directly) or an +RDFType, giving the parameter's expected RDF type.} \item{default_value}{Optional default value: an IRI, BlankNode, or Literal.} } \description{ Mirrors py_maplib's \code{Parameter} (lib/templates/src/python.rs:21-167). -\code{rdf_type} only supports a basic (non-nested, non-list) RDF type, given as -a datatype/class IRI -- RDFType.Nested()/Multi() equivalents are not yet -ported (they need the RDFType port, maplib-snz's remaining scope). +\code{rdf_type} only supports a basic (non-nested, non-list) RDF type -- +\code{RDFType$Nested()} is not ported (see \code{maplibr/R/rdf_type.R}). } diff --git a/maplibr/man/RDFType.Rd b/maplibr/man/RDFType.Rd new file mode 100644 index 00000000..ac328ee8 --- /dev/null +++ b/maplibr/man/RDFType.Rd @@ -0,0 +1,40 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/rdf_type.R +\name{RDFType} +\alias{RDFType} +\alias{rdf_type_iri} +\alias{rdf_type_blank_node} +\alias{rdf_type_literal} +\alias{rdf_type_unknown} +\alias{rdf_type_multi} +\title{An RDF node type, for use as a Parameter's \code{rdf_type}.} +\usage{ +RDFType(basic_type_iri = IRI()) + +rdf_type_iri() + +rdf_type_blank_node() + +rdf_type_literal(iri_or_datatype) + +rdf_type_unknown() + +rdf_type_multi(rdf_types) +} +\arguments{ +\item{basic_type_iri}{The resolved basic-type IRI, or NULL for +\code{rdf_type_unknown()} (no type constraint).} + +\item{iri_or_datatype}{An IRI (the literal's datatype), or a plain IRI string.} + +\item{rdf_types}{A list of RDFTypes.} +} +\description{ +Mirrors py_maplib's \code{RDFType} (lib/representation/src/python.rs:101-268). +Only represents a single basic type -- see the note in +\code{maplibr/R/rdf_type.R} for why \code{rdf_type_multi()} collapses multiple types +down to one IRI (\code{rdfs:Resource}) rather than preserving the set, and why +\code{RDFType.Nested()} isn't ported. Build one with \code{rdf_type_iri()}, +\code{rdf_type_blank_node()}, \code{rdf_type_literal()}, \code{rdf_type_unknown()}, or +\code{rdf_type_multi()}, not by calling \code{RDFType()} directly. +} From 1f4a7e48f0397d39a36e5c65f403f9e80d94f3b6 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 10:28:41 +0200 Subject: [PATCH 13/46] fix(maplibr): recover from Mutex poison instead of panicking forever - a panic while holding RModel's lock (e.g. serialize() hitting the disk stub) poisoned it, breaking every later call on that instance - added a lock() helper that ignores poison, routed all 12 call sites through it - verified: create_index()/reads() succeed after a prior serialize() panic Co-Authored-By: Claude Sonnet 5 --- maplibr/src/rust/src/model.rs | 41 +++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index 7254a342..84b07daf 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -41,6 +41,23 @@ pub struct RModel { inner: Mutex, } +impl RModel { + /// Lock the inner Model, recovering from poison rather than panicking. + /// + /// std's Mutex poisons itself if a panic happens while it's held (e.g. + /// serialize()/deserialize() hitting the lib/disk stub's unimplemented!()) + /// -- left as the default `.lock().unwrap()`, every later call on the + /// SAME RModel would then panic forever with PoisonError, even for + /// operations unrelated to whatever originally panicked. Python's GIL + /// mutex has no poisoning concept, so `into_inner()` (ignore the poison, + /// keep using the data) is the closest match to py_maplib's behavior -- + /// the data itself is still perfectly valid, since maplib's own methods + /// don't panic mid-mutation in a way that would leave it inconsistent. + fn lock(&self) -> std::sync::MutexGuard<'_, maplib::model::Model> { + self.inner.lock().unwrap_or_else(|e| e.into_inner()) + } +} + #[savvy] impl RModel { /// Create a new, empty Model. @@ -69,7 +86,7 @@ impl RModel { /// /// @export fn size(&self) -> savvy::Result { - let inner = self.inner.lock().unwrap(); + let inner = self.lock(); let graph = NamedGraph::from_maybe_named_node(None); (inner.graph_size(&graph) as i32).try_into() } @@ -83,7 +100,7 @@ impl RModel { fn reads(&self, s: &str, format: &str, graph: Option<&str>) -> savvy::Result<()> { let format = resolve_format(format)?; let named_graph = parse_optional_named_graph(graph)?; - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.lock(); inner .reads( s, @@ -111,7 +128,7 @@ impl RModel { .transpose()? .unwrap_or(RdfFormat::NTriples); let named_graph = parse_optional_named_graph(graph)?; - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.lock(); let mut out = Vec::new(); inner .write_triples(&mut out, &named_graph, format, None) @@ -126,7 +143,7 @@ impl RModel { /// /// @export fn create_index(&self) -> savvy::Result<()> { - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.lock(); inner .create_index(IndexingOptions::default()) .map_err(|e| savvy::Error::new(&e.to_string())) @@ -140,7 +157,7 @@ impl RModel { /// @export fn truncate_graph(&self, graph: Option<&str>) -> savvy::Result<()> { let named_graph = parse_optional_named_graph(graph)?; - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.lock(); inner.triplestore.truncate(&named_graph); Ok(()) } @@ -159,7 +176,7 @@ impl RModel { let nn = NamedNode::new(iri).map_err(|e| savvy::Error::new(&e.to_string()))?; parsed.insert(name.to_string(), nn); } - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.lock(); inner.prefixes.extend(parsed); Ok(()) } @@ -179,8 +196,8 @@ impl RModel { ) -> savvy::Result<()> { let source_graph = parse_optional_named_graph(source_graph)?; let target_graph = parse_optional_named_graph(target_graph)?; - let other_inner = other.inner.lock().unwrap(); - let mut inner = self.inner.lock().unwrap(); + let other_inner = other.lock(); + let mut inner = self.lock(); inner .add_graph(&other_inner.triplestore, source_graph, target_graph) .map_err(|e| savvy::Error::new(&e.to_string())) @@ -196,7 +213,7 @@ impl RModel { /// @export fn detach_graph(&self, preserve_name: bool, graph: Option<&str>) -> savvy::Result { let named_graph = parse_optional_named_graph(graph)?; - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.lock(); let sprout = inner .detach_graph(&named_graph, preserve_name) .map_err(|e| savvy::Error::new(&e.to_string()))?; @@ -213,7 +230,7 @@ impl RModel { /// @export fn infer_rdfs(&self, graph: Option<&str>) -> savvy::Result { let named_graph = parse_optional_named_graph(graph)?; - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.lock(); let n = inner .infer_rdfs(&named_graph) .map_err(|e| savvy::Error::new(&e.to_string()))?; @@ -224,7 +241,7 @@ impl RModel { /// /// @export fn compact(&self) -> savvy::Result<()> { - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.lock(); inner.compact().map_err(|e| savvy::Error::new(&e.to_string())) } @@ -235,7 +252,7 @@ impl RModel { /// @param path Directory to serialize into. /// @export fn serialize(&self, path: &str) -> savvy::Result<()> { - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.lock(); inner .serialize_triples(Path::new(path)) .map_err(|e| savvy::Error::new(&e.to_string())) From 5c2d4046c403167cce91d7bd9ea86e13efed3543 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 10:34:00 +0200 Subject: [PATCH 14/46] feat(maplibr): map MaplibError to typed, catchable R conditions - centralizes error conversion via a `[tag] message` prefix, parsed back into a classed R condition - R code can tryCatch by class (maplibr_argument_error, maplibr_maplib_error) - verified live for both error kinds Co-Authored-By: Claude Sonnet 5 --- maplibr/DESCRIPTION | 1 + maplibr/R/errors.R | 51 +++++++++++++++++++++ maplibr/R/model.R | 24 +++++----- maplibr/src/rust/src/errors.rs | 76 +++++++++++++++++++++++++++++++ maplibr/src/rust/src/lib.rs | 1 + maplibr/src/rust/src/model.rs | 30 ++++++------ maplibr/src/rust/src/templates.rs | 18 ++++---- maplibr/src/rust/src/terms.rs | 6 +-- 8 files changed, 169 insertions(+), 38 deletions(-) create mode 100644 maplibr/R/errors.R create mode 100644 maplibr/src/rust/src/errors.rs diff --git a/maplibr/DESCRIPTION b/maplibr/DESCRIPTION index d4294c37..42445a98 100644 --- a/maplibr/DESCRIPTION +++ b/maplibr/DESCRIPTION @@ -15,6 +15,7 @@ RoxygenNote: 8.0.0 SystemRequirements: Cargo (Rust's package manager), rustc Collate: '000-wrappers.R' + 'errors.R' 'terms.R' 'vocab.R' 'rdf_type.R' diff --git a/maplibr/R/errors.R b/maplibr/R/errors.R new file mode 100644 index 00000000..3f74489c --- /dev/null +++ b/maplibr/R/errors.R @@ -0,0 +1,51 @@ +# R-side half of maplib-dma: mirrors the intent of py_maplib's typed +# PyMaplibError/MaplibException/FunctionArgumentException hierarchy +# (py_maplib/src/error.rs), adapted to savvy's error boundary, which can only +# carry a plain message string across (unlike pyo3, which lets Rust raise an +# actual typed Python exception object). maplibr/src/rust/src/errors.rs +# tags every error it raises with a `[maplibr_xxx_error] ` prefix; +# `.rethrow()` parses that prefix back out here and re-raises as a proper R +# condition carrying a matching class, so R code can catch by class the same +# way Python code catches by exception type: +# +# tryCatch(m$reads(...), maplibr_argument_error = function(e) ...) +# +# Every Model method that calls into the underlying RModel savvy object goes +# through `.rethrow()` (see model.R) -- S7 term/template constructors +# (terms.R/templates.R) still surface the tagged message as plain text via +# their validators (S7 wraps validator failures in its own condition class +# already), not reclassified the same way; that's lower-value there since +# those are all just simple argument-validation failures to begin with, not +# the richer MaplibError/RuntimeError distinction this exists for. + +.maplibr_error_classes <- c( + "maplibr_maplib_error", + "maplibr_argument_error", + "maplibr_runtime_error" +) + +.reclass_maplibr_error <- function(e) { + msg <- conditionMessage(e) + m <- regmatches(msg, regexec("^\\[(maplibr_[a-z_]+)\\] (.*)$", msg))[[1]] + if (length(m) == 3 && m[2] %in% .maplibr_error_classes) { + cls <- c(m[2], "maplibr_error", "error", "condition") + msg <- m[3] + } else { + cls <- c("maplibr_error", "error", "condition") + } + stop(structure(class = cls, list(message = msg, call = NULL))) +} + +#' Run `expr`, reclassifying any maplibr-tagged error into a proper R +#' condition catchable by class. +#' +#' @param expr An expression to evaluate. +#' @noRd +.rethrow <- function(expr) { + tryCatch(expr, error = function(e) { + if (inherits(e, "maplibr_error")) { + stop(e) + } + .reclass_maplibr_error(e) + }) +} diff --git a/maplibr/R/model.R b/maplibr/R/model.R index 3794939c..7cd3a3ac 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -16,7 +16,7 @@ Model <- R6::R6Class( #' @param rmodel Internal use only. initialize = function(rmodel = NULL) { if (is.null(rmodel)) { - rmodel <- RModel$new() + rmodel <- .rethrow(RModel$new()) } else if (!inherits(rmodel, "RModel")) { stop("`rmodel` must be an RModel object", call. = FALSE) } @@ -33,7 +33,7 @@ Model <- R6::R6Class( #' @param format One of "ntriples", "turtle", "xml" (rdf/xml). #' @param graph Optional named graph IRI to read into (default graph if NULL). reads = function(s, format, graph = NULL) { - private$rmodel$reads(s, format, graph) + .rethrow(private$rmodel$reads(s, format, graph)) invisible(self) }, @@ -41,19 +41,19 @@ Model <- R6::R6Class( #' @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". #' @param graph Optional named graph IRI to write (default graph if NULL). writes = function(format = NULL, graph = NULL) { - private$rmodel$writes(format, graph) + .rethrow(private$rmodel$writes(format, graph)) }, #' @description Build the default (non-FTS) indexes. create_index = function() { - private$rmodel$create_index() + .rethrow(private$rmodel$create_index()) invisible(self) }, #' @description Remove all triples from a graph. #' @param graph Optional named graph IRI to truncate (default graph if NULL). truncate_graph = function(graph = NULL) { - private$rmodel$truncate_graph(graph) + .rethrow(private$rmodel$truncate_graph(graph)) invisible(self) }, @@ -61,7 +61,7 @@ Model <- R6::R6Class( #' @param prefixes A named R list of single strings: names are prefixes, #' values are IRIs, e.g. `list(ex = "http://example.org/")`. add_prefixes = function(prefixes) { - private$rmodel$add_prefixes(prefixes) + .rethrow(private$rmodel$add_prefixes(prefixes)) invisible(self) }, @@ -73,7 +73,7 @@ Model <- R6::R6Class( if (!inherits(other, "Model")) { stop("`other` must be a Model object", call. = FALSE) } - private$rmodel$add_graph(private$as_rmodel(other), source_graph, target_graph) + .rethrow(private$rmodel$add_graph(private$as_rmodel(other), source_graph, target_graph)) invisible(self) }, @@ -83,19 +83,19 @@ Model <- R6::R6Class( #' @param graph Optional named graph IRI to detach (default graph if NULL). #' @return A new Model containing only the detached graph. detach_graph = function(preserve_name = FALSE, graph = NULL) { - Model$new(private$rmodel$detach_graph(preserve_name, graph)) + Model$new(.rethrow(private$rmodel$detach_graph(preserve_name, graph))) }, #' @description Run RDFS inference over a graph in place. #' @param graph Optional named graph IRI to infer over (default graph if NULL). #' @return The number of interesting inference rules applied. infer_rdfs = function(graph = NULL) { - private$rmodel$infer_rdfs(graph) + .rethrow(private$rmodel$infer_rdfs(graph)) }, #' @description Compact on-disk storage. compact = function() { - private$rmodel$compact() + .rethrow(private$rmodel$compact()) invisible(self) }, @@ -104,7 +104,7 @@ Model <- R6::R6Class( #' use `$writes()` for that). #' @param path Directory to serialize into. serialize = function(path) { - private$rmodel$serialize(path) + .rethrow(private$rmodel$serialize(path)) invisible(self) }, @@ -128,5 +128,5 @@ Model <- R6::R6Class( # `Model$deserialize(path)` -- mirrors RModel's own associated-function # pattern in 000-wrappers.R. Model$deserialize <- function(path, storage_folder = NULL) { - Model$new(RModel$deserialize(path, storage_folder)) + Model$new(.rethrow(RModel$deserialize(path, storage_folder))) } diff --git a/maplibr/src/rust/src/errors.rs b/maplibr/src/rust/src/errors.rs new file mode 100644 index 00000000..97ae590c --- /dev/null +++ b/maplibr/src/rust/src/errors.rs @@ -0,0 +1,76 @@ +use std::fmt::Display; + +/// Mirrors py_maplib's `PyMaplibError` (py_maplib/src/error.rs) -- but savvy's +/// FFI boundary can only carry a plain error message string across (unlike +/// pyo3, which lets Rust raise an actual typed Python exception object), so +/// the "typed exception" intent travels as a machine-parseable `[tag] +/// message` prefix instead. `maplibr/R/errors.R`'s `.rethrow()` parses that +/// tag back out and re-raises as a proper R condition with a matching class, +/// so R code can `tryCatch(..., maplibr_argument_error = function(e) ...)` +/// the same way Python code can `except FunctionArgumentException`. +pub enum RMaplibError { + /// Wraps `maplib::errors::MaplibError` -- mirrors + /// `PyMaplibError::MaplibError`, the transparent `#[from]` variant + /// covering all of `MaplibError`'s many inner error types uniformly. + /// Matches py_maplib's own granularity: one exception class for the + /// whole enum, not one per inner variant. + Maplib(maplib::errors::MaplibError), + /// A bad argument from R: invalid IRI/variable-name/blank-node-id + /// syntax, an unrecognized format/list_expander string, etc. Mirrors + /// `PyMaplibError::FunctionArgumentError`. + Argument(String), + /// Something that should never happen (e.g. maplib producing triple + /// output that isn't valid UTF-8). Mirrors `PyMaplibError::RuntimeError`. + Runtime(String), +} + +impl RMaplibError { + fn tag(&self) -> &'static str { + match self { + RMaplibError::Maplib(_) => "maplibr_maplib_error", + RMaplibError::Argument(_) => "maplibr_argument_error", + RMaplibError::Runtime(_) => "maplibr_runtime_error", + } + } +} + +impl Display for RMaplibError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RMaplibError::Maplib(e) => write!(f, "{e}"), + RMaplibError::Argument(s) => write!(f, "{s}"), + RMaplibError::Runtime(s) => write!(f, "{s}"), + } + } +} + +impl From for RMaplibError { + fn from(e: maplib::errors::MaplibError) -> Self { + RMaplibError::Maplib(e) + } +} + +impl From for savvy::Error { + fn from(e: RMaplibError) -> Self { + savvy::Error::new(&format!("[{}] {}", e.tag(), e)) + } +} + +/// Wrap a bad-argument error (an `IriParseError`, `VariableNameParseError`, +/// `BlankNodeIdParseError`, or a hand-written message) as a `savvy::Error` +/// tagged for R-side reclassification via `.rethrow()`. +pub fn argument_error(msg: impl Display) -> savvy::Error { + RMaplibError::Argument(msg.to_string()).into() +} + +/// Wrap an "this should never happen" error as a `savvy::Error` tagged for +/// R-side reclassification via `.rethrow()`. +pub fn runtime_error(msg: impl Display) -> savvy::Error { + RMaplibError::Runtime(msg.to_string()).into() +} + +/// Wrap a `maplib::errors::MaplibError` as a `savvy::Error` tagged for +/// R-side reclassification via `.rethrow()`. +pub fn maplib_error(e: maplib::errors::MaplibError) -> savvy::Error { + RMaplibError::from(e).into() +} diff --git a/maplibr/src/rust/src/lib.rs b/maplibr/src/rust/src/lib.rs index 49b2491e..71966492 100644 --- a/maplibr/src/rust/src/lib.rs +++ b/maplibr/src/rust/src/lib.rs @@ -1,4 +1,5 @@ mod arrow_bridge; +mod errors; mod model; mod templates; mod terms; diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index 84b07daf..a2ee6d36 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -1,3 +1,5 @@ +use crate::errors::argument_error; +use crate::errors::maplib_error; use oxrdf::NamedNode; use oxrdfio::RdfFormat; use representation::dataset::NamedGraph; @@ -16,7 +18,7 @@ fn resolve_normal_format(format: &str) -> savvy::Result { "ntriples" => Ok(RdfFormat::NTriples), "turtle" => Ok(RdfFormat::Turtle), "rdf/xml" | "xml" | "rdfxml" => Ok(RdfFormat::RdfXml), - _ => Err(savvy::Error::new(&format!("Unknown format: {}", format))), + _ => Err(argument_error(format!("Unknown format: {}", format))), } } @@ -28,7 +30,7 @@ fn resolve_format(format: &str) -> savvy::Result { fn parse_optional_named_graph(graph: Option<&str>) -> savvy::Result { let nn = graph - .map(|g| NamedNode::new(g).map_err(|e| savvy::Error::new(&e.to_string()))) + .map(|g| NamedNode::new(g).map_err(argument_error)) .transpose()?; Ok(NamedGraph::from_maybe_named_node(nn.as_ref())) } @@ -76,7 +78,7 @@ impl RModel { pyo3::Python::initialize(); let model = maplib::model::Model::new(None, None, None, None) - .map_err(|e| savvy::Error::new(&e.to_string()))?; + .map_err(maplib_error)?; Ok(Self { inner: Mutex::new(model), }) @@ -114,7 +116,7 @@ impl RModel { DEFAULT_TRIPLES_BATCH_SIZE, HashMap::new(), ) - .map_err(|e| savvy::Error::new(&e.to_string())) + .map_err(maplib_error) } /// Serialize this Model's triples to a string. @@ -132,9 +134,9 @@ impl RModel { let mut out = Vec::new(); inner .write_triples(&mut out, &named_graph, format, None) - .map_err(|e| savvy::Error::new(&e.to_string()))?; + .map_err(maplib_error)?; String::from_utf8(out) - .map_err(|e| savvy::Error::new(&e.to_string()))? + .map_err(crate::errors::runtime_error)? .try_into() } @@ -146,7 +148,7 @@ impl RModel { let mut inner = self.lock(); inner .create_index(IndexingOptions::default()) - .map_err(|e| savvy::Error::new(&e.to_string())) + .map_err(maplib_error) } /// Remove all triples from a graph (mirrors truncate_graph_mutex, @@ -173,7 +175,7 @@ impl RModel { let mut parsed = HashMap::new(); for (name, value) in prefixes.iter() { let iri = <&str>::try_from(value)?; - let nn = NamedNode::new(iri).map_err(|e| savvy::Error::new(&e.to_string()))?; + let nn = NamedNode::new(iri).map_err(argument_error)?; parsed.insert(name.to_string(), nn); } let mut inner = self.lock(); @@ -200,7 +202,7 @@ impl RModel { let mut inner = self.lock(); inner .add_graph(&other_inner.triplestore, source_graph, target_graph) - .map_err(|e| savvy::Error::new(&e.to_string())) + .map_err(maplib_error) } /// Split a graph out of this Model into a brand new, standalone Model @@ -216,7 +218,7 @@ impl RModel { let mut inner = self.lock(); let sprout = inner .detach_graph(&named_graph, preserve_name) - .map_err(|e| savvy::Error::new(&e.to_string()))?; + .map_err(maplib_error)?; Ok(RModel { inner: Mutex::new(sprout), }) @@ -233,7 +235,7 @@ impl RModel { let mut inner = self.lock(); let n = inner .infer_rdfs(&named_graph) - .map_err(|e| savvy::Error::new(&e.to_string()))?; + .map_err(maplib_error)?; (n as i32).try_into() } @@ -242,7 +244,7 @@ impl RModel { /// @export fn compact(&self) -> savvy::Result<()> { let mut inner = self.lock(); - inner.compact().map_err(|e| savvy::Error::new(&e.to_string())) + inner.compact().map_err(maplib_error) } /// Serialize this Model's triples to a directory in maplib's own compact @@ -255,7 +257,7 @@ impl RModel { let mut inner = self.lock(); inner .serialize_triples(Path::new(path)) - .map_err(|e| savvy::Error::new(&e.to_string())) + .map_err(maplib_error) } /// Load a Model previously written by serialize() (mirrors @@ -271,7 +273,7 @@ impl RModel { Path::new(path), storage_folder.map(String::from), ) - .map_err(|e| savvy::Error::new(&e.to_string()))?; + .map_err(maplib_error)?; Ok(RModel { inner: Mutex::new(model), }) diff --git a/maplibr/src/rust/src/templates.rs b/maplibr/src/rust/src/templates.rs index 79a3242b..f7d7c2d6 100644 --- a/maplibr/src/rust/src/templates.rs +++ b/maplibr/src/rust/src/templates.rs @@ -12,7 +12,7 @@ fn parse_list_expander(s: Option<&str>) -> savvy::Result Ok(Some(ListExpanderType::Cross)), Some("zipMin") => Ok(Some(ListExpanderType::ZipMin)), Some("zipMax") => Ok(Some(ListExpanderType::ZipMax)), - Some(other) => Err(savvy::Error::new(&format!( + Some(other) => Err(crate::errors::argument_error(format!( "Unknown list_expander \"{other}\", expected one of \"cross\", \"zipMin\", \"zipMax\"" ))), } @@ -30,14 +30,14 @@ pub struct RConstantTerm { #[savvy] impl RConstantTerm { fn iri(iri: &str) -> savvy::Result { - let nn = NamedNode::new(iri).map_err(|e| savvy::Error::new(&e.to_string()))?; + let nn = NamedNode::new(iri).map_err(crate::errors::argument_error)?; Ok(RConstantTerm { inner: ConstantTerm::Iri(nn), }) } fn blank_node(id: &str) -> savvy::Result { - let bn = BlankNode::new(id).map_err(|e| savvy::Error::new(&e.to_string()))?; + let bn = BlankNode::new(id).map_err(crate::errors::argument_error)?; Ok(RConstantTerm { inner: ConstantTerm::BlankNode(bn), }) @@ -48,7 +48,7 @@ impl RConstantTerm { OxLiteral::new_language_tagged_literal_unchecked(value, language) } else { let dt = - NamedNode::new(datatype_iri).map_err(|e| savvy::Error::new(&e.to_string()))?; + NamedNode::new(datatype_iri).map_err(crate::errors::argument_error)?; OxLiteral::new_typed_literal(value, dt) }; Ok(RConstantTerm { @@ -75,7 +75,7 @@ pub struct RArgument { #[savvy] impl RArgument { fn from_variable(name: &str, list_expand: bool) -> savvy::Result { - let v = Variable::new(name).map_err(|e| savvy::Error::new(&e.to_string()))?; + let v = Variable::new(name).map_err(crate::errors::argument_error)?; Ok(RArgument { inner: Argument { list_expand, @@ -118,12 +118,12 @@ impl RParameter { default_value: Option<&RConstantTerm>, ) -> savvy::Result { let variable = - Variable::new(variable_name).map_err(|e| savvy::Error::new(&e.to_string()))?; + Variable::new(variable_name).map_err(crate::errors::argument_error)?; let ptype = rdf_type_iri .map(|iri| { NamedNode::new(iri) .map(PType::Basic) - .map_err(|e| savvy::Error::new(&e.to_string())) + .map_err(crate::errors::argument_error) }) .transpose()?; let default_value = @@ -157,7 +157,7 @@ impl RInstance { list_expander: Option<&str>, ) -> savvy::Result { let template_iri = - NamedNode::new(template_iri).map_err(|e| savvy::Error::new(&e.to_string()))?; + NamedNode::new(template_iri).map_err(crate::errors::argument_error)?; let list_expander = parse_list_expander(list_expander)?; let mut argument_list = Vec::new(); for (_, value) in arguments.iter() { @@ -190,7 +190,7 @@ pub struct RTemplate { #[savvy] impl RTemplate { fn new(iri: &str, parameters: ListSexp, instances: ListSexp) -> savvy::Result { - let iri = NamedNode::new(iri).map_err(|e| savvy::Error::new(&e.to_string()))?; + let iri = NamedNode::new(iri).map_err(crate::errors::argument_error)?; let mut parameter_list = Vec::new(); for (_, value) in parameters.iter() { let p = <&RParameter>::try_from(value)?; diff --git a/maplibr/src/rust/src/terms.rs b/maplibr/src/rust/src/terms.rs index ec8580bf..d800dcae 100644 --- a/maplibr/src/rust/src/terms.rs +++ b/maplibr/src/rust/src/terms.rs @@ -9,7 +9,7 @@ use savvy::savvy; /// @export #[savvy] fn validate_iri(iri: &str) -> savvy::Result<()> { - NamedNode::new(iri).map_err(|e| savvy::Error::new(&e.to_string()))?; + NamedNode::new(iri).map_err(crate::errors::argument_error)?; Ok(()) } @@ -21,7 +21,7 @@ fn validate_iri(iri: &str) -> savvy::Result<()> { /// @export #[savvy] fn validate_variable_name(name: &str) -> savvy::Result<()> { - Variable::new(name).map_err(|e| savvy::Error::new(&e.to_string()))?; + Variable::new(name).map_err(crate::errors::argument_error)?; Ok(()) } @@ -33,6 +33,6 @@ fn validate_variable_name(name: &str) -> savvy::Result<()> { /// @export #[savvy] fn validate_blank_node_id(id: &str) -> savvy::Result<()> { - BlankNode::new(id).map_err(|e| savvy::Error::new(&e.to_string()))?; + BlankNode::new(id).map_err(crate::errors::argument_error)?; Ok(()) } From edbfab152dad7f2c21ef6e897b5a85bf27047269 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 10:44:02 +0200 Subject: [PATCH 15/46] test(maplibr): add testthat scaffold, port query()-free py_maplib tests - covers model basics/graphs, errors, terms/templates/vocab (61 tests) - infer_rdfs comparison reduced to a smoke test: writes() can't serialize transient triples (maplib-tr6) - remaining ~25 pytest files need Model$query()/map() first (maplib-l2j) Co-Authored-By: Claude Sonnet 5 --- maplibr/DESCRIPTION | 3 + maplibr/tests/testthat.R | 12 ++++ maplibr/tests/testthat/test-model-basics.R | 43 ++++++++++++ maplibr/tests/testthat/test-model-errors.R | 34 ++++++++++ maplibr/tests/testthat/test-model-graphs.R | 43 ++++++++++++ .../tests/testthat/test-model-infer-rdfs.R | 31 +++++++++ maplibr/tests/testthat/test-templates.R | 65 +++++++++++++++++++ maplibr/tests/testthat/test-terms.R | 51 +++++++++++++++ maplibr/tests/testthat/test-vocab.R | 30 +++++++++ .../testdata/rdfs/rdfs10/expected.ttl | 6 ++ .../testthat/testdata/rdfs/rdfs10/input.ttl | 5 ++ .../testdata/rdfs/rdfs11/expected.ttl | 7 ++ .../testthat/testdata/rdfs/rdfs11/input.ttl | 7 ++ .../testthat/testdata/rdfs/rdfs2/expected.ttl | 8 +++ .../testthat/testdata/rdfs/rdfs2/input.ttl | 6 ++ .../testthat/testdata/rdfs/rdfs3/expected.ttl | 8 +++ .../testthat/testdata/rdfs/rdfs3/input.ttl | 6 ++ .../testthat/testdata/rdfs/rdfs5/expected.ttl | 16 +++++ .../testthat/testdata/rdfs/rdfs5/input.ttl | 14 ++++ .../testthat/testdata/rdfs/rdfs6/expected.ttl | 7 ++ .../testthat/testdata/rdfs/rdfs6/input.ttl | 6 ++ .../testthat/testdata/rdfs/rdfs8/expected.ttl | 5 ++ .../testthat/testdata/rdfs/rdfs8/input.ttl | 5 ++ .../testthat/testdata/rdfs/rdfs9/expected.ttl | 12 ++++ .../testthat/testdata/rdfs/rdfs9/input.ttl | 7 ++ 25 files changed, 437 insertions(+) create mode 100644 maplibr/tests/testthat.R create mode 100644 maplibr/tests/testthat/test-model-basics.R create mode 100644 maplibr/tests/testthat/test-model-errors.R create mode 100644 maplibr/tests/testthat/test-model-graphs.R create mode 100644 maplibr/tests/testthat/test-model-infer-rdfs.R create mode 100644 maplibr/tests/testthat/test-templates.R create mode 100644 maplibr/tests/testthat/test-terms.R create mode 100644 maplibr/tests/testthat/test-vocab.R create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs10/expected.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs10/input.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs11/expected.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs11/input.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs2/expected.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs2/input.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs3/expected.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs3/input.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs5/expected.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs5/input.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs6/expected.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs6/input.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs8/expected.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs8/input.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs9/expected.ttl create mode 100644 maplibr/tests/testthat/testdata/rdfs/rdfs9/input.ttl diff --git a/maplibr/DESCRIPTION b/maplibr/DESCRIPTION index 42445a98..e15c1f96 100644 --- a/maplibr/DESCRIPTION +++ b/maplibr/DESCRIPTION @@ -22,3 +22,6 @@ Collate: 'templates.R' 'model.R' 'zzz.R' +Suggests: + testthat (>= 3.0.0) +Config/testthat/edition: 3 diff --git a/maplibr/tests/testthat.R b/maplibr/tests/testthat.R new file mode 100644 index 00000000..e755b65c --- /dev/null +++ b/maplibr/tests/testthat.R @@ -0,0 +1,12 @@ +# This file is part of the standard setup for testthat. +# It is recommended that you do not modify it. +# +# Where should you do additional test configuration? +# Learn more about the roles of various files in: +# * https://r-pkgs.org/testing-design.html#sec-tests-files-overview +# * https://testthat.r-lib.org/articles/special-files.html + +library(testthat) +library(maplibr) + +test_check("maplibr") diff --git a/maplibr/tests/testthat/test-model-basics.R b/maplibr/tests/testthat/test-model-basics.R new file mode 100644 index 00000000..53034ade --- /dev/null +++ b/maplibr/tests/testthat/test-model-basics.R @@ -0,0 +1,43 @@ +# Ports the non-query subset of py_maplib/tests/test_basics.py. Most of +# that file's assertions go through Model.query()/Model.map(), neither of +# which exist in maplibr yet (see maplib-a2d's close notes) -- only the +# handful of assertions reachable without them are ported here. + +test_that("writes() on an empty Model returns without erroring", { + # Mirrors test_write_empty_model (py_maplib/tests/test_basics.py:20-22). + m <- Model$new() + s <- m$writes() + expect_type(s, "character") +}) + +test_that("reads()/writes() round-trip and chain", { + m <- Model$new() + result <- m$reads(' "c" .', format = "ntriples") + expect_identical(result, m) # chainable, returns self invisibly + + expect_equal(m$size(), 1) + expect_match(m$writes(format = "ntriples"), "", fixed = TRUE) +}) + +test_that("create_index() is chainable and doesn't error on a populated Model", { + m <- Model$new() + m$reads(' "c" .', format = "ntriples") + result <- m$create_index() + expect_identical(result, m) +}) + +test_that("truncate_graph() empties the default graph", { + m <- Model$new() + m$reads(' "c" .', format = "ntriples") + expect_equal(m$size(), 1) + m$truncate_graph() + expect_equal(m$size(), 0) +}) + +test_that("add_prefixes() affects writes() output", { + m <- Model$new() + m$reads(' .', format = "ntriples") + m$add_prefixes(list(ex = "http://example.org/")) + out <- m$writes(format = "turtle") + expect_match(out, "@prefix ex: ", fixed = TRUE) +}) diff --git a/maplibr/tests/testthat/test-model-errors.R b/maplibr/tests/testthat/test-model-errors.R new file mode 100644 index 00000000..2e4f18d4 --- /dev/null +++ b/maplibr/tests/testthat/test-model-errors.R @@ -0,0 +1,34 @@ +# Ports the portable subset of py_maplib/tests/test_exceptions.py. Most of +# that file's exception cases only trigger inside query() (not implemented +# in maplibr yet); test_model_exception (invalid RDF syntax) doesn't need +# query() at all and ports directly. See maplib-dma's close notes for the +# maplibr_maplib_error/maplibr_argument_error condition classes. + +test_that("reads() with invalid RDF syntax raises a catchable maplibr_maplib_error", { + # Mirrors test_model_exception (py_maplib/tests/test_exceptions.py:7-11). + m <- Model$new() + expect_error( + m$reads("abc", format = "turtle", graph = "http://example.com/data"), + class = "maplibr_maplib_error" + ) +}) + +test_that("reads() with an unrecognized format raises a catchable maplibr_argument_error", { + m <- Model$new() + expect_error( + m$reads(' .', format = "not-a-format"), + class = "maplibr_argument_error" + ) +}) + +test_that("an unrecognized list_expander raises an error naming it", { + # Not reclassed to maplibr_argument_error like Model's methods are -- + # templates.R's plain constructors aren't wired through .rethrow() (see + # errors.R's top-of-file note) -- but the underlying [maplibr_argument_error] + # tag is still present in the raw message. + a <- IRI("http://example.org/a") + expect_error( + Triple(a, a, a, list_expander = "not-a-real-expander"), + regexp = "not-a-real-expander" + ) +}) diff --git a/maplibr/tests/testthat/test-model-graphs.R b/maplibr/tests/testthat/test-model-graphs.R new file mode 100644 index 00000000..acd1d135 --- /dev/null +++ b/maplibr/tests/testthat/test-model-graphs.R @@ -0,0 +1,43 @@ +# Ports the non-query subset of py_maplib/tests/test_named_graphs.py. +# Those tests confirm named-graph routing via query() and a per-graph +# m.size(graph) that RModel$size() doesn't support yet (it always counts the +# default graph -- see model.rs; a real gap, not something to route around +# here). Routing is instead confirmed via writes(graph=), which does take a +# graph argument, without needing query() at all. + +gr1 <- ' "A" .' +gr2 <- ' "B" .' + +test_that("reads() routes triples into the requested named graph", { + # Mirrors test_basic_named_graph (py_maplib/tests/test_named_graphs.py:17-30). + ng2 <- "urn:graph:gr2" + m <- Model$new() + m$reads(gr1, format = "ntriples") + m$reads(gr2, format = "ntriples", graph = ng2) + + expect_equal(m$size(), 1) # default graph only has gr1's triple + expect_match(m$writes(format = "ntriples", graph = ng2), '"B"', fixed = TRUE) + expect_false(grepl('"B"', m$writes(format = "ntriples"), fixed = TRUE)) +}) + +test_that("add_graph() copies a named graph's triples into another Model", { + m1 <- Model$new() + m1$reads(gr1, format = "ntriples") + + m2 <- Model$new() + m2$add_graph(m1) + expect_equal(m2$size(), 1) + expect_match(m2$writes(format = "ntriples"), '"A"', fixed = TRUE) +}) + +test_that("detach_graph() splits a graph into a new, standalone Model", { + ng2 <- "urn:graph:gr2" + m <- Model$new() + m$reads(gr1, format = "ntriples") + m$reads(gr2, format = "ntriples", graph = ng2) + + detached <- m$detach_graph(graph = ng2) + expect_true(inherits(detached, "Model")) + expect_equal(detached$size(), 1) + expect_match(detached$writes(format = "ntriples"), '"B"', fixed = TRUE) +}) diff --git a/maplibr/tests/testthat/test-model-infer-rdfs.R b/maplibr/tests/testthat/test-model-infer-rdfs.R new file mode 100644 index 00000000..a4543c35 --- /dev/null +++ b/maplibr/tests/testthat/test-model-infer-rdfs.R @@ -0,0 +1,31 @@ +# Attempted to port py_maplib/tests/test_rdfs_inference.py against the same +# fixture data (still kept in tests/testthat/testdata/rdfs/, in case a +# future query()-based port wants it), comparing writes()-serialized +# N-Triples between an inferred Model and a fixture "expected.ttl" Model. +# +# That approach turned out to be unsound, not just blocked by query() not +# existing yet: confirmed live that Model$writes() never serializes +# transient/inferred triples at all. infer_rdfs() reports a positive count +# (e.g. 1 for rdfs2's fixture), but the newly-inferred triple never shows up +# in writes() output -- Triplestore::write_triples (lib/maplib/src/model.rs) +# has no include_transient parameter to opt into seeing it, unlike +# get_predicate_eager_solution_mappings/query-path methods, which do. So a +# writes()-based comparison can never observe what infer_rdfs() actually +# did, regardless of whether the entailment itself is correct -- this +# needed query() (or write_triples gaining an include_transient option) to +# test properly, same blocker as the rest of maplib-a2d. +# +# Left as a smoke test only: infer_rdfs() must run without erroring and +# report a plausible (non-negative) count on real RDFS input. + +test_that("infer_rdfs() runs without erroring and returns a plausible count", { + read_fixture <- function(path) paste(readLines(path, warn = FALSE), collapse = "\n") + + dir <- test_path("testdata", "rdfs", "rdfs2") + m <- Model$new() + m$reads(read_fixture(file.path(dir, "input.ttl")), format = "turtle") + + n <- m$infer_rdfs() + expect_true(is.numeric(n)) + expect_gte(n, 0) +}) diff --git a/maplibr/tests/testthat/test-templates.R b/maplibr/tests/testthat/test-templates.R new file mode 100644 index 00000000..b58b742b --- /dev/null +++ b/maplibr/tests/testthat/test-templates.R @@ -0,0 +1,65 @@ +# Tests for templates.R's OTTR classes (maplib-snz continued). Includes one +# direct port from py_maplib/tests/test_basics.py -- everything else there +# needs Model.map()/query(), not implemented in maplibr yet. + +test_that("Template prints identical OTTR syntax to py_maplib", { + # Direct port of test_programmatic_model_to_string + # (py_maplib/tests/test_basics.py:538-558). + ex <- Prefix("http://example.net/ns#", prefix_name = "ex") + example_template <- Template( + iri = suf(ex, "ExampleTemplate"), + parameters = list(), + instances = list( + Triple( + suf(ex, "myObject"), + suf(ex, "hasObj"), + suf(ex, "myOtherObject") + ) + ) + ) + expect_identical( + trimws(format(example_template)), + trimws( + "@prefix ottr: . +@prefix p0: . + +p0:ExampleTemplate [ ] :: { + ottr:Triple(p0:myObject, p0:hasObj, p0:myOtherObject) +} . " + ) + ) +}) + +test_that("Parameter accepts an IRI or an RDFType for rdf_type", { + p1 <- Parameter(Variable("x"), rdf_type = xsd$integer) + expect_true(inherits(p1, "maplibr::Parameter")) + + p2 <- Parameter(Variable("y"), rdf_type = rdf_type_iri()) + expect_true(inherits(p2, "maplibr::Parameter")) + + p3 <- Parameter(Variable("z"), rdf_type = rdf_type_unknown()) + expect_true(inherits(p3, "maplibr::Parameter")) +}) + +test_that("rdf_type_multi() collapses to rdfs:Resource", { + multi <- rdf_type_multi(list(rdf_type_iri(), rdf_type_literal(xsd$string))) + expect_equal(multi@basic_type_iri@iri, "http://www.w3.org/2000/01/rdf-schema#Resource") +}) + +test_that("Template$instantiate() builds an Instance calling the template", { + ex <- Prefix("http://example.org/", "ex") + t <- Template( + iri = suf(ex, "T"), + parameters = list(Parameter(Variable("x"))), + instances = list(Instance(IRI("http://ns.ottr.xyz/0.4/Triple"), list(Variable("x"), suf(ex, "p"), suf(ex, "o")))) + ) + inst <- instantiate(t, list(suf(ex, "a"))) + expect_true(inherits(inst, "maplibr::Instance")) + expect_equal(instance_template_iri(inst)@iri, "http://example.org/T") +}) + +test_that("Argument passthrough: passing an existing Argument returns it unchanged", { + a <- Argument(Variable("x"), list_expand = TRUE) + a2 <- Argument(a) + expect_identical(a, a2) +}) diff --git a/maplibr/tests/testthat/test-terms.R b/maplibr/tests/testthat/test-terms.R new file mode 100644 index 00000000..b3fef6f3 --- /dev/null +++ b/maplibr/tests/testthat/test-terms.R @@ -0,0 +1,51 @@ +# Tests for terms.R's S7 value classes (maplib-snz). No pytest equivalent +# to port directly against -- py_maplib's IRI/Prefix/Variable/BlankNode/ +# Literal tests are folded into larger query()/map()-based test files that +# aren't portable yet -- so these are written fresh, exercising the same +# constructors and validation py_maplib's own classes provide. + +test_that("IRI validates syntax and formats", { + i <- IRI("http://example.org/a") + expect_equal(i@iri, "http://example.org/a") + expect_match(format(i), "^IRI\\(") + expect_error(IRI("not an iri")) +}) + +test_that("Prefix builds suffixed IRIs via suf()", { + ex <- Prefix("http://example.org/", "ex") + a <- suf(ex, "a") + expect_true(inherits(a, "maplibr::IRI")) + expect_equal(a@iri, "http://example.org/a") +}) + +test_that("Variable validates syntax and formats", { + v <- Variable("x") + expect_equal(v@name, "x") + expect_equal(format(v), "?x") + expect_error(Variable("bad variable name")) +}) + +test_that("BlankNode validates syntax and formats", { + b <- BlankNode("b1") + expect_equal(b@name, "b1") + expect_equal(format(b), "_:b1") + expect_error(BlankNode("")) +}) + +test_that("Literal defaults to xsd:string, and language beats datatype", { + plain <- Literal("hello") + expect_equal(plain@datatype@iri, "http://www.w3.org/2001/XMLSchema#string") + expect_equal(format(plain), '"hello"') + + typed <- Literal("42", datatype = IRI("http://www.w3.org/2001/XMLSchema#integer")) + expect_equal(format(typed), '"42"^^IRI(http://www.w3.org/2001/XMLSchema#integer)') + + tagged <- Literal("bonjour", language = "fr") + expect_equal(tagged@datatype@iri, "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString") + expect_equal(format(tagged), '"bonjour"@fr') + + # language takes priority over an explicit datatype, matching py_maplib's + # constructor precedence (lib/representation/src/python.rs:368-382) + both <- Literal("x", datatype = IRI("http://example.org/dt"), language = "en") + expect_equal(both@datatype@iri, "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString") +}) diff --git a/maplibr/tests/testthat/test-vocab.R b/maplibr/tests/testthat/test-vocab.R new file mode 100644 index 00000000..15cb66ec --- /dev/null +++ b/maplibr/tests/testthat/test-vocab.R @@ -0,0 +1,30 @@ +# Spot-checks for vocab.R's xsd/rdf/rdfs/owl constants (maplib-snz +# continued), cross-checked against oxrdf's actual vocab.rs values when +# they were generated -- this just guards against regressions. + +test_that("vocab lists have the expected size and are built at load time", { + expect_length(xsd, 37) + expect_length(rdf, 17) + expect_length(rdfs, 15) + expect_length(owl, 78) +}) + +test_that("xsd/rdf/rdfs/owl constants resolve to the correct IRIs", { + expect_equal(xsd$dateTime@iri, "http://www.w3.org/2001/XMLSchema#dateTime") + expect_equal(xsd$anyURI@iri, "http://www.w3.org/2001/XMLSchema#anyURI") + expect_equal(xsd$int@iri, "http://www.w3.org/2001/XMLSchema#int") + expect_equal(rdf$type@iri, "http://www.w3.org/1999/02/22-rdf-syntax-ns#type") + expect_equal(rdf$langString@iri, "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString") + expect_equal(rdfs$subClassOf@iri, "http://www.w3.org/2000/01/rdf-schema#subClassOf") + expect_equal(rdfs$Class@iri, "http://www.w3.org/2000/01/rdf-schema#Class") + expect_equal(owl$Class@iri, "http://www.w3.org/2002/07/owl#Class") + expect_equal(owl$sameAs@iri, "http://www.w3.org/2002/07/owl#sameAs") +}) + +test_that("vocab IRIs interoperate with Literal/Parameter", { + lit <- Literal("42", datatype = xsd$integer) + expect_equal(format(lit), '"42"^^IRI(http://www.w3.org/2001/XMLSchema#integer)') + + p <- Parameter(Variable("x"), rdf_type = xsd$string) + expect_true(inherits(p, "maplibr::Parameter")) +}) diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs10/expected.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs10/expected.ttl new file mode 100644 index 00000000..2646e956 --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs10/expected.ttl @@ -0,0 +1,6 @@ +@prefix : . +@prefix rdfs: . + +:AstronomicalBody a rdfs:Class ; + rdfs:subClassOf :AstronomicalBody , rdfs:Resource . + diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs10/input.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs10/input.ttl new file mode 100644 index 00000000..f15fe34c --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs10/input.ttl @@ -0,0 +1,5 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . + +:AstronomicalBody a rdfs:Class . diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs11/expected.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs11/expected.ttl new file mode 100644 index 00000000..1992748d --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs11/expected.ttl @@ -0,0 +1,7 @@ +@prefix : . +@prefix rdfs: . + +:Planet rdfs:subClassOf :AstronomicalBody, + :PlanetaryBody. + +:PlanetaryBody rdfs:subClassOf :AstronomicalBody . \ No newline at end of file diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs11/input.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs11/input.ttl new file mode 100644 index 00000000..6ece9e82 --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs11/input.ttl @@ -0,0 +1,7 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . + +:PlanetaryBody rdfs:subClassOf :AstronomicalBody . +:Planet rdfs:subClassOf :PlanetaryBody . + diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs2/expected.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs2/expected.ttl new file mode 100644 index 00000000..6bf706fc --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs2/expected.ttl @@ -0,0 +1,8 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . + +:moon rdfs:domain :Planet . +:Mars :moon :Phobos . + +:Mars rdf:type :Planet . diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs2/input.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs2/input.ttl new file mode 100644 index 00000000..4311d4f9 --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs2/input.ttl @@ -0,0 +1,6 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . + +:moon rdfs:domain :Planet . +:Mars :moon :Phobos . diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs3/expected.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs3/expected.ttl new file mode 100644 index 00000000..1067ec9c --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs3/expected.ttl @@ -0,0 +1,8 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . + +:moon rdfs:range :Satellite . +:Mars :moon :Phobos . + +:Phobos rdf:type :Satellite . \ No newline at end of file diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs3/input.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs3/input.ttl new file mode 100644 index 00000000..f0eaf817 --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs3/input.ttl @@ -0,0 +1,6 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . + +:moon rdfs:range :Satellite . +:Mars :moon :Phobos . diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs5/expected.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs5/expected.ttl new file mode 100644 index 00000000..5916245e --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs5/expected.ttl @@ -0,0 +1,16 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . + +:siderealOrbitalPeriod a rdf:Property ; + rdfs:label "sidereal orbital period"@en ; + rdfs:subPropertyOf :orbitalPeriod, + :orbitalProperty, :siderealOrbitalPeriod . + +:orbitalPeriod a rdf:Property ; + rdfs:label "orbital period"@en ; + rdfs:subPropertyOf :orbitalProperty, :orbitalPeriod . + +:orbitalProperty a rdf:Property ; + rdfs:label "orbital property"@en ; + rdfs:subPropertyOf :orbitalProperty . \ No newline at end of file diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs5/input.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs5/input.ttl new file mode 100644 index 00000000..6a4348d3 --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs5/input.ttl @@ -0,0 +1,14 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . + +:orbitalProperty a rdf:Property ; + rdfs:label "orbital property"@en . + +:orbitalPeriod a rdf:Property ; + rdfs:label "orbital period"@en ; + rdfs:subPropertyOf :orbitalProperty . + +:siderealOrbitalPeriod a rdf:Property ; + rdfs:label "sidereal orbital period"@en ; + rdfs:subPropertyOf :orbitalPeriod . diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs6/expected.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs6/expected.ttl new file mode 100644 index 00000000..8afdb899 --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs6/expected.ttl @@ -0,0 +1,7 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . + +:orbitalProperty a rdf:Property ; + rdfs:label "orbital property"@en ; + rdfs:subPropertyOf :orbitalProperty . \ No newline at end of file diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs6/input.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs6/input.ttl new file mode 100644 index 00000000..813a27c3 --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs6/input.ttl @@ -0,0 +1,6 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . + +:orbitalProperty a rdf:Property ; + rdfs:label "orbital property"@en . \ No newline at end of file diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs8/expected.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs8/expected.ttl new file mode 100644 index 00000000..a43cf120 --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs8/expected.ttl @@ -0,0 +1,5 @@ +@prefix : . +@prefix rdfs: . + +:Planet a rdfs:Class ; + rdfs:subClassOf :Planet, rdfs:Resource . \ No newline at end of file diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs8/input.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs8/input.ttl new file mode 100644 index 00000000..72cdcd0b --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs8/input.ttl @@ -0,0 +1,5 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . + +:Planet a rdfs:Class . \ No newline at end of file diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs9/expected.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs9/expected.ttl new file mode 100644 index 00000000..d5c9c1d4 --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs9/expected.ttl @@ -0,0 +1,12 @@ +@prefix : . +@prefix rdfs: . + +:AstronomicalBody a rdfs:Class ; + rdfs:subClassOf :AstronomicalBody, rdfs:Resource . + +:Mars a :AstronomicalBody, + :Planet, + rdfs:Resource . + +:Planet rdfs:subClassOf :AstronomicalBody , rdfs:Resource . + diff --git a/maplibr/tests/testthat/testdata/rdfs/rdfs9/input.ttl b/maplibr/tests/testthat/testdata/rdfs/rdfs9/input.ttl new file mode 100644 index 00000000..42cbea2e --- /dev/null +++ b/maplibr/tests/testthat/testdata/rdfs/rdfs9/input.ttl @@ -0,0 +1,7 @@ +@prefix : . +@prefix rdf: . +@prefix rdfs: . + +:AstronomicalBody a rdfs:Class . +:Planet rdfs:subClassOf :AstronomicalBody . +:Mars a :Planet . From 552f9f52222aad19f9608d20f9d80453b889fa81 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 10:50:00 +0200 Subject: [PATCH 16/46] test(maplibr): add direct unit tests for error reclassification - covers the malformed-tag fallback and runtime_error branches, unexercised by existing tests Co-Authored-By: Claude Sonnet 5 --- maplibr/tests/testthat/test-errors.R | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 maplibr/tests/testthat/test-errors.R diff --git a/maplibr/tests/testthat/test-errors.R b/maplibr/tests/testthat/test-errors.R new file mode 100644 index 00000000..34609f1a --- /dev/null +++ b/maplibr/tests/testthat/test-errors.R @@ -0,0 +1,50 @@ +# Direct unit tests for .reclass_maplibr_error()/.rethrow()'s regex-parsing +# and fallback logic, flagged by roborev review of commits 5a55ec5/c670e29 +# as still untested: every error test elsewhere goes through Model$reads(), +# which only ever exercises the maplibr_maplib_error/maplibr_argument_error +# tags -- never the fallback branch for a malformed/unrecognized prefix, and +# never maplibr_runtime_error at all. No compiled code needed for these. + +test_that(".rethrow() reclassifies each known tag with its message intact", { + for (tag in c("maplibr_maplib_error", "maplibr_argument_error", "maplibr_runtime_error")) { + err <- tryCatch( + .rethrow(stop(sprintf("[%s] something went wrong", tag))), + error = function(e) e + ) + expect_s3_class(err, tag) + expect_s3_class(err, "maplibr_error") + expect_equal(conditionMessage(err), "something went wrong") + } +}) + +test_that(".rethrow() falls back to a generic maplibr_error for an unrecognized tag", { + err <- tryCatch( + .rethrow(stop("[maplibr_totally_made_up_error] oops")), + error = function(e) e + ) + expect_s3_class(err, "maplibr_error") + expect_false(inherits(err, "maplibr_maplib_error")) + expect_false(inherits(err, "maplibr_argument_error")) + expect_false(inherits(err, "maplibr_runtime_error")) + # Malformed/unrecognized prefixes are not stripped from the message -- + # only a recognized tag is parsed out. + expect_equal(conditionMessage(err), "[maplibr_totally_made_up_error] oops") +}) + +test_that(".rethrow() falls back to a generic maplibr_error for a message with no tag at all", { + err <- tryCatch( + .rethrow(stop("plain, untagged error")), + error = function(e) e + ) + expect_s3_class(err, "maplibr_error") + expect_equal(conditionMessage(err), "plain, untagged error") +}) + +test_that(".rethrow() passes through an already-reclassed maplibr_error unchanged", { + original <- structure( + class = c("maplibr_argument_error", "maplibr_error", "error", "condition"), + list(message = "already reclassed", call = NULL) + ) + err <- tryCatch(.rethrow(stop(original)), error = function(e) e) + expect_identical(err, original) +}) From 4c5d89132c08f0fd2f4e099cb59e40e8a9c4c5ad Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 11:05:15 +0200 Subject: [PATCH 17/46] feat(maplibr): add Model query() for SPARQL SELECT - streams results through the Arrow C Stream bridge, coalesces multi-typed columns - fix: category-coded IRI/literal values now decoded via format_native_columns (previously came back as raw codes) - CONSTRUCT not yet supported; 87 tests passing Co-Authored-By: Claude Sonnet 5 --- maplibr/DESCRIPTION | 4 +- maplibr/R/000-wrappers.R | 16 ++-- maplibr/R/model.R | 54 ++++++++++++ maplibr/man/Model.Rd | 43 ++++++++++ maplibr/man/export_test_solution_mappings.Rd | 18 ++-- maplibr/src/init.c | 6 ++ maplibr/src/rust/api.h | 1 + maplibr/src/rust/src/arrow_bridge.rs | 78 +++++++++++++++-- maplibr/src/rust/src/model.rs | 89 ++++++++++++++++++++ maplibr/tests/testthat/test-model-query.R | 79 +++++++++++++++++ 10 files changed, 363 insertions(+), 25 deletions(-) create mode 100644 maplibr/tests/testthat/test-model-query.R diff --git a/maplibr/DESCRIPTION b/maplibr/DESCRIPTION index e15c1f96..e44d6e58 100644 --- a/maplibr/DESCRIPTION +++ b/maplibr/DESCRIPTION @@ -9,7 +9,9 @@ License: `use_mit_license()`, `use_gpl3_license()` or friends to pick a Encoding: UTF-8 Imports: R6, - S7 + S7, + nanoarrow, + jsonlite Roxygen: list(markdown = TRUE) RoxygenNote: 8.0.0 SystemRequirements: Cargo (Rust's package manager), rustc diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index 2a7cdf89..f73bb3f2 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -46,11 +46,10 @@ NULL invisible(.Call(savvy_export_test_series__impl, `stream_ptr`)) } -#' Build the `build_test_solution_mappings()` fixture, collapse its -#' DataFrame to a Struct-typed Series (`DataFrame::into_struct`), and stream -#' it out to R through the Arrow C Stream Interface exactly like -#' `export_test_series` does -- proving the DataFrame case reuses that -#' mechanism unchanged. +#' Build the `build_test_solution_mappings()` fixture and stream it out via +#' `export_solution_mappings` -- proving the DataFrame case reuses the +#' Series-level mechanism unchanged, against a fixture with a genuinely +#' multi-typed column (see `build_test_solution_mappings`'s doc comment). #' #' @param stream_ptr An external pointer from `nanoarrow::nanoarrow_allocate_array_stream()`. #' @returns The `rdf_node_types` side-channel, as a JSON string. @@ -286,6 +285,12 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } } +`RModel_query` <- function(self) { + function(`sparql`, `stream_ptr`, `include_transient`, `graph` = NULL) { + .Call(savvy_RModel_query__impl, `self`, `sparql`, `stream_ptr`, `include_transient`, `graph`) + } +} + `RModel_reads` <- function(self) { function(`s`, `format`, `graph` = NULL) { invisible(.Call(savvy_RModel_reads__impl, `self`, `s`, `format`, `graph`)) @@ -325,6 +330,7 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") e$`create_index` <- `RModel_create_index`(ptr) e$`detach_graph` <- `RModel_detach_graph`(ptr) e$`infer_rdfs` <- `RModel_infer_rdfs`(ptr) + e$`query` <- `RModel_query`(ptr) e$`reads` <- `RModel_reads`(ptr) e$`serialize` <- `RModel_serialize`(ptr) e$`size` <- `RModel_size`(ptr) diff --git a/maplibr/R/model.R b/maplibr/R/model.R index 7cd3a3ac..77790e81 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -86,6 +86,38 @@ Model <- R6::R6Class( Model$new(.rethrow(private$rmodel$detach_graph(preserve_name, graph))) }, + #' @description Run a SPARQL SELECT query and return the result as a data.frame. + #' Mirrors py_maplib's Model.query() (py_maplib/src/py_model.rs:344-387), + #' but only the SELECT case -- CONSTRUCT queries are not yet supported + #' (raises a maplibr_argument_error), see maplib-l2j. + #' @param sparql The SPARQL query string. + #' @param graph Optional named graph IRI to restrict the query to (searches + #' across the whole store if NULL). + #' @param include_transient Whether to include transient (e.g. inferred) + #' triples in the query. + #' @return A data.frame. A column whose values are always the same RDF + #' node type (the common case) comes back as a plain vector of that + #' type's underlying value (an IRI/blank node id/literal value as a + #' plain string, exactly as py_maplib returns by default). A column + #' whose RDF node type can vary by row instead comes back from Arrow as + #' one sub-column per possible type (only one of which is non-NA on any + #' given row) -- collapsed here into a single vector by taking + #' whichever sub-column is non-NA per row. The precise per-column RDF + #' node type information (which py_maplib exposes via + #' SolutionMappings.rdf_types) is attached as the data.frame's + #' `"rdf_node_types"` attribute instead of a richer typed object, parsed + #' from the underlying JSON side-channel (maplibr/src/rust/src/ + #' arrow_bridge.rs) -- a full RDFType-based port of that accessor is + #' left for later. + query = function(sparql, graph = NULL, include_transient = FALSE) { + stream <- nanoarrow::nanoarrow_allocate_array_stream() + rdf_node_types_json <- .rethrow(private$rmodel$query(sparql, stream, include_transient, graph)) + df <- as.data.frame(stream) + df <- .collapse_multitype_columns(df) + attr(df, "rdf_node_types") <- jsonlite::fromJSON(rdf_node_types_json, simplifyVector = FALSE) + df + }, + #' @description Run RDFS inference over a graph in place. #' @param graph Optional named graph IRI to infer over (default graph if NULL). #' @return The number of interesting inference rules applied. @@ -123,6 +155,28 @@ Model <- R6::R6Class( ) ) +# A column whose RDF node type varies by row comes back from +# as.data.frame(stream) as a nested data.frame -- one sub-column per +# possible type (e.g. "I" for IRI, "B" for blank node, the datatype IRI for +# a literal; see MULTI_IRI_DT/MULTI_BLANK_DT, lib/representation/src/ +# multitype.rs:11-13), with exactly one non-NA per row. Collapses each such +# column into a single vector by coalescing across its sub-columns. +.collapse_multitype_columns <- function(df) { + for (col in names(df)) { + if (is.data.frame(df[[col]])) { + sub <- df[[col]] + collapsed <- sub[[1]] + if (ncol(sub) > 1) { + for (j in 2:ncol(sub)) { + collapsed <- ifelse(is.na(collapsed), sub[[j]], collapsed) + } + } + df[[col]] <- collapsed + } + } + df +} + # Load a Model previously written by `$serialize()`. Attached directly to # the (already @export'd) Model generator, so users call it as # `Model$deserialize(path)` -- mirrors RModel's own associated-function diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index 3a2387e4..38c0a34d 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -21,6 +21,7 @@ returns \code{self} invisibly). \item \href{#method-Model-add_prefixes}{\code{Model$add_prefixes()}} \item \href{#method-Model-add_graph}{\code{Model$add_graph()}} \item \href{#method-Model-detach_graph}{\code{Model$detach_graph()}} + \item \href{#method-Model-query}{\code{Model$query()}} \item \href{#method-Model-infer_rdfs}{\code{Model$infer_rdfs()}} \item \href{#method-Model-compact}{\code{Model$compact()}} \item \href{#method-Model-serialize}{\code{Model$serialize()}} @@ -198,6 +199,48 @@ than moving it to the default graph there.} } } +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-query}{}}} +\subsection{\code{Model$query()}}{ + Run a SPARQL SELECT query and return the result as a data.frame. +Mirrors py_maplib's Model.query() (py_maplib/src/py_model.rs:344-387), +but only the SELECT case -- CONSTRUCT queries are not yet supported +(raises a maplibr_argument_error), see maplib-l2j. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$query(sparql, graph = NULL, include_transient = FALSE)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{sparql}}{The SPARQL query string.} + \item{\code{graph}}{Optional named graph IRI to restrict the query to (searches +across the whole store if NULL).} + \item{\code{include_transient}}{Whether to include transient (e.g. inferred) +triples in the query.} + } + \if{html}{\out{
}} + } + \subsection{Returns}{ + A data.frame. A column whose values are always the same RDF +node type (the common case) comes back as a plain vector of that +type's underlying value (an IRI/blank node id/literal value as a +plain string, exactly as py_maplib returns by default). A column +whose RDF node type can vary by row instead comes back from Arrow as +one sub-column per possible type (only one of which is non-NA on any +given row) -- collapsed here into a single vector by taking +whichever sub-column is non-NA per row. The precise per-column RDF +node type information (which py_maplib exposes via +SolutionMappings.rdf_types) is attached as the data.frame's +\code{"rdf_node_types"} attribute instead of a richer typed object, parsed +from the underlying JSON side-channel (maplibr/src/rust/src/ +arrow_bridge.rs) -- a full RDFType-based port of that accessor is +left for later. + } +} + \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Model-infer_rdfs}{}}} diff --git a/maplibr/man/export_test_solution_mappings.Rd b/maplibr/man/export_test_solution_mappings.Rd index 8e1a0202..fbfe5e2f 100644 --- a/maplibr/man/export_test_solution_mappings.Rd +++ b/maplibr/man/export_test_solution_mappings.Rd @@ -2,11 +2,10 @@ % Please edit documentation in R/000-wrappers.R \name{export_test_solution_mappings} \alias{export_test_solution_mappings} -\title{Build the \code{build_test_solution_mappings()} fixture, collapse its -DataFrame to a Struct-typed Series (\code{DataFrame::into_struct}), and stream -it out to R through the Arrow C Stream Interface exactly like -\code{export_test_series} does -- proving the DataFrame case reuses that -mechanism unchanged.} +\title{Build the \code{build_test_solution_mappings()} fixture and stream it out via +\code{export_solution_mappings} -- proving the DataFrame case reuses the +Series-level mechanism unchanged, against a fixture with a genuinely +multi-typed column (see \code{build_test_solution_mappings}'s doc comment).} \usage{ export_test_solution_mappings(stream_ptr) } @@ -17,9 +16,8 @@ export_test_solution_mappings(stream_ptr) The \code{rdf_node_types} side-channel, as a JSON string. } \description{ -Build the \code{build_test_solution_mappings()} fixture, collapse its -DataFrame to a Struct-typed Series (\code{DataFrame::into_struct}), and stream -it out to R through the Arrow C Stream Interface exactly like -\code{export_test_series} does -- proving the DataFrame case reuses that -mechanism unchanged. +Build the \code{build_test_solution_mappings()} fixture and stream it out via +\code{export_solution_mappings} -- proving the DataFrame case reuses the +Series-level mechanism unchanged, against a fixture with a genuinely +multi-typed column (see \code{build_test_solution_mappings}'s doc comment). } diff --git a/maplibr/src/init.c b/maplibr/src/init.c index 4848991a..1c6036ea 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -159,6 +159,11 @@ SEXP savvy_RModel_new__impl(void) { return handle_result(res); } +SEXP savvy_RModel_query__impl(SEXP self__, SEXP c_arg__sparql, SEXP c_arg__stream_ptr, SEXP c_arg__include_transient, SEXP c_arg__graph) { + SEXP res = savvy_RModel_query__ffi(self__, c_arg__sparql, c_arg__stream_ptr, c_arg__include_transient, c_arg__graph); + return handle_result(res); +} + SEXP savvy_RModel_reads__impl(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEXP c_arg__graph) { SEXP res = savvy_RModel_reads__ffi(self__, c_arg__s, c_arg__format, c_arg__graph); return handle_result(res); @@ -235,6 +240,7 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_RModel_detach_graph__impl", (DL_FUNC) &savvy_RModel_detach_graph__impl, 3}, {"savvy_RModel_infer_rdfs__impl", (DL_FUNC) &savvy_RModel_infer_rdfs__impl, 2}, {"savvy_RModel_new__impl", (DL_FUNC) &savvy_RModel_new__impl, 0}, + {"savvy_RModel_query__impl", (DL_FUNC) &savvy_RModel_query__impl, 5}, {"savvy_RModel_reads__impl", (DL_FUNC) &savvy_RModel_reads__impl, 4}, {"savvy_RModel_serialize__impl", (DL_FUNC) &savvy_RModel_serialize__impl, 2}, {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 1}, diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index d1863d25..c55883b5 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -30,6 +30,7 @@ SEXP savvy_RModel_deserialize__ffi(SEXP c_arg__path, SEXP c_arg__storage_folder) SEXP savvy_RModel_detach_graph__ffi(SEXP self__, SEXP c_arg__preserve_name, SEXP c_arg__graph); SEXP savvy_RModel_infer_rdfs__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_new__ffi(void); +SEXP savvy_RModel_query__ffi(SEXP self__, SEXP c_arg__sparql, SEXP c_arg__stream_ptr, SEXP c_arg__include_transient, SEXP c_arg__graph); SEXP savvy_RModel_reads__ffi(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEXP c_arg__graph); SEXP savvy_RModel_serialize__ffi(SEXP self__, SEXP c_arg__path); SEXP savvy_RModel_size__ffi(SEXP self__); diff --git a/maplibr/src/rust/src/arrow_bridge.rs b/maplibr/src/rust/src/arrow_bridge.rs index c592274a..6c37341f 100644 --- a/maplibr/src/rust/src/arrow_bridge.rs +++ b/maplibr/src/rust/src/arrow_bridge.rs @@ -181,21 +181,68 @@ fn build_test_solution_mappings() -> EagerSolutionMappings { EagerSolutionMappings::from_query_solutions(&sols).unwrap() } -/// Build the `build_test_solution_mappings()` fixture, collapse its -/// DataFrame to a Struct-typed Series (`DataFrame::into_struct`), and stream -/// it out to R through the Arrow C Stream Interface exactly like -/// `export_test_series` does -- proving the DataFrame case reuses that -/// mechanism unchanged. +/// Recursively cast any Categorical/Enum-typed Series to plain String +/// (recursing into Struct fields too, since a "multi" RDFNodeState column is +/// itself Struct-typed). Real query results commonly store repeated IRI/ +/// literal values as `BaseCatState::CategoricalNative` (a Rust-side storage +/// optimization, lib/representation/src/solution_mapping.rs:13-30) -- Arrow's +/// dictionary encoding for a categorical column round-trips fine at the +/// top level, but confirmed live that nanoarrow's struct-to-data.frame +/// conversion does NOT resolve a dictionary-encoded field nested inside a +/// struct column back to its string values, instead surfacing the raw +/// integer codes. Decategorizing before the whole-DataFrame struct-collapse +/// sidesteps that -- the memory-efficiency benefit of categorical encoding +/// only matters Rust-side anyway, not for the R-bound wire format. +fn decategorize(s: Series) -> savvy::Result { + match s.dtype() { + DataType::Categorical(_, _) | DataType::Enum(_, _) => s + .cast(&DataType::String) + .map_err(|e| crate::errors::runtime_error(e.to_string())), + DataType::Struct(_) => { + let name = s.name().clone(); + let len = s.len(); + let ca = s + .struct_() + .map_err(|e| crate::errors::runtime_error(e.to_string()))?; + let fields: Vec = ca + .fields_as_series() + .into_iter() + .map(decategorize) + .collect::>()?; + StructChunked::from_series(name, len, fields.iter()) + .map(|sc| sc.into_series()) + .map_err(|e| crate::errors::runtime_error(e.to_string())) + } + _ => Ok(s), + } +} + +/// Collapse an `EagerSolutionMappings`' DataFrame to a Struct-typed Series +/// (`DataFrame::into_struct`) and stream it out to R through the Arrow C +/// Stream Interface, exactly like `export_test_series` does for a bare +/// Series -- the mechanism `RModel::query()` uses for a real query result, +/// proven first here against a hand-built fixture +/// (`export_test_solution_mappings`). /// /// @param stream_ptr An external pointer from `nanoarrow::nanoarrow_allocate_array_stream()`. /// @returns The `rdf_node_types` side-channel, as a JSON string. -/// @export -#[savvy] -fn export_test_solution_mappings(stream_ptr: Sexp) -> savvy::Result { +pub(crate) fn export_solution_mappings( + sm: EagerSolutionMappings, + stream_ptr: Sexp, +) -> savvy::Result { let EagerSolutionMappings { mappings, rdf_node_types, - } = build_test_solution_mappings(); + } = sm; + + let height = mappings.height(); + let columns: Vec = mappings + .columns() + .iter() + .map(|c| decategorize(c.as_materialized_series().clone()).map(Column::from)) + .collect::>()?; + let mappings = DataFrame::new(height, columns) + .map_err(|e| crate::errors::runtime_error(e.to_string()))?; let struct_series = mappings.into_struct("solution_mappings".into()).into_series(); let field = struct_series.field().to_arrow(CompatLevel::newest()); @@ -211,6 +258,19 @@ fn export_test_solution_mappings(stream_ptr: Sexp) -> savvy::Result { rdf_node_types_to_json(&rdf_node_types)?.try_into() } +/// Build the `build_test_solution_mappings()` fixture and stream it out via +/// `export_solution_mappings` -- proving the DataFrame case reuses the +/// Series-level mechanism unchanged, against a fixture with a genuinely +/// multi-typed column (see `build_test_solution_mappings`'s doc comment). +/// +/// @param stream_ptr An external pointer from `nanoarrow::nanoarrow_allocate_array_stream()`. +/// @returns The `rdf_node_types` side-channel, as a JSON string. +/// @export +#[savvy] +fn export_test_solution_mappings(stream_ptr: Sexp) -> savvy::Result { + export_solution_mappings(build_test_solution_mappings(), stream_ptr) +} + /// Read an Arrow C stream from R back into a Struct-typed Series, unnest it /// back into a DataFrame (`StructChunked::unnest`, the reverse of /// `into_struct`), and return a small human-readable summary combining the diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index a2ee6d36..32f8dd1a 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -2,6 +2,7 @@ use crate::errors::argument_error; use crate::errors::maplib_error; use oxrdf::NamedNode; use oxrdfio::RdfFormat; +use polars::prelude::IntoLazy; use representation::dataset::NamedGraph; use savvy::{savvy, ListSexp}; use std::collections::HashMap; @@ -35,6 +36,20 @@ fn parse_optional_named_graph(graph: Option<&str>) -> savvy::Result Ok(NamedGraph::from_maybe_named_node(nn.as_ref())) } +/// Parses `query()`'s `graph` argument. Unlike `reads`/`writes`, where a +/// missing graph means "the default graph" (`parse_optional_named_graph`), +/// a missing graph here means "no restriction to one named graph" -- mirrors +/// py_maplib's own `query`, which passes `graph: Option<&NamedGraph>` +/// through to `Model::query` unchanged rather than defaulting it (py_model.rs:359-360). +fn parse_query_graph(graph: Option<&str>) -> savvy::Result> { + graph + .map(|g| { + let nn = NamedNode::new(g).map_err(argument_error)?; + Ok(NamedGraph::from_maybe_named_node(Some(&nn))) + }) + .transpose() +} + /// A maplib knowledge graph model. /// /// @export @@ -93,6 +108,80 @@ impl RModel { (inner.graph_size(&graph) as i32).try_into() } + /// Run a SPARQL SELECT query, streaming the result out through the Arrow + /// C Stream Interface (mirrors PyModel::query, py_maplib/src/py_model.rs: + /// 344-387 -- but only the SELECT case; CONSTRUCT is not yet supported, + /// see maplib-l2j). + /// + /// @param sparql The SPARQL query string. + /// @param stream_ptr An external pointer from `nanoarrow::nanoarrow_allocate_array_stream()`. + /// @param include_transient Whether to include transient (e.g. inferred) + /// triples in the query. Defaults to FALSE. + /// @param graph Optional named graph IRI to restrict the query to (searches + /// across the whole store if NULL, matching py_maplib's default). + /// @returns The `rdf_node_types` side-channel, as a JSON string. + /// @export + fn query( + &self, + sparql: &str, + stream_ptr: savvy::Sexp, + include_transient: bool, + graph: Option<&str>, + ) -> savvy::Result { + let named_graph = parse_query_graph(graph)?; + let mut inner = self.lock(); + let global_cats = inner.triplestore.global_cats.clone(); + let result = inner + .query( + sparql, + None, + named_graph.as_ref(), + false, + include_transient, + None, + false, + None, + ) + .map_err(maplib_error)?; + match result.kind { + representation::result::QueryResultKind::Select(sm) => { + // Query results store repeated IRI/literal values as category + // codes into a Model-wide dictionary (Triplestore::global_cats, + // LockedCats) rather than plain polars Categorical-typed + // columns -- format_native_columns (mirrors py_maplib's + // native_dataframe=True path, py_model.rs query_to_result via + // fix_cats_and_multicolumns) is what actually resolves those + // codes back to real values (via maybe_decode_expr) and + // collapses "multi" RDF-typed columns into one Struct field + // per possible type, matching the shape export_solution_mappings + // expects. A plain Series-level Categorical->String cast + // (arrow_bridge::decategorize) does NOT do this -- confirmed + // live, a query result round-tripped to R without this step + // came back as raw integer category codes, not strings. + let representation::solution_mapping::EagerSolutionMappings { + mappings, + mut rdf_node_types, + } = sm; + let lf = representation::formatting::format_native_columns( + mappings.lazy(), + &mut rdf_node_types, + global_cats, + ); + let mappings = lf + .collect() + .map_err(|e| crate::errors::runtime_error(e.to_string()))?; + let sm = representation::solution_mapping::EagerSolutionMappings { + mappings, + rdf_node_types, + }; + crate::arrow_bridge::export_solution_mappings(sm, stream_ptr) + } + representation::result::QueryResultKind::Construct(_) => Err(argument_error( + "CONSTRUCT queries are not yet supported by maplibr's query() -- only SELECT", + )), + } + } + /// Parse RDF triples from a string into this Model. /// /// @param s RDF data as a string. diff --git a/maplibr/tests/testthat/test-model-query.R b/maplibr/tests/testthat/test-model-query.R new file mode 100644 index 00000000..4a1c92fc --- /dev/null +++ b/maplibr/tests/testthat/test-model-query.R @@ -0,0 +1,79 @@ +# Tests for Model$query() (maplib-l2j). Real query() support removes the +# blocker that limited earlier test files (test-model-basics.R, +# test-model-infer-rdfs.R, etc.) to non-query assertions -- this file covers +# query() itself; expanding the older files to use query() where py_maplib's +# own tests did is left for follow-up (maplib-a2d). + +test_that("a simple SELECT returns real, decoded values (not category codes)", { + # Confirmed live, worth guarding against regressing: query results store + # repeated IRI/literal values as category codes into a Model-wide + # dictionary, not plain polars Categorical columns -- an earlier attempt + # at decoding (a Series-level Categorical->String cast) silently did + # nothing, and values came back as raw integer codes (e.g. "0", "1") + # instead of the real strings. Fixed via representation::formatting:: + # format_native_columns (see model.rs's query()). + m <- Model$new() + m$reads(paste( + ' "Alice" .', + ' "Bob" .', + sep = "\n" + ), format = "ntriples") + + df <- m$query("SELECT ?s ?o WHERE { ?s ?o }") + expect_setequal(df$s, c("http://ex/a", "http://ex/b")) + expect_setequal(df$o, c("Alice", "Bob")) +}) + +test_that("a column whose RDF node type varies by row is collapsed to one vector", { + m <- Model$new() + m$reads(paste( + ' "Alice" .', + " .", + sep = "\n" + ), format = "ntriples") + + df <- m$query(' + SELECT ?x WHERE { + { ?x } + UNION + { ?x } + } + ') + expect_setequal(df$x, c("Alice", "http://ex/b")) + expect_false(is.data.frame(df$x)) + + types <- attr(df, "rdf_node_types")$x + kinds <- vapply(types, function(t) t$kind, character(1)) + expect_setequal(kinds, c("iri", "literal")) +}) + +test_that("include_transient controls whether inferred triples are visible to query()", { + m <- Model$new() + m$reads(paste( + " .", + " .", + sep = "\n" + ), format = "turtle") + + n <- m$infer_rdfs() + expect_gt(n, 0) + + with_transient <- m$query("SELECT * WHERE { ?s ?p ?o }", include_transient = TRUE) + without_transient <- m$query("SELECT * WHERE { ?s ?p ?o }", include_transient = FALSE) + expect_equal(nrow(with_transient), nrow(without_transient) + n) +}) + +test_that("query() restricted to a graph with no matching triples returns zero rows", { + m <- Model$new() + m$reads(' .', format = "ntriples") + df <- m$query("SELECT * WHERE { ?s ?p ?o }", graph = "http://nonexistent/graph") + expect_equal(nrow(df), 0) +}) + +test_that("query() raises a catchable error for invalid SPARQL syntax", { + m <- Model$new() + expect_error( + m$query("this is not valid SPARQL !!!"), + class = "maplibr_maplib_error" + ) +}) From cb1d678c2ae3de24d53f7def5db68c42c308d85d Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 11:09:30 +0200 Subject: [PATCH 18/46] docs(maplibr): clarify infer_rdfs() return value and coalesce assumption - infer_rdfs() returns a triple count, not a rule count; doc comments corrected - documented that the multitype coalesce assumes character sub-columns Co-Authored-By: Claude Sonnet 5 --- maplibr/R/model.R | 13 +++++++++++-- maplibr/man/Model.Rd | 4 +++- maplibr/src/rust/src/model.rs | 8 +++++--- maplibr/tests/testthat/test-model-query.R | 5 +++++ 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/maplibr/R/model.R b/maplibr/R/model.R index 77790e81..643cd724 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -120,7 +120,9 @@ Model <- R6::R6Class( #' @description Run RDFS inference over a graph in place. #' @param graph Optional named graph IRI to infer over (default graph if NULL). - #' @return The number of interesting inference rules applied. + #' @return The number of new triples inferred (a triple count, not a rule + #' count -- Triplestore::interesting_rdfs_rules sums per-rule + #' inserted-triple counts). infer_rdfs = function(graph = NULL) { .rethrow(private$rmodel$infer_rdfs(graph)) }, @@ -160,7 +162,14 @@ Model <- R6::R6Class( # possible type (e.g. "I" for IRI, "B" for blank node, the datatype IRI for # a literal; see MULTI_IRI_DT/MULTI_BLANK_DT, lib/representation/src/ # multitype.rs:11-13), with exactly one non-NA per row. Collapses each such -# column into a single vector by coalescing across its sub-columns. +# column into a single vector by coalescing across its sub-columns via +# ifelse(), which is safe today only because every sub-column is plain +# character (IRI/blank-node id/literal all surface as strings via +# format_native_columns) -- ifelse() silently coerces on a type mismatch, so +# if a future multi-typed column ever carried a non-character sub-column +# (e.g. a natively-typed numeric literal), values could be silently +# mangled. Revisit with an explicit type check (or vctrs::vec_coalesce) if +# native-typed literal columns are ever added. .collapse_multitype_columns <- function(df) { for (col in names(df)) { if (is.data.frame(df[[col]])) { diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index 38c0a34d..3a610250 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -259,7 +259,9 @@ left for later. \if{html}{\out{}} } \subsection{Returns}{ - The number of interesting inference rules applied. + The number of new triples inferred (a triple count, not a rule +count -- Triplestore::interesting_rdfs_rules sums per-rule +inserted-triple counts). } } diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index 32f8dd1a..2dc00860 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -313,9 +313,11 @@ impl RModel { }) } - /// Run RDFS inference over a graph in place, returning the number of - /// interesting inference rules applied (mirrors PyModel::infer_rdfs, - /// py_maplib/src/py_model.rs:903-916). + /// Run RDFS inference over a graph in place, returning the number of new + /// triples inferred -- a triple count, not a rule count + /// (Triplestore::interesting_rdfs_rules, lib/triplestore/src/ + /// rdfs_inferencing.rs, sums per-rule inserted-triple counts) -- mirrors + /// PyModel::infer_rdfs (py_maplib/src/py_model.rs:903-916). /// /// @param graph Optional named graph IRI to infer over (default graph if NULL). /// @export diff --git a/maplibr/tests/testthat/test-model-query.R b/maplibr/tests/testthat/test-model-query.R index 4a1c92fc..05b36d90 100644 --- a/maplibr/tests/testthat/test-model-query.R +++ b/maplibr/tests/testthat/test-model-query.R @@ -60,6 +60,11 @@ test_that("include_transient controls whether inferred triples are visible to qu with_transient <- m$query("SELECT * WHERE { ?s ?p ?o }", include_transient = TRUE) without_transient <- m$query("SELECT * WHERE { ?s ?p ?o }", include_transient = FALSE) + # n is a count of newly-inserted triples (Triplestore::interesting_rdfs_rules, + # lib/triplestore/src/rdfs_inferencing.rs:170, sums per-rule inserted-triple + # counts), not a count of rules applied, despite Model$infer_rdfs's own doc + # comment saying "the number of interesting inference rules applied" -- so + # this equality is guaranteed, not coincidental. expect_equal(nrow(with_transient), nrow(without_transient) + n) }) From ae14dfa741f0f2231513e3796299a0ef034989e6 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 20:38:07 +0200 Subject: [PATCH 19/46] feat(maplibr): implement Model map()/add_template(), add size(graph) - add_template/add_template_string/map/map_no_data backed by a new import_dataframe() Arrow-import helper - size()/Model$size() gain a graph argument, matching py_maplib Co-Authored-By: Claude Sonnet 5 --- maplibr/R/000-wrappers.R | 33 +++++- maplibr/R/model.R | 72 +++++++++++- maplibr/man/Model.Rd | 70 +++++++++++- maplibr/src/init.c | 30 ++++- maplibr/src/rust/api.h | 6 +- maplibr/src/rust/src/arrow_bridge.rs | 35 ++++++ maplibr/src/rust/src/model.rs | 111 ++++++++++++++++++- maplibr/tests/testthat/test-model-graphs.R | 121 ++++++++++++++++++--- maplibr/tests/testthat/test-model-map.R | 82 ++++++++++++++ 9 files changed, 527 insertions(+), 33 deletions(-) create mode 100644 maplibr/tests/testthat/test-model-map.R diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index f73bb3f2..083aa3be 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -261,6 +261,19 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } } +`RModel_add_template` <- function(self) { + function(`template`) { + `template` <- .savvy_extract_ptr(`template`, "maplibr::RTemplate") + invisible(.Call(savvy_RModel_add_template__impl, `self`, `template`)) + } +} + +`RModel_add_template_string` <- function(self) { + function(`doc`) { + .Call(savvy_RModel_add_template_string__impl, `self`, `doc`) + } +} + `RModel_compact` <- function(self) { function() { invisible(.Call(savvy_RModel_compact__impl, `self`)) @@ -285,6 +298,18 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } } +`RModel_map` <- function(self) { + function(`template_iri`, `stream_ptr`, `graph` = NULL, `validate_iris` = NULL) { + invisible(.Call(savvy_RModel_map__impl, `self`, `template_iri`, `stream_ptr`, `graph`, `validate_iris`)) + } +} + +`RModel_map_no_data` <- function(self) { + function(`template_iri`, `graph` = NULL, `validate_iris` = NULL) { + invisible(.Call(savvy_RModel_map_no_data__impl, `self`, `template_iri`, `graph`, `validate_iris`)) + } +} + `RModel_query` <- function(self) { function(`sparql`, `stream_ptr`, `include_transient`, `graph` = NULL) { .Call(savvy_RModel_query__impl, `self`, `sparql`, `stream_ptr`, `include_transient`, `graph`) @@ -304,8 +329,8 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } `RModel_size` <- function(self) { - function() { - .Call(savvy_RModel_size__impl, `self`) + function(`graph` = NULL) { + .Call(savvy_RModel_size__impl, `self`, `graph`) } } @@ -326,10 +351,14 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") e$.ptr <- ptr e$`add_graph` <- `RModel_add_graph`(ptr) e$`add_prefixes` <- `RModel_add_prefixes`(ptr) + e$`add_template` <- `RModel_add_template`(ptr) + e$`add_template_string` <- `RModel_add_template_string`(ptr) e$`compact` <- `RModel_compact`(ptr) e$`create_index` <- `RModel_create_index`(ptr) e$`detach_graph` <- `RModel_detach_graph`(ptr) e$`infer_rdfs` <- `RModel_infer_rdfs`(ptr) + e$`map` <- `RModel_map`(ptr) + e$`map_no_data` <- `RModel_map_no_data`(ptr) e$`query` <- `RModel_query`(ptr) e$`reads` <- `RModel_reads`(ptr) e$`serialize` <- `RModel_serialize`(ptr) diff --git a/maplibr/R/model.R b/maplibr/R/model.R index 643cd724..c223c522 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -23,9 +23,10 @@ Model <- R6::R6Class( private$rmodel <- rmodel }, - #' @description Number of triples in the default graph. - size = function() { - private$rmodel$size() + #' @description Number of triples in a graph. + #' @param graph Optional named graph IRI to count (default graph if NULL). + size = function(graph = NULL) { + .rethrow(private$rmodel$size(graph)) }, #' @description Parse RDF triples from a string into this Model. @@ -118,6 +119,45 @@ Model <- R6::R6Class( df }, + #' @description Register a Template so it can be referenced by IRI from + #' `$map()`. + #' @param template A `Template` (see `templates.R`), or an stOTTR document + #' string defining one or more templates. + #' @return The IRI of the registered template, invisibly (for a `Template` + #' object, this is just `template@iri`; for a document string, it's the + #' IRI of the first template the document defines). + add_template = function(template) { + invisible(private$.resolve_template_iri(template)) + }, + + #' @description Expand a template against a data.frame, adding the + #' resulting triples to this Model. Mirrors py_maplib's `Model.map()` + #' (py_maplib/src/py_model.rs:118-142). + #' @param template A `Template` object, the IRI of an already-registered + #' template (`$add_template()`), or an stOTTR document string (defining + #' exactly the one template to expand -- registered as a side effect). + #' @param data Optional data.frame, one row per template instantiation. If + #' NULL (or zero rows), the template is expanded once against no + #' variables -- only valid for templates whose instances are entirely + #' constant terms. + #' @param graph Optional named graph IRI to add the resulting triples to + #' (default graph if NULL). + #' @param validate_iris Whether to validate that IRI-typed columns contain + #' valid IRIs. Defaults to TRUE. + map = function(template, data = NULL, graph = NULL, validate_iris = NULL) { + template_iri <- private$.resolve_template_iri(template) + if (is.null(data)) { + .rethrow(private$rmodel$map_no_data(template_iri, graph, validate_iris)) + } else { + # A zero-row data.frame is a silent no-op (RModel$map(), mirroring + # py_maplib's map_mutex) -- distinct from `data = NULL`, which + # expands the template once against no variables at all. + stream <- nanoarrow::as_nanoarrow_array_stream(data) + .rethrow(private$rmodel$map(template_iri, stream, graph, validate_iris)) + } + invisible(self) + }, + #' @description Run RDFS inference over a graph in place. #' @param graph Optional named graph IRI to infer over (default graph if NULL). #' @return The number of new triples inferred (a triple count, not a rule @@ -145,7 +185,7 @@ Model <- R6::R6Class( #' @description Print a short summary of this Model. #' @param ... Unused. print = function(...) { - cat("", private$rmodel$size(), "triples (default graph)\n") + cat("", private$rmodel$size(NULL), "triples (default graph)\n") invisible(self) } ), @@ -153,6 +193,30 @@ Model <- R6::R6Class( rmodel = NULL, as_rmodel = function(model) { model$.__enclos_env__$private$rmodel + }, + # Resolves `template` (a Template object, a template IRI, or an stOTTR + # document string) to a template IRI, registering it first if needed. + # A Template object or document string is always (re-)registered on + # every call -- mirrors py_maplib's own map_mutex, which re-registers a + # PyTemplate/doc-string `template` argument on every map() call too + # (py_maplib/src/mutexes.rs:120-153), rather than requiring a separate + # add_template() call first. + .resolve_template_iri = function(template) { + if (S7::S7_inherits(template, Template)) { + .rethrow(private$rmodel$add_template(template@raw)) + template@iri@iri + } else if (is.character(template) && length(template) == 1) { + if (grepl("::", template, fixed = TRUE)) { + .rethrow(private$rmodel$add_template_string(template)) + } else { + template + } + } else { + stop( + "`template` must be a Template, a template IRI, or an stOTTR document string", + call. = FALSE + ) + } } ) ) diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index 3a610250..83cacb09 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -22,6 +22,8 @@ returns \code{self} invisibly). \item \href{#method-Model-add_graph}{\code{Model$add_graph()}} \item \href{#method-Model-detach_graph}{\code{Model$detach_graph()}} \item \href{#method-Model-query}{\code{Model$query()}} + \item \href{#method-Model-add_template}{\code{Model$add_template()}} + \item \href{#method-Model-map}{\code{Model$map()}} \item \href{#method-Model-infer_rdfs}{\code{Model$infer_rdfs()}} \item \href{#method-Model-compact}{\code{Model$compact()}} \item \href{#method-Model-serialize}{\code{Model$serialize()}} @@ -54,10 +56,17 @@ is for internal use only. \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Model-size}{}}} \subsection{\code{Model$size()}}{ - Number of triples in the default graph. + Number of triples in a graph. \subsection{Usage}{ \if{html}{\out{
}} - \preformatted{Model$size()} + \preformatted{Model$size(graph = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{graph}}{Optional named graph IRI to count (default graph if NULL).} + } \if{html}{\out{
}} } } @@ -241,6 +250,63 @@ left for later. } } +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-add_template}{}}} +\subsection{\code{Model$add_template()}}{ + Register a Template so it can be referenced by IRI from +\verb{$map()}. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$add_template(template)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{template}}{A \code{Template} (see \code{templates.R}), or an stOTTR document +string defining one or more templates.} + } + \if{html}{\out{
}} + } + \subsection{Returns}{ + The IRI of the registered template, invisibly (for a \code{Template} +object, this is just \code{template@iri}; for a document string, it's the +IRI of the first template the document defines). + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-map}{}}} +\subsection{\code{Model$map()}}{ + Expand a template against a data.frame, adding the +resulting triples to this Model. Mirrors py_maplib's \code{Model.map()} +(py_maplib/src/py_model.rs:118-142). + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$map(template, data = NULL, graph = NULL, validate_iris = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{template}}{A \code{Template} object, the IRI of an already-registered +template (\verb{$add_template()}), or an stOTTR document string (defining +exactly the one template to expand -- registered as a side effect).} + \item{\code{data}}{Optional data.frame, one row per template instantiation. If +NULL (or zero rows), the template is expanded once against no +variables -- only valid for templates whose instances are entirely +constant terms.} + \item{\code{graph}}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + \item{\code{validate_iris}}{Whether to validate that IRI-typed columns contain +valid IRIs. Defaults to TRUE.} + } + \if{html}{\out{
}} + } +} + \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Model-infer_rdfs}{}}} diff --git a/maplibr/src/init.c b/maplibr/src/init.c index 1c6036ea..e9273af2 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -129,6 +129,16 @@ SEXP savvy_RModel_add_prefixes__impl(SEXP self__, SEXP c_arg__prefixes) { return handle_result(res); } +SEXP savvy_RModel_add_template__impl(SEXP self__, SEXP c_arg__template) { + SEXP res = savvy_RModel_add_template__ffi(self__, c_arg__template); + return handle_result(res); +} + +SEXP savvy_RModel_add_template_string__impl(SEXP self__, SEXP c_arg__doc) { + SEXP res = savvy_RModel_add_template_string__ffi(self__, c_arg__doc); + return handle_result(res); +} + SEXP savvy_RModel_compact__impl(SEXP self__) { SEXP res = savvy_RModel_compact__ffi(self__); return handle_result(res); @@ -154,6 +164,16 @@ SEXP savvy_RModel_infer_rdfs__impl(SEXP self__, SEXP c_arg__graph) { return handle_result(res); } +SEXP savvy_RModel_map__impl(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__stream_ptr, SEXP c_arg__graph, SEXP c_arg__validate_iris) { + SEXP res = savvy_RModel_map__ffi(self__, c_arg__template_iri, c_arg__stream_ptr, c_arg__graph, c_arg__validate_iris); + return handle_result(res); +} + +SEXP savvy_RModel_map_no_data__impl(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__graph, SEXP c_arg__validate_iris) { + SEXP res = savvy_RModel_map_no_data__ffi(self__, c_arg__template_iri, c_arg__graph, c_arg__validate_iris); + return handle_result(res); +} + SEXP savvy_RModel_new__impl(void) { SEXP res = savvy_RModel_new__ffi(); return handle_result(res); @@ -174,8 +194,8 @@ SEXP savvy_RModel_serialize__impl(SEXP self__, SEXP c_arg__path) { return handle_result(res); } -SEXP savvy_RModel_size__impl(SEXP self__) { - SEXP res = savvy_RModel_size__ffi(self__); +SEXP savvy_RModel_size__impl(SEXP self__, SEXP c_arg__graph) { + SEXP res = savvy_RModel_size__ffi(self__, c_arg__graph); return handle_result(res); } @@ -234,16 +254,20 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_RInstance_new__impl", (DL_FUNC) &savvy_RInstance_new__impl, 3}, {"savvy_RModel_add_graph__impl", (DL_FUNC) &savvy_RModel_add_graph__impl, 4}, {"savvy_RModel_add_prefixes__impl", (DL_FUNC) &savvy_RModel_add_prefixes__impl, 2}, + {"savvy_RModel_add_template__impl", (DL_FUNC) &savvy_RModel_add_template__impl, 2}, + {"savvy_RModel_add_template_string__impl", (DL_FUNC) &savvy_RModel_add_template_string__impl, 2}, {"savvy_RModel_compact__impl", (DL_FUNC) &savvy_RModel_compact__impl, 1}, {"savvy_RModel_create_index__impl", (DL_FUNC) &savvy_RModel_create_index__impl, 1}, {"savvy_RModel_deserialize__impl", (DL_FUNC) &savvy_RModel_deserialize__impl, 2}, {"savvy_RModel_detach_graph__impl", (DL_FUNC) &savvy_RModel_detach_graph__impl, 3}, {"savvy_RModel_infer_rdfs__impl", (DL_FUNC) &savvy_RModel_infer_rdfs__impl, 2}, + {"savvy_RModel_map__impl", (DL_FUNC) &savvy_RModel_map__impl, 5}, + {"savvy_RModel_map_no_data__impl", (DL_FUNC) &savvy_RModel_map_no_data__impl, 4}, {"savvy_RModel_new__impl", (DL_FUNC) &savvy_RModel_new__impl, 0}, {"savvy_RModel_query__impl", (DL_FUNC) &savvy_RModel_query__impl, 5}, {"savvy_RModel_reads__impl", (DL_FUNC) &savvy_RModel_reads__impl, 4}, {"savvy_RModel_serialize__impl", (DL_FUNC) &savvy_RModel_serialize__impl, 2}, - {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 1}, + {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 2}, {"savvy_RModel_truncate_graph__impl", (DL_FUNC) &savvy_RModel_truncate_graph__impl, 2}, {"savvy_RModel_writes__impl", (DL_FUNC) &savvy_RModel_writes__impl, 3}, {"savvy_RParameter_new__impl", (DL_FUNC) &savvy_RParameter_new__impl, 5}, diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index c55883b5..0b4a4aed 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -24,16 +24,20 @@ SEXP savvy_RInstance_new__ffi(SEXP c_arg__template_iri, SEXP c_arg__arguments, S // methods and associated functions for RModel SEXP savvy_RModel_add_graph__ffi(SEXP self__, SEXP c_arg__other, SEXP c_arg__source_graph, SEXP c_arg__target_graph); SEXP savvy_RModel_add_prefixes__ffi(SEXP self__, SEXP c_arg__prefixes); +SEXP savvy_RModel_add_template__ffi(SEXP self__, SEXP c_arg__template); +SEXP savvy_RModel_add_template_string__ffi(SEXP self__, SEXP c_arg__doc); SEXP savvy_RModel_compact__ffi(SEXP self__); SEXP savvy_RModel_create_index__ffi(SEXP self__); SEXP savvy_RModel_deserialize__ffi(SEXP c_arg__path, SEXP c_arg__storage_folder); SEXP savvy_RModel_detach_graph__ffi(SEXP self__, SEXP c_arg__preserve_name, SEXP c_arg__graph); SEXP savvy_RModel_infer_rdfs__ffi(SEXP self__, SEXP c_arg__graph); +SEXP savvy_RModel_map__ffi(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__stream_ptr, SEXP c_arg__graph, SEXP c_arg__validate_iris); +SEXP savvy_RModel_map_no_data__ffi(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__graph, SEXP c_arg__validate_iris); SEXP savvy_RModel_new__ffi(void); SEXP savvy_RModel_query__ffi(SEXP self__, SEXP c_arg__sparql, SEXP c_arg__stream_ptr, SEXP c_arg__include_transient, SEXP c_arg__graph); SEXP savvy_RModel_reads__ffi(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEXP c_arg__graph); SEXP savvy_RModel_serialize__ffi(SEXP self__, SEXP c_arg__path); -SEXP savvy_RModel_size__ffi(SEXP self__); +SEXP savvy_RModel_size__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_truncate_graph__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_writes__ffi(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph); diff --git a/maplibr/src/rust/src/arrow_bridge.rs b/maplibr/src/rust/src/arrow_bridge.rs index 6c37341f..d9f81225 100644 --- a/maplibr/src/rust/src/arrow_bridge.rs +++ b/maplibr/src/rust/src/arrow_bridge.rs @@ -271,6 +271,41 @@ fn export_test_solution_mappings(stream_ptr: Sexp) -> savvy::Result { export_solution_mappings(build_test_solution_mappings(), stream_ptr) } +/// Read an Arrow C stream from R into a plain `DataFrame` -- the mapping-side +/// counterpart of `export_solution_mappings`. Used by `RModel::map`/ +/// `RModel::add_template` (maplib-cgd) to bring a plain R data.frame (via +/// `nanoarrow::as_nanoarrow_array_stream(df)` on the R side) across as the +/// `data` argument to `Model::expand`. Unlike the export path, there is no +/// `rdf_node_types` side-channel here -- an input mapping data.frame is +/// plain tabular data, its RDF typing comes from the target Template's +/// signature, not from the data itself. Columns are decategorized the same +/// way `export_solution_mappings` decategorizes on the way out: an R factor +/// column arrives as a dictionary-encoded Arrow array, and `expand()`'s +/// column validation (model/expansion/validation.rs) expects plain String +/// columns, not Categorical/Enum. +pub(crate) fn import_dataframe(stream_ptr: Sexp) -> savvy::Result { + let mut stream = unsafe { + let boxed = Box::new(std::ptr::replace( + ExternalPointerSexp::try_from(stream_ptr)?.cast_mut_unchecked::(), + ArrowArrayStream::empty(), + )); + ArrowArrayStreamReader::try_new(boxed).map_err(|e| savvy::Error::new(&e.to_string()))? + }; + + let mut arrays = Vec::new(); + while let Some(arr) = unsafe { stream.next() } { + arrays.push(arr.map_err(|e| savvy::Error::new(&e.to_string()))?); + } + + let struct_series = Series::from_arrow_chunks("data".into(), arrays) + .map_err(|e| savvy::Error::new(&e.to_string()))?; + let struct_series = decategorize(struct_series)?; + struct_series + .struct_() + .map_err(|e| crate::errors::runtime_error(e.to_string())) + .map(|ca| ca.clone().unnest()) +} + /// Read an Arrow C stream from R back into a Struct-typed Series, unnest it /// back into a DataFrame (`StructChunked::unnest`, the reverse of /// `into_struct`), and return a small human-readable summary combining the diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index 2dc00860..6116057f 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -1,10 +1,12 @@ use crate::errors::argument_error; use crate::errors::maplib_error; +use crate::templates::RTemplate; +use maplib::model::MapOptions; use oxrdf::NamedNode; use oxrdfio::RdfFormat; use polars::prelude::IntoLazy; use representation::dataset::NamedGraph; -use savvy::{savvy, ListSexp}; +use savvy::{savvy, ListSexp, Sexp}; use std::collections::HashMap; use std::path::Path; use std::sync::Mutex; @@ -99,13 +101,16 @@ impl RModel { }) } - /// Number of triples in the default graph. + /// Number of triples in a graph (mirrors PyModel::size, + /// py_maplib/src/py_model.rs:144-151 / size_mutex, py_maplib/src/ + /// mutexes.rs:66-73). /// + /// @param graph Optional named graph IRI to count (default graph if NULL). /// @export - fn size(&self) -> savvy::Result { + fn size(&self, graph: Option<&str>) -> savvy::Result { + let named_graph = parse_optional_named_graph(graph)?; let inner = self.lock(); - let graph = NamedGraph::from_maybe_named_node(None); - (inner.graph_size(&graph) as i32).try_into() + (inner.graph_size(&named_graph) as i32).try_into() } /// Run a SPARQL SELECT query, streaming the result out through the Arrow @@ -351,6 +356,102 @@ impl RModel { .map_err(maplib_error) } + /// Register an already-built Template (mirrors `Model::add_template`, + /// lib/maplib/src/model.rs:165 -- py_maplib's own `add_template` just + /// forwards a parsed `Template` here too, py_model.rs:74-80). + /// + /// @param template An RTemplate (the raw pointer behind maplibr's S7 + /// `Template` class, `template@raw`). + /// @export + fn add_template(&self, template: &RTemplate) -> savvy::Result<()> { + let mut inner = self.lock(); + inner + .add_template(template.inner.clone()) + .map_err(maplib_error) + } + + /// Parse an stOTTR document string and register every template it + /// defines (mirrors `Model::add_templates_from_string`, + /// lib/maplib/src/model.rs:177-194). Errors if the document defines no + /// templates at all, matching py_maplib's own message for that case + /// (py_maplib/src/mutexes.rs:132-138). + /// + /// @param doc An stOTTR document, as a string. + /// @returns The IRI of the first template the document defines. + /// @export + fn add_template_string(&self, doc: &str) -> savvy::Result { + let mut inner = self.lock(); + let iri = inner + .add_templates_from_string(doc) + .map_err(maplib_error)? + .ok_or_else(|| { + argument_error("Template stOTTR document contained no templates") + })?; + iri.as_str().to_string().try_into() + } + + /// Expand a template against a data.frame, adding the resulting triples + /// to this Model (mirrors `map_mutex`'s data-driven branch, + /// py_maplib/src/mutexes.rs:120-169 -- `Model::expand`, + /// lib/maplib/src/model/expansion.rs:58). + /// + /// @param template_iri IRI of an already-registered template (see + /// `add_template`/`add_template_string`). + /// @param stream_ptr A *filled* Arrow C Stream Interface pointer (e.g. + /// from `nanoarrow::as_nanoarrow_array_stream(df)`), one row per + /// template instantiation. + /// @param graph Optional named graph IRI to add the resulting triples to + /// (default graph if NULL). + /// @param validate_iris Whether to validate that IRI-typed columns + /// contain valid IRIs. Defaults to TRUE, matching py_maplib. + /// @export + fn map( + &self, + template_iri: &str, + stream_ptr: Sexp, + graph: Option<&str>, + validate_iris: Option, + ) -> savvy::Result<()> { + let df = crate::arrow_bridge::import_dataframe(stream_ptr)?; + if df.height() == 0 { + // Matches py_maplib's own map_mutex (py_maplib/src/mutexes.rs: + // 158-162): an empty-but-present DataFrame is a silent no-op, + // not the same as no DataFrame at all -- expand(None) assumes a + // signature with no variables, which isn't true here. + return Ok(()); + } + let named_graph = parse_optional_named_graph(graph)?; + let options = MapOptions::from_args(named_graph, validate_iris); + let mut inner = self.lock(); + inner + .expand(template_iri, Some(df), None, options) + .map_err(maplib_error) + } + + /// Expand a template with no input data.frame -- for templates whose + /// instances are entirely made of constant terms (mirrors `map_mutex`'s + /// `df.is_none()` branch, py_maplib/src/mutexes.rs:163-168). + /// + /// @param template_iri IRI of an already-registered template. + /// @param graph Optional named graph IRI to add the resulting triples to + /// (default graph if NULL). + /// @param validate_iris Whether to validate that IRI-typed columns + /// contain valid IRIs. Defaults to TRUE. + /// @export + fn map_no_data( + &self, + template_iri: &str, + graph: Option<&str>, + validate_iris: Option, + ) -> savvy::Result<()> { + let named_graph = parse_optional_named_graph(graph)?; + let options = MapOptions::from_args(named_graph, validate_iris); + let mut inner = self.lock(); + inner + .expand(template_iri, None, None, options) + .map_err(maplib_error) + } + /// Load a Model previously written by serialize() (mirrors /// PyModel::deserialize, py_maplib/src/py_model.rs:936-954). This is an /// associated function, not a method -- call as `RModel$deserialize(path)`. diff --git a/maplibr/tests/testthat/test-model-graphs.R b/maplibr/tests/testthat/test-model-graphs.R index acd1d135..038f7324 100644 --- a/maplibr/tests/testthat/test-model-graphs.R +++ b/maplibr/tests/testthat/test-model-graphs.R @@ -1,23 +1,89 @@ -# Ports the non-query subset of py_maplib/tests/test_named_graphs.py. -# Those tests confirm named-graph routing via query() and a per-graph -# m.size(graph) that RModel$size() doesn't support yet (it always counts the -# default graph -- see model.rs; a real gap, not something to route around -# here). Routing is instead confirmed via writes(graph=), which does take a -# graph argument, without needing query() at all. +# Ports py_maplib/tests/test_named_graphs.py. query() (maplib-l2j) and +# size(graph) (maplib-0mj) are both available now, so this no longer needs to +# route around m.size(graph) via writes(graph=) the way an earlier version of +# this file did. gr1 <- ' "A" .' gr2 <- ' "B" .' +gr3 <- ' + "B" . + "C" . +' test_that("reads() routes triples into the requested named graph", { - # Mirrors test_basic_named_graph (py_maplib/tests/test_named_graphs.py:17-30). + # Ports test_basic_named_graph (py_maplib/tests/test_named_graphs.py:17-30). ng2 <- "urn:graph:gr2" m <- Model$new() m$reads(gr1, format = "ntriples") m$reads(gr2, format = "ntriples", graph = ng2) - expect_equal(m$size(), 1) # default graph only has gr1's triple - expect_match(m$writes(format = "ntriples", graph = ng2), '"B"', fixed = TRUE) - expect_false(grepl('"B"', m$writes(format = "ntriples"), fixed = TRUE)) + df <- m$query("SELECT * WHERE { GRAPH { ?a ?b ?c } }") + expect_equal(nrow(df), 1) + expect_equal(df$c[1], "B") + expect_equal(m$size(ng2), 1) + expect_equal(m$size(), 1) +}) + +test_that("reads() with no graph routes into the default graph, queryable via maplib:DefaultGraph", { + # Ports test_basic_named_graph_named_default (py_maplib/tests/ + # test_named_graphs.py:35-51). + ng2 <- "urn:graph:gr2" + m <- Model$new() + m$reads(gr1, format = "ntriples", graph = ng2) + m$reads(gr2, format = "ntriples") + + df <- m$query("SELECT * WHERE { GRAPH maplib:DefaultGraph { ?a ?b ?c } }") + expect_equal(nrow(df), 1) + expect_equal(df$c[1], "B") + expect_equal(m$size(ng2), 1) + expect_equal(m$size(), 1) +}) + +test_that("the default graph's full IRI is also a valid graph argument", { + # Ports test_basic_named_graph_named_default_as_arg (py_maplib/tests/ + # test_named_graphs.py:53-67). + ng2 <- "urn:graph:gr2" + m <- Model$new() + m$reads(gr1, format = "ntriples", graph = ng2) + m$reads(gr2, format = "ntriples", graph = "https://datatreehouse.github.io/maplib/vocab#DefaultGraph") + + df <- m$query("SELECT * WHERE { ?a ?b ?c }") + expect_equal(nrow(df), 1) + expect_equal(df$c[1], "B") + expect_equal(m$size(ng2), 1) + expect_equal(m$size(), 1) +}) + +test_that("size(graph) counts only that graph's triples", { + # Ports test_basic_named_graph_more_triples (py_maplib/tests/ + # test_named_graphs.py:69-79). + ng2 <- "urn:graph:gr2" + ng3 <- "urn:graph:gr3" + m <- Model$new() + m$reads(gr1, format = "ntriples") + m$reads(gr3, format = "ntriples", graph = ng2) + + expect_equal(m$size(ng2), 2) + expect_equal(m$size(), 1) + expect_equal(m$size(ng3), 0) +}) + +test_that("query() can join across two named graphs in one WHERE clause", { + # Ports test_basic_named_graph_2 (py_maplib/tests/test_named_graphs.py:82-100). + ng1 <- "urn:graph:gr1" + ng2 <- "urn:graph:gr2" + m <- Model$new() + m$reads(gr1, format = "ntriples", graph = ng1) + m$reads(gr2, format = "ntriples", graph = ng2) + + df <- m$query(' + SELECT * WHERE { + GRAPH { ?a ?b1 ?c1 } + GRAPH { ?a ?b2 ?c2 } + } + ') + expect_equal(nrow(df), 1) + expect_equal(df$c2[1], "B") }) test_that("add_graph() copies a named graph's triples into another Model", { @@ -30,14 +96,37 @@ test_that("add_graph() copies a named graph's triples into another Model", { expect_match(m2$writes(format = "ntriples"), '"A"', fixed = TRUE) }) -test_that("detach_graph() splits a graph into a new, standalone Model", { +test_that("detach_graph() splits a named graph into a new, standalone Model", { + # Ports test_detach_graph (py_maplib/tests/test_named_graphs.py:102-146). + ng1 <- "urn:graph:gr1" ng2 <- "urn:graph:gr2" m <- Model$new() - m$reads(gr1, format = "ntriples") + m$reads(gr1, format = "ntriples", graph = ng1) m$reads(gr2, format = "ntriples", graph = ng2) + m2 <- m$detach_graph(preserve_name = TRUE, graph = ng2) + + expect_equal(nrow(m$query("SELECT * WHERE { GRAPH { ?a ?b2 ?c2 } }")), 0) + expect_equal(nrow(m$query("SELECT * WHERE { GRAPH { ?a ?b1 ?c1 } }")), 1) + + df2 <- m2$query("SELECT * WHERE { GRAPH { ?a ?b2 ?c2 } }") + expect_equal(nrow(df2), 1) + expect_equal(df2$c2[1], "B") + expect_equal(nrow(m2$query("SELECT * WHERE { GRAPH { ?a ?b1 ?c1 } }")), 0) +}) + +test_that("detach_graph() with no graph splits off the default graph", { + # Ports test_detach_default_graph (py_maplib/tests/test_named_graphs.py:148-188). + ng1 <- "urn:graph:gr1" + m <- Model$new() + m$reads(gr1, format = "ntriples", graph = ng1) + m$reads(gr2, format = "ntriples") + m2 <- m$detach_graph() + + expect_equal(nrow(m$query("SELECT * WHERE { ?a ?b2 ?c2 }")), 0) + expect_equal(nrow(m$query("SELECT * WHERE { GRAPH { ?a ?b1 ?c1 } }")), 1) - detached <- m$detach_graph(graph = ng2) - expect_true(inherits(detached, "Model")) - expect_equal(detached$size(), 1) - expect_match(detached$writes(format = "ntriples"), '"B"', fixed = TRUE) + df2 <- m2$query("SELECT * WHERE { ?a ?b2 ?c2 }") + expect_equal(nrow(df2), 1) + expect_equal(df2$c2[1], "B") + expect_equal(nrow(m2$query("SELECT * WHERE { GRAPH { ?a ?b1 ?c1 } }")), 0) }) diff --git a/maplibr/tests/testthat/test-model-map.R b/maplibr/tests/testthat/test-model-map.R new file mode 100644 index 00000000..0dfe1d9f --- /dev/null +++ b/maplibr/tests/testthat/test-model-map.R @@ -0,0 +1,82 @@ +# Tests for Model$map()/Model$add_template() (maplib-cgd). Ports the subset +# of py_maplib/tests/test_basics.py that only needs map()/add_template() plus +# query() (already available, maplib-l2j) -- OTTR features query() itself +# doesn't cover yet (CONSTRUCT, streaming=) are out of scope here. + +test_that("add_template(doc) registers a template usable by IRI in map()", { + # Ports test_add_template_instead_of_constructor_df (py_maplib/tests/ + # test_basics.py:43-54). + doc <- ' + @prefix ex:. + ex:ExampleTemplate [?MyValue] :: { + ottr:Triple(ex:myObject, ex:hasValue, ?MyValue) + } . + ' + m <- Model$new() + iri <- m$add_template(doc) + expect_equal(iri, "http://example.net/ns#ExampleTemplate") + + m$map("http://example.net/ns#ExampleTemplate", data.frame(MyValue = "A")) + df <- m$query("SELECT ?o WHERE { ?o }") + expect_equal(df$o, "A") +}) + +test_that("map() accepts a Template object directly, with an optional missing parameter", { + # Ports test_create_programmatic_model_with_optional_value_missing_df + # (py_maplib/tests/test_basics.py:101-145), sans the streaming= parametrization + # (query() has no streaming argument to port). + ex <- Prefix("http://example.net/ns#", "ex") + my_value <- Variable("MyValue") + my_other_value <- Variable("MyOtherValue") + my_object <- suf(ex, "MyObject") + template <- Template( + iri = suf(ex, "ExampleTemplate"), + parameters = list(Parameter(my_value), Parameter(my_other_value, optional = TRUE)), + instances = list( + Triple(my_object, suf(ex, "hasValue"), my_value), + Triple(my_object, suf(ex, "hasOtherValue"), my_other_value) + ) + ) + m <- Model$new() + m$map(template, data.frame(MyValue = "A")) + + df <- m$query("PREFIX ex: SELECT ?A WHERE { ?obj1 ex:hasValue ?A }") + expect_equal(df$A, "A") + + df2 <- m$query("PREFIX ex: SELECT ?A WHERE { ?obj1 ex:hasOtherValue ?A }") + expect_equal(nrow(df2), 0) +}) + +test_that("map() with no data.frame expands a constant-only template once", { + doc <- ' + @prefix ex:. + ex:StaticTemplate [] :: { + ottr:Triple(ex:a, ex:b, ex:c) + } . + ' + m <- Model$new() + m$add_template(doc) + m$map("http://example.net/ns#StaticTemplate") + expect_equal(m$size(), 1) +}) + +test_that("map() with a zero-row data.frame is a no-op", { + # Ports test_create_model_from_empty_polars_df (py_maplib/tests/ + # test_basics.py:24-35). + doc <- ' + @prefix ex:. + ex:ExampleTemplate [?MyValue] :: { + ottr:Triple(ex:myObject, ex:hasValue, ?MyValue) + } . + ' + m <- Model$new() + m$add_template(doc) + m$map("http://example.net/ns#ExampleTemplate", data.frame(MyValue = character(0))) + expect_equal(m$size(), 0) +}) + +test_that("add_template()/map() reject an unrecognized `template` argument", { + m <- Model$new() + expect_error(m$add_template(42), "Template") + expect_error(m$map(42), "Template") +}) From ca4637419010c796b10ccba3d6884676359f3f37 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 20:42:02 +0200 Subject: [PATCH 20/46] fix(maplibr): fix doc-string/IRI misclassification in template resolution - a literal "::" substring check misclassified some real IRIs as stOTTR documents - switch the discriminator to the "{" instance-block brace, never a legal IRI character Co-Authored-By: Claude Sonnet 5 --- maplibr/R/model.R | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/maplibr/R/model.R b/maplibr/R/model.R index c223c522..a0673cbd 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -206,7 +206,14 @@ Model <- R6::R6Class( .rethrow(private$rmodel$add_template(template@raw)) template@iri@iri } else if (is.character(template) && length(template) == 1) { - if (grepl("::", template, fixed = TRUE)) { + # Discriminates a doc string from a bare IRI by the presence of a + # "{" instance-block brace: every stOTTR document has one (its + # `[params] :: { ... }` body), and "{" is never a legal IRI + # character (RFC 3987), so a real template IRI can never contain + # one -- unlike an earlier "::" substring check (roborev job 22), + # which would misclassify a bare IRI containing "::" (e.g. some URN + # forms) as a doc string. + if (grepl("{", template, fixed = TRUE)) { .rethrow(private$rmodel$add_template_string(template)) } else { template From ab42c042a72fc4a5850c56734a205ad3d58259ae Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 20:43:53 +0200 Subject: [PATCH 21/46] test(maplibr): add regression test for IRI-containing-'::' misclassification - locks in 85498bd's fix Co-Authored-By: Claude Sonnet 5 --- maplibr/tests/testthat/test-model-map.R | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/maplibr/tests/testthat/test-model-map.R b/maplibr/tests/testthat/test-model-map.R index 0dfe1d9f..34db70b8 100644 --- a/maplibr/tests/testthat/test-model-map.R +++ b/maplibr/tests/testthat/test-model-map.R @@ -75,6 +75,31 @@ test_that("map() with a zero-row data.frame is a no-op", { expect_equal(m$size(), 0) }) +test_that("map() treats a template IRI containing '::' as a plain reference, not a doc string", { + # Regression test for roborev job 22/24: .resolve_template_iri used to + # discriminate a doc string from a bare IRI by checking for a literal + # "::" substring, which would misclassify a real IRI containing "::" + # (e.g. some URN forms) as a doc string and send it to + # add_template_string(), raising a confusing stOTTR parse error instead + # of using it as a plain reference. + ex <- Prefix("http://example.net/ns#", "ex") + iri_with_colons <- "http://example.net/ns#Example::Template" + my_value <- Variable("MyValue") + template <- Template( + iri = IRI(iri_with_colons), + parameters = list(Parameter(my_value)), + instances = list(Triple(suf(ex, "myObject"), suf(ex, "hasValue"), my_value)) + ) + m <- Model$new() + m$add_template(template) + + # Passed as a plain character string (not the Template object) so it goes + # through the doc-string/IRI discriminator in .resolve_template_iri. + m$map(iri_with_colons, data.frame(MyValue = "A")) + df <- m$query("SELECT ?o WHERE { ?o }") + expect_equal(df$o, "A") +}) + test_that("add_template()/map() reject an unrecognized `template` argument", { m <- Model$new() expect_error(m$add_template(42), "Template") From 8ac927314450863272e943713149ecc8e3497caa Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 20:46:52 +0200 Subject: [PATCH 22/46] fix(maplibr): prevent add_graph() deadlock on a self-aliased Model - m$add_graph(m), or two wrappers sharing one pointer, double-locked the same non-reentrant Mutex, hanging R permanently - now compares the raw external pointer behind both wrappers and errors clearly instead Co-Authored-By: Claude Sonnet 5 --- maplibr/R/model.R | 17 ++++++++++++++++- maplibr/tests/testthat/test-model-graphs.R | 9 +++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/maplibr/R/model.R b/maplibr/R/model.R index a0673cbd..e4603c58 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -74,7 +74,22 @@ Model <- R6::R6Class( if (!inherits(other, "Model")) { stop("`other` must be a Model object", call. = FALSE) } - .rethrow(private$rmodel$add_graph(private$as_rmodel(other), source_graph, target_graph)) + other_rmodel <- private$as_rmodel(other) + # The underlying Rust call locks `other` then `self` in fixed order, + # so if both wrap the same Mutex (`other` is `self`, or an aliased + # Model sharing its underlying pointer -- e.g. a future clone() that + # doesn't deep-copy), it locks the same non-reentrant Mutex twice on + # one thread and deadlocks the R session permanently. Compared via + # the raw external pointer (`.ptr`), not `identical(self, other)`, + # so this also catches two distinct Model wrapper objects that + # happen to share one underlying RModel. + if (identical(private$rmodel$.ptr, other_rmodel$.ptr)) { + stop( + "`other` must not be the same underlying Model as `self` -- add_graph(m, m) would deadlock (self and other share one lock)", + call. = FALSE + ) + } + .rethrow(private$rmodel$add_graph(other_rmodel, source_graph, target_graph)) invisible(self) }, diff --git a/maplibr/tests/testthat/test-model-graphs.R b/maplibr/tests/testthat/test-model-graphs.R index 038f7324..411da53f 100644 --- a/maplibr/tests/testthat/test-model-graphs.R +++ b/maplibr/tests/testthat/test-model-graphs.R @@ -96,6 +96,15 @@ test_that("add_graph() copies a named graph's triples into another Model", { expect_match(m2$writes(format = "ntriples"), '"A"', fixed = TRUE) }) +test_that("add_graph() refuses to add a Model to itself", { + # add_graph()'s underlying Rust call locks other then self in fixed + # order; other = self would lock the same non-reentrant Mutex twice on + # one thread and hang the R session. Must error instead. + m <- Model$new() + m$reads(gr1, format = "ntriples") + expect_error(m$add_graph(m), "same underlying Model") +}) + test_that("detach_graph() splits a named graph into a new, standalone Model", { # Ports test_detach_graph (py_maplib/tests/test_named_graphs.py:102-146). ng1 <- "urn:graph:gr1" From 91619640ea6334bcab6aad76174d60e1abb8089a Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 20:49:21 +0200 Subject: [PATCH 23/46] test(maplibr): cover the aliased-wrapper case in the add_graph deadlock test Co-Authored-By: Claude Sonnet 5 --- maplibr/tests/testthat/test-model-graphs.R | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/maplibr/tests/testthat/test-model-graphs.R b/maplibr/tests/testthat/test-model-graphs.R index 411da53f..5803b52c 100644 --- a/maplibr/tests/testthat/test-model-graphs.R +++ b/maplibr/tests/testthat/test-model-graphs.R @@ -105,6 +105,16 @@ test_that("add_graph() refuses to add a Model to itself", { expect_error(m$add_graph(m), "same underlying Model") }) +test_that("add_graph() refuses two distinct Model wrappers sharing one underlying RModel", { + # The pointer comparison (not identical(self, other)) exists specifically + # to also catch this case: two separate R6 objects wrapping the same + # underlying Rust Mutex, as a shallow clone would produce. + m <- Model$new() + m$reads(gr1, format = "ntriples") + aliased <- Model$new(m$.__enclos_env__$private$rmodel) + expect_error(m$add_graph(aliased), "same underlying Model") +}) + test_that("detach_graph() splits a named graph into a new, standalone Model", { # Ports test_detach_graph (py_maplib/tests/test_named_graphs.py:102-146). ng1 <- "urn:graph:gr1" From 5838d7ffd32e4eb3f892be6a41abbd32eeace779 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 20:50:47 +0200 Subject: [PATCH 24/46] fix(maplibr): disable Model's default R6 clone() - R6's default clone() only copied the pointer, not the underlying Rust Mutex state, so clones silently shared state - no cheap deep-copy path exists; detach_graph() remains the supported way to split off an independent Model Co-Authored-By: Claude Sonnet 5 --- maplibr/R/model.R | 12 ++++++++++++ maplibr/man/Model.Rd | 20 -------------------- maplibr/tests/testthat/test-model-basics.R | 11 +++++++++++ 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/maplibr/R/model.R b/maplibr/R/model.R index e4603c58..84fb1263 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -8,6 +8,18 @@ #' @export Model <- R6::R6Class( "Model", + # R6's default clone() is a shallow copy: it would copy the R-level + # reference to `private$rmodel` (a savvy external pointer wrapping a Rust + # `Mutex`), not the underlying Rust state itself -- + # so `m2 <- m$clone()` would look like an independent copy while actually + # sharing the same mutex-guarded triplestore, and mutating one would + # silently mutate the other with no error or warning. There's no cheap + # way to deep-copy the underlying Rust Model (that would need a real + # serialize/deserialize round trip, which itself isn't available for an + # in-memory Model -- see `$serialize()`). Disabling clone() entirely is + # safer than a misleading shallow one; `$detach_graph()` is the supported + # way to get an independent Model. + cloneable = FALSE, public = list( #' @description #' Create a new, empty Model. Not used to wrap an existing `RModel` diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index 83cacb09..130d209f 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -28,7 +28,6 @@ returns \code{self} invisibly). \item \href{#method-Model-compact}{\code{Model$compact()}} \item \href{#method-Model-serialize}{\code{Model$serialize()}} \item \href{#method-Model-print}{\code{Model$print()}} - \item \href{#method-Model-clone}{\code{Model$clone()}} } } \if{html}{\out{
}} @@ -383,23 +382,4 @@ use \verb{$writes()} for that). } } -\if{html}{\out{
}} -\if{html}{\out{}} -\if{latex}{\out{\hypertarget{method-Model-clone}{}}} -\subsection{\code{Model$clone()}}{ - The objects of this class are cloneable with this method. - \subsection{Usage}{ - \if{html}{\out{
}} - \preformatted{Model$clone(deep = FALSE)} - \if{html}{\out{
}} - } - \subsection{Arguments}{ - \if{html}{\out{
}} - \describe{ - \item{\code{deep}}{Whether to make a deep clone.} - } - \if{html}{\out{
}} - } -} - } diff --git a/maplibr/tests/testthat/test-model-basics.R b/maplibr/tests/testthat/test-model-basics.R index 53034ade..9e77c493 100644 --- a/maplibr/tests/testthat/test-model-basics.R +++ b/maplibr/tests/testthat/test-model-basics.R @@ -41,3 +41,14 @@ test_that("add_prefixes() affects writes() output", { out <- m$writes(format = "turtle") expect_match(out, "@prefix ex: ", fixed = TRUE) }) + +test_that("clone() is disabled -- R6's default shallow clone would share the underlying Mutex", { + # R6's default clone() only copies the R-level reference to + # private$rmodel, not the underlying Rust state -- m2 <- m$clone() would + # look independent while actually sharing the same mutex-guarded + # triplestore. cloneable = FALSE removes $clone() entirely rather than + # leaving a misleading shallow one in place. + m <- Model$new() + expect_null(m$clone) + expect_error(m$clone()) +}) From 529eed32510c82cfa14f60fe906c47c1609d605a Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 20:51:42 +0200 Subject: [PATCH 25/46] docs(maplibr): fix stale comment referencing a hypothetical future clone() Co-Authored-By: Claude Sonnet 5 --- maplibr/R/model.R | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/maplibr/R/model.R b/maplibr/R/model.R index 84fb1263..dd92eb16 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -89,12 +89,13 @@ Model <- R6::R6Class( other_rmodel <- private$as_rmodel(other) # The underlying Rust call locks `other` then `self` in fixed order, # so if both wrap the same Mutex (`other` is `self`, or an aliased - # Model sharing its underlying pointer -- e.g. a future clone() that - # doesn't deep-copy), it locks the same non-reentrant Mutex twice on - # one thread and deadlocks the R session permanently. Compared via - # the raw external pointer (`.ptr`), not `identical(self, other)`, - # so this also catches two distinct Model wrapper objects that - # happen to share one underlying RModel. + # Model sharing its underlying pointer -- clone() is disabled, but + # two Model$new() calls can still be handed the same RModel), it + # locks the same non-reentrant Mutex twice on one thread and + # deadlocks the R session permanently. Compared via the raw external + # pointer (`.ptr`), not `identical(self, other)`, so this also + # catches two distinct Model wrapper objects that happen to share + # one underlying RModel. if (identical(private$rmodel$.ptr, other_rmodel$.ptr)) { stop( "`other` must not be the same underlying Model as `self` -- add_graph(m, m) would deadlock (self and other share one lock)", From 802ceda6c83a2652966bc8a73e671e06c47a1584 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 21:01:16 +0200 Subject: [PATCH 26/46] feat: add include_transient to write_triples for inferred triples - Triplestore::write_triples never read graph_transient_triples_map; query() could see transient triples but write()/writes() couldn't - implemented for NTriples and generic RdfSerializer paths; pretty Turtle errors clearly instead of dropping them - maplibr's Model$writes() gains the same opt-in argument, default FALSE Co-Authored-By: Claude Sonnet 5 --- lib/maplib/src/model.rs | 3 +- lib/triplestore/src/triples_write.rs | 212 +++++++++++------- maplibr/R/000-wrappers.R | 4 +- maplibr/R/model.R | 6 +- maplibr/man/Model.Rd | 4 +- maplibr/src/init.c | 6 +- maplibr/src/rust/api.h | 2 +- maplibr/src/rust/src/model.rs | 19 +- .../tests/testthat/test-model-infer-rdfs.R | 88 +++++--- py_maplib/src/mutexes.rs | 4 +- 10 files changed, 228 insertions(+), 120 deletions(-) diff --git a/lib/maplib/src/model.rs b/lib/maplib/src/model.rs index 63d41ab2..0c57fa6e 100644 --- a/lib/maplib/src/model.rs +++ b/lib/maplib/src/model.rs @@ -529,13 +529,14 @@ impl Model { graph: &NamedGraph, rdf_format: RdfFormat, prefixes: Option<&HashMap>, + include_transient: bool, ) -> Result<(), MaplibError> { let mut use_prefixes = self.prefixes.clone(); if let Some(prefixes) = prefixes { use_prefixes.extend(prefixes.clone()); } self.triplestore - .write_triples(buffer, rdf_format, graph, &use_prefixes) + .write_triples(buffer, rdf_format, graph, &use_prefixes, include_transient) .map_err(MaplibError::TriplestoreError)?; Ok(()) } diff --git a/lib/triplestore/src/triples_write.rs b/lib/triplestore/src/triples_write.rs index 1c8ec403..979664f8 100644 --- a/lib/triplestore/src/triples_write.rs +++ b/lib/triplestore/src/triples_write.rs @@ -1,5 +1,6 @@ use super::Triplestore; use crate::errors::TriplestoreError; +use crate::storage::Triples; use oxrdf::NamedNode; use oxrdfio::{RdfFormat, RdfSerializer}; use polars::prelude::{by_name, col}; @@ -12,7 +13,8 @@ use representation::polars_to_rdf::{ date_column_to_strings, datetime_column_to_strings, global_df_as_triples, }; use representation::{ - LANG_STRING_LANG_FIELD, LANG_STRING_VALUE_FIELD, OBJECT_COL_NAME, SUBJECT_COL_NAME, + BaseRDFNodeType, LANG_STRING_LANG_FIELD, LANG_STRING_VALUE_FIELD, OBJECT_COL_NAME, + SUBJECT_COL_NAME, }; use std::collections::HashMap; use std::io::Write; @@ -24,6 +26,8 @@ mod serializers; const CHUNK_SIZE: usize = 1_024; +type PredicateTriplesMap = HashMap>; + impl Triplestore { pub fn write_triples( &mut self, @@ -31,97 +35,44 @@ impl Triplestore { format: RdfFormat, graph: &NamedGraph, prefixes: &HashMap, + include_transient: bool, ) -> Result<(), TriplestoreError> { - if !self.does_graph_exist(graph) { + let has_regular = self.does_graph_exist(graph); + let has_transient = + include_transient && self.graph_transient_triples_map.contains_key(graph); + if !has_regular && !has_transient { return Ok(()); } if RdfFormat::NTriples == format { let n_threads = THREAD_POOL.current_num_threads(); - for (predicate, df_map) in self.graph_triples_map.get(graph).unwrap() { - let predicate_string = predicate.to_string(); - let predicate_bytes = predicate_string.as_bytes(); - for ((subject_type, object_type), tt) in df_map { - let types = if object_type.is_lang_string() { - HashMap::from([ - (SUBJECT_COL_NAME.to_string(), subject_type.clone()), - (LANG_STRING_VALUE_FIELD.to_string(), object_type.clone()), - (LANG_STRING_LANG_FIELD.to_string(), object_type.clone()), - ]) - } else { - HashMap::from([ - (SUBJECT_COL_NAME.to_string(), subject_type.clone()), - (OBJECT_COL_NAME.to_string(), object_type.clone()), - ]) - }; - for (mut lf, _) in tt.get_lazy_frames(&None, &None)? { - lf = lf.with_columns([ - maybe_decode_complex_expr( - col(SUBJECT_COL_NAME), - subject_type, - &subject_type.default_stored_cat_state(), - self.global_cats.clone(), - ) - .alias(SUBJECT_COL_NAME), - maybe_decode_complex_expr( - col(OBJECT_COL_NAME), - object_type, - &object_type.default_stored_cat_state(), - self.global_cats.clone(), - ) - .alias(OBJECT_COL_NAME), - ]); - if object_type.is_lang_string() { - lf = lf - .unnest(by_name([OBJECT_COL_NAME], true, false), None) - .select([ - col(SUBJECT_COL_NAME), - col(LANG_STRING_VALUE_FIELD), - col(LANG_STRING_LANG_FIELD), - ]); - } else { - lf = lf.select([col(SUBJECT_COL_NAME), col(OBJECT_COL_NAME)]); - }; - let mut df = lf.collect().unwrap(); - if !object_type.is_lang_string() { - convert_datelike_to_string(&mut df, OBJECT_COL_NAME); - } - - fast_ntriples::write_triples_in_df( - buf, - &df, - predicate_bytes, - &types, - CHUNK_SIZE, - n_threads, - ) - .unwrap(); - } + if let Some(map) = self.graph_triples_map.get(graph) { + self.write_ntriples_map(buf, map, n_threads)?; + } + if has_transient { + if let Some(map) = self.graph_transient_triples_map.get(graph) { + self.write_ntriples_map(buf, map, n_threads)?; } } } else if RdfFormat::Turtle == format { + if has_transient { + // write_pretty_turtle's blank-node/list nesting logic reads + // graph_triples_map directly and isn't set up to merge in a + // second (transient) source of triples -- rather than + // silently drop them, refuse until that's implemented. + return Err(TriplestoreError::WriteTurtleError( + "include_transient is not yet supported when writing pretty Turtle output" + .to_string(), + )); + } self.write_pretty_turtle(buf, graph, prefixes)?; } else { let mut writer = RdfSerializer::from_format(format).for_writer(buf); - - for (predicate, df_map) in self.graph_triples_map.get(graph).unwrap() { - for ((subject_type, object_type), tt) in df_map { - for (lf, _) in tt.get_lazy_frames(&None, &None)? { - let triples = global_df_as_triples( - lf.collect().unwrap(), - subject_type.clone(), - object_type.clone(), - predicate, - self.global_cats.clone(), - ); - for t in &triples { - writer.serialize_triple(t).map_err(|x| { - TriplestoreError::WriteTurtleError(format!( - "Error serializing triple {}: {}", - t, x - )) - })?; - } - } + if let Some(map) = self.graph_triples_map.get(graph) { + self.write_generic_map(&mut writer, map)?; + } + if has_transient { + if let Some(map) = self.graph_transient_triples_map.get(graph) { + self.write_generic_map(&mut writer, map)?; } } writer.finish().map_err(|x| { @@ -134,6 +85,105 @@ impl Triplestore { Ok(()) } + fn write_ntriples_map( + &self, + buf: &mut W, + map: &PredicateTriplesMap, + n_threads: usize, + ) -> Result<(), TriplestoreError> { + for (predicate, df_map) in map { + let predicate_string = predicate.to_string(); + let predicate_bytes = predicate_string.as_bytes(); + for ((subject_type, object_type), tt) in df_map { + let types = if object_type.is_lang_string() { + HashMap::from([ + (SUBJECT_COL_NAME.to_string(), subject_type.clone()), + (LANG_STRING_VALUE_FIELD.to_string(), object_type.clone()), + (LANG_STRING_LANG_FIELD.to_string(), object_type.clone()), + ]) + } else { + HashMap::from([ + (SUBJECT_COL_NAME.to_string(), subject_type.clone()), + (OBJECT_COL_NAME.to_string(), object_type.clone()), + ]) + }; + for (mut lf, _) in tt.get_lazy_frames(&None, &None)? { + lf = lf.with_columns([ + maybe_decode_complex_expr( + col(SUBJECT_COL_NAME), + subject_type, + &subject_type.default_stored_cat_state(), + self.global_cats.clone(), + ) + .alias(SUBJECT_COL_NAME), + maybe_decode_complex_expr( + col(OBJECT_COL_NAME), + object_type, + &object_type.default_stored_cat_state(), + self.global_cats.clone(), + ) + .alias(OBJECT_COL_NAME), + ]); + if object_type.is_lang_string() { + lf = lf + .unnest(by_name([OBJECT_COL_NAME], true, false), None) + .select([ + col(SUBJECT_COL_NAME), + col(LANG_STRING_VALUE_FIELD), + col(LANG_STRING_LANG_FIELD), + ]); + } else { + lf = lf.select([col(SUBJECT_COL_NAME), col(OBJECT_COL_NAME)]); + }; + let mut df = lf.collect().unwrap(); + if !object_type.is_lang_string() { + convert_datelike_to_string(&mut df, OBJECT_COL_NAME); + } + + fast_ntriples::write_triples_in_df( + buf, + &df, + predicate_bytes, + &types, + CHUNK_SIZE, + n_threads, + ) + .unwrap(); + } + } + } + Ok(()) + } + + fn write_generic_map( + &self, + writer: &mut oxrdfio::WriterQuadSerializer, + map: &PredicateTriplesMap, + ) -> Result<(), TriplestoreError> { + for (predicate, df_map) in map { + for ((subject_type, object_type), tt) in df_map { + for (lf, _) in tt.get_lazy_frames(&None, &None)? { + let triples = global_df_as_triples( + lf.collect().unwrap(), + subject_type.clone(), + object_type.clone(), + predicate, + self.global_cats.clone(), + ); + for t in &triples { + writer.serialize_triple(t).map_err(|x| { + TriplestoreError::WriteTurtleError(format!( + "Error serializing triple {}: {}", + t, x + )) + })?; + } + } + } + } + Ok(()) + } + pub fn write_hdt( &mut self, buf: &mut W, diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index 083aa3be..e5499f25 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -341,8 +341,8 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } `RModel_writes` <- function(self) { - function(`format` = NULL, `graph` = NULL) { - .Call(savvy_RModel_writes__impl, `self`, `format`, `graph`) + function(`format` = NULL, `graph` = NULL, `include_transient` = NULL) { + .Call(savvy_RModel_writes__impl, `self`, `format`, `graph`, `include_transient`) } } diff --git a/maplibr/R/model.R b/maplibr/R/model.R index dd92eb16..d2326c00 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -53,8 +53,10 @@ Model <- R6::R6Class( #' @description Serialize this Model's triples to a string. #' @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". #' @param graph Optional named graph IRI to write (default graph if NULL). - writes = function(format = NULL, graph = NULL) { - .rethrow(private$rmodel$writes(format, graph)) + #' @param include_transient Whether to also serialize transient (e.g. + #' inferred) triples. Not supported together with format = "turtle". + writes = function(format = NULL, graph = NULL, include_transient = FALSE) { + .rethrow(private$rmodel$writes(format, graph, include_transient)) }, #' @description Build the default (non-FTS) indexes. diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index 130d209f..ba48e12b 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -98,7 +98,7 @@ is for internal use only. Serialize this Model's triples to a string. \subsection{Usage}{ \if{html}{\out{
}} - \preformatted{Model$writes(format = NULL, graph = NULL)} + \preformatted{Model$writes(format = NULL, graph = NULL, include_transient = FALSE)} \if{html}{\out{
}} } \subsection{Arguments}{ @@ -106,6 +106,8 @@ is for internal use only. \describe{ \item{\code{format}}{One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples".} \item{\code{graph}}{Optional named graph IRI to write (default graph if NULL).} + \item{\code{include_transient}}{Whether to also serialize transient (e.g. +inferred) triples. Not supported together with format = "turtle".} } \if{html}{\out{}} } diff --git a/maplibr/src/init.c b/maplibr/src/init.c index e9273af2..e0382485 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -204,8 +204,8 @@ SEXP savvy_RModel_truncate_graph__impl(SEXP self__, SEXP c_arg__graph) { return handle_result(res); } -SEXP savvy_RModel_writes__impl(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph) { - SEXP res = savvy_RModel_writes__ffi(self__, c_arg__format, c_arg__graph); +SEXP savvy_RModel_writes__impl(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph, SEXP c_arg__include_transient) { + SEXP res = savvy_RModel_writes__ffi(self__, c_arg__format, c_arg__graph, c_arg__include_transient); return handle_result(res); } @@ -269,7 +269,7 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_RModel_serialize__impl", (DL_FUNC) &savvy_RModel_serialize__impl, 2}, {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 2}, {"savvy_RModel_truncate_graph__impl", (DL_FUNC) &savvy_RModel_truncate_graph__impl, 2}, - {"savvy_RModel_writes__impl", (DL_FUNC) &savvy_RModel_writes__impl, 3}, + {"savvy_RModel_writes__impl", (DL_FUNC) &savvy_RModel_writes__impl, 4}, {"savvy_RParameter_new__impl", (DL_FUNC) &savvy_RParameter_new__impl, 5}, {"savvy_RTemplate_instance__impl", (DL_FUNC) &savvy_RTemplate_instance__impl, 3}, {"savvy_RTemplate_iri__impl", (DL_FUNC) &savvy_RTemplate_iri__impl, 1}, diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index 0b4a4aed..6aa12881 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -39,7 +39,7 @@ SEXP savvy_RModel_reads__ffi(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEX SEXP savvy_RModel_serialize__ffi(SEXP self__, SEXP c_arg__path); SEXP savvy_RModel_size__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_truncate_graph__ffi(SEXP self__, SEXP c_arg__graph); -SEXP savvy_RModel_writes__ffi(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph); +SEXP savvy_RModel_writes__ffi(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph, SEXP c_arg__include_transient); // methods and associated functions for RParameter SEXP savvy_RParameter_new__ffi(SEXP c_arg__variable_name, SEXP c_arg__optional, SEXP c_arg__allow_blank, SEXP c_arg__rdf_type_iri, SEXP c_arg__default_value); diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index 6116057f..d008de02 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -217,8 +217,17 @@ impl RModel { /// /// @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". /// @param graph Optional named graph IRI to write (default graph if NULL). + /// @param include_transient Whether to also serialize transient (e.g. + /// inferred) triples. Defaults to FALSE. Not supported together with + /// format = "turtle" (errors) -- pretty Turtle output doesn't + /// currently support merging in transient triples. /// @export - fn writes(&self, format: Option<&str>, graph: Option<&str>) -> savvy::Result { + fn writes( + &self, + format: Option<&str>, + graph: Option<&str>, + include_transient: Option, + ) -> savvy::Result { let format = format .map(resolve_normal_format) .transpose()? @@ -227,7 +236,13 @@ impl RModel { let mut inner = self.lock(); let mut out = Vec::new(); inner - .write_triples(&mut out, &named_graph, format, None) + .write_triples( + &mut out, + &named_graph, + format, + None, + include_transient.unwrap_or(false), + ) .map_err(maplib_error)?; String::from_utf8(out) .map_err(crate::errors::runtime_error)? diff --git a/maplibr/tests/testthat/test-model-infer-rdfs.R b/maplibr/tests/testthat/test-model-infer-rdfs.R index a4543c35..a83b5064 100644 --- a/maplibr/tests/testthat/test-model-infer-rdfs.R +++ b/maplibr/tests/testthat/test-model-infer-rdfs.R @@ -1,31 +1,69 @@ -# Attempted to port py_maplib/tests/test_rdfs_inference.py against the same -# fixture data (still kept in tests/testthat/testdata/rdfs/, in case a -# future query()-based port wants it), comparing writes()-serialized -# N-Triples between an inferred Model and a fixture "expected.ttl" Model. -# -# That approach turned out to be unsound, not just blocked by query() not -# existing yet: confirmed live that Model$writes() never serializes -# transient/inferred triples at all. infer_rdfs() reports a positive count -# (e.g. 1 for rdfs2's fixture), but the newly-inferred triple never shows up -# in writes() output -- Triplestore::write_triples (lib/maplib/src/model.rs) -# has no include_transient parameter to opt into seeing it, unlike -# get_predicate_eager_solution_mappings/query-path methods, which do. So a -# writes()-based comparison can never observe what infer_rdfs() actually -# did, regardless of whether the entailment itself is correct -- this -# needed query() (or write_triples gaining an include_transient option) to -# test properly, same blocker as the rest of maplib-a2d. -# -# Left as a smoke test only: infer_rdfs() must run without erroring and -# report a plausible (non-negative) count on real RDFS input. - -test_that("infer_rdfs() runs without erroring and returns a plausible count", { - read_fixture <- function(path) paste(readLines(path, warn = FALSE), collapse = "\n") +# Ports test_rdfs_inference.py (py_maplib/tests/test_rdfs_inference.py) +# against the same fixture data (tests/testthat/testdata/rdfs/). An earlier +# version of this file judged this unportable without writes() gaining an +# include_transient option -- that turned out to be a misdiagnosis: the +# py_maplib test itself only ever uses query(), which already supports +# include_transient (maplib-l2j's query() port). Now genuinely portable. +read_fixture <- function(path) paste(readLines(path, warn = FALSE), collapse = "\n") + +rdfs_folders <- file.path( + test_path("testdata", "rdfs"), + c("rdfs2", "rdfs3", "rdfs5", "rdfs6", "rdfs8", "rdfs9", "rdfs10", "rdfs11") +) + +for (dir in rdfs_folders) { + test_that(paste0("infer_rdfs() matches the expected fixture (", basename(dir), ")"), { + m_input <- Model$new() + m_input$reads(read_fixture(file.path(dir, "input.ttl")), format = "turtle") + m_input$infer_rdfs() + + m_expected <- Model$new() + m_expected$reads(read_fixture(file.path(dir, "expected.ttl")), format = "turtle") + + order_rows <- function(df) df[do.call(order, df), , drop = FALSE] + df_inferred <- order_rows(m_input$query( + "SELECT * WHERE { ?a ?b ?c }", + include_transient = TRUE + )) + df_expected <- order_rows(m_expected$query("SELECT * WHERE { ?a ?b ?c }")) + + rownames(df_inferred) <- NULL + rownames(df_expected) <- NULL + attr(df_inferred, "rdf_node_types") <- NULL + attr(df_expected, "rdf_node_types") <- NULL + expect_equal(df_inferred, df_expected) + }) +} + +test_that("writes(include_transient = TRUE) serializes inferred triples", { + # Confirms the fix for the gap this file used to work around: writes() + # used to never serialize transient/inferred triples at all, regardless + # of any flag, because Triplestore::write_triples had no include_transient + # parameter (unlike the query path). dir <- test_path("testdata", "rdfs", "rdfs2") m <- Model$new() m$reads(read_fixture(file.path(dir, "input.ttl")), format = "turtle") - n <- m$infer_rdfs() - expect_true(is.numeric(n)) - expect_gte(n, 0) + expect_gt(n, 0) + + without <- m$writes(format = "ntriples") + with_transient <- m$writes(format = "ntriples", include_transient = TRUE) + expect_gt(nchar(with_transient), nchar(without)) + + # rdfs2's fixture: "moon rdfs:domain Planet" + "Mars moon Phobos" entails + # "Mars rdf:type Planet", which only the include_transient = TRUE output + # should contain. + expect_false(grepl("22-rdf-syntax-ns#type", without, fixed = TRUE)) + expect_match(with_transient, "22-rdf-syntax-ns#type", fixed = TRUE) +}) + +test_that("writes(include_transient = TRUE, format = \"turtle\") errors clearly", { + # Pretty-Turtle output doesn't merge in transient triples yet -- refuses + # rather than silently dropping them. + dir <- test_path("testdata", "rdfs", "rdfs2") + m <- Model$new() + m$reads(read_fixture(file.path(dir, "input.ttl")), format = "turtle") + m$infer_rdfs() + expect_error(m$writes(format = "turtle", include_transient = TRUE)) }) diff --git a/py_maplib/src/mutexes.rs b/py_maplib/src/mutexes.rs index 2c85b6bc..9ce98ce3 100644 --- a/py_maplib/src/mutexes.rs +++ b/py_maplib/src/mutexes.rs @@ -636,7 +636,7 @@ pub(crate) fn write_triples_mutex( match format { ExtendedRdfFormat::Normal(format) => { inner - .write_triples(&mut actual_file, &named_graph, format, prefixes.as_ref()) + .write_triples(&mut actual_file, &named_graph, format, prefixes.as_ref(), false) .map_err(PyMaplibError::from)?; } ExtendedRdfFormat::HDT => { @@ -731,7 +731,7 @@ pub(crate) fn writes_mutex( let graph = parse_optional_named_node(graph)?; let named_graph = NamedGraph::from_maybe_named_node(graph.as_ref()); inner - .write_triples(&mut out, &named_graph, format, prefixes.as_ref()) + .write_triples(&mut out, &named_graph, format, prefixes.as_ref(), false) .map_err(PyMaplibError::from)?; Ok(String::from_utf8(out).unwrap()) } From ce7db1db2c2b01189dc6dcd6a8074c9428729a5c Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 21:07:39 +0200 Subject: [PATCH 27/46] revert: drop include_transient support in writes() - required changes to shared triplestore/model engine code this package doesn't own - kept the RDFS-inference test coverage, which relies only on query()'s existing include_transient support Co-Authored-By: Claude Sonnet 5 --- lib/maplib/src/model.rs | 3 +- lib/triplestore/src/triples_write.rs | 212 +++++++----------- maplibr/R/000-wrappers.R | 4 +- maplibr/R/model.R | 6 +- maplibr/man/Model.Rd | 4 +- maplibr/src/init.c | 6 +- maplibr/src/rust/api.h | 2 +- maplibr/src/rust/src/model.rs | 19 +- .../tests/testthat/test-model-infer-rdfs.R | 35 +-- py_maplib/src/mutexes.rs | 4 +- 10 files changed, 99 insertions(+), 196 deletions(-) diff --git a/lib/maplib/src/model.rs b/lib/maplib/src/model.rs index 0c57fa6e..63d41ab2 100644 --- a/lib/maplib/src/model.rs +++ b/lib/maplib/src/model.rs @@ -529,14 +529,13 @@ impl Model { graph: &NamedGraph, rdf_format: RdfFormat, prefixes: Option<&HashMap>, - include_transient: bool, ) -> Result<(), MaplibError> { let mut use_prefixes = self.prefixes.clone(); if let Some(prefixes) = prefixes { use_prefixes.extend(prefixes.clone()); } self.triplestore - .write_triples(buffer, rdf_format, graph, &use_prefixes, include_transient) + .write_triples(buffer, rdf_format, graph, &use_prefixes) .map_err(MaplibError::TriplestoreError)?; Ok(()) } diff --git a/lib/triplestore/src/triples_write.rs b/lib/triplestore/src/triples_write.rs index 979664f8..1c8ec403 100644 --- a/lib/triplestore/src/triples_write.rs +++ b/lib/triplestore/src/triples_write.rs @@ -1,6 +1,5 @@ use super::Triplestore; use crate::errors::TriplestoreError; -use crate::storage::Triples; use oxrdf::NamedNode; use oxrdfio::{RdfFormat, RdfSerializer}; use polars::prelude::{by_name, col}; @@ -13,8 +12,7 @@ use representation::polars_to_rdf::{ date_column_to_strings, datetime_column_to_strings, global_df_as_triples, }; use representation::{ - BaseRDFNodeType, LANG_STRING_LANG_FIELD, LANG_STRING_VALUE_FIELD, OBJECT_COL_NAME, - SUBJECT_COL_NAME, + LANG_STRING_LANG_FIELD, LANG_STRING_VALUE_FIELD, OBJECT_COL_NAME, SUBJECT_COL_NAME, }; use std::collections::HashMap; use std::io::Write; @@ -26,8 +24,6 @@ mod serializers; const CHUNK_SIZE: usize = 1_024; -type PredicateTriplesMap = HashMap>; - impl Triplestore { pub fn write_triples( &mut self, @@ -35,44 +31,97 @@ impl Triplestore { format: RdfFormat, graph: &NamedGraph, prefixes: &HashMap, - include_transient: bool, ) -> Result<(), TriplestoreError> { - let has_regular = self.does_graph_exist(graph); - let has_transient = - include_transient && self.graph_transient_triples_map.contains_key(graph); - if !has_regular && !has_transient { + if !self.does_graph_exist(graph) { return Ok(()); } if RdfFormat::NTriples == format { let n_threads = THREAD_POOL.current_num_threads(); - if let Some(map) = self.graph_triples_map.get(graph) { - self.write_ntriples_map(buf, map, n_threads)?; - } - if has_transient { - if let Some(map) = self.graph_transient_triples_map.get(graph) { - self.write_ntriples_map(buf, map, n_threads)?; + for (predicate, df_map) in self.graph_triples_map.get(graph).unwrap() { + let predicate_string = predicate.to_string(); + let predicate_bytes = predicate_string.as_bytes(); + for ((subject_type, object_type), tt) in df_map { + let types = if object_type.is_lang_string() { + HashMap::from([ + (SUBJECT_COL_NAME.to_string(), subject_type.clone()), + (LANG_STRING_VALUE_FIELD.to_string(), object_type.clone()), + (LANG_STRING_LANG_FIELD.to_string(), object_type.clone()), + ]) + } else { + HashMap::from([ + (SUBJECT_COL_NAME.to_string(), subject_type.clone()), + (OBJECT_COL_NAME.to_string(), object_type.clone()), + ]) + }; + for (mut lf, _) in tt.get_lazy_frames(&None, &None)? { + lf = lf.with_columns([ + maybe_decode_complex_expr( + col(SUBJECT_COL_NAME), + subject_type, + &subject_type.default_stored_cat_state(), + self.global_cats.clone(), + ) + .alias(SUBJECT_COL_NAME), + maybe_decode_complex_expr( + col(OBJECT_COL_NAME), + object_type, + &object_type.default_stored_cat_state(), + self.global_cats.clone(), + ) + .alias(OBJECT_COL_NAME), + ]); + if object_type.is_lang_string() { + lf = lf + .unnest(by_name([OBJECT_COL_NAME], true, false), None) + .select([ + col(SUBJECT_COL_NAME), + col(LANG_STRING_VALUE_FIELD), + col(LANG_STRING_LANG_FIELD), + ]); + } else { + lf = lf.select([col(SUBJECT_COL_NAME), col(OBJECT_COL_NAME)]); + }; + let mut df = lf.collect().unwrap(); + if !object_type.is_lang_string() { + convert_datelike_to_string(&mut df, OBJECT_COL_NAME); + } + + fast_ntriples::write_triples_in_df( + buf, + &df, + predicate_bytes, + &types, + CHUNK_SIZE, + n_threads, + ) + .unwrap(); + } } } } else if RdfFormat::Turtle == format { - if has_transient { - // write_pretty_turtle's blank-node/list nesting logic reads - // graph_triples_map directly and isn't set up to merge in a - // second (transient) source of triples -- rather than - // silently drop them, refuse until that's implemented. - return Err(TriplestoreError::WriteTurtleError( - "include_transient is not yet supported when writing pretty Turtle output" - .to_string(), - )); - } self.write_pretty_turtle(buf, graph, prefixes)?; } else { let mut writer = RdfSerializer::from_format(format).for_writer(buf); - if let Some(map) = self.graph_triples_map.get(graph) { - self.write_generic_map(&mut writer, map)?; - } - if has_transient { - if let Some(map) = self.graph_transient_triples_map.get(graph) { - self.write_generic_map(&mut writer, map)?; + + for (predicate, df_map) in self.graph_triples_map.get(graph).unwrap() { + for ((subject_type, object_type), tt) in df_map { + for (lf, _) in tt.get_lazy_frames(&None, &None)? { + let triples = global_df_as_triples( + lf.collect().unwrap(), + subject_type.clone(), + object_type.clone(), + predicate, + self.global_cats.clone(), + ); + for t in &triples { + writer.serialize_triple(t).map_err(|x| { + TriplestoreError::WriteTurtleError(format!( + "Error serializing triple {}: {}", + t, x + )) + })?; + } + } } } writer.finish().map_err(|x| { @@ -85,105 +134,6 @@ impl Triplestore { Ok(()) } - fn write_ntriples_map( - &self, - buf: &mut W, - map: &PredicateTriplesMap, - n_threads: usize, - ) -> Result<(), TriplestoreError> { - for (predicate, df_map) in map { - let predicate_string = predicate.to_string(); - let predicate_bytes = predicate_string.as_bytes(); - for ((subject_type, object_type), tt) in df_map { - let types = if object_type.is_lang_string() { - HashMap::from([ - (SUBJECT_COL_NAME.to_string(), subject_type.clone()), - (LANG_STRING_VALUE_FIELD.to_string(), object_type.clone()), - (LANG_STRING_LANG_FIELD.to_string(), object_type.clone()), - ]) - } else { - HashMap::from([ - (SUBJECT_COL_NAME.to_string(), subject_type.clone()), - (OBJECT_COL_NAME.to_string(), object_type.clone()), - ]) - }; - for (mut lf, _) in tt.get_lazy_frames(&None, &None)? { - lf = lf.with_columns([ - maybe_decode_complex_expr( - col(SUBJECT_COL_NAME), - subject_type, - &subject_type.default_stored_cat_state(), - self.global_cats.clone(), - ) - .alias(SUBJECT_COL_NAME), - maybe_decode_complex_expr( - col(OBJECT_COL_NAME), - object_type, - &object_type.default_stored_cat_state(), - self.global_cats.clone(), - ) - .alias(OBJECT_COL_NAME), - ]); - if object_type.is_lang_string() { - lf = lf - .unnest(by_name([OBJECT_COL_NAME], true, false), None) - .select([ - col(SUBJECT_COL_NAME), - col(LANG_STRING_VALUE_FIELD), - col(LANG_STRING_LANG_FIELD), - ]); - } else { - lf = lf.select([col(SUBJECT_COL_NAME), col(OBJECT_COL_NAME)]); - }; - let mut df = lf.collect().unwrap(); - if !object_type.is_lang_string() { - convert_datelike_to_string(&mut df, OBJECT_COL_NAME); - } - - fast_ntriples::write_triples_in_df( - buf, - &df, - predicate_bytes, - &types, - CHUNK_SIZE, - n_threads, - ) - .unwrap(); - } - } - } - Ok(()) - } - - fn write_generic_map( - &self, - writer: &mut oxrdfio::WriterQuadSerializer, - map: &PredicateTriplesMap, - ) -> Result<(), TriplestoreError> { - for (predicate, df_map) in map { - for ((subject_type, object_type), tt) in df_map { - for (lf, _) in tt.get_lazy_frames(&None, &None)? { - let triples = global_df_as_triples( - lf.collect().unwrap(), - subject_type.clone(), - object_type.clone(), - predicate, - self.global_cats.clone(), - ); - for t in &triples { - writer.serialize_triple(t).map_err(|x| { - TriplestoreError::WriteTurtleError(format!( - "Error serializing triple {}: {}", - t, x - )) - })?; - } - } - } - } - Ok(()) - } - pub fn write_hdt( &mut self, buf: &mut W, diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index e5499f25..083aa3be 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -341,8 +341,8 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } `RModel_writes` <- function(self) { - function(`format` = NULL, `graph` = NULL, `include_transient` = NULL) { - .Call(savvy_RModel_writes__impl, `self`, `format`, `graph`, `include_transient`) + function(`format` = NULL, `graph` = NULL) { + .Call(savvy_RModel_writes__impl, `self`, `format`, `graph`) } } diff --git a/maplibr/R/model.R b/maplibr/R/model.R index d2326c00..dd92eb16 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -53,10 +53,8 @@ Model <- R6::R6Class( #' @description Serialize this Model's triples to a string. #' @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". #' @param graph Optional named graph IRI to write (default graph if NULL). - #' @param include_transient Whether to also serialize transient (e.g. - #' inferred) triples. Not supported together with format = "turtle". - writes = function(format = NULL, graph = NULL, include_transient = FALSE) { - .rethrow(private$rmodel$writes(format, graph, include_transient)) + writes = function(format = NULL, graph = NULL) { + .rethrow(private$rmodel$writes(format, graph)) }, #' @description Build the default (non-FTS) indexes. diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index ba48e12b..130d209f 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -98,7 +98,7 @@ is for internal use only. Serialize this Model's triples to a string. \subsection{Usage}{ \if{html}{\out{
}} - \preformatted{Model$writes(format = NULL, graph = NULL, include_transient = FALSE)} + \preformatted{Model$writes(format = NULL, graph = NULL)} \if{html}{\out{
}} } \subsection{Arguments}{ @@ -106,8 +106,6 @@ is for internal use only. \describe{ \item{\code{format}}{One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples".} \item{\code{graph}}{Optional named graph IRI to write (default graph if NULL).} - \item{\code{include_transient}}{Whether to also serialize transient (e.g. -inferred) triples. Not supported together with format = "turtle".} } \if{html}{\out{}} } diff --git a/maplibr/src/init.c b/maplibr/src/init.c index e0382485..e9273af2 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -204,8 +204,8 @@ SEXP savvy_RModel_truncate_graph__impl(SEXP self__, SEXP c_arg__graph) { return handle_result(res); } -SEXP savvy_RModel_writes__impl(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph, SEXP c_arg__include_transient) { - SEXP res = savvy_RModel_writes__ffi(self__, c_arg__format, c_arg__graph, c_arg__include_transient); +SEXP savvy_RModel_writes__impl(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph) { + SEXP res = savvy_RModel_writes__ffi(self__, c_arg__format, c_arg__graph); return handle_result(res); } @@ -269,7 +269,7 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_RModel_serialize__impl", (DL_FUNC) &savvy_RModel_serialize__impl, 2}, {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 2}, {"savvy_RModel_truncate_graph__impl", (DL_FUNC) &savvy_RModel_truncate_graph__impl, 2}, - {"savvy_RModel_writes__impl", (DL_FUNC) &savvy_RModel_writes__impl, 4}, + {"savvy_RModel_writes__impl", (DL_FUNC) &savvy_RModel_writes__impl, 3}, {"savvy_RParameter_new__impl", (DL_FUNC) &savvy_RParameter_new__impl, 5}, {"savvy_RTemplate_instance__impl", (DL_FUNC) &savvy_RTemplate_instance__impl, 3}, {"savvy_RTemplate_iri__impl", (DL_FUNC) &savvy_RTemplate_iri__impl, 1}, diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index 6aa12881..0b4a4aed 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -39,7 +39,7 @@ SEXP savvy_RModel_reads__ffi(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEX SEXP savvy_RModel_serialize__ffi(SEXP self__, SEXP c_arg__path); SEXP savvy_RModel_size__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_truncate_graph__ffi(SEXP self__, SEXP c_arg__graph); -SEXP savvy_RModel_writes__ffi(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph, SEXP c_arg__include_transient); +SEXP savvy_RModel_writes__ffi(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph); // methods and associated functions for RParameter SEXP savvy_RParameter_new__ffi(SEXP c_arg__variable_name, SEXP c_arg__optional, SEXP c_arg__allow_blank, SEXP c_arg__rdf_type_iri, SEXP c_arg__default_value); diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index d008de02..6116057f 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -217,17 +217,8 @@ impl RModel { /// /// @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". /// @param graph Optional named graph IRI to write (default graph if NULL). - /// @param include_transient Whether to also serialize transient (e.g. - /// inferred) triples. Defaults to FALSE. Not supported together with - /// format = "turtle" (errors) -- pretty Turtle output doesn't - /// currently support merging in transient triples. /// @export - fn writes( - &self, - format: Option<&str>, - graph: Option<&str>, - include_transient: Option, - ) -> savvy::Result { + fn writes(&self, format: Option<&str>, graph: Option<&str>) -> savvy::Result { let format = format .map(resolve_normal_format) .transpose()? @@ -236,13 +227,7 @@ impl RModel { let mut inner = self.lock(); let mut out = Vec::new(); inner - .write_triples( - &mut out, - &named_graph, - format, - None, - include_transient.unwrap_or(false), - ) + .write_triples(&mut out, &named_graph, format, None) .map_err(maplib_error)?; String::from_utf8(out) .map_err(crate::errors::runtime_error)? diff --git a/maplibr/tests/testthat/test-model-infer-rdfs.R b/maplibr/tests/testthat/test-model-infer-rdfs.R index a83b5064..2adaec9a 100644 --- a/maplibr/tests/testthat/test-model-infer-rdfs.R +++ b/maplibr/tests/testthat/test-model-infer-rdfs.R @@ -36,34 +36,7 @@ for (dir in rdfs_folders) { }) } -test_that("writes(include_transient = TRUE) serializes inferred triples", { - # Confirms the fix for the gap this file used to work around: writes() - # used to never serialize transient/inferred triples at all, regardless - # of any flag, because Triplestore::write_triples had no include_transient - # parameter (unlike the query path). - dir <- test_path("testdata", "rdfs", "rdfs2") - m <- Model$new() - m$reads(read_fixture(file.path(dir, "input.ttl")), format = "turtle") - n <- m$infer_rdfs() - expect_gt(n, 0) - - without <- m$writes(format = "ntriples") - with_transient <- m$writes(format = "ntriples", include_transient = TRUE) - expect_gt(nchar(with_transient), nchar(without)) - - # rdfs2's fixture: "moon rdfs:domain Planet" + "Mars moon Phobos" entails - # "Mars rdf:type Planet", which only the include_transient = TRUE output - # should contain. - expect_false(grepl("22-rdf-syntax-ns#type", without, fixed = TRUE)) - expect_match(with_transient, "22-rdf-syntax-ns#type", fixed = TRUE) -}) - -test_that("writes(include_transient = TRUE, format = \"turtle\") errors clearly", { - # Pretty-Turtle output doesn't merge in transient triples yet -- refuses - # rather than silently dropping them. - dir <- test_path("testdata", "rdfs", "rdfs2") - m <- Model$new() - m$reads(read_fixture(file.path(dir, "input.ttl")), format = "turtle") - m$infer_rdfs() - expect_error(m$writes(format = "turtle", include_transient = TRUE)) -}) +# NOTE: writes() still can't serialize transient/inferred triples at all -- +# Triplestore::write_triples (core engine, not this package) has no +# include_transient option, unlike the query path. That's a core-engine gap, +# not maplibr's to fix; flagged for upstream instead of worked around here. diff --git a/py_maplib/src/mutexes.rs b/py_maplib/src/mutexes.rs index 9ce98ce3..2c85b6bc 100644 --- a/py_maplib/src/mutexes.rs +++ b/py_maplib/src/mutexes.rs @@ -636,7 +636,7 @@ pub(crate) fn write_triples_mutex( match format { ExtendedRdfFormat::Normal(format) => { inner - .write_triples(&mut actual_file, &named_graph, format, prefixes.as_ref(), false) + .write_triples(&mut actual_file, &named_graph, format, prefixes.as_ref()) .map_err(PyMaplibError::from)?; } ExtendedRdfFormat::HDT => { @@ -731,7 +731,7 @@ pub(crate) fn writes_mutex( let graph = parse_optional_named_node(graph)?; let named_graph = NamedGraph::from_maybe_named_node(graph.as_ref()); inner - .write_triples(&mut out, &named_graph, format, prefixes.as_ref(), false) + .write_triples(&mut out, &named_graph, format, prefixes.as_ref()) .map_err(PyMaplibError::from)?; Ok(String::from_utf8(out).unwrap()) } From 0cba63d2999b9701a0a85038dda3cd4fef407ff5 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 21:11:45 +0200 Subject: [PATCH 28/46] chore(maplibr): fill in DESCRIPTION metadata - Title/Authors@R/Description/License were still scaffold placeholders; invalid License failed R CMD check outright - License set to Apache-2.0, matching the wrapped Rust engine Co-Authored-By: Claude Sonnet 5 --- maplibr/DESCRIPTION | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/maplibr/DESCRIPTION b/maplibr/DESCRIPTION index e44d6e58..3adddf8d 100644 --- a/maplibr/DESCRIPTION +++ b/maplibr/DESCRIPTION @@ -1,11 +1,16 @@ Package: maplibr -Title: What the Package Does (One Line, Title Case) +Title: R Interface to the 'maplib' Knowledge Graph Engine Version: 0.0.0.9000 -Authors@R: - person("First", "Last", , "first.last@example.com", role = c("aut", "cre")) -Description: What the package does (one paragraph). -License: `use_mit_license()`, `use_gpl3_license()` or friends to pick a - license +Authors@R: c( + person("Novica", "Nakov", , "nnovica@gmail.com", role = c("aut", "cre", "cph"), + comment = c(ORCID = "0009-0005-7773-7718"))) +Description: Build, query, and reason over RDF knowledge graphs backed by the + 'maplib' Rust engine. Graphs are constructed from tabular data using OTTR + mapping templates, queried with SPARQL, and returned as ordinary R + data frames via the Arrow C Stream Interface. Also supports RDFS + inference and reading/writing common RDF serializations (Turtle, + N-Triples, RDF/XML). +License: Apache License (>= 2.0) Encoding: UTF-8 Imports: R6, From 7318380908e0d9a552566df2c31e4456a264c3e7 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 21:14:14 +0200 Subject: [PATCH 29/46] chore(maplibr): pull polars-arrow's version from the shared workspace pin - was hardcoded, could silently drift from the rest of the workspace - polars itself stays pinned explicitly, since Cargo ignores a member's default-features override on a workspace dep Co-Authored-By: Claude Sonnet 5 --- maplibr/src/rust/Cargo.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/maplibr/src/rust/Cargo.toml b/maplibr/src/rust/Cargo.toml index 773e48a0..7592fbe7 100644 --- a/maplibr/src/rust/Cargo.toml +++ b/maplibr/src/rust/Cargo.toml @@ -15,8 +15,13 @@ oxrdf = { workspace = true } oxrdfio = { workspace = true } sparesults = { workspace = true } pyo3 = { workspace = true } +# Pinned explicitly rather than `workspace = true`: the root workspace's +# `polars` entry doesn't itself set `default-features`, and Cargo silently +# ignores a member's `default-features` override in that case (warns +# "could become a hard error in the future") -- switching this one to +# workspace = true would silently turn default-features back on here. polars = { version = "0.54.4", default-features = false } -polars-arrow = "0.54.4" +polars-arrow = { workspace = true } savvy = "0.8" serde = { workspace = true } serde_json = { workspace = true } From 98362f8d5de8dbc6614c7a4f64337a4a05cdf070 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 21:19:28 +0200 Subject: [PATCH 30/46] fix(maplibr): default serialize()/deserialize() path to "./serialized_triples" - matches py_maplib's default; both still error at runtime since lib/disk is an unimplemented stub Co-Authored-By: Claude Sonnet 5 --- maplibr/R/000-wrappers.R | 4 +-- maplibr/R/model.R | 7 ++--- maplibr/man/Model.Rd | 5 ++-- maplibr/src/rust/src/model.rs | 14 +++++----- maplibr/tests/testthat/test-model-basics.R | 30 ++++++++++++++++++++++ 5 files changed, 47 insertions(+), 13 deletions(-) diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index 083aa3be..35196386 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -323,7 +323,7 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } `RModel_serialize` <- function(self) { - function(`path`) { + function(`path` = NULL) { invisible(.Call(savvy_RModel_serialize__impl, `self`, `path`)) } } @@ -378,7 +378,7 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") ### associated functions for RModel -`RModel`$`deserialize` <- function(`path`, `storage_folder` = NULL) { +`RModel`$`deserialize` <- function(`path` = NULL, `storage_folder` = NULL) { .savvy_wrap_RModel(.Call(savvy_RModel_deserialize__impl, `path`, `storage_folder`)) } diff --git a/maplibr/R/model.R b/maplibr/R/model.R index dd92eb16..322028bb 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -204,8 +204,9 @@ Model <- R6::R6Class( #' @description Serialize this Model's triples to a directory in #' maplib's own compact on-disk format (not an RDF interchange format -- #' use `$writes()` for that). - #' @param path Directory to serialize into. - serialize = function(path) { + #' @param path Directory to serialize into. Defaults to + #' "./serialized_triples". + serialize = function(path = NULL) { .rethrow(private$rmodel$serialize(path)) invisible(self) }, @@ -289,6 +290,6 @@ Model <- R6::R6Class( # the (already @export'd) Model generator, so users call it as # `Model$deserialize(path)` -- mirrors RModel's own associated-function # pattern in 000-wrappers.R. -Model$deserialize <- function(path, storage_folder = NULL) { +Model$deserialize <- function(path = NULL, storage_folder = NULL) { Model$new(.rethrow(RModel$deserialize(path, storage_folder))) } diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index 130d209f..96eb4984 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -351,13 +351,14 @@ maplib's own compact on-disk format (not an RDF interchange format -- use \verb{$writes()} for that). \subsection{Usage}{ \if{html}{\out{
}} - \preformatted{Model$serialize(path)} + \preformatted{Model$serialize(path = NULL)} \if{html}{\out{
}} } \subsection{Arguments}{ \if{html}{\out{
}} \describe{ - \item{\code{path}}{Directory to serialize into.} + \item{\code{path}}{Directory to serialize into. Defaults to +"./serialized_triples".} } \if{html}{\out{
}} } diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index 6116057f..d81a67df 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -347,12 +347,13 @@ impl RModel { /// on-disk format (not an RDF interchange format -- use write()/writes() /// for that). Mirrors PyModel::serialize, py_maplib/src/py_model.rs:926-934. /// - /// @param path Directory to serialize into. + /// @param path Directory to serialize into. Defaults to + /// "./serialized_triples", matching py_maplib. /// @export - fn serialize(&self, path: &str) -> savvy::Result<()> { + fn serialize(&self, path: Option<&str>) -> savvy::Result<()> { let mut inner = self.lock(); inner - .serialize_triples(Path::new(path)) + .serialize_triples(Path::new(path.unwrap_or("./serialized_triples"))) .map_err(maplib_error) } @@ -456,13 +457,14 @@ impl RModel { /// PyModel::deserialize, py_maplib/src/py_model.rs:936-954). This is an /// associated function, not a method -- call as `RModel$deserialize(path)`. /// - /// @param path Directory previously written by serialize(). + /// @param path Directory previously written by serialize(). Defaults to + /// "./serialized_triples", matching py_maplib. /// @param storage_folder Optional folder for on-disk (rather than /// in-memory) triplestore storage. /// @export - fn deserialize(path: &str, storage_folder: Option<&str>) -> savvy::Result { + fn deserialize(path: Option<&str>, storage_folder: Option<&str>) -> savvy::Result { let model = maplib::model::Model::deserialize_triples( - Path::new(path), + Path::new(path.unwrap_or("./serialized_triples")), storage_folder.map(String::from), ) .map_err(maplib_error)?; diff --git a/maplibr/tests/testthat/test-model-basics.R b/maplibr/tests/testthat/test-model-basics.R index 9e77c493..54206e24 100644 --- a/maplibr/tests/testthat/test-model-basics.R +++ b/maplibr/tests/testthat/test-model-basics.R @@ -42,6 +42,36 @@ test_that("add_prefixes() affects writes() output", { expect_match(out, "@prefix ex: ", fixed = TRUE) }) +test_that("serialize()/deserialize() no longer require path -- it defaults to \"./serialized_triples\"", { + # Before this fix, `path` was a required argument in the underlying Rust + # signature -- omitting it would fail immediately with R's own + # missing-argument error, before ever reaching serialize_triples()/ + # deserialize_triples(). It's optional now (defaulting on the Rust side to + # "./serialized_triples", matching py_maplib): calling with no path still + # errors -- the on-disk store is a closed-source stub in this checkout, + # panicking regardless of path -- but not from a missing argument. + is_missing_arg_error <- function(msg) grepl("argument", msg, fixed = TRUE) && grepl("missing", msg, fixed = TRUE) + + # serialize() creates real on-disk structure at its (default) path before + # panicking in the stub -- run from a scratch directory so this doesn't + # leave a "serialized_triples" directory behind in the working tree. + scratch_dir <- tempfile("maplibr-test-") + dir.create(scratch_dir) + old_wd <- setwd(scratch_dir) + on.exit({ + setwd(old_wd) + unlink(scratch_dir, recursive = TRUE) + }, add = TRUE) + + m <- Model$new() + m$reads(' "c" .', format = "ntriples") + err <- tryCatch(m$serialize(), error = function(e) conditionMessage(e)) + expect_false(is_missing_arg_error(err)) + + err2 <- tryCatch(Model$deserialize(), error = function(e) conditionMessage(e)) + expect_false(is_missing_arg_error(err2)) +}) + test_that("clone() is disabled -- R6's default shallow clone would share the underlying Mutex", { # R6's default clone() only copies the R-level reference to # private$rmodel, not the underlying Rust state -- m2 <- m$clone() would From 858d79080e6615c9d9ddf6936e48ec4273d5e40e Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 21:23:30 +0200 Subject: [PATCH 31/46] fix(maplibr): rename xsd$int to xsd$int_ for exact naming parity - py_maplib exposes int_ (int is a Python builtin); this package's generator stripped the underscore unnecessarily Co-Authored-By: Claude Sonnet 5 --- maplibr/R/vocab.R | 2 +- maplibr/tests/testthat/test-vocab.R | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/maplibr/R/vocab.R b/maplibr/R/vocab.R index 0f980ed0..0d971ec6 100644 --- a/maplibr/R/vocab.R +++ b/maplibr/R/vocab.R @@ -30,7 +30,7 @@ xsd <- NULL `double` = IRI("http://www.w3.org/2001/XMLSchema#double"), `duration` = IRI("http://www.w3.org/2001/XMLSchema#duration"), `float` = IRI("http://www.w3.org/2001/XMLSchema#float"), - `int` = IRI("http://www.w3.org/2001/XMLSchema#int"), + `int_` = IRI("http://www.w3.org/2001/XMLSchema#int"), `integer` = IRI("http://www.w3.org/2001/XMLSchema#integer"), `language` = IRI("http://www.w3.org/2001/XMLSchema#language"), `long` = IRI("http://www.w3.org/2001/XMLSchema#long"), diff --git a/maplibr/tests/testthat/test-vocab.R b/maplibr/tests/testthat/test-vocab.R index 15cb66ec..4314ccce 100644 --- a/maplibr/tests/testthat/test-vocab.R +++ b/maplibr/tests/testthat/test-vocab.R @@ -12,7 +12,7 @@ test_that("vocab lists have the expected size and are built at load time", { test_that("xsd/rdf/rdfs/owl constants resolve to the correct IRIs", { expect_equal(xsd$dateTime@iri, "http://www.w3.org/2001/XMLSchema#dateTime") expect_equal(xsd$anyURI@iri, "http://www.w3.org/2001/XMLSchema#anyURI") - expect_equal(xsd$int@iri, "http://www.w3.org/2001/XMLSchema#int") + expect_equal(xsd$int_@iri, "http://www.w3.org/2001/XMLSchema#int") expect_equal(rdf$type@iri, "http://www.w3.org/1999/02/22-rdf-syntax-ns#type") expect_equal(rdf$langString@iri, "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString") expect_equal(rdfs$subClassOf@iri, "http://www.w3.org/2000/01/rdf-schema#subClassOf") From 1a37ba673ac30787c5961bebc5c731b0cf245058 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 21:34:19 +0200 Subject: [PATCH 32/46] feat(maplibr): support CONSTRUCT in query() - returns a combined subject/predicate/object data.frame, flattening each pattern's matched rows - row-varying columns coalesced to one string column rather than cast directly, avoiding silent NA - returns less type info than SELECT by design Co-Authored-By: Claude Sonnet 5 --- maplibr/R/model.R | 16 +- maplibr/man/Model.Rd | 16 +- maplibr/src/rust/src/model.rs | 196 +++++++++++++++++++++- maplibr/tests/testthat/test-model-query.R | 71 ++++++++ 4 files changed, 283 insertions(+), 16 deletions(-) diff --git a/maplibr/R/model.R b/maplibr/R/model.R index 322028bb..83c30a5e 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -115,10 +115,18 @@ Model <- R6::R6Class( Model$new(.rethrow(private$rmodel$detach_graph(preserve_name, graph))) }, - #' @description Run a SPARQL SELECT query and return the result as a data.frame. - #' Mirrors py_maplib's Model.query() (py_maplib/src/py_model.rs:344-387), - #' but only the SELECT case -- CONSTRUCT queries are not yet supported - #' (raises a maplibr_argument_error), see maplib-l2j. + #' @description Run a SPARQL SELECT or CONSTRUCT query and return the + #' result as a data.frame. Mirrors py_maplib's Model.query() + #' (py_maplib/src/py_model.rs:344-387). For CONSTRUCT, every matched + #' triple pattern's results are concatenated into one combined + #' subject/predicate/object data.frame (py_maplib instead returns one + #' data.frame per pattern, as a Python list -- not done here since a + #' single query() call always returns exactly one data.frame). Subject + #' and object values are always returned as plain strings for CONSTRUCT + #' results (unlike SELECT, which preserves each column's native type + #' where a single type applies) -- different patterns can produce + #' different column types, which a single concatenated data.frame can't + #' represent without collapsing to a common type regardless. #' @param sparql The SPARQL query string. #' @param graph Optional named graph IRI to restrict the query to (searches #' across the whole store if NULL). diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index 96eb4984..79ce991f 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -211,10 +211,18 @@ than moving it to the default graph there.} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Model-query}{}}} \subsection{\code{Model$query()}}{ - Run a SPARQL SELECT query and return the result as a data.frame. -Mirrors py_maplib's Model.query() (py_maplib/src/py_model.rs:344-387), -but only the SELECT case -- CONSTRUCT queries are not yet supported -(raises a maplibr_argument_error), see maplib-l2j. + Run a SPARQL SELECT or CONSTRUCT query and return the +result as a data.frame. Mirrors py_maplib's Model.query() +(py_maplib/src/py_model.rs:344-387). For CONSTRUCT, every matched +triple pattern's results are concatenated into one combined +subject/predicate/object data.frame (py_maplib instead returns one +data.frame per pattern, as a Python list -- not done here since a +single query() call always returns exactly one data.frame). Subject +and object values are always returned as plain strings for CONSTRUCT +results (unlike SELECT, which preserves each column's native type +where a single type applies) -- different patterns can produce +different column types, which a single concatenated data.frame can't +represent without collapsing to a common type regardless. \subsection{Usage}{ \if{html}{\out{
}} \preformatted{Model$query(sparql, graph = NULL, include_transient = FALSE)} diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index d81a67df..dad4b1c3 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -4,8 +4,17 @@ use crate::templates::RTemplate; use maplib::model::MapOptions; use oxrdf::NamedNode; use oxrdfio::RdfFormat; -use polars::prelude::IntoLazy; +use polars::prelude::{ + coalesce, col, concat, Column, DataFrame, DataType, Expr, IntoLazy, Series, UnionArgs, +}; +use representation::cats::LockedCats; use representation::dataset::NamedGraph; +use representation::rdf_to_polars::rdf_named_node_to_polars_literal_value; +use representation::solution_mapping::EagerSolutionMappings; +use representation::{ + BaseRDFNodeType, RDFNodeState, LANG_STRING_VALUE_FIELD, OBJECT_COL_NAME, PREDICATE_COL_NAME, + SUBJECT_COL_NAME, +}; use savvy::{savvy, ListSexp, Sexp}; use std::collections::HashMap; use std::path::Path; @@ -52,6 +61,175 @@ fn parse_query_graph(graph: Option<&str>) -> savvy::Result> { .transpose() } +/// Flattens a CONSTRUCT query's per-pattern results (each pattern's own +/// solution mappings, plus a constant predicate when the pattern used one +/// literally rather than binding it, e.g. `CONSTRUCT { ?s a ?type }`) into a +/// single subject/predicate/object `EagerSolutionMappings`, matching +/// `RModel::query()`'s single-DataFrame-per-call design. Mirrors py_maplib's +/// own per-pattern handling (`query_to_result`, py_maplib/src/lib.rs:235-263) +/// for adding the constant predicate column, but concatenates every +/// pattern's rows into one combined DataFrame instead of returning a +/// separate DataFrame per pattern -- py_maplib can return a heterogeneous +/// Python list; a single R data.frame per query() call cannot. +/// +/// Deliberately simplified relative to `query()`'s SELECT path: every +/// pattern's subject/predicate/object columns are cast to plain strings +/// before concatenating, rather than preserved as natively-typed (e.g. +/// integer/date) or per-row multi-typed columns. Different CONSTRUCT +/// patterns can produce genuinely different column dtypes (one pattern's +/// object might be an integer literal, another's a string), which a single +/// concatenated DataFrame can't represent without collapsing to one common +/// type anyway -- so the `rdf_node_types` side-channel reports predicate as +/// IRI (always true per the CONSTRUCT grammar) and subject/object as +/// untyped, rather than claiming a precision this can't actually preserve. +fn construct_result_to_solution_mappings( + groups: Vec<(EagerSolutionMappings, Option)>, + global_cats: LockedCats, +) -> savvy::Result { + let mut lfs = Vec::with_capacity(groups.len()); + for (sm, predicate) in groups { + let EagerSolutionMappings { + mappings, + mut rdf_node_types, + } = sm; + // A pattern's own subject/object binds against a WHERE clause + // variable, which is very commonly multi-typed (its RDF node type + // can vary by row, e.g. one row's ?o is a string, another's an + // integer) -- format_native_columns represents that as a Struct + // column (one field per possible type, at most one non-null per + // row), the same shape query()'s SELECT path sends to R for + // R-side collapsing (.collapse_multitype_columns). That collapsing + // has to happen here instead, before casting to String and + // concatenating: a Struct column can't be cast to String directly + // (confirmed live -- it silently casts every row to NA rather than + // erroring), and each pattern's own R-side round trip is skipped + // for CONSTRUCT (only the final combined frame goes to R). + let subject_state = rdf_node_types.get(SUBJECT_COL_NAME).cloned(); + let object_state = rdf_node_types.get(OBJECT_COL_NAME).cloned(); + let mut lf = representation::formatting::format_native_columns( + mappings.lazy(), + &mut rdf_node_types, + global_cats.clone(), + ); + if let Some(state) = subject_state { + lf = lf.with_column(flatten_to_string(SUBJECT_COL_NAME, &state)); + } + if let Some(state) = object_state { + lf = lf.with_column(flatten_to_string(OBJECT_COL_NAME, &state)); + } + if let Some(predicate) = &predicate { + lf = lf.with_column( + polars::prelude::lit(rdf_named_node_to_polars_literal_value(predicate)) + .alias(PREDICATE_COL_NAME), + ); + } else { + lf = lf.with_column(col(PREDICATE_COL_NAME).cast(DataType::String)); + } + lf = lf.select([ + col(SUBJECT_COL_NAME), + col(PREDICATE_COL_NAME), + col(OBJECT_COL_NAME), + ]); + lfs.push(lf); + } + let mappings = if lfs.is_empty() { + // A CONSTRUCT template with literally zero triple patterns (valid + // but pointless SPARQL) -- build an empty, correctly-shaped + // DataFrame rather than concat()ing an empty list of LazyFrames + // (which would produce a schema-less frame, not the three empty + // String columns export_solution_mappings expects). + let empty_col = + |name: &str| Column::from(Series::new_empty(name.into(), &DataType::String)); + DataFrame::new( + 0, + vec![ + empty_col(SUBJECT_COL_NAME), + empty_col(PREDICATE_COL_NAME), + empty_col(OBJECT_COL_NAME), + ], + ) + .map_err(|e| crate::errors::runtime_error(e.to_string()))? + } else { + concat( + lfs, + UnionArgs { + parallel: true, + rechunk: true, + ..Default::default() + }, + ) + .map_err(|e| crate::errors::runtime_error(e.to_string()))? + .collect() + .map_err(|e| crate::errors::runtime_error(e.to_string()))? + }; + + let rdf_node_types = HashMap::from([ + ( + SUBJECT_COL_NAME.to_string(), + BaseRDFNodeType::None.into_default_input_rdf_node_state(), + ), + ( + PREDICATE_COL_NAME.to_string(), + BaseRDFNodeType::IRI.into_default_input_rdf_node_state(), + ), + ( + OBJECT_COL_NAME.to_string(), + BaseRDFNodeType::None.into_default_input_rdf_node_state(), + ), + ]); + Ok(EagerSolutionMappings { + mappings, + rdf_node_types, + }) +} + +/// Flattens `col(name)` -- already run through `format_native_columns`, so +/// either a single decoded column or (if `state.is_multi()`) a Struct with +/// one field per possible type -- down to one plain String column. For a +/// multi-typed column, coalesces across the Struct's fields (named per +/// `BaseRDFNodeType::field_col_name()`, matching exactly what +/// `expression_to_native`'s multi branch built them as; the lang-string +/// type is the one exception, itself a 2-field value/lang Struct rather +/// than a single field, hence the `LANG_STRING_VALUE_FIELD` special case -- +/// its language tag is dropped here, same simplification as +/// `.collapse_multitype_columns` on the R side), each field individually +/// cast to String first so mismatched underlying dtypes (e.g. an integer +/// field alongside a string field) don't need a common supertype. +fn flatten_to_string(name: &str, state: &RDFNodeState) -> Expr { + if !state.is_multi() { + // A single-typed rdf:langString column is *also* Struct-shaped + // (value + language tag fields, not one flat column) -- + // expression_to_native's non-multi branch itself falls back to + // as_struct(exprs) whenever the base type needs more than one + // expression, which is exactly the lang-string case. + return if state.is_literal() && state.get_base_type().unwrap().is_lang_string() { + col(name) + .struct_() + .field_by_name(LANG_STRING_VALUE_FIELD) + .cast(DataType::String) + .alias(name) + } else { + col(name).cast(DataType::String).alias(name) + }; + } + let candidates: Vec = state + .get_sorted_types() + .into_iter() + .map(|t| { + let field = if t.is_lang_string() { + LANG_STRING_VALUE_FIELD.to_string() + } else { + t.field_col_name() + }; + col(name) + .struct_() + .field_by_name(&field) + .cast(DataType::String) + }) + .collect(); + coalesce(&candidates).alias(name) +} + /// A maplib knowledge graph model. /// /// @export @@ -113,10 +291,11 @@ impl RModel { (inner.graph_size(&named_graph) as i32).try_into() } - /// Run a SPARQL SELECT query, streaming the result out through the Arrow - /// C Stream Interface (mirrors PyModel::query, py_maplib/src/py_model.rs: - /// 344-387 -- but only the SELECT case; CONSTRUCT is not yet supported, - /// see maplib-l2j). + /// Run a SPARQL SELECT or CONSTRUCT query, streaming the result out + /// through the Arrow C Stream Interface (mirrors PyModel::query, + /// py_maplib/src/py_model.rs:344-387; CONSTRUCT results are flattened + /// into one combined subject/predicate/object DataFrame, see + /// construct_result_to_solution_mappings). /// /// @param sparql The SPARQL query string. /// @param stream_ptr An external pointer from `nanoarrow::nanoarrow_allocate_array_stream()`. @@ -181,9 +360,10 @@ impl RModel { }; crate::arrow_bridge::export_solution_mappings(sm, stream_ptr) } - representation::result::QueryResultKind::Construct(_) => Err(argument_error( - "CONSTRUCT queries are not yet supported by maplibr's query() -- only SELECT", - )), + representation::result::QueryResultKind::Construct(groups) => { + construct_result_to_solution_mappings(groups, global_cats) + .and_then(|sm| crate::arrow_bridge::export_solution_mappings(sm, stream_ptr)) + } } } diff --git a/maplibr/tests/testthat/test-model-query.R b/maplibr/tests/testthat/test-model-query.R index 05b36d90..a86af813 100644 --- a/maplibr/tests/testthat/test-model-query.R +++ b/maplibr/tests/testthat/test-model-query.R @@ -82,3 +82,74 @@ test_that("query() raises a catchable error for invalid SPARQL syntax", { class = "maplibr_maplib_error" ) }) + +test_that("CONSTRUCT with a literal predicate returns a subject/predicate/object data.frame", { + m <- Model$new() + m$reads(paste( + ' "Alice" .', + ' "Bob" .', + sep = "\n" + ), format = "ntriples") + + df <- m$query("CONSTRUCT { ?s ?o } WHERE { ?s ?o }") + expect_setequal(names(df), c("subject", "predicate", "object")) + expect_equal(nrow(df), 2) + expect_true(all(df$predicate == "http://ex/hasName")) + expect_setequal(df$object, c("Alice", "Bob")) +}) + +test_that("CONSTRUCT with a bound ?p and a row-varying ?o flattens multi-typed values correctly", { + # Regression case: ?o here is bound to both a string and an integer + # literal across different rows of the SAME pattern -- format_native_ + # columns represents that as a Struct (one field per possible type), which + # must be coalesced down to one string column before concatenating across + # patterns, not cast directly (a direct Struct->String cast silently + # produces NA for every row instead of erroring). + m <- Model$new() + m$reads(paste( + ' "Alice" .', + " 30 .", + sep = "\n" + ), format = "turtle") + + df <- m$query("CONSTRUCT { ?s ?o . ?s ?p ?o } WHERE { ?s ?p ?o }") + expect_equal(nrow(df), 4) + expect_false(any(is.na(df$object))) + copy_rows <- df[df$predicate == "http://ex/copy", ] + expect_setequal(copy_rows$object, c("Alice", "30")) +}) + +test_that("CONSTRUCT preserves a language-tagged literal's value (dropping the language tag)", { + m <- Model$new() + m$reads(' "hello"@en .', format = "turtle") + df <- m$query("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }") + expect_equal(df$object, "hello") +}) + +test_that("CONSTRUCT with multiple template patterns concatenates all of their rows", { + m <- Model$new() + m$reads(paste( + ' "Alice" .', + " .", + sep = "\n" + ), format = "ntriples") + + df <- m$query(" + CONSTRUCT { + ?s ?name . + ?s ?friend . + } WHERE { + ?s ?name . + ?s ?friend . + } + ") + expect_equal(nrow(df), 2) + expect_setequal(df$predicate, c("http://ex/hasName", "http://ex/hasFriend")) +}) + +test_that("a CONSTRUCT query matching nothing returns zero rows, not an error", { + m <- Model$new() + df <- m$query("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }") + expect_equal(nrow(df), 0) + expect_setequal(names(df), c("subject", "predicate", "object")) +}) From 305dbb7d40306e4a7004ae24d3ee330b64ee172a Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 21:41:26 +0200 Subject: [PATCH 33/46] fix(maplibr): flatten multi-typed CONSTRUCT predicate variables too - a predicate variable can be multi-typed via UNION, not just subject/object, hitting the same silent-NA bug - routed through the existing flattening path, added a regression test Co-Authored-By: Claude Sonnet 5 --- maplibr/src/rust/src/model.rs | 12 ++++++++++-- maplibr/tests/testthat/test-model-query.R | 24 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index dad4b1c3..ccbf06e6 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -106,6 +106,14 @@ fn construct_result_to_solution_mappings( // for CONSTRUCT (only the final combined frame goes to R). let subject_state = rdf_node_types.get(SUBJECT_COL_NAME).cloned(); let object_state = rdf_node_types.get(OBJECT_COL_NAME).cloned(); + // A variable-bound predicate (the `None` case below) is, in + // practice, always singly IRI-typed -- RDF's predicate position is + // always an IRI -- but the same variable name could in principle + // also appear in a non-predicate position elsewhere in the WHERE + // clause (e.g. across a UNION), making its RDFNodeState multi just + // like subject/object can be. Flattened the same way, rather than + // assumed safe to cast directly. + let predicate_state = rdf_node_types.get(PREDICATE_COL_NAME).cloned(); let mut lf = representation::formatting::format_native_columns( mappings.lazy(), &mut rdf_node_types, @@ -122,8 +130,8 @@ fn construct_result_to_solution_mappings( polars::prelude::lit(rdf_named_node_to_polars_literal_value(predicate)) .alias(PREDICATE_COL_NAME), ); - } else { - lf = lf.with_column(col(PREDICATE_COL_NAME).cast(DataType::String)); + } else if let Some(state) = predicate_state { + lf = lf.with_column(flatten_to_string(PREDICATE_COL_NAME, &state)); } lf = lf.select([ col(SUBJECT_COL_NAME), diff --git a/maplibr/tests/testthat/test-model-query.R b/maplibr/tests/testthat/test-model-query.R index a86af813..87c68928 100644 --- a/maplibr/tests/testthat/test-model-query.R +++ b/maplibr/tests/testthat/test-model-query.R @@ -119,6 +119,30 @@ test_that("CONSTRUCT with a bound ?p and a row-varying ?o flattens multi-typed v expect_setequal(copy_rows$object, c("Alice", "30")) }) +test_that("CONSTRUCT flattens a multi-typed *predicate* variable too, not just subject/object", { + # Regression case (roborev review of the CONSTRUCT-support commit): the + # variable-bound-predicate branch of construct_result_to_solution_mappings + # used to cast the predicate column directly, on the assumption that a + # predicate variable is always singly IRI-typed -- true of RDF's predicate + # *position*, but not guaranteed of a *variable*, which can also appear in + # a non-predicate role elsewhere in the WHERE clause (here, via UNION), + # making its RDFNodeState multi just like subject/object can be. + m <- Model$new() + m$reads(' "Alice" .', format = "ntriples") + + df <- m$query(' + CONSTRUCT { ?a ?p ?b } + WHERE { + { ?a ?p ?b } + UNION + { BIND("not-an-iri" AS ?p) BIND( AS ?a) BIND( AS ?b) } + } + ') + expect_equal(nrow(df), 2) + expect_false(any(is.na(df$predicate))) + expect_setequal(df$predicate, c("http://ex/name", "not-an-iri")) +}) + test_that("CONSTRUCT preserves a language-tagged literal's value (dropping the language tag)", { m <- Model$new() m$reads(' "hello"@en .', format = "turtle") From 35b00abdf980d54f59c6f1854c58e6ce88707765 Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 21:47:34 +0200 Subject: [PATCH 34/46] docs(maplibr): add a getting-started vignette - covers reads/writes, SELECT/CONSTRUCT, OTTR mapping, RDFS inference, named graphs, prefixes/indexing, graph viz - every chunk is a real, executed example - supersedes an older standalone Quarto demo Co-Authored-By: Claude Sonnet 5 --- maplibr/DESCRIPTION | 8 +- maplibr/vignettes/maplibr.Rmd | 191 ++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 maplibr/vignettes/maplibr.Rmd diff --git a/maplibr/DESCRIPTION b/maplibr/DESCRIPTION index 3adddf8d..387d0938 100644 --- a/maplibr/DESCRIPTION +++ b/maplibr/DESCRIPTION @@ -29,6 +29,10 @@ Collate: 'templates.R' 'model.R' 'zzz.R' -Suggests: - testthat (>= 3.0.0) +Suggests: + testthat (>= 3.0.0), + knitr, + rmarkdown, + igraph Config/testthat/edition: 3 +VignetteBuilder: knitr diff --git a/maplibr/vignettes/maplibr.Rmd b/maplibr/vignettes/maplibr.Rmd new file mode 100644 index 00000000..873047f0 --- /dev/null +++ b/maplibr/vignettes/maplibr.Rmd @@ -0,0 +1,191 @@ +--- +title: "Getting Started with maplibr" +output: rmarkdown::html_vignette +vignette: > + %\VignetteIndexEntry{Getting Started with maplibr} + %\VignetteEngine{knitr::rmarkdown} + %\VignetteEncoding{UTF-8} +--- + +```{r, include = FALSE} +knitr::opts_chunk$set(collapse = TRUE, comment = "#>") +``` + +`maplibr` is an R interface to `maplib`, a Rust RDF knowledge-graph engine +(the same engine the Python package `py_maplib` wraps). This vignette walks +through what's implemented today: building a graph, querying it with SPARQL, +mapping tabular data into RDF with OTTR templates, RDFS inference, and +managing named graphs. + +```{r setup} +library(maplibr) +``` + +## Creating a Model and reading triples + +`Model` is the main entry point -- an in-memory RDF knowledge graph. + +```{r create-and-read} +m <- Model$new() + +m$reads(' + . + . + "Alice" . +', format = "ntriples") + +m$size() +``` + +## Serializing back out + +```{r writes} +cat(m$writes()) +``` + +Turtle works too: + +```{r writes-turtle} +cat(m$writes(format = "turtle")) +``` + +## Querying with SPARQL + +`$query()` runs a SPARQL query and returns the result as a data.frame. + +```{r query-select} +m$query("SELECT ?s ?o WHERE { ?s ?o }") +``` + +CONSTRUCT queries are supported too, returned as a combined +subject/predicate/object data.frame: + +```{r query-construct} +m$query(" + CONSTRUCT { ?s ?type } + WHERE { ?s a ?type } +") +``` + +## Mapping tabular data into RDF with OTTR templates + +`$map()` expands an OTTR template against a data.frame, adding the resulting +triples to the Model. Templates can be built programmatically... + +```{r map-programmatic} +ex <- Prefix("http://example.org/", "ex") +my_value <- Variable("MyValue") +template <- Template( + iri = suf(ex, "HasNickname"), + parameters = list(Parameter(my_value)), + instances = list(Triple(suf(ex, "alice"), suf(ex, "nickname"), my_value)) +) + +m$map(template, data.frame(MyValue = c("Ally", "Al"))) +m$query("SELECT ?o WHERE { ?o }") +``` + +...or from an stOTTR document string, matching `py_maplib`'s own syntax (see +the root project's README for the OTTR template language itself): + +```{r map-doc-string} +doc <- ' +@prefix ex:. +ex:HasFriend [?Friend] :: { + ottr:Triple(ex:alice, ex:knows, ?Friend) +} . +' +m$add_template(doc) +m$map("http://example.org/HasFriend", data.frame(Friend = "http://example.org/bob")) +m$query("SELECT ?o WHERE { ?o }") +``` + +## RDFS inference + +`alice` is a `Person`, and `Person` is a subclass of `Agent` -- RDFS +inference should let us derive that `alice` is also an `Agent`. + +```{r infer} +n <- m$infer_rdfs() +n +``` + +Inferred triples are *transient* -- kept separately from the main graph, and +only visible to `$query()` when asked for explicitly (`$writes()` doesn't +currently support this at all, a core-engine limitation, not an R-wrapper +one -- see the "Upstream issues" section of the package's development notes): + +```{r infer-transient} +without <- m$query("SELECT * WHERE { ?s ?p ?o }") +with_transient <- m$query("SELECT * WHERE { ?s ?p ?o }", include_transient = TRUE) +nrow(without) +nrow(with_transient) +``` + +## Named graphs + +Triples can be routed into named graphs, copied between Models, and split +back out. + +```{r graphs} +m$reads( + ' "Bob" .', + format = "ntriples", + graph = "urn:graph:extra" +) +m$size("urn:graph:extra") +m$size() # default graph, unaffected + +m2 <- Model$new() +m2$add_graph(m) +m2$size() + +sprout <- m$detach_graph(graph = "urn:graph:extra") +sprout$size() +m$size("urn:graph:extra") # emptied by the detach +``` + +## Prefixes and indexing + +```{r prefixes-index} +m$add_prefixes(list(ex = "http://example.org/")) +m$create_index() +``` + +## Visualizing a graph + +`maplibr` has no graph-visualization function of its own, but a query result +is a plain data.frame, so any R graph package works directly on it. + +```{r graph-plot, fig.width=6, fig.height=4.5} +if (requireNamespace("igraph", quietly = TRUE)) { + triples <- m$query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }") + short_label <- function(iri) sub(".*[/#]", "", iri) + + edges <- data.frame(from = short_label(triples$s), to = short_label(triples$o)) + g <- igraph::graph_from_data_frame(edges, directed = TRUE) + igraph::E(g)$label <- short_label(triples$p) + + plot( + g, + edge.label = igraph::E(g)$label, + vertex.size = 32, + vertex.color = "lightblue", + vertex.label.cex = 0.9, + edge.arrow.size = 0.5, + layout = igraph::layout_with_fr(g) + ) +} +``` + +## What doesn't work yet, and why + +| Method(s) | Status | +|---|---| +| `$writes(include_transient = TRUE)` | Not supported for any format -- the underlying engine's triple-serialization code has no way to include transient/inferred triples, unlike its query path. A core-engine limitation, not specific to this package. | +| `$validate()` (SHACL) | Not implemented -- also blocked on a licensing question (the root project's README describes SHACL as proprietary, but its source is present and unrestricted in this checkout; not a decision this package can make unilaterally). | +| `$serialize()` / `$deserialize()` | Wired up correctly, but panic at runtime -- the on-disk storage engine they depend on is a closed-source stub in this checkout. | +| CIM XML, HDT | Not ported. | + +See the package's development notes for the full, current list of known +issues and their upstream/local status. From 1752787107d317ac4241d56a6a8bc93f864c3afd Mon Sep 17 00:00:00 2001 From: novica Date: Sun, 13 Sep 2026 21:54:03 +0200 Subject: [PATCH 35/46] refactor(maplibr): move Instance()'s raw-pointer escape hatch to a private subclass - public constructor no longer has any way to bypass IRI/Argument validation - internal callers (Template$instance(), Triple()) use the private subclass instead Co-Authored-By: Claude Sonnet 5 --- maplibr/R/templates.R | 26 +++++++++++++++++++------ maplibr/man/Instance.Rd | 7 +------ maplibr/tests/testthat/test-templates.R | 18 +++++++++++++++++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/maplibr/R/templates.R b/maplibr/R/templates.R index 43bf4117..d4e0ca5f 100644 --- a/maplibr/R/templates.R +++ b/maplibr/R/templates.R @@ -102,10 +102,7 @@ Instance <- S7::new_class( "Instance", package = "maplibr", properties = list(raw = S7::class_any), - constructor = function(template_iri = NULL, arguments = NULL, list_expander = NULL, .raw = NULL) { - if (!is.null(.raw)) { - return(S7::new_object(S7::S7_object(), raw = .raw)) - } + constructor = function(template_iri, arguments = NULL, list_expander = NULL) { stopifnot(S7::S7_inherits(template_iri, IRI)) arg_ptrs <- lapply(arguments, .as_argument_ptr) raw <- RInstance$new(template_iri@iri, arg_ptrs, list_expander) @@ -113,6 +110,23 @@ Instance <- S7::new_class( } ) +# Wraps an already-built RInstance (from Template$instance()/make_triple(), +# which validate their own arguments) as an Instance, without exposing a +# validation-bypassing raw-pointer parameter on Instance()'s own public +# constructor. S7's new_object() can only be called from within the literal +# function registered as some class's constructor -- not from an arbitrary +# unexported helper, even one Instance()'s own constructor delegates to -- +# so this is a genuine subclass with its own constructor, not just a thin +# wrapper. Its instances still satisfy S7_inherits(x, Instance) (and base +# inherits()), since S7 subclass instances carry their full ancestor class +# vector, so every existing Instance check downstream is unaffected. +.RawInstance <- S7::new_class( + "RawInstance", + package = "maplibr", + parent = Instance, + constructor = function(raw) S7::new_object(S7::S7_object(), raw = raw) +) + #' The IRI of the template an Instance calls. #' #' @param x An Instance. @@ -169,7 +183,7 @@ instantiate <- S7::new_generic("instantiate", "template") S7::method(instantiate, Template) <- function(template, arguments, list_expander = NULL) { arg_ptrs <- lapply(arguments, .as_argument_ptr) raw <- template@raw$instance(arg_ptrs, list_expander) - Instance(.raw = raw) + .RawInstance(raw) } #' Build an rdf:type-style Triple instance. @@ -187,5 +201,5 @@ Triple <- function(subject, predicate, object, list_expander = NULL) { p <- Argument(predicate) o <- Argument(object) raw <- make_triple(s@raw, p@raw, o@raw, list_expander) - Instance(.raw = raw) + .RawInstance(raw) } diff --git a/maplibr/man/Instance.Rd b/maplibr/man/Instance.Rd index b6af8338..12d3fd6a 100644 --- a/maplibr/man/Instance.Rd +++ b/maplibr/man/Instance.Rd @@ -4,12 +4,7 @@ \alias{Instance} \title{An OTTR template instance: a call to a template with concrete arguments.} \usage{ -Instance( - template_iri = NULL, - arguments = NULL, - list_expander = NULL, - .raw = NULL -) +Instance(template_iri, arguments = NULL, list_expander = NULL) } \arguments{ \item{template_iri}{An IRI: the template being instantiated.} diff --git a/maplibr/tests/testthat/test-templates.R b/maplibr/tests/testthat/test-templates.R index b58b742b..da076d9d 100644 --- a/maplibr/tests/testthat/test-templates.R +++ b/maplibr/tests/testthat/test-templates.R @@ -63,3 +63,21 @@ test_that("Argument passthrough: passing an existing Argument returns it unchang a2 <- Argument(a) expect_identical(a, a2) }) + +test_that("Instance() no longer accepts a validation-bypassing .raw argument", { + # Regression test: .raw used to be a real, exported, documented parameter + # of Instance()'s own public constructor -- a validation-bypassing escape + # hatch, needed internally by instantiate()/Triple() to wrap an + # already-built RInstance, but with no business being publicly callable. + # It's now a private, unexported subclass (.RawInstance) instead. + expect_error(Instance(.raw = "anything")) + expect_false(".raw" %in% names(formals(Instance))) +}) + +test_that("Triple() (via the private RawInstance subclass) builds a valid, checkable Instance", { + ex <- Prefix("http://example.org/", "ex") + inst <- Triple(suf(ex, "s"), suf(ex, "p"), suf(ex, "o")) + expect_true(S7::S7_inherits(inst, Instance)) + expect_true(inherits(inst, "maplibr::Instance")) # base inherits() too, not just S7_inherits() + expect_equal(instance_template_iri(inst)@iri, "http://ns.ottr.xyz/0.4/Triple") +}) From c8854f6c78816119bf2713a157a3f1db0655f524 Mon Sep 17 00:00:00 2001 From: novica Date: Mon, 14 Sep 2026 20:42:16 +0200 Subject: [PATCH 36/46] feat(maplibr): add Model update()/insert() for SPARQL UPDATE/INSERT - insert() only accepts CONSTRUCT queries, matching the engine's contract - bare INSERT DATA/DELETE DATA panics (engine limitation); the WHERE {}-based form works and is documented as the workaround Co-Authored-By: Claude Sonnet 5 --- maplibr/R/000-wrappers.R | 14 ++++ maplibr/R/model.R | 37 +++++++++ maplibr/man/Model.Rd | 68 ++++++++++++++++ maplibr/src/init.c | 12 +++ maplibr/src/rust/api.h | 2 + maplibr/src/rust/src/model.rs | 76 ++++++++++++++++++ .../tests/testthat/test-model-insert-update.R | 80 +++++++++++++++++++ 7 files changed, 289 insertions(+) create mode 100644 maplibr/tests/testthat/test-model-insert-update.R diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index 35196386..bae35dde 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -298,6 +298,12 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } } +`RModel_insert` <- function(self) { + function(`query`, `include_transient`, `transient`, `source_graph` = NULL, `target_graph` = NULL) { + invisible(.Call(savvy_RModel_insert__impl, `self`, `query`, `include_transient`, `transient`, `source_graph`, `target_graph`)) + } +} + `RModel_map` <- function(self) { function(`template_iri`, `stream_ptr`, `graph` = NULL, `validate_iris` = NULL) { invisible(.Call(savvy_RModel_map__impl, `self`, `template_iri`, `stream_ptr`, `graph`, `validate_iris`)) @@ -340,6 +346,12 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } } +`RModel_update` <- function(self) { + function(`update`, `include_transient`, `graph` = NULL) { + invisible(.Call(savvy_RModel_update__impl, `self`, `update`, `include_transient`, `graph`)) + } +} + `RModel_writes` <- function(self) { function(`format` = NULL, `graph` = NULL) { .Call(savvy_RModel_writes__impl, `self`, `format`, `graph`) @@ -357,6 +369,7 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") e$`create_index` <- `RModel_create_index`(ptr) e$`detach_graph` <- `RModel_detach_graph`(ptr) e$`infer_rdfs` <- `RModel_infer_rdfs`(ptr) + e$`insert` <- `RModel_insert`(ptr) e$`map` <- `RModel_map`(ptr) e$`map_no_data` <- `RModel_map_no_data`(ptr) e$`query` <- `RModel_query`(ptr) @@ -364,6 +377,7 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") e$`serialize` <- `RModel_serialize`(ptr) e$`size` <- `RModel_size`(ptr) e$`truncate_graph` <- `RModel_truncate_graph`(ptr) + e$`update` <- `RModel_update`(ptr) e$`writes` <- `RModel_writes`(ptr) class(e) <- c("maplibr::RModel", "RModel", "savvy_maplibr__sealed") diff --git a/maplibr/R/model.R b/maplibr/R/model.R index 83c30a5e..48e5699e 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -155,6 +155,43 @@ Model <- R6::R6Class( df }, + #' @description Run a SPARQL UPDATE (modify-style INSERT/DELETE ... WHERE) + #' against this Model in place. + #' @param update The SPARQL UPDATE string. Must be a DELETE/INSERT/WHERE + #' form (`INSERT { ... } WHERE { ... }`, optionally with a DELETE + #' clause too, and an empty `WHERE {}` when there's nothing to match + #' against) -- the bare `INSERT DATA { ... }`/`DELETE DATA { ... }` + #' forms are unimplemented in the core engine and panic if used (not + #' fixable in this package -- see maplib-cai upstream issue). + #' @param graph Optional named graph IRI to restrict the update to + #' (no restriction, i.e. the whole store, if NULL -- same "missing + #' means unrestricted" semantics as `$query()`'s `graph` argument, not + #' `$reads()`/`$writes()`'s "missing means the default graph"). + #' @param include_transient Whether the WHERE clause may match transient + #' (e.g. inferred) triples. + update = function(update, graph = NULL, include_transient = FALSE) { + .rethrow(private$rmodel$update(update, include_transient, graph)) + invisible(self) + }, + + #' @description Run a SPARQL CONSTRUCT query and insert its results as + #' new triples into a (possibly different) graph. + #' @param query The SPARQL CONSTRUCT query string to source new triples + #' from (a SELECT query errors -- INSERT needs a triple-shaped result). + #' @param source_graph Optional named graph IRI to query from (default + #' graph if NULL). + #' @param target_graph Optional named graph IRI to insert into (default + #' graph if NULL). + #' @param include_transient Whether the query may match transient (e.g. + #' inferred) triples in the source graph. + #' @param transient Whether the newly-inserted triples themselves should + #' be marked transient rather than permanent. + insert = function(query, source_graph = NULL, target_graph = NULL, + include_transient = FALSE, transient = FALSE) { + .rethrow(private$rmodel$insert(query, include_transient, transient, source_graph, target_graph)) + invisible(self) + }, + #' @description Register a Template so it can be referenced by IRI from #' `$map()`. #' @param template A `Template` (see `templates.R`), or an stOTTR document diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index 79ce991f..fa4a1bfd 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -22,6 +22,8 @@ returns \code{self} invisibly). \item \href{#method-Model-add_graph}{\code{Model$add_graph()}} \item \href{#method-Model-detach_graph}{\code{Model$detach_graph()}} \item \href{#method-Model-query}{\code{Model$query()}} + \item \href{#method-Model-update}{\code{Model$update()}} + \item \href{#method-Model-insert}{\code{Model$insert()}} \item \href{#method-Model-add_template}{\code{Model$add_template()}} \item \href{#method-Model-map}{\code{Model$map()}} \item \href{#method-Model-infer_rdfs}{\code{Model$infer_rdfs()}} @@ -257,6 +259,72 @@ left for later. } } +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-update}{}}} +\subsection{\code{Model$update()}}{ + Run a SPARQL UPDATE (modify-style INSERT/DELETE ... WHERE) +against this Model in place. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$update(update, graph = NULL, include_transient = FALSE)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{update}}{The SPARQL UPDATE string. Must be a DELETE/INSERT/WHERE +form (\verb{INSERT \{ ... \} WHERE \{ ... \}}, optionally with a DELETE +clause too, and an empty \verb{WHERE \{\}} when there's nothing to match +against) -- the bare \verb{INSERT DATA \{ ... \}}/\verb{DELETE DATA \{ ... \}} +forms are unimplemented in the core engine and panic if used (not +fixable in this package -- see maplib-cai upstream issue).} + \item{\code{graph}}{Optional named graph IRI to restrict the update to +(no restriction, i.e. the whole store, if NULL -- same "missing +means unrestricted" semantics as \verb{$query()}'s \code{graph} argument, not +\verb{$reads()}/\verb{$writes()}'s "missing means the default graph").} + \item{\code{include_transient}}{Whether the WHERE clause may match transient +(e.g. inferred) triples.} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-insert}{}}} +\subsection{\code{Model$insert()}}{ + Run a SPARQL CONSTRUCT query and insert its results as +new triples into a (possibly different) graph. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$insert( + query, + source_graph = NULL, + target_graph = NULL, + include_transient = FALSE, + transient = FALSE +)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{query}}{The SPARQL CONSTRUCT query string to source new triples +from (a SELECT query errors -- INSERT needs a triple-shaped result).} + \item{\code{source_graph}}{Optional named graph IRI to query from (default +graph if NULL).} + \item{\code{target_graph}}{Optional named graph IRI to insert into (default +graph if NULL).} + \item{\code{include_transient}}{Whether the query may match transient (e.g. +inferred) triples in the source graph.} + \item{\code{transient}}{Whether the newly-inserted triples themselves should +be marked transient rather than permanent.} + } + \if{html}{\out{
}} + } +} + \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Model-add_template}{}}} diff --git a/maplibr/src/init.c b/maplibr/src/init.c index e9273af2..ab4071bb 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -164,6 +164,11 @@ SEXP savvy_RModel_infer_rdfs__impl(SEXP self__, SEXP c_arg__graph) { return handle_result(res); } +SEXP savvy_RModel_insert__impl(SEXP self__, SEXP c_arg__query, SEXP c_arg__include_transient, SEXP c_arg__transient, SEXP c_arg__source_graph, SEXP c_arg__target_graph) { + SEXP res = savvy_RModel_insert__ffi(self__, c_arg__query, c_arg__include_transient, c_arg__transient, c_arg__source_graph, c_arg__target_graph); + return handle_result(res); +} + SEXP savvy_RModel_map__impl(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__stream_ptr, SEXP c_arg__graph, SEXP c_arg__validate_iris) { SEXP res = savvy_RModel_map__ffi(self__, c_arg__template_iri, c_arg__stream_ptr, c_arg__graph, c_arg__validate_iris); return handle_result(res); @@ -204,6 +209,11 @@ SEXP savvy_RModel_truncate_graph__impl(SEXP self__, SEXP c_arg__graph) { return handle_result(res); } +SEXP savvy_RModel_update__impl(SEXP self__, SEXP c_arg__update, SEXP c_arg__include_transient, SEXP c_arg__graph) { + SEXP res = savvy_RModel_update__ffi(self__, c_arg__update, c_arg__include_transient, c_arg__graph); + return handle_result(res); +} + SEXP savvy_RModel_writes__impl(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph) { SEXP res = savvy_RModel_writes__ffi(self__, c_arg__format, c_arg__graph); return handle_result(res); @@ -261,6 +271,7 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_RModel_deserialize__impl", (DL_FUNC) &savvy_RModel_deserialize__impl, 2}, {"savvy_RModel_detach_graph__impl", (DL_FUNC) &savvy_RModel_detach_graph__impl, 3}, {"savvy_RModel_infer_rdfs__impl", (DL_FUNC) &savvy_RModel_infer_rdfs__impl, 2}, + {"savvy_RModel_insert__impl", (DL_FUNC) &savvy_RModel_insert__impl, 6}, {"savvy_RModel_map__impl", (DL_FUNC) &savvy_RModel_map__impl, 5}, {"savvy_RModel_map_no_data__impl", (DL_FUNC) &savvy_RModel_map_no_data__impl, 4}, {"savvy_RModel_new__impl", (DL_FUNC) &savvy_RModel_new__impl, 0}, @@ -269,6 +280,7 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_RModel_serialize__impl", (DL_FUNC) &savvy_RModel_serialize__impl, 2}, {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 2}, {"savvy_RModel_truncate_graph__impl", (DL_FUNC) &savvy_RModel_truncate_graph__impl, 2}, + {"savvy_RModel_update__impl", (DL_FUNC) &savvy_RModel_update__impl, 4}, {"savvy_RModel_writes__impl", (DL_FUNC) &savvy_RModel_writes__impl, 3}, {"savvy_RParameter_new__impl", (DL_FUNC) &savvy_RParameter_new__impl, 5}, {"savvy_RTemplate_instance__impl", (DL_FUNC) &savvy_RTemplate_instance__impl, 3}, diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index 0b4a4aed..620b6072 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -31,6 +31,7 @@ SEXP savvy_RModel_create_index__ffi(SEXP self__); SEXP savvy_RModel_deserialize__ffi(SEXP c_arg__path, SEXP c_arg__storage_folder); SEXP savvy_RModel_detach_graph__ffi(SEXP self__, SEXP c_arg__preserve_name, SEXP c_arg__graph); SEXP savvy_RModel_infer_rdfs__ffi(SEXP self__, SEXP c_arg__graph); +SEXP savvy_RModel_insert__ffi(SEXP self__, SEXP c_arg__query, SEXP c_arg__include_transient, SEXP c_arg__transient, SEXP c_arg__source_graph, SEXP c_arg__target_graph); SEXP savvy_RModel_map__ffi(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__stream_ptr, SEXP c_arg__graph, SEXP c_arg__validate_iris); SEXP savvy_RModel_map_no_data__ffi(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__graph, SEXP c_arg__validate_iris); SEXP savvy_RModel_new__ffi(void); @@ -39,6 +40,7 @@ SEXP savvy_RModel_reads__ffi(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEX SEXP savvy_RModel_serialize__ffi(SEXP self__, SEXP c_arg__path); SEXP savvy_RModel_size__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_truncate_graph__ffi(SEXP self__, SEXP c_arg__graph); +SEXP savvy_RModel_update__ffi(SEXP self__, SEXP c_arg__update, SEXP c_arg__include_transient, SEXP c_arg__graph); SEXP savvy_RModel_writes__ffi(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph); // methods and associated functions for RParameter diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index ccbf06e6..64cc9bf8 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -375,6 +375,82 @@ impl RModel { } } + /// Run a SPARQL UPDATE (INSERT DATA/DELETE DATA/DELETE-INSERT-WHERE) + /// against this Model in place (mirrors PyModel::update, + /// py_maplib/src/py_model.rs:415-469 / update_mutex, py_maplib/src/ + /// mutexes.rs:375-397). + /// + /// @param update The SPARQL UPDATE string. Must be a DELETE/INSERT/WHERE + /// form (`INSERT { ... } WHERE { ... }`, optionally with a DELETE + /// clause too, and an empty `WHERE {}` when there's nothing to match + /// against) -- the bare `INSERT DATA { ... }`/`DELETE DATA { ... }` + /// forms are unimplemented in the core engine (`Triplestore:: + /// update_parsed`, lib/triplestore/src/sparql.rs:302-307, a bare + /// `todo!()`) and panic if used. Not fixable in this package -- see + /// maplib-cai upstream issue. + /// @param include_transient Whether the WHERE clause may match transient + /// (e.g. inferred) triples. Defaults to FALSE. + /// @param graph Optional named graph IRI to restrict the update to + /// (no restriction, i.e. the whole store, if NULL -- same "missing + /// means unrestricted" semantics as query()'s `graph` argument, not + /// reads()/writes()'s "missing means the default graph"). + /// @export + fn update(&self, update: &str, include_transient: bool, graph: Option<&str>) -> savvy::Result<()> { + let named_graph = parse_query_graph(graph)?; + let mut inner = self.lock(); + inner + .update(update, None, named_graph.as_ref(), false, include_transient, None, false) + .map_err(maplib_error)?; + Ok(()) + } + + /// Run a SPARQL SELECT/CONSTRUCT query and insert its results as new + /// triples into a (possibly different) graph (mirrors PyModel::insert, + /// py_maplib/src/py_model.rs:540-600 / insert_mutex, py_maplib/src/ + /// mutexes.rs:498-519). New triples' solution mappings (py_maplib's + /// optional return dict) aren't surfaced here -- callers who need the + /// new triples back can query() the target graph again. + /// + /// @param query The SPARQL CONSTRUCT query string to source new triples + /// from (a SELECT query errors -- INSERT needs a triple-shaped result, + /// matching `Triplestore::insert_parsed`'s own constraint, + /// lib/triplestore/src/sparql.rs:240-264). + /// @param include_transient Whether the query may match transient (e.g. + /// inferred) triples in the source graph. Defaults to FALSE. + /// @param transient Whether the newly-inserted triples themselves should + /// be marked transient rather than permanent. Defaults to FALSE. + /// @param source_graph Optional named graph IRI to query from (default + /// graph if NULL). + /// @param target_graph Optional named graph IRI to insert into (default + /// graph if NULL). + /// @export + fn insert( + &self, + query: &str, + include_transient: bool, + transient: bool, + source_graph: Option<&str>, + target_graph: Option<&str>, + ) -> savvy::Result<()> { + let source_graph = parse_optional_named_graph(source_graph)?; + let target_graph = parse_optional_named_graph(target_graph)?; + let mut inner = self.lock(); + inner + .insert( + query, + None, + &source_graph, + include_transient, + &target_graph, + transient, + false, + None, + false, + ) + .map_err(maplib_error)?; + Ok(()) + } + /// Parse RDF triples from a string into this Model. /// /// @param s RDF data as a string. diff --git a/maplibr/tests/testthat/test-model-insert-update.R b/maplibr/tests/testthat/test-model-insert-update.R new file mode 100644 index 00000000..762d74e8 --- /dev/null +++ b/maplibr/tests/testthat/test-model-insert-update.R @@ -0,0 +1,80 @@ +# Tests for Model$update() and Model$insert() (maplib-zlr). + +test_that("update() runs a DELETE/INSERT/WHERE modify against existing data", { + m <- Model$new() + m$reads(' .', format = "ntriples") + + m$update("INSERT { } WHERE { }") + expect_equal(m$size(), 2) + + m$update("DELETE { } WHERE { }") + expect_equal(m$size(), 1) +}) + +test_that("update() with an empty WHERE {} inserts/deletes constant triples", { + # INSERT DATA/DELETE DATA panic in the core engine (todo!() in + # Triplestore::update_parsed) -- WHERE {} is the documented workaround, see + # Model$update()'s roxygen docs and maplib-cai (upstream issue). + m <- Model$new() + m$update("INSERT { } WHERE {}") + expect_equal(m$size(), 1) + + m$update("DELETE { } WHERE {}") + expect_equal(m$size(), 0) +}) + +test_that("update()'s graph argument restricts which graph's data is matched/modified", { + m <- Model$new() + m$reads(' .', format = "ntriples", graph = "http://ex/g1") + + m$update( + "INSERT { ?s ?o } WHERE { ?s ?o }", + graph = "http://ex/g2" + ) + expect_equal(m$size(graph = "http://ex/g1"), 1) + expect_equal(m$size(graph = "http://ex/g2"), 0) +}) + +test_that("update() raises a catchable error for invalid SPARQL syntax", { + m <- Model$new() + expect_error( + m$update("this is not valid SPARQL UPDATE !!!"), + class = "maplibr_maplib_error" + ) +}) + +test_that("insert() adds a CONSTRUCT query's results into the target graph", { + m <- Model$new() + m$reads(' .', format = "ntriples") + + m$insert( + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + target_graph = "http://ex/g2" + ) + expect_equal(m$size(), 1) + expect_equal(m$size(graph = "http://ex/g2"), 1) + + df <- m$query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }", graph = "http://ex/g2") + expect_equal(df$s, "http://ex/a") +}) + +test_that("insert()'s source_graph restricts which graph is queried for new triples", { + m <- Model$new() + m$reads(' .', format = "ntriples", graph = "http://ex/g1") + + m$insert( + "CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", + source_graph = "http://ex/nonexistent", + target_graph = "http://ex/g2" + ) + expect_equal(m$size(graph = "http://ex/g2"), 0) +}) + +test_that("insert() rejects a SELECT query (INSERT needs a triple-shaped CONSTRUCT result)", { + m <- Model$new() + m$reads(' .', format = "ntriples") + expect_error( + m$insert("SELECT ?s ?p ?o WHERE { ?s ?p ?o }"), + class = "maplibr_maplib_error" + ) +}) From 23bc487234cb278def1998ce967ca5dca0704356 Mon Sep 17 00:00:00 2001 From: novica Date: Mon, 14 Sep 2026 20:45:52 +0200 Subject: [PATCH 37/46] feat(maplibr): add Model read()/write() for file-based RDF I/O - mirrors py_maplib's read(path)/write(path) - read() guesses format from the file extension, write() defaults to N-Triples Co-Authored-By: Claude Sonnet 5 --- maplibr/R/000-wrappers.R | 14 +++++ maplibr/R/model.R | 19 ++++++ maplibr/man/Model.Rd | 45 ++++++++++++++ maplibr/src/init.c | 12 ++++ maplibr/src/rust/api.h | 2 + maplibr/src/rust/src/model.rs | 56 +++++++++++++++++ .../tests/testthat/test-model-read-write.R | 61 +++++++++++++++++++ 7 files changed, 209 insertions(+) create mode 100644 maplibr/tests/testthat/test-model-read-write.R diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index bae35dde..2bd1102a 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -322,6 +322,12 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } } +`RModel_read` <- function(self) { + function(`path`, `format` = NULL, `graph` = NULL) { + invisible(.Call(savvy_RModel_read__impl, `self`, `path`, `format`, `graph`)) + } +} + `RModel_reads` <- function(self) { function(`s`, `format`, `graph` = NULL) { invisible(.Call(savvy_RModel_reads__impl, `self`, `s`, `format`, `graph`)) @@ -352,6 +358,12 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } } +`RModel_write` <- function(self) { + function(`path`, `format` = NULL, `graph` = NULL) { + invisible(.Call(savvy_RModel_write__impl, `self`, `path`, `format`, `graph`)) + } +} + `RModel_writes` <- function(self) { function(`format` = NULL, `graph` = NULL) { .Call(savvy_RModel_writes__impl, `self`, `format`, `graph`) @@ -373,11 +385,13 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") e$`map` <- `RModel_map`(ptr) e$`map_no_data` <- `RModel_map_no_data`(ptr) e$`query` <- `RModel_query`(ptr) + e$`read` <- `RModel_read`(ptr) e$`reads` <- `RModel_reads`(ptr) e$`serialize` <- `RModel_serialize`(ptr) e$`size` <- `RModel_size`(ptr) e$`truncate_graph` <- `RModel_truncate_graph`(ptr) e$`update` <- `RModel_update`(ptr) + e$`write` <- `RModel_write`(ptr) e$`writes` <- `RModel_writes`(ptr) class(e) <- c("maplibr::RModel", "RModel", "savvy_maplibr__sealed") diff --git a/maplibr/R/model.R b/maplibr/R/model.R index 48e5699e..fd9b85f2 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -50,6 +50,16 @@ Model <- R6::R6Class( invisible(self) }, + #' @description Parse RDF triples from a file into this Model. + #' @param path Path to the RDF file to read. + #' @param format One of "ntriples", "turtle", "xml" (rdf/xml). Guessed + #' from the file extension if NULL (.ttl/.nt/.xml or .rdf). + #' @param graph Optional named graph IRI to read into (default graph if NULL). + read = function(path, format = NULL, graph = NULL) { + .rethrow(private$rmodel$read(path, format, graph)) + invisible(self) + }, + #' @description Serialize this Model's triples to a string. #' @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". #' @param graph Optional named graph IRI to write (default graph if NULL). @@ -57,6 +67,15 @@ Model <- R6::R6Class( .rethrow(private$rmodel$writes(format, graph)) }, + #' @description Serialize this Model's triples to a file. + #' @param path Path to write the RDF file to (overwritten if it exists). + #' @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". + #' @param graph Optional named graph IRI to write (default graph if NULL). + write = function(path, format = NULL, graph = NULL) { + .rethrow(private$rmodel$write(path, format, graph)) + invisible(self) + }, + #' @description Build the default (non-FTS) indexes. create_index = function() { .rethrow(private$rmodel$create_index()) diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index fa4a1bfd..12cb4588 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -15,7 +15,9 @@ returns \code{self} invisibly). \item \href{#method-Model-initialize}{\code{Model$new()}} \item \href{#method-Model-size}{\code{Model$size()}} \item \href{#method-Model-reads}{\code{Model$reads()}} + \item \href{#method-Model-read}{\code{Model$read()}} \item \href{#method-Model-writes}{\code{Model$writes()}} + \item \href{#method-Model-write}{\code{Model$write()}} \item \href{#method-Model-create_index}{\code{Model$create_index()}} \item \href{#method-Model-truncate_graph}{\code{Model$truncate_graph()}} \item \href{#method-Model-add_prefixes}{\code{Model$add_prefixes()}} @@ -93,6 +95,28 @@ is for internal use only. } } +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-read}{}}} +\subsection{\code{Model$read()}}{ + Parse RDF triples from a file into this Model. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$read(path, format = NULL, graph = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{path}}{Path to the RDF file to read.} + \item{\code{format}}{One of "ntriples", "turtle", "xml" (rdf/xml). Guessed +from the file extension if NULL (.ttl/.nt/.xml or .rdf).} + \item{\code{graph}}{Optional named graph IRI to read into (default graph if NULL).} + } + \if{html}{\out{
}} + } +} + \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Model-writes}{}}} @@ -113,6 +137,27 @@ is for internal use only. } } +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-write}{}}} +\subsection{\code{Model$write()}}{ + Serialize this Model's triples to a file. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$write(path, format = NULL, graph = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{path}}{Path to write the RDF file to (overwritten if it exists).} + \item{\code{format}}{One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples".} + \item{\code{graph}}{Optional named graph IRI to write (default graph if NULL).} + } + \if{html}{\out{
}} + } +} + \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Model-create_index}{}}} diff --git a/maplibr/src/init.c b/maplibr/src/init.c index ab4071bb..9dac67de 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -189,6 +189,11 @@ SEXP savvy_RModel_query__impl(SEXP self__, SEXP c_arg__sparql, SEXP c_arg__strea return handle_result(res); } +SEXP savvy_RModel_read__impl(SEXP self__, SEXP c_arg__path, SEXP c_arg__format, SEXP c_arg__graph) { + SEXP res = savvy_RModel_read__ffi(self__, c_arg__path, c_arg__format, c_arg__graph); + return handle_result(res); +} + SEXP savvy_RModel_reads__impl(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEXP c_arg__graph) { SEXP res = savvy_RModel_reads__ffi(self__, c_arg__s, c_arg__format, c_arg__graph); return handle_result(res); @@ -214,6 +219,11 @@ SEXP savvy_RModel_update__impl(SEXP self__, SEXP c_arg__update, SEXP c_arg__incl return handle_result(res); } +SEXP savvy_RModel_write__impl(SEXP self__, SEXP c_arg__path, SEXP c_arg__format, SEXP c_arg__graph) { + SEXP res = savvy_RModel_write__ffi(self__, c_arg__path, c_arg__format, c_arg__graph); + return handle_result(res); +} + SEXP savvy_RModel_writes__impl(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph) { SEXP res = savvy_RModel_writes__ffi(self__, c_arg__format, c_arg__graph); return handle_result(res); @@ -276,11 +286,13 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_RModel_map_no_data__impl", (DL_FUNC) &savvy_RModel_map_no_data__impl, 4}, {"savvy_RModel_new__impl", (DL_FUNC) &savvy_RModel_new__impl, 0}, {"savvy_RModel_query__impl", (DL_FUNC) &savvy_RModel_query__impl, 5}, + {"savvy_RModel_read__impl", (DL_FUNC) &savvy_RModel_read__impl, 4}, {"savvy_RModel_reads__impl", (DL_FUNC) &savvy_RModel_reads__impl, 4}, {"savvy_RModel_serialize__impl", (DL_FUNC) &savvy_RModel_serialize__impl, 2}, {"savvy_RModel_size__impl", (DL_FUNC) &savvy_RModel_size__impl, 2}, {"savvy_RModel_truncate_graph__impl", (DL_FUNC) &savvy_RModel_truncate_graph__impl, 2}, {"savvy_RModel_update__impl", (DL_FUNC) &savvy_RModel_update__impl, 4}, + {"savvy_RModel_write__impl", (DL_FUNC) &savvy_RModel_write__impl, 4}, {"savvy_RModel_writes__impl", (DL_FUNC) &savvy_RModel_writes__impl, 3}, {"savvy_RParameter_new__impl", (DL_FUNC) &savvy_RParameter_new__impl, 5}, {"savvy_RTemplate_instance__impl", (DL_FUNC) &savvy_RTemplate_instance__impl, 3}, diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index 620b6072..f4b28432 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -36,11 +36,13 @@ SEXP savvy_RModel_map__ffi(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__st SEXP savvy_RModel_map_no_data__ffi(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__graph, SEXP c_arg__validate_iris); SEXP savvy_RModel_new__ffi(void); SEXP savvy_RModel_query__ffi(SEXP self__, SEXP c_arg__sparql, SEXP c_arg__stream_ptr, SEXP c_arg__include_transient, SEXP c_arg__graph); +SEXP savvy_RModel_read__ffi(SEXP self__, SEXP c_arg__path, SEXP c_arg__format, SEXP c_arg__graph); SEXP savvy_RModel_reads__ffi(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEXP c_arg__graph); SEXP savvy_RModel_serialize__ffi(SEXP self__, SEXP c_arg__path); SEXP savvy_RModel_size__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_truncate_graph__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_update__ffi(SEXP self__, SEXP c_arg__update, SEXP c_arg__include_transient, SEXP c_arg__graph); +SEXP savvy_RModel_write__ffi(SEXP self__, SEXP c_arg__path, SEXP c_arg__format, SEXP c_arg__graph); SEXP savvy_RModel_writes__ffi(SEXP self__, SEXP c_arg__format, SEXP c_arg__graph); // methods and associated functions for RParameter diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index 64cc9bf8..032f1f2b 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -17,6 +17,7 @@ use representation::{ }; use savvy::{savvy, ListSexp, Sexp}; use std::collections::HashMap; +use std::fs::File; use std::path::Path; use std::sync::Mutex; use triplestore::triples_read::ExtendedRdfFormat; @@ -477,6 +478,37 @@ impl RModel { .map_err(maplib_error) } + /// Parse RDF triples from a file into this Model (mirrors PyModel::read, + /// py_maplib/src/py_model.rs:616-647 / read_mutex, py_maplib/src/ + /// mutexes.rs:527-563 -- CIM XML and HDT deliberately unsupported here, + /// same as reads()/resolve_format). + /// + /// @param path Path to the RDF file to read. + /// @param format One of "ntriples", "turtle", "xml" (rdf/xml). Guessed + /// from the file extension if NULL (.ttl/.nt/.xml or .rdf), matching + /// py_maplib's own default. + /// @param graph Optional named graph IRI to read into (default graph if NULL). + /// @export + fn read(&self, path: &str, format: Option<&str>, graph: Option<&str>) -> savvy::Result<()> { + let format = format.map(resolve_format).transpose()?; + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.lock(); + inner + .read_triples( + Path::new(path), + format, + None, + false, + None, + true, + &named_graph, + false, + DEFAULT_TRIPLES_BATCH_SIZE, + HashMap::new(), + ) + .map_err(maplib_error) + } + /// Serialize this Model's triples to a string. /// /// @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". @@ -498,6 +530,30 @@ impl RModel { .try_into() } + /// Serialize this Model's triples to a file (mirrors PyModel::write, + /// py_maplib/src/py_model.rs:737-752 / write_triples_mutex, py_maplib/src/ + /// mutexes.rs:613-650 -- CIM XML and HDT deliberately unsupported here, + /// same as writes()/resolve_normal_format; there's no dedicated + /// path-taking write method on `Model` itself, py_maplib's own mutex + /// opens the `File` at this same layer). + /// + /// @param path Path to write the RDF file to (overwritten if it exists). + /// @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". + /// @param graph Optional named graph IRI to write (default graph if NULL). + /// @export + fn write(&self, path: &str, format: Option<&str>, graph: Option<&str>) -> savvy::Result<()> { + let format = format + .map(resolve_normal_format) + .transpose()? + .unwrap_or(RdfFormat::NTriples); + let named_graph = parse_optional_named_graph(graph)?; + let mut file = File::create(path).map_err(crate::errors::runtime_error)?; + let mut inner = self.lock(); + inner + .write_triples(&mut file, &named_graph, format, None) + .map_err(maplib_error) + } + /// Build the default (non-FTS) indexes, matching py_maplib's create_index() /// with no options (create_index_mutex, py_maplib/src/mutexes.rs:402-413). /// diff --git a/maplibr/tests/testthat/test-model-read-write.R b/maplibr/tests/testthat/test-model-read-write.R new file mode 100644 index 00000000..f26e3979 --- /dev/null +++ b/maplibr/tests/testthat/test-model-read-write.R @@ -0,0 +1,61 @@ +# Tests for Model$read()/Model$write() (maplib-5us) -- file-based +# counterparts to $reads()/$writes(). + +test_that("write() then read() round-trips triples through a file, explicit format", { + m <- Model$new() + m$reads(' .', format = "ntriples") + + path <- tempfile(fileext = ".nt") + on.exit(unlink(path)) + m$write(path, format = "ntriples") + + m2 <- Model$new() + m2$read(path, format = "ntriples") + expect_equal(m2$size(), 1) + df <- m2$query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }") + expect_equal(df$s, "http://ex/a") +}) + +test_that("write() defaults to N-Triples format", { + m <- Model$new() + m$reads(' .', format = "ntriples") + + path <- tempfile(fileext = ".nt") + on.exit(unlink(path)) + m$write(path) + expect_match(readLines(path), "http://ex/a", fixed = TRUE, all = FALSE) +}) + +test_that("read() guesses the format from the file extension when format is NULL", { + m <- Model$new() + m$reads(' .', format = "ntriples") + path <- tempfile(fileext = ".nt") + on.exit(unlink(path)) + m$write(path, format = "ntriples") + + m2 <- Model$new() + m2$read(path) + expect_equal(m2$size(), 1) +}) + +test_that("read()/write()'s graph argument scopes to a single named graph", { + m <- Model$new() + m$reads(' .', format = "ntriples", graph = "http://ex/g1") + + path <- tempfile(fileext = ".nt") + on.exit(unlink(path)) + m$write(path, graph = "http://ex/g1") + + m2 <- Model$new() + m2$read(path, graph = "http://ex/g2") + expect_equal(m2$size(graph = "http://ex/g2"), 1) + expect_equal(m2$size(), 0) +}) + +test_that("read() raises a catchable error for a nonexistent file", { + m <- Model$new() + expect_error( + m$read(tempfile(fileext = ".nt")), + class = "maplibr_maplib_error" + ) +}) From 4514ba0ec5f2306a958e0917b698dd8d4dd21271 Mon Sep 17 00:00:00 2001 From: novica Date: Mon, 14 Sep 2026 20:51:48 +0200 Subject: [PATCH 38/46] fix(maplibr): fix Model read() aborting the R session on an unguessable format - an unrecognized extension hit the engine's panic fallback; this crate builds with panic = "abort", so it took down the whole R session - guess the format in the R wrapper itself, restricted to supported formats, raising a normal argument error otherwise Co-Authored-By: Claude Sonnet 5 --- maplibr/R/model.R | 4 +- maplibr/man/Model.Rd | 4 +- maplibr/src/rust/src/model.rs | 49 +++++++++++++++++-- .../tests/testthat/test-model-read-write.R | 13 +++++ 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/maplibr/R/model.R b/maplibr/R/model.R index fd9b85f2..15a6459f 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -53,7 +53,9 @@ Model <- R6::R6Class( #' @description Parse RDF triples from a file into this Model. #' @param path Path to the RDF file to read. #' @param format One of "ntriples", "turtle", "xml" (rdf/xml). Guessed - #' from the file extension if NULL (.ttl/.nt/.xml or .rdf). + #' from the file extension if NULL (.ttl -> turtle, .nt -> ntriples, + #' .xml/.rdf -> xml) -- any other or missing extension raises a normal + #' error, so pass `format` explicitly for those. #' @param graph Optional named graph IRI to read into (default graph if NULL). read = function(path, format = NULL, graph = NULL) { .rethrow(private$rmodel$read(path, format, graph)) diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index 12cb4588..70895c75 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -110,7 +110,9 @@ is for internal use only. \describe{ \item{\code{path}}{Path to the RDF file to read.} \item{\code{format}}{One of "ntriples", "turtle", "xml" (rdf/xml). Guessed -from the file extension if NULL (.ttl/.nt/.xml or .rdf).} +from the file extension if NULL (.ttl -> turtle, .nt -> ntriples, +.xml/.rdf -> xml) -- any other or missing extension raises a normal +error, so pass \code{format} explicitly for those.} \item{\code{graph}}{Optional named graph IRI to read into (default graph if NULL).} } \if{html}{\out{
}} diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index 032f1f2b..c964dbcf 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -41,6 +41,37 @@ fn resolve_format(format: &str) -> savvy::Result { resolve_normal_format(format).map(ExtendedRdfFormat::Normal) } +/// Guesses a file's RDF format from its extension, for `read()`'s +/// `format = NULL` case -- deliberately its own, narrower copy of +/// `Triplestore::read_triples_from_path`'s own extension-guessing +/// (lib/triplestore/src/triples_read.rs:62-82), NOT a passthrough of `None` +/// into that function. That function's guesser falls into a bare `todo!()` +/// for any extension it doesn't recognize (also true of JSON-LD/HDT, which +/// it *does* recognize but this package deliberately doesn't support yet, +/// see `resolve_format`) -- a `todo!()` panics, and this crate's release +/// profile sets `panic = "abort"` (src/rust/Cargo.toml), so reaching that +/// code path with an unsupported or missing extension would abort the whole +/// R session, not raise a catchable error. Guessing here instead means an +/// unrecognized extension hits this function's own `Err` arm, which +/// `read()` turns into a normal `maplibr_argument_error` well before ever +/// calling into `read_triples`. +fn guess_format_from_extension(path: &str) -> savvy::Result { + let ext = Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()); + match ext.as_deref() { + Some("ttl") => Ok(ExtendedRdfFormat::Normal(RdfFormat::Turtle)), + Some("nt") => Ok(ExtendedRdfFormat::Normal(RdfFormat::NTriples)), + Some("xml") | Some("rdf") => Ok(ExtendedRdfFormat::Normal(RdfFormat::RdfXml)), + _ => Err(argument_error(format!( + "Could not guess an RDF format from the extension of '{}' -- pass `format` explicitly \ + (one of \"ntriples\", \"turtle\", \"xml\")", + path + ))), + } +} + fn parse_optional_named_graph(graph: Option<&str>) -> savvy::Result { let nn = graph .map(|g| NamedNode::new(g).map_err(argument_error)) @@ -481,22 +512,30 @@ impl RModel { /// Parse RDF triples from a file into this Model (mirrors PyModel::read, /// py_maplib/src/py_model.rs:616-647 / read_mutex, py_maplib/src/ /// mutexes.rs:527-563 -- CIM XML and HDT deliberately unsupported here, - /// same as reads()/resolve_format). + /// same as reads()/resolve_format). Never passes `format = NULL` through + /// to the core engine's own extension-guessing (see + /// guess_format_from_extension's doc comment for why: its guesser's + /// unrecognized-extension fallback is a process-aborting panic, not a + /// catchable error). /// /// @param path Path to the RDF file to read. /// @param format One of "ntriples", "turtle", "xml" (rdf/xml). Guessed - /// from the file extension if NULL (.ttl/.nt/.xml or .rdf), matching - /// py_maplib's own default. + /// from the file extension if NULL (.ttl -> turtle, .nt -> ntriples, + /// .xml/.rdf -> xml) -- any other or missing extension is a normal + /// error, pass `format` explicitly instead. /// @param graph Optional named graph IRI to read into (default graph if NULL). /// @export fn read(&self, path: &str, format: Option<&str>, graph: Option<&str>) -> savvy::Result<()> { - let format = format.map(resolve_format).transpose()?; + let format = match format { + Some(f) => resolve_format(f)?, + None => guess_format_from_extension(path)?, + }; let named_graph = parse_optional_named_graph(graph)?; let mut inner = self.lock(); inner .read_triples( Path::new(path), - format, + Some(format), None, false, None, diff --git a/maplibr/tests/testthat/test-model-read-write.R b/maplibr/tests/testthat/test-model-read-write.R index f26e3979..2e76bedd 100644 --- a/maplibr/tests/testthat/test-model-read-write.R +++ b/maplibr/tests/testthat/test-model-read-write.R @@ -59,3 +59,16 @@ test_that("read() raises a catchable error for a nonexistent file", { class = "maplibr_maplib_error" ) }) + +test_that("read() raises a catchable error for an unsupported or missing extension, instead of crashing", { + # Regression case: read()'s format-guessing used to pass format = NULL + # straight through to the core engine's own extension guesser, whose + # unrecognized-extension fallback is a bare todo!() -- a panic, and with + # this crate's release profile set to panic = "abort", that would abort + # the whole R session rather than raise a catchable error. Guessing the + # format in the R wrapper itself (guess_format_from_extension) means an + # unrecognized extension is a normal error well before reaching that code. + m <- Model$new() + expect_error(m$read(tempfile()), class = "maplibr_argument_error") + expect_error(m$read(tempfile(fileext = ".csv")), class = "maplibr_argument_error") +}) From 08d6f8b9215a64ef2a0ec1f245de87a1873bad0d Mon Sep 17 00:00:00 2001 From: novica Date: Mon, 14 Sep 2026 21:01:40 +0200 Subject: [PATCH 39/46] feat(maplibr): add Model query(bindings=) to pre-bind SPARQL variables - new RGroundTerm value type restricted to IRI/Literal (blank-node binding has no way to be represented here) - a bound variable can only be referenced in the query body, not also SELECT-ed as output Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 2 + maplibr/NAMESPACE | 2 + maplibr/R/000-wrappers.R | 47 +++++++++++++++- maplibr/R/model.R | 18 ++++++- maplibr/R/terms.R | 14 +++++ maplibr/man/Model.Rd | 9 +++- maplibr/man/RGroundTerm.Rd | 25 +++++++++ maplibr/src/init.c | 18 +++++-- maplibr/src/rust/Cargo.toml | 1 + maplibr/src/rust/api.h | 6 ++- maplibr/src/rust/src/model.rs | 21 +++++++- maplibr/src/rust/src/terms.rs | 43 ++++++++++++++- .../testthat/test-model-query-bindings.R | 53 +++++++++++++++++++ 13 files changed, 248 insertions(+), 11 deletions(-) create mode 100644 maplibr/man/RGroundTerm.Rd create mode 100644 maplibr/tests/testthat/test-model-query-bindings.R diff --git a/Cargo.lock b/Cargo.lock index bdb78b76..989fd8db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2328,6 +2328,8 @@ dependencies = [ "serde", "serde_json", "sparesults", + "spargebra", + "templates", "triplestore", ] diff --git a/maplibr/NAMESPACE b/maplibr/NAMESPACE index f667de0e..8da92ca3 100644 --- a/maplibr/NAMESPACE +++ b/maplibr/NAMESPACE @@ -4,6 +4,7 @@ S3method("$<-",savvy_maplibr__sealed) S3method("[[<-",savvy_maplibr__sealed) S3method(print,"maplibr::RArgument__bundle") S3method(print,"maplibr::RConstantTerm__bundle") +S3method(print,"maplibr::RGroundTerm__bundle") S3method(print,"maplibr::RInstance__bundle") S3method(print,"maplibr::RModel__bundle") S3method(print,"maplibr::RParameter__bundle") @@ -19,6 +20,7 @@ export(Prefix) export(RArgument) export(RConstantTerm) export(RDFType) +export(RGroundTerm) export(RInstance) export(RModel) export(RParameter) diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index 2bd1102a..90b2eeac 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -208,6 +208,49 @@ class(`RConstantTerm`) <- c("maplibr::RConstantTerm__bundle", "savvy_maplibr__se cat('maplibr::RConstantTerm\n') } +### wrapper functions for RGroundTerm + + +`.savvy_wrap_RGroundTerm` <- function(ptr) { + e <- new.env(parent = emptyenv()) + e$.ptr <- ptr + + + class(e) <- c("maplibr::RGroundTerm", "RGroundTerm", "savvy_maplibr__sealed") + e +} + + +#' A ground SPARQL binding value: an IRI or a Literal (no blank nodes, +#' unlike `RConstantTerm` -- blank nodes can't be bound to a query variable, +#' so there's no variant for one to construct in the first place, unlike +#' py_maplib's equivalent, which has to reject a blank node at runtime since +#' it accepts a plain Python value that could be anything, see +#' `maybe_parse_bindings`, py_maplib/src/lib.rs:479-528). Built from an R +#' `IRI`/`Literal` (terms.R) by `.to_ground_term()`, and consumed by +#' `RModel::query`'s `bindings` argument. +#' +#' @export +`RGroundTerm` <- new.env(parent = emptyenv()) + +### associated functions for RGroundTerm + +`RGroundTerm`$`iri` <- function(`iri`) { + .savvy_wrap_RGroundTerm(.Call(savvy_RGroundTerm_iri__impl, `iri`)) +} + +`RGroundTerm`$`literal` <- function(`value`, `datatype_iri`, `language` = NULL) { + .savvy_wrap_RGroundTerm(.Call(savvy_RGroundTerm_literal__impl, `value`, `datatype_iri`, `language`)) +} + + +class(`RGroundTerm`) <- c("maplibr::RGroundTerm__bundle", "savvy_maplibr__sealed") + +#' @export +`print.maplibr::RGroundTerm__bundle` <- function(x, ...) { + cat('maplibr::RGroundTerm\n') +} + ### wrapper functions for RInstance `RInstance_iri` <- function(self) { @@ -317,8 +360,8 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } `RModel_query` <- function(self) { - function(`sparql`, `stream_ptr`, `include_transient`, `graph` = NULL) { - .Call(savvy_RModel_query__impl, `self`, `sparql`, `stream_ptr`, `include_transient`, `graph`) + function(`sparql`, `stream_ptr`, `include_transient`, `graph` = NULL, `bindings` = NULL) { + .Call(savvy_RModel_query__impl, `self`, `sparql`, `stream_ptr`, `include_transient`, `graph`, `bindings`) } } diff --git a/maplibr/R/model.R b/maplibr/R/model.R index 15a6459f..116343ea 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -153,6 +153,13 @@ Model <- R6::R6Class( #' across the whole store if NULL). #' @param include_transient Whether to include transient (e.g. inferred) #' triples in the query. + #' @param bindings Optional named list of `IRI`/`Literal` objects + #' (see `terms.R`) -- pre-binds each named SPARQL variable to a fixed + #' value before the query runs, e.g. `list(b = IRI("http://ex/a"))` + #' binds `?b`. A bound variable can only be used inside the query body + #' (e.g. a `FILTER`), not listed directly in `SELECT` -- binding a + #' variable that's also a `SELECT`-ed output column is a normal error + #' (`SELECT *` still works, since it doesn't name variables itself). #' @return A data.frame. A column whose values are always the same RDF #' node type (the common case) comes back as a plain vector of that #' type's underlying value (an IRI/blank node id/literal value as a @@ -167,9 +174,16 @@ Model <- R6::R6Class( #' from the underlying JSON side-channel (maplibr/src/rust/src/ #' arrow_bridge.rs) -- a full RDFType-based port of that accessor is #' left for later. - query = function(sparql, graph = NULL, include_transient = FALSE) { + query = function(sparql, graph = NULL, include_transient = FALSE, bindings = NULL) { stream <- nanoarrow::nanoarrow_allocate_array_stream() - rdf_node_types_json <- .rethrow(private$rmodel$query(sparql, stream, include_transient, graph)) + raw_bindings <- if (!is.null(bindings)) { + lapply(bindings, function(x) .savvy_extract_ptr(.to_ground_term(x), "maplibr::RGroundTerm")) + } else { + NULL + } + rdf_node_types_json <- .rethrow( + private$rmodel$query(sparql, stream, include_transient, graph, raw_bindings) + ) df <- as.data.frame(stream) df <- .collapse_multitype_columns(df) attr(df, "rdf_node_types") <- jsonlite::fromJSON(rdf_node_types_json, simplifyVector = FALSE) diff --git a/maplibr/R/terms.R b/maplibr/R/terms.R index 844cdebc..5bd899b8 100644 --- a/maplibr/R/terms.R +++ b/maplibr/R/terms.R @@ -215,3 +215,17 @@ S7::method(print, Literal) <- function(x, ...) { cat(format(x), "\n", sep = "") invisible(x) } + +# Converts an IRI/Literal into an RGroundTerm (maplibr/src/rust/src/terms.rs) +# for Model$query()'s bindings= argument. Mirrors templates.R's +# .as_raw_constant_term() -- no BlankNode/"none" cases here, since a query +# variable can only ever be bound to an IRI or a literal value. +.to_ground_term <- function(x) { + if (S7::S7_inherits(x, IRI)) { + RGroundTerm$iri(x@iri) + } else if (S7::S7_inherits(x, Literal)) { + RGroundTerm$literal(x@value, x@datatype@iri, x@language) + } else { + stop("bindings values must be IRI or Literal objects, got: ", class(x)[1], call. = FALSE) + } +} diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index 70895c75..6ee9bb70 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -274,7 +274,7 @@ different column types, which a single concatenated data.frame can't represent without collapsing to a common type regardless. \subsection{Usage}{ \if{html}{\out{
}} - \preformatted{Model$query(sparql, graph = NULL, include_transient = FALSE)} + \preformatted{Model$query(sparql, graph = NULL, include_transient = FALSE, bindings = NULL)} \if{html}{\out{
}} } \subsection{Arguments}{ @@ -285,6 +285,13 @@ represent without collapsing to a common type regardless. across the whole store if NULL).} \item{\code{include_transient}}{Whether to include transient (e.g. inferred) triples in the query.} + \item{\code{bindings}}{Optional named list of \code{IRI}/\code{Literal} objects +(see \code{terms.R}) -- pre-binds each named SPARQL variable to a fixed +value before the query runs, e.g. \code{list(b = IRI("http://ex/a"))} +binds \code{?b}. A bound variable can only be used inside the query body +(e.g. a \code{FILTER}), not listed directly in \code{SELECT} -- binding a +variable that's also a \code{SELECT}-ed output column is a normal error +(\verb{SELECT *} still works, since it doesn't name variables itself).} } \if{html}{\out{}} } diff --git a/maplibr/man/RGroundTerm.Rd b/maplibr/man/RGroundTerm.Rd new file mode 100644 index 00000000..f8a324ee --- /dev/null +++ b/maplibr/man/RGroundTerm.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/000-wrappers.R +\name{RGroundTerm} +\alias{RGroundTerm} +\title{A ground SPARQL binding value: an IRI or a Literal (no blank nodes, +unlike \code{RConstantTerm} -- blank nodes can't be bound to a query variable, +so there's no variant for one to construct in the first place, unlike +py_maplib's equivalent, which has to reject a blank node at runtime since +it accepts a plain Python value that could be anything, see +\code{maybe_parse_bindings}, py_maplib/src/lib.rs:479-528). Built from an R +\code{IRI}/\code{Literal} (terms.R) by \code{.to_ground_term()}, and consumed by +\code{RModel::query}'s \code{bindings} argument.} +\usage{ +RGroundTerm +} +\description{ +A ground SPARQL binding value: an IRI or a Literal (no blank nodes, +unlike \code{RConstantTerm} -- blank nodes can't be bound to a query variable, +so there's no variant for one to construct in the first place, unlike +py_maplib's equivalent, which has to reject a blank node at runtime since +it accepts a plain Python value that could be anything, see +\code{maybe_parse_bindings}, py_maplib/src/lib.rs:479-528). Built from an R +\code{IRI}/\code{Literal} (terms.R) by \code{.to_ground_term()}, and consumed by +\code{RModel::query}'s \code{bindings} argument. +} diff --git a/maplibr/src/init.c b/maplibr/src/init.c index 9dac67de..4f36d706 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -109,6 +109,16 @@ SEXP savvy_RConstantTerm_none__impl(void) { return handle_result(res); } +SEXP savvy_RGroundTerm_iri__impl(SEXP c_arg__iri) { + SEXP res = savvy_RGroundTerm_iri__ffi(c_arg__iri); + return handle_result(res); +} + +SEXP savvy_RGroundTerm_literal__impl(SEXP c_arg__value, SEXP c_arg__datatype_iri, SEXP c_arg__language) { + SEXP res = savvy_RGroundTerm_literal__ffi(c_arg__value, c_arg__datatype_iri, c_arg__language); + return handle_result(res); +} + SEXP savvy_RInstance_iri__impl(SEXP self__) { SEXP res = savvy_RInstance_iri__ffi(self__); return handle_result(res); @@ -184,8 +194,8 @@ SEXP savvy_RModel_new__impl(void) { return handle_result(res); } -SEXP savvy_RModel_query__impl(SEXP self__, SEXP c_arg__sparql, SEXP c_arg__stream_ptr, SEXP c_arg__include_transient, SEXP c_arg__graph) { - SEXP res = savvy_RModel_query__ffi(self__, c_arg__sparql, c_arg__stream_ptr, c_arg__include_transient, c_arg__graph); +SEXP savvy_RModel_query__impl(SEXP self__, SEXP c_arg__sparql, SEXP c_arg__stream_ptr, SEXP c_arg__include_transient, SEXP c_arg__graph, SEXP c_arg__bindings) { + SEXP res = savvy_RModel_query__ffi(self__, c_arg__sparql, c_arg__stream_ptr, c_arg__include_transient, c_arg__graph, c_arg__bindings); return handle_result(res); } @@ -270,6 +280,8 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_RConstantTerm_iri__impl", (DL_FUNC) &savvy_RConstantTerm_iri__impl, 1}, {"savvy_RConstantTerm_literal__impl", (DL_FUNC) &savvy_RConstantTerm_literal__impl, 3}, {"savvy_RConstantTerm_none__impl", (DL_FUNC) &savvy_RConstantTerm_none__impl, 0}, + {"savvy_RGroundTerm_iri__impl", (DL_FUNC) &savvy_RGroundTerm_iri__impl, 1}, + {"savvy_RGroundTerm_literal__impl", (DL_FUNC) &savvy_RGroundTerm_literal__impl, 3}, {"savvy_RInstance_iri__impl", (DL_FUNC) &savvy_RInstance_iri__impl, 1}, {"savvy_RInstance_new__impl", (DL_FUNC) &savvy_RInstance_new__impl, 3}, {"savvy_RModel_add_graph__impl", (DL_FUNC) &savvy_RModel_add_graph__impl, 4}, @@ -285,7 +297,7 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_RModel_map__impl", (DL_FUNC) &savvy_RModel_map__impl, 5}, {"savvy_RModel_map_no_data__impl", (DL_FUNC) &savvy_RModel_map_no_data__impl, 4}, {"savvy_RModel_new__impl", (DL_FUNC) &savvy_RModel_new__impl, 0}, - {"savvy_RModel_query__impl", (DL_FUNC) &savvy_RModel_query__impl, 5}, + {"savvy_RModel_query__impl", (DL_FUNC) &savvy_RModel_query__impl, 6}, {"savvy_RModel_read__impl", (DL_FUNC) &savvy_RModel_read__impl, 4}, {"savvy_RModel_reads__impl", (DL_FUNC) &savvy_RModel_reads__impl, 4}, {"savvy_RModel_serialize__impl", (DL_FUNC) &savvy_RModel_serialize__impl, 2}, diff --git a/maplibr/src/rust/Cargo.toml b/maplibr/src/rust/Cargo.toml index 7592fbe7..6a828940 100644 --- a/maplibr/src/rust/Cargo.toml +++ b/maplibr/src/rust/Cargo.toml @@ -9,6 +9,7 @@ crate-type = ["lib", "staticlib"] [dependencies] maplib = { path = "../../../lib/maplib", features = ["pyo3"] } representation = { path = "../../../lib/representation" } +spargebra = { path = "../../../lib/spargebra" } templates = { path = "../../../lib/templates" } triplestore = { path = "../../../lib/triplestore" } oxrdf = { workspace = true } diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index f4b28432..02750ecd 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -17,6 +17,10 @@ SEXP savvy_RConstantTerm_iri__ffi(SEXP c_arg__iri); SEXP savvy_RConstantTerm_literal__ffi(SEXP c_arg__value, SEXP c_arg__datatype_iri, SEXP c_arg__language); SEXP savvy_RConstantTerm_none__ffi(void); +// methods and associated functions for RGroundTerm +SEXP savvy_RGroundTerm_iri__ffi(SEXP c_arg__iri); +SEXP savvy_RGroundTerm_literal__ffi(SEXP c_arg__value, SEXP c_arg__datatype_iri, SEXP c_arg__language); + // methods and associated functions for RInstance SEXP savvy_RInstance_iri__ffi(SEXP self__); SEXP savvy_RInstance_new__ffi(SEXP c_arg__template_iri, SEXP c_arg__arguments, SEXP c_arg__list_expander); @@ -35,7 +39,7 @@ SEXP savvy_RModel_insert__ffi(SEXP self__, SEXP c_arg__query, SEXP c_arg__includ SEXP savvy_RModel_map__ffi(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__stream_ptr, SEXP c_arg__graph, SEXP c_arg__validate_iris); SEXP savvy_RModel_map_no_data__ffi(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__graph, SEXP c_arg__validate_iris); SEXP savvy_RModel_new__ffi(void); -SEXP savvy_RModel_query__ffi(SEXP self__, SEXP c_arg__sparql, SEXP c_arg__stream_ptr, SEXP c_arg__include_transient, SEXP c_arg__graph); +SEXP savvy_RModel_query__ffi(SEXP self__, SEXP c_arg__sparql, SEXP c_arg__stream_ptr, SEXP c_arg__include_transient, SEXP c_arg__graph, SEXP c_arg__bindings); SEXP savvy_RModel_read__ffi(SEXP self__, SEXP c_arg__path, SEXP c_arg__format, SEXP c_arg__graph); SEXP savvy_RModel_reads__ffi(SEXP self__, SEXP c_arg__s, SEXP c_arg__format, SEXP c_arg__graph); SEXP savvy_RModel_serialize__ffi(SEXP self__, SEXP c_arg__path); diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index c964dbcf..3b05fb6a 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -1,6 +1,7 @@ use crate::errors::argument_error; use crate::errors::maplib_error; use crate::templates::RTemplate; +use crate::terms::RGroundTerm; use maplib::model::MapOptions; use oxrdf::NamedNode; use oxrdfio::RdfFormat; @@ -343,6 +344,13 @@ impl RModel { /// triples in the query. Defaults to FALSE. /// @param graph Optional named graph IRI to restrict the query to (searches /// across the whole store if NULL, matching py_maplib's default). + /// @param bindings Optional named list of `RGroundTerm` (see terms.R's + /// `.to_ground_term()`) -- pre-binds each named SPARQL variable to a + /// fixed IRI/Literal value before the query runs, mirroring py_maplib's + /// `query(bindings=)`. A bound variable can't also be a `SELECT`-ed + /// output column (`Triplestore::query`'s underlying `maybe_replace_bindings` + /// rejects that combination with a normal error, not a panic -- + /// `SELECT *` is fine, since it names no variables itself). /// @returns The `rdf_node_types` side-channel, as a JSON string. /// @export fn query( @@ -351,8 +359,19 @@ impl RModel { stream_ptr: savvy::Sexp, include_transient: bool, graph: Option<&str>, + bindings: Option, ) -> savvy::Result { let named_graph = parse_query_graph(graph)?; + let bindings = bindings + .map(|b| -> savvy::Result> { + let mut map = HashMap::new(); + for (name, value) in b.iter() { + let term = <&RGroundTerm>::try_from(value)?; + map.insert(name.to_string(), term.inner.clone()); + } + Ok(map) + }) + .transpose()?; let mut inner = self.lock(); let global_cats = inner.triplestore.global_cats.clone(); let result = inner @@ -364,7 +383,7 @@ impl RModel { include_transient, None, false, - None, + bindings.as_ref(), ) .map_err(maplib_error)?; match result.kind { diff --git a/maplibr/src/rust/src/terms.rs b/maplibr/src/rust/src/terms.rs index d800dcae..78f19080 100644 --- a/maplibr/src/rust/src/terms.rs +++ b/maplibr/src/rust/src/terms.rs @@ -1,5 +1,6 @@ -use oxrdf::{BlankNode, NamedNode, Variable}; +use oxrdf::{BlankNode, Literal, NamedNode, Variable}; use savvy::savvy; +use spargebra::term::GroundTerm; /// Validate an IRI's syntax (mirrors oxrdf::NamedNode::new, the same check /// py_maplib's PyIRI::new uses, lib/representation/src/python.rs:279-282). @@ -36,3 +37,43 @@ fn validate_blank_node_id(id: &str) -> savvy::Result<()> { BlankNode::new(id).map_err(crate::errors::argument_error)?; Ok(()) } + +/// A ground SPARQL binding value: an IRI or a Literal (no blank nodes, +/// unlike `RConstantTerm` -- blank nodes can't be bound to a query variable, +/// so there's no variant for one to construct in the first place, unlike +/// py_maplib's equivalent, which has to reject a blank node at runtime since +/// it accepts a plain Python value that could be anything, see +/// `maybe_parse_bindings`, py_maplib/src/lib.rs:479-528). Built from an R +/// `IRI`/`Literal` (terms.R) by `.to_ground_term()`, and consumed by +/// `RModel::query`'s `bindings` argument. +/// +/// @export +#[savvy] +pub struct RGroundTerm { + pub inner: GroundTerm, +} + +#[savvy] +impl RGroundTerm { + fn iri(iri: &str) -> savvy::Result { + let nn = NamedNode::new(iri).map_err(crate::errors::argument_error)?; + Ok(RGroundTerm { + inner: GroundTerm::NamedNode(nn), + }) + } + + /// Mirrors `RConstantTerm::literal` (templates.rs) exactly: a language + /// tag takes precedence over -- rather than being combined with -- + /// `datatype_iri`, matching py_maplib's `PyLiteral::new`. + fn literal(value: &str, datatype_iri: &str, language: Option<&str>) -> savvy::Result { + let literal = if let Some(language) = language { + Literal::new_language_tagged_literal_unchecked(value, language) + } else { + let dt = NamedNode::new(datatype_iri).map_err(crate::errors::argument_error)?; + Literal::new_typed_literal(value, dt) + }; + Ok(RGroundTerm { + inner: GroundTerm::Literal(literal), + }) + } +} diff --git a/maplibr/tests/testthat/test-model-query-bindings.R b/maplibr/tests/testthat/test-model-query-bindings.R new file mode 100644 index 00000000..1f1fe174 --- /dev/null +++ b/maplibr/tests/testthat/test-model-query-bindings.R @@ -0,0 +1,53 @@ +# Tests for Model$query(bindings=) (maplib-aae) -- pre-binding SPARQL +# variables to R values before a query runs. Usage pattern (SELECT * plus a +# FILTER comparing against the bound variable, rather than SELECT-ing the +# bound variable directly) mirrors py_maplib's own test_replace_bindings.py. + +test_that("bindings pre-binds a variable to a Literal, usable in a FILTER", { + m <- Model$new() + df <- m$query( + 'SELECT * WHERE { VALUES ?a { "a" "b" } FILTER(?a = ?b) }', + bindings = list(b = Literal("b")) + ) + expect_equal(nrow(df), 1) + expect_equal(df$a, "b") +}) + +test_that("bindings pre-binds a variable to an IRI, usable in a FILTER", { + m <- Model$new() + df <- m$query( + "SELECT * WHERE { VALUES ?a { \"a\" } FILTER(?a = ?b) }", + bindings = list(b = IRI("urn:abc")) + ) + expect_equal(nrow(df), 1) + expect_equal(df$a, "urn:abc") +}) + +test_that("a query with no bindings behaves exactly as before", { + m <- Model$new() + df <- m$query('SELECT * WHERE { VALUES ?a { "a" "b" } }') + expect_equal(nrow(df), 2) +}) + +test_that("bindings values must be IRI or Literal objects", { + m <- Model$new() + expect_error( + m$query('SELECT * WHERE { VALUES ?a { "a" } }', bindings = list(a = "not-a-term-object")), + "IRI or Literal" + ) +}) + +test_that("binding a variable that's also SELECT-ed raises a catchable error, not a crash", { + # Core-engine restriction (Triplestore::query's underlying + # maybe_replace_bindings/replace_bindings_graph_pattern, lib/query_processing/ + # src/bindings.rs's Project match arm): a bound variable can't be listed + # directly in SELECT, only used inside the query body (e.g. a FILTER). + m <- Model$new() + expect_error( + m$query( + 'SELECT ?b WHERE { VALUES ?a { "a" } FILTER(?a = ?b) }', + bindings = list(b = Literal("a")) + ), + class = "maplibr_maplib_error" + ) +}) From 7f23bfbedf19dc5c4dbb861fc5b58ee7abe1f6f3 Mon Sep 17 00:00:00 2001 From: novica Date: Mon, 14 Sep 2026 21:09:42 +0200 Subject: [PATCH 40/46] feat(maplibr): add direct-to-triples mapping entry points - map_json()/map_json_string() and map_xml()/map_xml_string() apply the Facade-X convention - map_df() maps a data.frame's columns to triples; map_triples() maps subject/predicate/object columns directly - all mirror equivalent methods in the existing Python binding Co-Authored-By: Claude Sonnet 5 --- maplibr/R/000-wrappers.R | 42 ++++ maplibr/R/model.R | 93 ++++++++ maplibr/man/Model.Rd | 179 +++++++++++++++ maplibr/src/init.c | 36 ++++ maplibr/src/rust/api.h | 6 + maplibr/src/rust/src/model.rs | 203 ++++++++++++++++++ .../test-model-map-json-xml-df-triples.R | 114 ++++++++++ 7 files changed, 673 insertions(+) create mode 100644 maplibr/tests/testthat/test-model-map-json-xml-df-triples.R diff --git a/maplibr/R/000-wrappers.R b/maplibr/R/000-wrappers.R index 90b2eeac..5ac3ac01 100644 --- a/maplibr/R/000-wrappers.R +++ b/maplibr/R/000-wrappers.R @@ -353,12 +353,48 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") } } +`RModel_map_df` <- function(self) { + function(`stream_ptr`, `graph` = NULL, `uuid_namespace` = NULL) { + invisible(.Call(savvy_RModel_map_df__impl, `self`, `stream_ptr`, `graph`, `uuid_namespace`)) + } +} + +`RModel_map_json` <- function(self) { + function(`path`, `transient`, `graph` = NULL, `uuid_namespace` = NULL) { + invisible(.Call(savvy_RModel_map_json__impl, `self`, `path`, `transient`, `graph`, `uuid_namespace`)) + } +} + +`RModel_map_json_string` <- function(self) { + function(`json`, `transient`, `graph` = NULL, `uuid_namespace` = NULL) { + invisible(.Call(savvy_RModel_map_json_string__impl, `self`, `json`, `transient`, `graph`, `uuid_namespace`)) + } +} + `RModel_map_no_data` <- function(self) { function(`template_iri`, `graph` = NULL, `validate_iris` = NULL) { invisible(.Call(savvy_RModel_map_no_data__impl, `self`, `template_iri`, `graph`, `validate_iris`)) } } +`RModel_map_triples` <- function(self) { + function(`stream_ptr`, `predicate` = NULL, `graph` = NULL, `validate_iris` = NULL) { + invisible(.Call(savvy_RModel_map_triples__impl, `self`, `stream_ptr`, `predicate`, `graph`, `validate_iris`)) + } +} + +`RModel_map_xml` <- function(self) { + function(`path`, `transient`, `graph` = NULL, `uuid_namespace` = NULL) { + invisible(.Call(savvy_RModel_map_xml__impl, `self`, `path`, `transient`, `graph`, `uuid_namespace`)) + } +} + +`RModel_map_xml_string` <- function(self) { + function(`xml`, `transient`, `graph` = NULL, `uuid_namespace` = NULL) { + invisible(.Call(savvy_RModel_map_xml_string__impl, `self`, `xml`, `transient`, `graph`, `uuid_namespace`)) + } +} + `RModel_query` <- function(self) { function(`sparql`, `stream_ptr`, `include_transient`, `graph` = NULL, `bindings` = NULL) { .Call(savvy_RModel_query__impl, `self`, `sparql`, `stream_ptr`, `include_transient`, `graph`, `bindings`) @@ -426,7 +462,13 @@ class(`RInstance`) <- c("maplibr::RInstance__bundle", "savvy_maplibr__sealed") e$`infer_rdfs` <- `RModel_infer_rdfs`(ptr) e$`insert` <- `RModel_insert`(ptr) e$`map` <- `RModel_map`(ptr) + e$`map_df` <- `RModel_map_df`(ptr) + e$`map_json` <- `RModel_map_json`(ptr) + e$`map_json_string` <- `RModel_map_json_string`(ptr) e$`map_no_data` <- `RModel_map_no_data`(ptr) + e$`map_triples` <- `RModel_map_triples`(ptr) + e$`map_xml` <- `RModel_map_xml`(ptr) + e$`map_xml_string` <- `RModel_map_xml_string`(ptr) e$`query` <- `RModel_query`(ptr) e$`read` <- `RModel_read`(ptr) e$`reads` <- `RModel_reads`(ptr) diff --git a/maplibr/R/model.R b/maplibr/R/model.R index 116343ea..41de35db 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -266,6 +266,99 @@ Model <- R6::R6Class( invisible(self) }, + #' @description Map a JSON file straight to triples via a fixed + #' convention (Facade-X; no template involved). + #' @param path Path to the JSON file to map. + #' @param graph Optional named graph IRI to add the resulting triples to + #' (default graph if NULL). + #' @param transient Whether the resulting triples should be transient + #' rather than permanent. + #' @param uuid_namespace Optional namespace string for the UUIDv5 blank + #' node IRIs minted for JSON objects/arrays. Defaults to the path. + map_json = function(path, graph = NULL, transient = FALSE, uuid_namespace = NULL) { + .rethrow(private$rmodel$map_json(path, transient, graph, uuid_namespace)) + invisible(self) + }, + + #' @description Map a JSON string straight to triples via a fixed + #' convention. See `$map_json()` for the file-based equivalent. + #' @param json The JSON document, as a string. + #' @param graph Optional named graph IRI to add the resulting triples to + #' (default graph if NULL). + #' @param transient Whether the resulting triples should be transient + #' rather than permanent. + #' @param uuid_namespace Optional namespace string for the UUIDv5 blank + #' node IRIs minted for JSON objects/arrays. Defaults to a random UUID + #' if not given. + map_json_string = function(json, graph = NULL, transient = FALSE, uuid_namespace = NULL) { + .rethrow(private$rmodel$map_json_string(json, transient, graph, uuid_namespace)) + invisible(self) + }, + + #' @description Map an XML file straight to triples via a fixed + #' convention. + #' @param path Path to the XML file to map. + #' @param graph Optional named graph IRI to add the resulting triples to + #' (default graph if NULL). + #' @param transient Whether the resulting triples should be transient + #' rather than permanent. + #' @param uuid_namespace Optional namespace string for the UUIDv5 blank + #' node IRIs minted for XML elements. Defaults to the path. + map_xml = function(path, graph = NULL, transient = FALSE, uuid_namespace = NULL) { + .rethrow(private$rmodel$map_xml(path, transient, graph, uuid_namespace)) + invisible(self) + }, + + #' @description Map an XML string straight to triples via a fixed + #' convention. See `$map_xml()` for the file-based equivalent. + #' @param xml The XML document, as a string. + #' @param graph Optional named graph IRI to add the resulting triples to + #' (default graph if NULL). + #' @param transient Whether the resulting triples should be transient + #' rather than permanent. + #' @param uuid_namespace Optional namespace string for the UUIDv5 blank + #' node IRIs minted for XML elements. Defaults to a random UUID if not + #' given. + map_xml_string = function(xml, graph = NULL, transient = FALSE, uuid_namespace = NULL) { + .rethrow(private$rmodel$map_xml_string(xml, transient, graph, uuid_namespace)) + invisible(self) + }, + + #' @description Map a data.frame's columns directly to + #' subject/predicate/object triples -- one column per predicate, a fresh + #' IRI subject per row, no OTTR template involved. + #' @param data A data.frame, one row per subject; every column becomes a + #' predicate (named by the column name) with that row's value as the + #' object. A zero-row data.frame is a silent no-op. + #' @param graph Optional named graph IRI to add the resulting triples to + #' (default graph if NULL). + #' @param uuid_namespace Optional namespace string for the UUIDv5 subject + #' IRIs minted per row. Defaults to a random UUID if not given. + map_df = function(data, graph = NULL, uuid_namespace = NULL) { + stream <- nanoarrow::as_nanoarrow_array_stream(data) + .rethrow(private$rmodel$map_df(stream, graph, uuid_namespace)) + invisible(self) + }, + + #' @description Map a data.frame's columns directly to + #' subject/predicate/object triples using the built-in `ottr:Triple` + #' template, optionally fixing every row's predicate to a single + #' constant IRI instead of reading it from a `predicate` column. + #' @param data A data.frame with subject/predicate/object columns (or + #' just subject/object if `predicate` is given). A zero-row data.frame + #' is a silent no-op. + #' @param predicate Optional constant predicate IRI to use for every + #' row, instead of a `predicate` column in `data`. + #' @param graph Optional named graph IRI to add the resulting triples to + #' (default graph if NULL). + #' @param validate_iris Whether to validate that IRI-typed columns + #' contain valid IRIs. Defaults to TRUE. + map_triples = function(data, predicate = NULL, graph = NULL, validate_iris = NULL) { + stream <- nanoarrow::as_nanoarrow_array_stream(data) + .rethrow(private$rmodel$map_triples(stream, predicate, graph, validate_iris)) + invisible(self) + }, + #' @description Run RDFS inference over a graph in place. #' @param graph Optional named graph IRI to infer over (default graph if NULL). #' @return The number of new triples inferred (a triple count, not a rule diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index 6ee9bb70..c1ddffa9 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -28,6 +28,12 @@ returns \code{self} invisibly). \item \href{#method-Model-insert}{\code{Model$insert()}} \item \href{#method-Model-add_template}{\code{Model$add_template()}} \item \href{#method-Model-map}{\code{Model$map()}} + \item \href{#method-Model-map_json}{\code{Model$map_json()}} + \item \href{#method-Model-map_json_string}{\code{Model$map_json_string()}} + \item \href{#method-Model-map_xml}{\code{Model$map_xml()}} + \item \href{#method-Model-map_xml_string}{\code{Model$map_xml_string()}} + \item \href{#method-Model-map_df}{\code{Model$map_df()}} + \item \href{#method-Model-map_triples}{\code{Model$map_triples()}} \item \href{#method-Model-infer_rdfs}{\code{Model$infer_rdfs()}} \item \href{#method-Model-compact}{\code{Model$compact()}} \item \href{#method-Model-serialize}{\code{Model$serialize()}} @@ -436,6 +442,179 @@ valid IRIs. Defaults to TRUE.} } } +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-map_json}{}}} +\subsection{\code{Model$map_json()}}{ + Map a JSON file straight to triples via a fixed +convention (Facade-X; no template involved). + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$map_json(path, graph = NULL, transient = FALSE, uuid_namespace = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{path}}{Path to the JSON file to map.} + \item{\code{graph}}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + \item{\code{transient}}{Whether the resulting triples should be transient +rather than permanent.} + \item{\code{uuid_namespace}}{Optional namespace string for the UUIDv5 blank +node IRIs minted for JSON objects/arrays. Defaults to the path.} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-map_json_string}{}}} +\subsection{\code{Model$map_json_string()}}{ + Map a JSON string straight to triples via a fixed +convention. See \verb{$map_json()} for the file-based equivalent. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$map_json_string( + json, + graph = NULL, + transient = FALSE, + uuid_namespace = NULL +)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{json}}{The JSON document, as a string.} + \item{\code{graph}}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + \item{\code{transient}}{Whether the resulting triples should be transient +rather than permanent.} + \item{\code{uuid_namespace}}{Optional namespace string for the UUIDv5 blank +node IRIs minted for JSON objects/arrays. Defaults to a random UUID +if not given.} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-map_xml}{}}} +\subsection{\code{Model$map_xml()}}{ + Map an XML file straight to triples via a fixed +convention. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$map_xml(path, graph = NULL, transient = FALSE, uuid_namespace = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{path}}{Path to the XML file to map.} + \item{\code{graph}}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + \item{\code{transient}}{Whether the resulting triples should be transient +rather than permanent.} + \item{\code{uuid_namespace}}{Optional namespace string for the UUIDv5 blank +node IRIs minted for XML elements. Defaults to the path.} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-map_xml_string}{}}} +\subsection{\code{Model$map_xml_string()}}{ + Map an XML string straight to triples via a fixed +convention. See \verb{$map_xml()} for the file-based equivalent. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$map_xml_string( + xml, + graph = NULL, + transient = FALSE, + uuid_namespace = NULL +)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{xml}}{The XML document, as a string.} + \item{\code{graph}}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + \item{\code{transient}}{Whether the resulting triples should be transient +rather than permanent.} + \item{\code{uuid_namespace}}{Optional namespace string for the UUIDv5 blank +node IRIs minted for XML elements. Defaults to a random UUID if not +given.} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-map_df}{}}} +\subsection{\code{Model$map_df()}}{ + Map a data.frame's columns directly to +subject/predicate/object triples -- one column per predicate, a fresh +IRI subject per row, no OTTR template involved. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$map_df(data, graph = NULL, uuid_namespace = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{data}}{A data.frame, one row per subject; every column becomes a +predicate (named by the column name) with that row's value as the +object. A zero-row data.frame is a silent no-op.} + \item{\code{graph}}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + \item{\code{uuid_namespace}}{Optional namespace string for the UUIDv5 subject +IRIs minted per row. Defaults to a random UUID if not given.} + } + \if{html}{\out{
}} + } +} + +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Model-map_triples}{}}} +\subsection{\code{Model$map_triples()}}{ + Map a data.frame's columns directly to +subject/predicate/object triples using the built-in \code{ottr:Triple} +template, optionally fixing every row's predicate to a single +constant IRI instead of reading it from a \code{predicate} column. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Model$map_triples(data, predicate = NULL, graph = NULL, validate_iris = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{data}}{A data.frame with subject/predicate/object columns (or +just subject/object if \code{predicate} is given). A zero-row data.frame +is a silent no-op.} + \item{\code{predicate}}{Optional constant predicate IRI to use for every +row, instead of a \code{predicate} column in \code{data}.} + \item{\code{graph}}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + \item{\code{validate_iris}}{Whether to validate that IRI-typed columns +contain valid IRIs. Defaults to TRUE.} + } + \if{html}{\out{
}} + } +} + \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Model-infer_rdfs}{}}} diff --git a/maplibr/src/init.c b/maplibr/src/init.c index 4f36d706..8fa53843 100644 --- a/maplibr/src/init.c +++ b/maplibr/src/init.c @@ -184,11 +184,41 @@ SEXP savvy_RModel_map__impl(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__s return handle_result(res); } +SEXP savvy_RModel_map_df__impl(SEXP self__, SEXP c_arg__stream_ptr, SEXP c_arg__graph, SEXP c_arg__uuid_namespace) { + SEXP res = savvy_RModel_map_df__ffi(self__, c_arg__stream_ptr, c_arg__graph, c_arg__uuid_namespace); + return handle_result(res); +} + +SEXP savvy_RModel_map_json__impl(SEXP self__, SEXP c_arg__path, SEXP c_arg__transient, SEXP c_arg__graph, SEXP c_arg__uuid_namespace) { + SEXP res = savvy_RModel_map_json__ffi(self__, c_arg__path, c_arg__transient, c_arg__graph, c_arg__uuid_namespace); + return handle_result(res); +} + +SEXP savvy_RModel_map_json_string__impl(SEXP self__, SEXP c_arg__json, SEXP c_arg__transient, SEXP c_arg__graph, SEXP c_arg__uuid_namespace) { + SEXP res = savvy_RModel_map_json_string__ffi(self__, c_arg__json, c_arg__transient, c_arg__graph, c_arg__uuid_namespace); + return handle_result(res); +} + SEXP savvy_RModel_map_no_data__impl(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__graph, SEXP c_arg__validate_iris) { SEXP res = savvy_RModel_map_no_data__ffi(self__, c_arg__template_iri, c_arg__graph, c_arg__validate_iris); return handle_result(res); } +SEXP savvy_RModel_map_triples__impl(SEXP self__, SEXP c_arg__stream_ptr, SEXP c_arg__predicate, SEXP c_arg__graph, SEXP c_arg__validate_iris) { + SEXP res = savvy_RModel_map_triples__ffi(self__, c_arg__stream_ptr, c_arg__predicate, c_arg__graph, c_arg__validate_iris); + return handle_result(res); +} + +SEXP savvy_RModel_map_xml__impl(SEXP self__, SEXP c_arg__path, SEXP c_arg__transient, SEXP c_arg__graph, SEXP c_arg__uuid_namespace) { + SEXP res = savvy_RModel_map_xml__ffi(self__, c_arg__path, c_arg__transient, c_arg__graph, c_arg__uuid_namespace); + return handle_result(res); +} + +SEXP savvy_RModel_map_xml_string__impl(SEXP self__, SEXP c_arg__xml, SEXP c_arg__transient, SEXP c_arg__graph, SEXP c_arg__uuid_namespace) { + SEXP res = savvy_RModel_map_xml_string__ffi(self__, c_arg__xml, c_arg__transient, c_arg__graph, c_arg__uuid_namespace); + return handle_result(res); +} + SEXP savvy_RModel_new__impl(void) { SEXP res = savvy_RModel_new__ffi(); return handle_result(res); @@ -295,7 +325,13 @@ static const R_CallMethodDef CallEntries[] = { {"savvy_RModel_infer_rdfs__impl", (DL_FUNC) &savvy_RModel_infer_rdfs__impl, 2}, {"savvy_RModel_insert__impl", (DL_FUNC) &savvy_RModel_insert__impl, 6}, {"savvy_RModel_map__impl", (DL_FUNC) &savvy_RModel_map__impl, 5}, + {"savvy_RModel_map_df__impl", (DL_FUNC) &savvy_RModel_map_df__impl, 4}, + {"savvy_RModel_map_json__impl", (DL_FUNC) &savvy_RModel_map_json__impl, 5}, + {"savvy_RModel_map_json_string__impl", (DL_FUNC) &savvy_RModel_map_json_string__impl, 5}, {"savvy_RModel_map_no_data__impl", (DL_FUNC) &savvy_RModel_map_no_data__impl, 4}, + {"savvy_RModel_map_triples__impl", (DL_FUNC) &savvy_RModel_map_triples__impl, 5}, + {"savvy_RModel_map_xml__impl", (DL_FUNC) &savvy_RModel_map_xml__impl, 5}, + {"savvy_RModel_map_xml_string__impl", (DL_FUNC) &savvy_RModel_map_xml_string__impl, 5}, {"savvy_RModel_new__impl", (DL_FUNC) &savvy_RModel_new__impl, 0}, {"savvy_RModel_query__impl", (DL_FUNC) &savvy_RModel_query__impl, 6}, {"savvy_RModel_read__impl", (DL_FUNC) &savvy_RModel_read__impl, 4}, diff --git a/maplibr/src/rust/api.h b/maplibr/src/rust/api.h index 02750ecd..d78115a5 100644 --- a/maplibr/src/rust/api.h +++ b/maplibr/src/rust/api.h @@ -37,7 +37,13 @@ SEXP savvy_RModel_detach_graph__ffi(SEXP self__, SEXP c_arg__preserve_name, SEXP SEXP savvy_RModel_infer_rdfs__ffi(SEXP self__, SEXP c_arg__graph); SEXP savvy_RModel_insert__ffi(SEXP self__, SEXP c_arg__query, SEXP c_arg__include_transient, SEXP c_arg__transient, SEXP c_arg__source_graph, SEXP c_arg__target_graph); SEXP savvy_RModel_map__ffi(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__stream_ptr, SEXP c_arg__graph, SEXP c_arg__validate_iris); +SEXP savvy_RModel_map_df__ffi(SEXP self__, SEXP c_arg__stream_ptr, SEXP c_arg__graph, SEXP c_arg__uuid_namespace); +SEXP savvy_RModel_map_json__ffi(SEXP self__, SEXP c_arg__path, SEXP c_arg__transient, SEXP c_arg__graph, SEXP c_arg__uuid_namespace); +SEXP savvy_RModel_map_json_string__ffi(SEXP self__, SEXP c_arg__json, SEXP c_arg__transient, SEXP c_arg__graph, SEXP c_arg__uuid_namespace); SEXP savvy_RModel_map_no_data__ffi(SEXP self__, SEXP c_arg__template_iri, SEXP c_arg__graph, SEXP c_arg__validate_iris); +SEXP savvy_RModel_map_triples__ffi(SEXP self__, SEXP c_arg__stream_ptr, SEXP c_arg__predicate, SEXP c_arg__graph, SEXP c_arg__validate_iris); +SEXP savvy_RModel_map_xml__ffi(SEXP self__, SEXP c_arg__path, SEXP c_arg__transient, SEXP c_arg__graph, SEXP c_arg__uuid_namespace); +SEXP savvy_RModel_map_xml_string__ffi(SEXP self__, SEXP c_arg__xml, SEXP c_arg__transient, SEXP c_arg__graph, SEXP c_arg__uuid_namespace); SEXP savvy_RModel_new__ffi(void); SEXP savvy_RModel_query__ffi(SEXP self__, SEXP c_arg__sparql, SEXP c_arg__stream_ptr, SEXP c_arg__include_transient, SEXP c_arg__graph, SEXP c_arg__bindings); SEXP savvy_RModel_read__ffi(SEXP self__, SEXP c_arg__path, SEXP c_arg__format, SEXP c_arg__graph); diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index 3b05fb6a..c59ce622 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -831,6 +831,209 @@ impl RModel { .map_err(maplib_error) } + /// Map a JSON file straight to triples via a fixed convention (Facade-X; + /// no template involved), mirroring PyModel::map_json's path branch + /// (py_maplib/src/py_model.rs:153-167 / map_json_mutex, py_maplib/src/ + /// mutexes.rs:181-228 -- split into a path- and a string-taking method + /// here rather than one auto-detecting argument, matching this + /// package's own read()/reads() split). + /// + /// @param path Path to the JSON file to map. + /// @param transient Whether the resulting triples should be transient + /// rather than permanent. Defaults to FALSE. + /// @param graph Optional named graph IRI to add the resulting triples to + /// (default graph if NULL). + /// @param uuid_namespace Optional namespace string for the UUIDv5 blank + /// node IRIs minted for JSON objects/arrays. Defaults to the path. + /// @export + fn map_json( + &self, + path: &str, + transient: bool, + graph: Option<&str>, + uuid_namespace: Option<&str>, + ) -> savvy::Result<()> { + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.lock(); + inner + .map_json_path( + Path::new(path), + &named_graph, + transient, + uuid_namespace.map(String::from), + ) + .map_err(maplib_error) + } + + /// Map a JSON string straight to triples via a fixed convention + /// (mirrors PyModel::map_json's string branch). See map_json() for the + /// file-based equivalent. + /// + /// @param json The JSON document, as a string. + /// @param transient Whether the resulting triples should be transient + /// rather than permanent. Defaults to FALSE. + /// @param graph Optional named graph IRI to add the resulting triples to + /// (default graph if NULL). + /// @param uuid_namespace Optional namespace string for the UUIDv5 blank + /// node IRIs minted for JSON objects/arrays. Defaults to a random UUID + /// (v4) if not given, matching `Model::map_json_string`. + /// @export + fn map_json_string( + &self, + json: &str, + transient: bool, + graph: Option<&str>, + uuid_namespace: Option<&str>, + ) -> savvy::Result<()> { + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.lock(); + inner + .map_json_string( + json.to_string(), + &named_graph, + transient, + uuid_namespace.map(String::from), + ) + .map_err(maplib_error) + } + + /// Map an XML file straight to triples via a fixed convention (mirrors + /// PyModel::map_xml's path branch, py_maplib/src/py_model.rs:169-183 / + /// map_xml_mutex, py_maplib/src/mutexes.rs:230-278 -- split into a path- + /// and a string-taking method here, same rationale as map_json()). + /// + /// @param path Path to the XML file to map. + /// @param transient Whether the resulting triples should be transient + /// rather than permanent. Defaults to FALSE. + /// @param graph Optional named graph IRI to add the resulting triples to + /// (default graph if NULL). + /// @param uuid_namespace Optional namespace string for the UUIDv5 blank + /// node IRIs minted for XML elements. Defaults to the path. + /// @export + fn map_xml( + &self, + path: &str, + transient: bool, + graph: Option<&str>, + uuid_namespace: Option<&str>, + ) -> savvy::Result<()> { + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.lock(); + inner + .map_xml_path( + Path::new(path), + &named_graph, + transient, + uuid_namespace.map(String::from), + ) + .map_err(maplib_error) + } + + /// Map an XML string straight to triples via a fixed convention (mirrors + /// PyModel::map_xml's string branch). See map_xml() for the file-based + /// equivalent. + /// + /// @param xml The XML document, as a string. + /// @param transient Whether the resulting triples should be transient + /// rather than permanent. Defaults to FALSE. + /// @param graph Optional named graph IRI to add the resulting triples to + /// (default graph if NULL). + /// @param uuid_namespace Optional namespace string for the UUIDv5 blank + /// node IRIs minted for XML elements. Defaults to a random UUID (v4) + /// if not given, matching `Model::map_xml_string`. + /// @export + fn map_xml_string( + &self, + xml: &str, + transient: bool, + graph: Option<&str>, + uuid_namespace: Option<&str>, + ) -> savvy::Result<()> { + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.lock(); + inner + .map_xml_string( + xml.to_string(), + &named_graph, + transient, + uuid_namespace.map(String::from), + ) + .map_err(maplib_error) + } + + /// Map a data.frame's columns directly to subject/predicate/object + /// triples -- one column per predicate, a fresh IRI subject per row, no + /// OTTR template involved (mirrors PyModel::map_df, py_maplib/src/ + /// py_model.rs:216-243 / map_df_mutex, py_maplib/src/mutexes.rs:324-355). + /// A zero-row data.frame is a no-op, same as map()'s convention. + /// + /// @param stream_ptr A *filled* Arrow C Stream Interface pointer (e.g. + /// from `nanoarrow::as_nanoarrow_array_stream(df)`). + /// @param graph Optional named graph IRI to add the resulting triples to + /// (default graph if NULL). + /// @param uuid_namespace Optional namespace string for the UUIDv5 + /// subject IRIs minted per row. Defaults to a random UUID (v4) if not + /// given, matching `Triplestore::map_df`. + /// @export + fn map_df( + &self, + stream_ptr: Sexp, + graph: Option<&str>, + uuid_namespace: Option<&str>, + ) -> savvy::Result<()> { + let df = crate::arrow_bridge::import_dataframe(stream_ptr)?; + if df.height() == 0 { + return Ok(()); + } + let named_graph = parse_optional_named_graph(graph)?; + let mut inner = self.lock(); + inner + .map_df(&df, &named_graph, uuid_namespace.map(String::from)) + .map_err(maplib_error) + } + + /// Map a data.frame's columns directly to subject/predicate/object + /// triples using the built-in `ottr:Triple` template, optionally fixing + /// every row's predicate to a single constant IRI instead of reading it + /// from a `predicate` column (mirrors PyModel::map_triples, py_maplib/src/ + /// py_model.rs:184-214 / map_triples_mutex, py_maplib/src/mutexes.rs: + /// 280-301 -- both call `Model::expand_triples`, which is really an + /// `expand()` against the built-in triple template rather than a + /// separate `Triplestore` method). + /// + /// @param stream_ptr A *filled* Arrow C Stream Interface pointer, with + /// subject/predicate/object columns (or just subject/object if + /// `predicate` is given). + /// @param predicate Optional constant predicate IRI to use for every + /// row, instead of a `predicate` column in the data. + /// @param graph Optional named graph IRI to add the resulting triples to + /// (default graph if NULL). + /// @param validate_iris Whether to validate that IRI-typed columns + /// contain valid IRIs. Defaults to TRUE. + /// @export + fn map_triples( + &self, + stream_ptr: Sexp, + predicate: Option<&str>, + graph: Option<&str>, + validate_iris: Option, + ) -> savvy::Result<()> { + let df = crate::arrow_bridge::import_dataframe(stream_ptr)?; + if df.height() == 0 { + return Ok(()); + } + let predicate = predicate + .map(NamedNode::new) + .transpose() + .map_err(argument_error)?; + let named_graph = parse_optional_named_graph(graph)?; + let options = MapOptions::from_args(named_graph, validate_iris); + let mut inner = self.lock(); + inner + .expand_triples(df, None, predicate, options) + .map_err(maplib_error) + } + /// Load a Model previously written by serialize() (mirrors /// PyModel::deserialize, py_maplib/src/py_model.rs:936-954). This is an /// associated function, not a method -- call as `RModel$deserialize(path)`. diff --git a/maplibr/tests/testthat/test-model-map-json-xml-df-triples.R b/maplibr/tests/testthat/test-model-map-json-xml-df-triples.R new file mode 100644 index 00000000..d20d7a68 --- /dev/null +++ b/maplibr/tests/testthat/test-model-map-json-xml-df-triples.R @@ -0,0 +1,114 @@ +# Tests for Model$map_json()/map_json_string()/map_xml()/map_xml_string()/ +# map_df()/map_triples() (maplib-b98) -- direct-to-triples mapping entry +# points that don't go through an OTTR template, unlike $map()/$map_no_data(). + +test_that("map_json_string() maps a JSON string via the Facade-X convention", { + m <- Model$new() + m$map_json_string('{"a": 1, "b": "x"}') + df <- m$query("SELECT ?p ?o WHERE { ?s ?p ?o }") + expect_true(any(df$p == "http://sparql.xyz/facade-x/data/a" & df$o == "1")) + expect_true(any(df$p == "http://sparql.xyz/facade-x/data/b" & df$o == "x")) +}) + +test_that("map_json() maps a JSON file the same way as map_json_string()", { + m <- Model$new() + path <- tempfile(fileext = ".json") + on.exit(unlink(path)) + writeLines('{"a": 1, "b": "x"}', path) + m$map_json(path) + df <- m$query("SELECT ?p ?o WHERE { ?s ?p ?o }") + expect_true(any(df$p == "http://sparql.xyz/facade-x/data/a" & df$o == "1")) +}) + +test_that("map_json()'s graph argument scopes the mapped triples to one named graph", { + m <- Model$new() + path <- tempfile(fileext = ".json") + on.exit(unlink(path)) + writeLines('{"a": 1}', path) + m$map_json(path, graph = "http://ex/g1") + expect_gt(m$size(graph = "http://ex/g1"), 0) + expect_equal(m$size(), 0) +}) + +test_that("map_xml_string() maps an XML string via the Facade-X convention", { + # map_xml()'s convention represents each element's tag name as an rdf:type + # value (e.g. xyz:data/a), and its text content as an fx:child leaf value + # -- not "predicate named after the tag", unlike map_df(). + m <- Model$new() + m$map_xml_string("1x") + df <- m$query("SELECT ?p ?o WHERE { ?s ?p ?o }") + types <- df$o[df$p == "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"] + expect_true("http://sparql.xyz/facade-x/data/a" %in% types) + expect_true("http://sparql.xyz/facade-x/data/b" %in% types) + leaves <- df$o[df$p == "http://sparql.xyz/facade-x/ns/child"] + expect_true("1" %in% leaves) + expect_true("x" %in% leaves) +}) + +test_that("map_xml() maps an XML file the same way as map_xml_string()", { + m <- Model$new() + path <- tempfile(fileext = ".xml") + on.exit(unlink(path)) + writeLines("1", path) + m$map_xml(path) + df <- m$query("SELECT ?p ?o WHERE { ?s ?p ?o }") + types <- df$o[df$p == "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"] + expect_true("http://sparql.xyz/facade-x/data/a" %in% types) +}) + +test_that("map_df() maps a data.frame's columns directly, one predicate per column", { + # map_df() wraps the whole data.frame as a Facade-X-style document: a + # fresh root subject, one fx:child + fx:childNumber pair per row (for + # ordering), plus each row's own columns as direct data predicates -- + # confirmed against Triplestore::map_df (lib/triplestore/src/map_df.rs), + # not the flatter "N rows x N columns = N*M triples" shape one might + # assume from the name alone. + m <- Model$new() + d <- data.frame(name = c("Alice", "Bob"), age = c(30L, 25L), stringsAsFactors = FALSE) + m$map_df(d) + df <- m$query("SELECT ?p ?o WHERE { ?s ?p ?o }") + expect_equal(nrow(df), 9) + name_rows <- df[df$p == "http://sparql.xyz/facade-x/data/name", ] + expect_setequal(name_rows$o, c("Alice", "Bob")) +}) + +test_that("map_df() with a zero-row data.frame is a silent no-op", { + m <- Model$new() + d <- data.frame(name = character(0), stringsAsFactors = FALSE) + m$map_df(d) + expect_equal(m$size(), 0) +}) + +test_that("map_triples() maps subject/predicate/object columns directly", { + m <- Model$new() + d <- data.frame( + subject = "http://ex/a", predicate = "http://ex/p", object = "http://ex/o", + stringsAsFactors = FALSE + ) + m$map_triples(d) + expect_equal(m$size(), 1) + df <- m$query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }") + expect_equal(df$s, "http://ex/a") + expect_equal(df$p, "http://ex/p") + expect_equal(df$o, "http://ex/o") +}) + +test_that("map_triples()'s predicate argument fixes a constant predicate for every row", { + m <- Model$new() + d <- data.frame( + subject = c("http://ex/a", "http://ex/b"), + object = c("http://ex/o1", "http://ex/o2"), + stringsAsFactors = FALSE + ) + m$map_triples(d, predicate = "http://ex/const") + expect_equal(m$size(), 2) + df <- m$query("SELECT ?p WHERE { ?s ?p ?o }") + expect_true(all(df$p == "http://ex/const")) +}) + +test_that("map_triples() with a zero-row data.frame is a silent no-op", { + m <- Model$new() + d <- data.frame(subject = character(0), predicate = character(0), object = character(0)) + m$map_triples(d) + expect_equal(m$size(), 0) +}) From 62902c9ad5b1f8d62ef60b0dff64c3fd4bf599c0 Mon Sep 17 00:00:00 2001 From: novica Date: Mon, 14 Sep 2026 21:19:14 +0200 Subject: [PATCH 41/46] fix(maplibr): don't skip validation/root-triple on zero-row map_df/map_triples - map_triples() skipped column-existence validation on empty input, hiding misnamed-column bugs until a later call - map_df() skipped the root triple the engine always adds, diverging from py_maplib Co-Authored-By: Claude Sonnet 5 --- maplibr/R/model.R | 7 +++-- maplibr/man/Model.Rd | 7 +++-- maplibr/src/rust/src/model.rs | 20 +++++++----- .../test-model-map-json-xml-df-triples.R | 31 +++++++++++++++++-- 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/maplibr/R/model.R b/maplibr/R/model.R index 41de35db..5774b6fe 100644 --- a/maplibr/R/model.R +++ b/maplibr/R/model.R @@ -329,7 +329,9 @@ Model <- R6::R6Class( #' IRI subject per row, no OTTR template involved. #' @param data A data.frame, one row per subject; every column becomes a #' predicate (named by the column name) with that row's value as the - #' object. A zero-row data.frame is a silent no-op. + #' object. A zero-row data.frame is NOT a no-op here (unlike `$map()`): + #' a single root `rdf:type` triple is still added, matching the + #' underlying engine call. #' @param graph Optional named graph IRI to add the resulting triples to #' (default graph if NULL). #' @param uuid_namespace Optional namespace string for the UUIDv5 subject @@ -346,7 +348,8 @@ Model <- R6::R6Class( #' constant IRI instead of reading it from a `predicate` column. #' @param data A data.frame with subject/predicate/object columns (or #' just subject/object if `predicate` is given). A zero-row data.frame - #' is a silent no-op. + #' is NOT a no-op: missing/misnamed columns still raise a normal error + #' regardless of row count. #' @param predicate Optional constant predicate IRI to use for every #' row, instead of a `predicate` column in `data`. #' @param graph Optional named graph IRI to add the resulting triples to diff --git a/maplibr/man/Model.Rd b/maplibr/man/Model.Rd index c1ddffa9..5c16aa0b 100644 --- a/maplibr/man/Model.Rd +++ b/maplibr/man/Model.Rd @@ -575,7 +575,9 @@ IRI subject per row, no OTTR template involved. \describe{ \item{\code{data}}{A data.frame, one row per subject; every column becomes a predicate (named by the column name) with that row's value as the -object. A zero-row data.frame is a silent no-op.} +object. A zero-row data.frame is NOT a no-op here (unlike \verb{$map()}): +a single root \code{rdf:type} triple is still added, matching the +underlying engine call.} \item{\code{graph}}{Optional named graph IRI to add the resulting triples to (default graph if NULL).} \item{\code{uuid_namespace}}{Optional namespace string for the UUIDv5 subject @@ -603,7 +605,8 @@ constant IRI instead of reading it from a \code{predicate} column. \describe{ \item{\code{data}}{A data.frame with subject/predicate/object columns (or just subject/object if \code{predicate} is given). A zero-row data.frame -is a silent no-op.} +is NOT a no-op: missing/misnamed columns still raise a normal error +regardless of row count.} \item{\code{predicate}}{Optional constant predicate IRI to use for every row, instead of a \code{predicate} column in \code{data}.} \item{\code{graph}}{Optional named graph IRI to add the resulting triples to diff --git a/maplibr/src/rust/src/model.rs b/maplibr/src/rust/src/model.rs index c59ce622..0daeddaf 100644 --- a/maplibr/src/rust/src/model.rs +++ b/maplibr/src/rust/src/model.rs @@ -965,7 +965,11 @@ impl RModel { /// triples -- one column per predicate, a fresh IRI subject per row, no /// OTTR template involved (mirrors PyModel::map_df, py_maplib/src/ /// py_model.rs:216-243 / map_df_mutex, py_maplib/src/mutexes.rs:324-355). - /// A zero-row data.frame is a no-op, same as map()'s convention. + /// Unlike map()'s own zero-row handling, a zero-row data.frame here is + /// NOT a no-op: `Triplestore::map_df` (lib/triplestore/src/map_df.rs: + /// 115-132) unconditionally adds one ` rdf:type fx:root` triple + /// regardless of row count, and py_maplib's own map_df has no + /// zero-row early return either -- so neither does this one, to match. /// /// @param stream_ptr A *filled* Arrow C Stream Interface pointer (e.g. /// from `nanoarrow::as_nanoarrow_array_stream(df)`). @@ -982,9 +986,6 @@ impl RModel { uuid_namespace: Option<&str>, ) -> savvy::Result<()> { let df = crate::arrow_bridge::import_dataframe(stream_ptr)?; - if df.height() == 0 { - return Ok(()); - } let named_graph = parse_optional_named_graph(graph)?; let mut inner = self.lock(); inner @@ -999,7 +1000,13 @@ impl RModel { /// py_model.rs:184-214 / map_triples_mutex, py_maplib/src/mutexes.rs: /// 280-301 -- both call `Model::expand_triples`, which is really an /// `expand()` against the built-in triple template rather than a - /// separate `Triplestore` method). + /// separate `Triplestore` method). Unlike map()'s zero-row handling, + /// there is deliberately no zero-row early return here: `expand_triples` + /// runs the same subject/predicate/object column-existence validation + /// (lib/maplib/src/model/expansion/validation.rs) regardless of row + /// count, and py_maplib's own map_triples has no early return either -- + /// skipping it would let a misnamed/missing column go undetected on an + /// (incidentally) zero-row call instead of raising a normal error. /// /// @param stream_ptr A *filled* Arrow C Stream Interface pointer, with /// subject/predicate/object columns (or just subject/object if @@ -1019,9 +1026,6 @@ impl RModel { validate_iris: Option, ) -> savvy::Result<()> { let df = crate::arrow_bridge::import_dataframe(stream_ptr)?; - if df.height() == 0 { - return Ok(()); - } let predicate = predicate .map(NamedNode::new) .transpose() diff --git a/maplibr/tests/testthat/test-model-map-json-xml-df-triples.R b/maplibr/tests/testthat/test-model-map-json-xml-df-triples.R index d20d7a68..fae554af 100644 --- a/maplibr/tests/testthat/test-model-map-json-xml-df-triples.R +++ b/maplibr/tests/testthat/test-model-map-json-xml-df-triples.R @@ -3,6 +3,12 @@ # points that don't go through an OTTR template, unlike $map()/$map_no_data(). test_that("map_json_string() maps a JSON string via the Facade-X convention", { + # `1` is mapped to an xsd:long-typed literal, not a string + # (lib/triplestore/src/map_json.rs) -- `df$o == "1"` only passes because + # the resulting multi-typed ?o column gets coalesced to character by + # .collapse_multitype_columns(). If that coercion's behavior for + # non-character sub-columns ever changes, this assertion is the one that + # would need updating, not a sign the mapping itself broke. m <- Model$new() m$map_json_string('{"a": 1, "b": "x"}') df <- m$query("SELECT ?p ?o WHERE { ?s ?p ?o }") @@ -72,11 +78,18 @@ test_that("map_df() maps a data.frame's columns directly, one predicate per colu expect_setequal(name_rows$o, c("Alice", "Bob")) }) -test_that("map_df() with a zero-row data.frame is a silent no-op", { +test_that("map_df() with a zero-row data.frame still adds the root triple", { + # NOT a no-op, unlike $map(): Triplestore::map_df unconditionally adds one + # rdf:type fx:root triple regardless of row count, and py_maplib's + # own map_df has no zero-row early return either -- matched here rather + # than diverging from it. m <- Model$new() d <- data.frame(name = character(0), stringsAsFactors = FALSE) m$map_df(d) - expect_equal(m$size(), 0) + expect_equal(m$size(), 1) + df <- m$query("SELECT ?p ?o WHERE { ?s ?p ?o }") + expect_equal(df$p, "http://www.w3.org/1999/02/22-rdf-syntax-ns#type") + expect_equal(df$o, "http://sparql.xyz/facade-x/ns/root") }) test_that("map_triples() maps subject/predicate/object columns directly", { @@ -106,9 +119,21 @@ test_that("map_triples()'s predicate argument fixes a constant predicate for eve expect_true(all(df$p == "http://ex/const")) }) -test_that("map_triples() with a zero-row data.frame is a silent no-op", { +test_that("map_triples() with a zero-row data.frame and correct columns is a no-op", { m <- Model$new() d <- data.frame(subject = character(0), predicate = character(0), object = character(0)) m$map_triples(d) expect_equal(m$size(), 0) }) + +test_that("map_triples() still validates columns on a zero-row data.frame", { + # Regression case: an earlier version skipped column validation entirely + # for a zero-row input, so a misnamed/missing column (e.g. a data.frame + # upstream-filtered down to zero rows) would silently succeed instead of + # raising a normal error -- expand_triples's own column-existence check + # (lib/maplib/src/model/expansion/validation.rs) runs regardless of row + # count, and this now does too. + m <- Model$new() + d <- data.frame(subj = character(0), pred = character(0), obj = character(0)) + expect_error(m$map_triples(d), class = "maplibr_maplib_error") +}) From eea8488e4ce2cb979b030710141b845f363d7854 Mon Sep 17 00:00:00 2001 From: novica Date: Mon, 14 Sep 2026 21:34:02 +0200 Subject: [PATCH 42/46] docs(maplibr): update getting-started vignette for new methods - adds examples for read()/write(), update()/insert(), query(bindings=), direct-to-triples mapping - adds add_udf() and bare INSERT/DELETE DATA to the known-limitations table Co-Authored-By: Claude Sonnet 5 --- maplibr/vignettes/maplibr.Rmd | 96 +++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/maplibr/vignettes/maplibr.Rmd b/maplibr/vignettes/maplibr.Rmd index 873047f0..6c45ee15 100644 --- a/maplibr/vignettes/maplibr.Rmd +++ b/maplibr/vignettes/maplibr.Rmd @@ -13,9 +13,9 @@ knitr::opts_chunk$set(collapse = TRUE, comment = "#>") `maplibr` is an R interface to `maplib`, a Rust RDF knowledge-graph engine (the same engine the Python package `py_maplib` wraps). This vignette walks -through what's implemented today: building a graph, querying it with SPARQL, -mapping tabular data into RDF with OTTR templates, RDFS inference, and -managing named graphs. +through what's implemented today: building a graph, querying and updating it +with SPARQL, mapping data into RDF (both with OTTR templates and directly), +RDFS inference, and managing named graphs. ```{r setup} library(maplibr) @@ -49,6 +49,20 @@ Turtle works too: cat(m$writes(format = "turtle")) ``` +`$read()`/`$write()` are the file-based counterparts to +`$reads()`/`$writes()` -- `$read()` guesses the format from the file +extension (`.ttl`/`.nt`/`.xml`/`.rdf`) when `format` isn't given: + +```{r read-write-file} +path <- tempfile(fileext = ".ttl") +m$write(path, format = "turtle") + +m_from_file <- Model$new() +m_from_file$read(path) +m_from_file$size() +unlink(path) +``` + ## Querying with SPARQL `$query()` runs a SPARQL query and returns the result as a data.frame. @@ -67,6 +81,43 @@ m$query(" ") ``` +`bindings` pre-binds SPARQL variables to fixed `IRI`/`Literal` values before +the query runs -- useful inside a `FILTER`, though not as a `SELECT`-ed +column itself: + +```{r query-bindings} +m$query( + 'SELECT * WHERE { VALUES ?name { "Alice" "Bob" } FILTER(?name = ?who) }', + bindings = list(who = Literal("Bob")) +) +``` + +## Updating data with SPARQL UPDATE and INSERT + +`$update()` runs a SPARQL UPDATE (a `DELETE`/`INSERT` ... `WHERE` modify +statement) against the Model in place. The bare `INSERT DATA { ... }` form +isn't supported yet by the underlying engine (see the table at the end) -- +use an empty `WHERE {}` instead: + +```{r update} +m$update(' + PREFIX ex: + INSERT { ex:alice ex:greeting "Hello" } WHERE {} +') +m$query("SELECT ?o WHERE { ?o }") +``` + +`$insert()` runs a SPARQL `CONSTRUCT` query and adds its results as new +triples, optionally into a different graph than it queried: + +```{r insert} +m$insert( + "CONSTRUCT { ?s ?type } WHERE { ?s a ?type }", + target_graph = "urn:graph:types" +) +m$size("urn:graph:types") +``` + ## Mapping tabular data into RDF with OTTR templates `$map()` expands an OTTR template against a data.frame, adding the resulting @@ -100,6 +151,41 @@ m$map("http://example.org/HasFriend", data.frame(Friend = "http://example.org/bo m$query("SELECT ?o WHERE { ?o }") ``` +## Mapping data directly to triples, without a template + +For data that doesn't need OTTR's templating power, several `$map_*()` +methods add triples directly instead. `$map_df()` turns a data.frame's +columns into predicates, one row per subject: + +```{r map-df} +readings <- data.frame(city = c("Oslo", "Bergen"), temp_c = c(14, 16)) +m_direct <- Model$new() +m_direct$map_df(readings) +m_direct$query("SELECT ?p ?o WHERE { ?s ?p ?o }") +``` + +`$map_triples()` maps subject/predicate/object columns directly (or just +subject/object, with a constant `predicate`): + +```{r map-triples} +m_triples <- Model$new() +m_triples$map_triples( + data.frame(subject = "http://example.org/alice", object = "http://example.org/bob"), + predicate = "http://example.org/knows" +) +m_triples$query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }") +``` + +`$map_json_string()`/`$map_xml_string()` (and their file-based +`$map_json()`/`$map_xml()` counterparts) apply a fixed convention +(Facade-X) to a whole JSON or XML document instead: + +```{r map-json} +m_json <- Model$new() +m_json$map_json_string('{"name": "Alice", "age": 30}') +m_json$query("SELECT ?p ?o WHERE { ?s ?p ?o }") +``` + ## RDFS inference `alice` is a `Person`, and `Person` is a subclass of `Agent` -- RDFS @@ -185,7 +271,9 @@ if (requireNamespace("igraph", quietly = TRUE)) { | `$writes(include_transient = TRUE)` | Not supported for any format -- the underlying engine's triple-serialization code has no way to include transient/inferred triples, unlike its query path. A core-engine limitation, not specific to this package. | | `$validate()` (SHACL) | Not implemented -- also blocked on a licensing question (the root project's README describes SHACL as proprietary, but its source is present and unrestricted in this checkout; not a decision this package can make unilaterally). | | `$serialize()` / `$deserialize()` | Wired up correctly, but panic at runtime -- the on-disk storage engine they depend on is a closed-source stub in this checkout. | -| CIM XML, HDT | Not ported. | +| `$add_udf()` | Not implemented -- the underlying engine's UDF callback mechanism is CPython-specific end to end (it calls into Python's own `io`/`polars` modules to invoke the callback), with no way for a non-Python binding to register one instead. A core-engine limitation. | +| bare `INSERT DATA { ... }` / `DELETE DATA { ... }` (in `$update()`) | Panics instead of raising a normal error -- unimplemented in the underlying engine's SPARQL UPDATE handling. Use `INSERT { ... } WHERE {}` / `DELETE { ... } WHERE {}` instead, which works correctly. A core-engine limitation. | +| CIM XML, HDT, JSON-LD | Not ported. | See the package's development notes for the full, current list of known issues and their upstream/local status. From 409ef0f4951b3e8c12d49deb9d91ee0f0ca16535 Mon Sep 17 00:00:00 2001 From: novica Date: Mon, 14 Sep 2026 21:46:59 +0200 Subject: [PATCH 43/46] feat(maplibr): add pipe-friendly model_*() functional wrappers - every Model R6 method gets a standalone model_*() function taking the Model first; purely additive alongside $-methods - `model_` prefix avoids masking base::write/purrr::map/stats::update - requires R >= 4.1 for the native pipe; adds passthrough tests Co-Authored-By: Claude Sonnet 5 --- maplibr/DESCRIPTION | 3 + maplibr/NAMESPACE | 25 ++ maplibr/R/model-verbs.R | 393 ++++++++++++++++++++++ maplibr/man/model_add_graph.Rd | 26 ++ maplibr/man/model_add_prefixes.Rd | 23 ++ maplibr/man/model_add_template.Rd | 22 ++ maplibr/man/model_compact.Rd | 20 ++ maplibr/man/model_create_index.Rd | 20 ++ maplibr/man/model_deserialize.Rd | 27 ++ maplibr/man/model_detach_graph.Rd | 25 ++ maplibr/man/model_infer_rdfs.Rd | 22 ++ maplibr/man/model_insert.Rd | 41 +++ maplibr/man/model_map.Rd | 31 ++ maplibr/man/model_map_df.Rd | 29 ++ maplibr/man/model_map_json.Rd | 31 ++ maplibr/man/model_map_json_string.Rd | 37 ++ maplibr/man/model_map_triples.Rd | 38 +++ maplibr/man/model_map_xml.Rd | 31 ++ maplibr/man/model_map_xml_string.Rd | 37 ++ maplibr/man/model_query.Rd | 37 ++ maplibr/man/model_read.Rd | 27 ++ maplibr/man/model_reads.Rd | 26 ++ maplibr/man/model_serialize.Rd | 22 ++ maplibr/man/model_size.Rd | 22 ++ maplibr/man/model_truncate_graph.Rd | 22 ++ maplibr/man/model_update.Rd | 28 ++ maplibr/man/model_write.Rd | 26 ++ maplibr/man/model_writes.Rd | 24 ++ maplibr/tests/testthat/test-model-verbs.R | 120 +++++++ maplibr/vignettes/maplibr.Rmd | 28 ++ 30 files changed, 1263 insertions(+) create mode 100644 maplibr/R/model-verbs.R create mode 100644 maplibr/man/model_add_graph.Rd create mode 100644 maplibr/man/model_add_prefixes.Rd create mode 100644 maplibr/man/model_add_template.Rd create mode 100644 maplibr/man/model_compact.Rd create mode 100644 maplibr/man/model_create_index.Rd create mode 100644 maplibr/man/model_deserialize.Rd create mode 100644 maplibr/man/model_detach_graph.Rd create mode 100644 maplibr/man/model_infer_rdfs.Rd create mode 100644 maplibr/man/model_insert.Rd create mode 100644 maplibr/man/model_map.Rd create mode 100644 maplibr/man/model_map_df.Rd create mode 100644 maplibr/man/model_map_json.Rd create mode 100644 maplibr/man/model_map_json_string.Rd create mode 100644 maplibr/man/model_map_triples.Rd create mode 100644 maplibr/man/model_map_xml.Rd create mode 100644 maplibr/man/model_map_xml_string.Rd create mode 100644 maplibr/man/model_query.Rd create mode 100644 maplibr/man/model_read.Rd create mode 100644 maplibr/man/model_reads.Rd create mode 100644 maplibr/man/model_serialize.Rd create mode 100644 maplibr/man/model_size.Rd create mode 100644 maplibr/man/model_truncate_graph.Rd create mode 100644 maplibr/man/model_update.Rd create mode 100644 maplibr/man/model_write.Rd create mode 100644 maplibr/man/model_writes.Rd create mode 100644 maplibr/tests/testthat/test-model-verbs.R diff --git a/maplibr/DESCRIPTION b/maplibr/DESCRIPTION index 387d0938..e2d4002a 100644 --- a/maplibr/DESCRIPTION +++ b/maplibr/DESCRIPTION @@ -12,6 +12,8 @@ Description: Build, query, and reason over RDF knowledge graphs backed by the N-Triples, RDF/XML). License: Apache License (>= 2.0) Encoding: UTF-8 +Depends: + R (>= 4.1) Imports: R6, S7, @@ -28,6 +30,7 @@ Collate: 'rdf_type.R' 'templates.R' 'model.R' + 'model-verbs.R' 'zzz.R' Suggests: testthat (>= 3.0.0), diff --git a/maplibr/NAMESPACE b/maplibr/NAMESPACE index 8da92ca3..4283d402 100644 --- a/maplibr/NAMESPACE +++ b/maplibr/NAMESPACE @@ -35,6 +35,31 @@ export(import_test_solution_mappings) export(instance_template_iri) export(instantiate) export(make_triple) +export(model_add_graph) +export(model_add_prefixes) +export(model_add_template) +export(model_compact) +export(model_create_index) +export(model_deserialize) +export(model_detach_graph) +export(model_infer_rdfs) +export(model_insert) +export(model_map) +export(model_map_df) +export(model_map_json) +export(model_map_json_string) +export(model_map_triples) +export(model_map_xml) +export(model_map_xml_string) +export(model_query) +export(model_read) +export(model_reads) +export(model_serialize) +export(model_size) +export(model_truncate_graph) +export(model_update) +export(model_write) +export(model_writes) export(owl) export(rdf) export(rdf_type_blank_node) diff --git a/maplibr/R/model-verbs.R b/maplibr/R/model-verbs.R new file mode 100644 index 00000000..948e74c3 --- /dev/null +++ b/maplibr/R/model-verbs.R @@ -0,0 +1,393 @@ +# Pipe-friendly functional wrappers around Model's R6 methods (`model_*()`), +# so `m |> model_query(...)` works as an alternative to `m$query(...)` -- +# both stay available side by side, same convention `arrow` (Table/Dataset +# R6 objects plus dplyr-verb S3 methods) and `torch` (`x$add(y)` alongside +# free-function `torch_add(x, y)`) already use. Named with a `model_` +# prefix rather than bare verbs (`read()`/`write()`/`map()`/`update()`) +# because those would mask `base::write`, `purrr::map`, and the +# `stats::update` S3 generic respectively if this package were attached +# alongside a normal tidyverse session. +# +# Every function here is a one-line passthrough: call the matching `Model` +# method and return exactly what it returns. Mutating `Model` methods +# already return `invisible(self)` (see model.R), so no extra work is +# needed here to make chaining/piping work -- these wrappers don't add +# behavior, only an alternative calling convention. See `?Model` for full +# parameter documentation; the descriptions below are intentionally terse. + +#' Number of triples in a graph +#' +#' Pipe-friendly wrapper for `Model$size()`. +#' @param m A `Model`. +#' @param graph Optional named graph IRI to count (default graph if NULL). +#' @return An integer triple count. +#' @seealso [Model] +#' @export +model_size <- function(m, graph = NULL) { + m$size(graph) +} + +#' Parse RDF triples from a string into a Model +#' +#' Pipe-friendly wrapper for `Model$reads()`. +#' @param m A `Model`. +#' @param s RDF data as a string. +#' @param format One of "ntriples", "turtle", "xml" (rdf/xml). +#' @param graph Optional named graph IRI to read into (default graph if NULL). +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_reads <- function(m, s, format, graph = NULL) { + m$reads(s, format, graph) +} + +#' Parse RDF triples from a file into a Model +#' +#' Pipe-friendly wrapper for `Model$read()`. +#' @param m A `Model`. +#' @param path Path to the RDF file to read. +#' @param format One of "ntriples", "turtle", "xml" (rdf/xml). Guessed from +#' the file extension if NULL. +#' @param graph Optional named graph IRI to read into (default graph if NULL). +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_read <- function(m, path, format = NULL, graph = NULL) { + m$read(path, format, graph) +} + +#' Serialize a Model's triples to a string +#' +#' Pipe-friendly wrapper for `Model$writes()`. +#' @param m A `Model`. +#' @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". +#' @param graph Optional named graph IRI to write (default graph if NULL). +#' @return A string. +#' @seealso [Model] +#' @export +model_writes <- function(m, format = NULL, graph = NULL) { + m$writes(format, graph) +} + +#' Serialize a Model's triples to a file +#' +#' Pipe-friendly wrapper for `Model$write()`. +#' @param m A `Model`. +#' @param path Path to write the RDF file to (overwritten if it exists). +#' @param format One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples". +#' @param graph Optional named graph IRI to write (default graph if NULL). +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_write <- function(m, path, format = NULL, graph = NULL) { + m$write(path, format, graph) +} + +#' Build a Model's default indexes +#' +#' Pipe-friendly wrapper for `Model$create_index()`. +#' @param m A `Model`. +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_create_index <- function(m) { + m$create_index() +} + +#' Remove all triples from a graph +#' +#' Pipe-friendly wrapper for `Model$truncate_graph()`. +#' @param m A `Model`. +#' @param graph Optional named graph IRI to truncate (default graph if NULL). +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_truncate_graph <- function(m, graph = NULL) { + m$truncate_graph(graph) +} + +#' Add prefix -> IRI mappings to a Model +#' +#' Pipe-friendly wrapper for `Model$add_prefixes()`. +#' @param m A `Model`. +#' @param prefixes A named R list of single strings: names are prefixes, +#' values are IRIs, e.g. `list(ex = "http://example.org/")`. +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_add_prefixes <- function(m, prefixes) { + m$add_prefixes(prefixes) +} + +#' Copy a graph from another Model into this one +#' +#' Pipe-friendly wrapper for `Model$add_graph()`. +#' @param m A `Model`. +#' @param other Another Model to copy a graph from. +#' @param source_graph Optional named graph IRI in `other` (default graph if NULL). +#' @param target_graph Optional named graph IRI in `m` to copy into (default graph if NULL). +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_add_graph <- function(m, other, source_graph = NULL, target_graph = NULL) { + m$add_graph(other, source_graph, target_graph) +} + +#' Split a graph out of a Model into a new, standalone Model +#' +#' Pipe-friendly wrapper for `Model$detach_graph()`. +#' @param m A `Model`. +#' @param preserve_name Keep the graph's own name in the new Model rather +#' than moving it to the default graph there. +#' @param graph Optional named graph IRI to detach (default graph if NULL). +#' @return A new `Model` containing only the detached graph. +#' @seealso [Model] +#' @export +model_detach_graph <- function(m, preserve_name = FALSE, graph = NULL) { + m$detach_graph(preserve_name, graph) +} + +#' Run a SPARQL SELECT or CONSTRUCT query +#' +#' Pipe-friendly wrapper for `Model$query()`. +#' @param m A `Model`. +#' @param sparql The SPARQL query string. +#' @param graph Optional named graph IRI to restrict the query to (searches +#' across the whole store if NULL). +#' @param include_transient Whether to include transient (e.g. inferred) +#' triples in the query. +#' @param bindings Optional named list of `IRI`/`Literal` objects, pre-binding +#' SPARQL variables to fixed values before the query runs. +#' @return A data.frame. +#' @seealso [Model] +#' @export +model_query <- function(m, sparql, graph = NULL, include_transient = FALSE, bindings = NULL) { + m$query(sparql, graph, include_transient, bindings) +} + +#' Run a SPARQL UPDATE against a Model +#' +#' Pipe-friendly wrapper for `Model$update()`. +#' @param m A `Model`. +#' @param update The SPARQL UPDATE string. +#' @param graph Optional named graph IRI to restrict the update to (no +#' restriction, i.e. the whole store, if NULL). +#' @param include_transient Whether the WHERE clause may match transient +#' (e.g. inferred) triples. +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_update <- function(m, update, graph = NULL, include_transient = FALSE) { + m$update(update, graph, include_transient) +} + +#' Insert a SPARQL CONSTRUCT query's results as new triples +#' +#' Pipe-friendly wrapper for `Model$insert()`. +#' @param m A `Model`. +#' @param query The SPARQL CONSTRUCT query string to source new triples from. +#' @param source_graph Optional named graph IRI to query from (default +#' graph if NULL). +#' @param target_graph Optional named graph IRI to insert into (default +#' graph if NULL). +#' @param include_transient Whether the query may match transient (e.g. +#' inferred) triples in the source graph. +#' @param transient Whether the newly-inserted triples themselves should be +#' marked transient rather than permanent. +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_insert <- function(m, query, source_graph = NULL, target_graph = NULL, + include_transient = FALSE, transient = FALSE) { + m$insert(query, source_graph, target_graph, include_transient, transient) +} + +#' Register a Template on a Model +#' +#' Pipe-friendly wrapper for `Model$add_template()`. +#' @param m A `Model`. +#' @param template A `Template` object, or an stOTTR document string. +#' @return The IRI of the registered template, invisibly. +#' @seealso [Model] +#' @export +model_add_template <- function(m, template) { + m$add_template(template) +} + +#' Expand a template against a data.frame +#' +#' Pipe-friendly wrapper for `Model$map()`. +#' @param m A `Model`. +#' @param template A `Template` object, the IRI of an already-registered +#' template, or an stOTTR document string. +#' @param data Optional data.frame, one row per template instantiation. +#' @param graph Optional named graph IRI to add the resulting triples to +#' (default graph if NULL). +#' @param validate_iris Whether to validate that IRI-typed columns contain +#' valid IRIs. Defaults to TRUE. +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_map <- function(m, template, data = NULL, graph = NULL, validate_iris = NULL) { + m$map(template, data, graph, validate_iris) +} + +#' Map a JSON file straight to triples via a fixed convention +#' +#' Pipe-friendly wrapper for `Model$map_json()`. +#' @param m A `Model`. +#' @param path Path to the JSON file to map. +#' @param graph Optional named graph IRI to add the resulting triples to +#' (default graph if NULL). +#' @param transient Whether the resulting triples should be transient +#' rather than permanent. +#' @param uuid_namespace Optional namespace string for the UUIDv5 blank +#' node IRIs minted for JSON objects/arrays. +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_map_json <- function(m, path, graph = NULL, transient = FALSE, uuid_namespace = NULL) { + m$map_json(path, graph, transient, uuid_namespace) +} + +#' Map a JSON string straight to triples via a fixed convention +#' +#' Pipe-friendly wrapper for `Model$map_json_string()`. +#' @param m A `Model`. +#' @param json The JSON document, as a string. +#' @param graph Optional named graph IRI to add the resulting triples to +#' (default graph if NULL). +#' @param transient Whether the resulting triples should be transient +#' rather than permanent. +#' @param uuid_namespace Optional namespace string for the UUIDv5 blank +#' node IRIs minted for JSON objects/arrays. +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_map_json_string <- function(m, json, graph = NULL, transient = FALSE, uuid_namespace = NULL) { + m$map_json_string(json, graph, transient, uuid_namespace) +} + +#' Map an XML file straight to triples via a fixed convention +#' +#' Pipe-friendly wrapper for `Model$map_xml()`. +#' @param m A `Model`. +#' @param path Path to the XML file to map. +#' @param graph Optional named graph IRI to add the resulting triples to +#' (default graph if NULL). +#' @param transient Whether the resulting triples should be transient +#' rather than permanent. +#' @param uuid_namespace Optional namespace string for the UUIDv5 blank +#' node IRIs minted for XML elements. +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_map_xml <- function(m, path, graph = NULL, transient = FALSE, uuid_namespace = NULL) { + m$map_xml(path, graph, transient, uuid_namespace) +} + +#' Map an XML string straight to triples via a fixed convention +#' +#' Pipe-friendly wrapper for `Model$map_xml_string()`. +#' @param m A `Model`. +#' @param xml The XML document, as a string. +#' @param graph Optional named graph IRI to add the resulting triples to +#' (default graph if NULL). +#' @param transient Whether the resulting triples should be transient +#' rather than permanent. +#' @param uuid_namespace Optional namespace string for the UUIDv5 blank +#' node IRIs minted for XML elements. +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_map_xml_string <- function(m, xml, graph = NULL, transient = FALSE, uuid_namespace = NULL) { + m$map_xml_string(xml, graph, transient, uuid_namespace) +} + +#' Map a data.frame's columns directly to triples, one predicate per column +#' +#' Pipe-friendly wrapper for `Model$map_df()`. +#' @param m A `Model`. +#' @param data A data.frame, one row per subject; every column becomes a +#' predicate with that row's value as the object. +#' @param graph Optional named graph IRI to add the resulting triples to +#' (default graph if NULL). +#' @param uuid_namespace Optional namespace string for the UUIDv5 subject +#' IRIs minted per row. +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_map_df <- function(m, data, graph = NULL, uuid_namespace = NULL) { + m$map_df(data, graph, uuid_namespace) +} + +#' Map a data.frame's subject/predicate/object columns directly to triples +#' +#' Pipe-friendly wrapper for `Model$map_triples()`. +#' @param m A `Model`. +#' @param data A data.frame with subject/predicate/object columns (or just +#' subject/object if `predicate` is given). +#' @param predicate Optional constant predicate IRI to use for every row, +#' instead of a `predicate` column in `data`. +#' @param graph Optional named graph IRI to add the resulting triples to +#' (default graph if NULL). +#' @param validate_iris Whether to validate that IRI-typed columns contain +#' valid IRIs. Defaults to TRUE. +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_map_triples <- function(m, data, predicate = NULL, graph = NULL, validate_iris = NULL) { + m$map_triples(data, predicate, graph, validate_iris) +} + +#' Run RDFS inference over a graph in place +#' +#' Pipe-friendly wrapper for `Model$infer_rdfs()`. +#' @param m A `Model`. +#' @param graph Optional named graph IRI to infer over (default graph if NULL). +#' @return The number of new triples inferred. +#' @seealso [Model] +#' @export +model_infer_rdfs <- function(m, graph = NULL) { + m$infer_rdfs(graph) +} + +#' Compact a Model's on-disk storage +#' +#' Pipe-friendly wrapper for `Model$compact()`. +#' @param m A `Model`. +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_compact <- function(m) { + m$compact() +} + +#' Serialize a Model's triples to maplib's own compact on-disk format +#' +#' Pipe-friendly wrapper for `Model$serialize()`. +#' @param m A `Model`. +#' @param path Directory to serialize into. Defaults to "./serialized_triples". +#' @return `m`, invisibly. +#' @seealso [Model] +#' @export +model_serialize <- function(m, path = NULL) { + m$serialize(path) +} + +#' Load a Model previously written by `model_serialize()`/`Model$serialize()` +#' +#' Pipe-friendly wrapper for `Model$deserialize()`. Unlike the other +#' `model_*()` functions, this one has no `m` to take as a first argument -- +#' it creates one -- so it's a pipe *source*, not something to pipe an +#' existing Model into: `model_deserialize(path) |> model_query(...)`. +#' @param path Directory previously written by serialize(). Defaults to +#' "./serialized_triples". +#' @param storage_folder Optional folder for on-disk (rather than +#' in-memory) triplestore storage. +#' @return A new `Model`. +#' @seealso [Model] +#' @export +model_deserialize <- function(path = NULL, storage_folder = NULL) { + Model$deserialize(path, storage_folder) +} diff --git a/maplibr/man/model_add_graph.Rd b/maplibr/man/model_add_graph.Rd new file mode 100644 index 00000000..8be0fed6 --- /dev/null +++ b/maplibr/man/model_add_graph.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_add_graph} +\alias{model_add_graph} +\title{Copy a graph from another Model into this one} +\usage{ +model_add_graph(m, other, source_graph = NULL, target_graph = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{other}{Another Model to copy a graph from.} + +\item{source_graph}{Optional named graph IRI in \code{other} (default graph if NULL).} + +\item{target_graph}{Optional named graph IRI in \code{m} to copy into (default graph if NULL).} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$add_graph()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_add_prefixes.Rd b/maplibr/man/model_add_prefixes.Rd new file mode 100644 index 00000000..fcdfaa22 --- /dev/null +++ b/maplibr/man/model_add_prefixes.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_add_prefixes} +\alias{model_add_prefixes} +\title{Add prefix -> IRI mappings to a Model} +\usage{ +model_add_prefixes(m, prefixes) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{prefixes}{A named R list of single strings: names are prefixes, +values are IRIs, e.g. \code{list(ex = "http://example.org/")}.} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$add_prefixes()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_add_template.Rd b/maplibr/man/model_add_template.Rd new file mode 100644 index 00000000..aef1b459 --- /dev/null +++ b/maplibr/man/model_add_template.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_add_template} +\alias{model_add_template} +\title{Register a Template on a Model} +\usage{ +model_add_template(m, template) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{template}{A \code{Template} object, or an stOTTR document string.} +} +\value{ +The IRI of the registered template, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$add_template()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_compact.Rd b/maplibr/man/model_compact.Rd new file mode 100644 index 00000000..1238e12c --- /dev/null +++ b/maplibr/man/model_compact.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_compact} +\alias{model_compact} +\title{Compact a Model's on-disk storage} +\usage{ +model_compact(m) +} +\arguments{ +\item{m}{A \code{Model}.} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$compact()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_create_index.Rd b/maplibr/man/model_create_index.Rd new file mode 100644 index 00000000..d66bdc98 --- /dev/null +++ b/maplibr/man/model_create_index.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_create_index} +\alias{model_create_index} +\title{Build a Model's default indexes} +\usage{ +model_create_index(m) +} +\arguments{ +\item{m}{A \code{Model}.} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$create_index()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_deserialize.Rd b/maplibr/man/model_deserialize.Rd new file mode 100644 index 00000000..3c0e981c --- /dev/null +++ b/maplibr/man/model_deserialize.Rd @@ -0,0 +1,27 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_deserialize} +\alias{model_deserialize} +\title{Load a Model previously written by \code{model_serialize()}/\code{Model$serialize()}} +\usage{ +model_deserialize(path = NULL, storage_folder = NULL) +} +\arguments{ +\item{path}{Directory previously written by serialize(). Defaults to +"./serialized_triples".} + +\item{storage_folder}{Optional folder for on-disk (rather than +in-memory) triplestore storage.} +} +\value{ +A new \code{Model}. +} +\description{ +Pipe-friendly wrapper for \code{Model$deserialize()}. Unlike the other +\verb{model_*()} functions, this one has no \code{m} to take as a first argument -- +it creates one -- so it's a pipe \emph{source}, not something to pipe an +existing Model into: \code{model_deserialize(path) |> model_query(...)}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_detach_graph.Rd b/maplibr/man/model_detach_graph.Rd new file mode 100644 index 00000000..b6b82e80 --- /dev/null +++ b/maplibr/man/model_detach_graph.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_detach_graph} +\alias{model_detach_graph} +\title{Split a graph out of a Model into a new, standalone Model} +\usage{ +model_detach_graph(m, preserve_name = FALSE, graph = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{preserve_name}{Keep the graph's own name in the new Model rather +than moving it to the default graph there.} + +\item{graph}{Optional named graph IRI to detach (default graph if NULL).} +} +\value{ +A new \code{Model} containing only the detached graph. +} +\description{ +Pipe-friendly wrapper for \code{Model$detach_graph()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_infer_rdfs.Rd b/maplibr/man/model_infer_rdfs.Rd new file mode 100644 index 00000000..33dab7ee --- /dev/null +++ b/maplibr/man/model_infer_rdfs.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_infer_rdfs} +\alias{model_infer_rdfs} +\title{Run RDFS inference over a graph in place} +\usage{ +model_infer_rdfs(m, graph = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{graph}{Optional named graph IRI to infer over (default graph if NULL).} +} +\value{ +The number of new triples inferred. +} +\description{ +Pipe-friendly wrapper for \code{Model$infer_rdfs()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_insert.Rd b/maplibr/man/model_insert.Rd new file mode 100644 index 00000000..1007a295 --- /dev/null +++ b/maplibr/man/model_insert.Rd @@ -0,0 +1,41 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_insert} +\alias{model_insert} +\title{Insert a SPARQL CONSTRUCT query's results as new triples} +\usage{ +model_insert( + m, + query, + source_graph = NULL, + target_graph = NULL, + include_transient = FALSE, + transient = FALSE +) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{query}{The SPARQL CONSTRUCT query string to source new triples from.} + +\item{source_graph}{Optional named graph IRI to query from (default +graph if NULL).} + +\item{target_graph}{Optional named graph IRI to insert into (default +graph if NULL).} + +\item{include_transient}{Whether the query may match transient (e.g. +inferred) triples in the source graph.} + +\item{transient}{Whether the newly-inserted triples themselves should be +marked transient rather than permanent.} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$insert()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_map.Rd b/maplibr/man/model_map.Rd new file mode 100644 index 00000000..731ef4f1 --- /dev/null +++ b/maplibr/man/model_map.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_map} +\alias{model_map} +\title{Expand a template against a data.frame} +\usage{ +model_map(m, template, data = NULL, graph = NULL, validate_iris = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{template}{A \code{Template} object, the IRI of an already-registered +template, or an stOTTR document string.} + +\item{data}{Optional data.frame, one row per template instantiation.} + +\item{graph}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + +\item{validate_iris}{Whether to validate that IRI-typed columns contain +valid IRIs. Defaults to TRUE.} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$map()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_map_df.Rd b/maplibr/man/model_map_df.Rd new file mode 100644 index 00000000..7c386f71 --- /dev/null +++ b/maplibr/man/model_map_df.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_map_df} +\alias{model_map_df} +\title{Map a data.frame's columns directly to triples, one predicate per column} +\usage{ +model_map_df(m, data, graph = NULL, uuid_namespace = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{data}{A data.frame, one row per subject; every column becomes a +predicate with that row's value as the object.} + +\item{graph}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + +\item{uuid_namespace}{Optional namespace string for the UUIDv5 subject +IRIs minted per row.} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$map_df()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_map_json.Rd b/maplibr/man/model_map_json.Rd new file mode 100644 index 00000000..9739c3c4 --- /dev/null +++ b/maplibr/man/model_map_json.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_map_json} +\alias{model_map_json} +\title{Map a JSON file straight to triples via a fixed convention} +\usage{ +model_map_json(m, path, graph = NULL, transient = FALSE, uuid_namespace = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{path}{Path to the JSON file to map.} + +\item{graph}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + +\item{transient}{Whether the resulting triples should be transient +rather than permanent.} + +\item{uuid_namespace}{Optional namespace string for the UUIDv5 blank +node IRIs minted for JSON objects/arrays.} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$map_json()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_map_json_string.Rd b/maplibr/man/model_map_json_string.Rd new file mode 100644 index 00000000..6c1bc463 --- /dev/null +++ b/maplibr/man/model_map_json_string.Rd @@ -0,0 +1,37 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_map_json_string} +\alias{model_map_json_string} +\title{Map a JSON string straight to triples via a fixed convention} +\usage{ +model_map_json_string( + m, + json, + graph = NULL, + transient = FALSE, + uuid_namespace = NULL +) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{json}{The JSON document, as a string.} + +\item{graph}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + +\item{transient}{Whether the resulting triples should be transient +rather than permanent.} + +\item{uuid_namespace}{Optional namespace string for the UUIDv5 blank +node IRIs minted for JSON objects/arrays.} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$map_json_string()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_map_triples.Rd b/maplibr/man/model_map_triples.Rd new file mode 100644 index 00000000..9bb423c6 --- /dev/null +++ b/maplibr/man/model_map_triples.Rd @@ -0,0 +1,38 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_map_triples} +\alias{model_map_triples} +\title{Map a data.frame's subject/predicate/object columns directly to triples} +\usage{ +model_map_triples( + m, + data, + predicate = NULL, + graph = NULL, + validate_iris = NULL +) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{data}{A data.frame with subject/predicate/object columns (or just +subject/object if \code{predicate} is given).} + +\item{predicate}{Optional constant predicate IRI to use for every row, +instead of a \code{predicate} column in \code{data}.} + +\item{graph}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + +\item{validate_iris}{Whether to validate that IRI-typed columns contain +valid IRIs. Defaults to TRUE.} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$map_triples()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_map_xml.Rd b/maplibr/man/model_map_xml.Rd new file mode 100644 index 00000000..16bff214 --- /dev/null +++ b/maplibr/man/model_map_xml.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_map_xml} +\alias{model_map_xml} +\title{Map an XML file straight to triples via a fixed convention} +\usage{ +model_map_xml(m, path, graph = NULL, transient = FALSE, uuid_namespace = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{path}{Path to the XML file to map.} + +\item{graph}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + +\item{transient}{Whether the resulting triples should be transient +rather than permanent.} + +\item{uuid_namespace}{Optional namespace string for the UUIDv5 blank +node IRIs minted for XML elements.} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$map_xml()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_map_xml_string.Rd b/maplibr/man/model_map_xml_string.Rd new file mode 100644 index 00000000..3c46cf12 --- /dev/null +++ b/maplibr/man/model_map_xml_string.Rd @@ -0,0 +1,37 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_map_xml_string} +\alias{model_map_xml_string} +\title{Map an XML string straight to triples via a fixed convention} +\usage{ +model_map_xml_string( + m, + xml, + graph = NULL, + transient = FALSE, + uuid_namespace = NULL +) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{xml}{The XML document, as a string.} + +\item{graph}{Optional named graph IRI to add the resulting triples to +(default graph if NULL).} + +\item{transient}{Whether the resulting triples should be transient +rather than permanent.} + +\item{uuid_namespace}{Optional namespace string for the UUIDv5 blank +node IRIs minted for XML elements.} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$map_xml_string()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_query.Rd b/maplibr/man/model_query.Rd new file mode 100644 index 00000000..20e28a1f --- /dev/null +++ b/maplibr/man/model_query.Rd @@ -0,0 +1,37 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_query} +\alias{model_query} +\title{Run a SPARQL SELECT or CONSTRUCT query} +\usage{ +model_query( + m, + sparql, + graph = NULL, + include_transient = FALSE, + bindings = NULL +) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{sparql}{The SPARQL query string.} + +\item{graph}{Optional named graph IRI to restrict the query to (searches +across the whole store if NULL).} + +\item{include_transient}{Whether to include transient (e.g. inferred) +triples in the query.} + +\item{bindings}{Optional named list of \code{IRI}/\code{Literal} objects, pre-binding +SPARQL variables to fixed values before the query runs.} +} +\value{ +A data.frame. +} +\description{ +Pipe-friendly wrapper for \code{Model$query()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_read.Rd b/maplibr/man/model_read.Rd new file mode 100644 index 00000000..67e5d05e --- /dev/null +++ b/maplibr/man/model_read.Rd @@ -0,0 +1,27 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_read} +\alias{model_read} +\title{Parse RDF triples from a file into a Model} +\usage{ +model_read(m, path, format = NULL, graph = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{path}{Path to the RDF file to read.} + +\item{format}{One of "ntriples", "turtle", "xml" (rdf/xml). Guessed from +the file extension if NULL.} + +\item{graph}{Optional named graph IRI to read into (default graph if NULL).} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$read()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_reads.Rd b/maplibr/man/model_reads.Rd new file mode 100644 index 00000000..23b52b2d --- /dev/null +++ b/maplibr/man/model_reads.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_reads} +\alias{model_reads} +\title{Parse RDF triples from a string into a Model} +\usage{ +model_reads(m, s, format, graph = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{s}{RDF data as a string.} + +\item{format}{One of "ntriples", "turtle", "xml" (rdf/xml).} + +\item{graph}{Optional named graph IRI to read into (default graph if NULL).} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$reads()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_serialize.Rd b/maplibr/man/model_serialize.Rd new file mode 100644 index 00000000..9d7b6316 --- /dev/null +++ b/maplibr/man/model_serialize.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_serialize} +\alias{model_serialize} +\title{Serialize a Model's triples to maplib's own compact on-disk format} +\usage{ +model_serialize(m, path = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{path}{Directory to serialize into. Defaults to "./serialized_triples".} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$serialize()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_size.Rd b/maplibr/man/model_size.Rd new file mode 100644 index 00000000..7641652a --- /dev/null +++ b/maplibr/man/model_size.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_size} +\alias{model_size} +\title{Number of triples in a graph} +\usage{ +model_size(m, graph = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{graph}{Optional named graph IRI to count (default graph if NULL).} +} +\value{ +An integer triple count. +} +\description{ +Pipe-friendly wrapper for \code{Model$size()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_truncate_graph.Rd b/maplibr/man/model_truncate_graph.Rd new file mode 100644 index 00000000..5ebc783d --- /dev/null +++ b/maplibr/man/model_truncate_graph.Rd @@ -0,0 +1,22 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_truncate_graph} +\alias{model_truncate_graph} +\title{Remove all triples from a graph} +\usage{ +model_truncate_graph(m, graph = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{graph}{Optional named graph IRI to truncate (default graph if NULL).} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$truncate_graph()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_update.Rd b/maplibr/man/model_update.Rd new file mode 100644 index 00000000..a136e9d6 --- /dev/null +++ b/maplibr/man/model_update.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_update} +\alias{model_update} +\title{Run a SPARQL UPDATE against a Model} +\usage{ +model_update(m, update, graph = NULL, include_transient = FALSE) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{update}{The SPARQL UPDATE string.} + +\item{graph}{Optional named graph IRI to restrict the update to (no +restriction, i.e. the whole store, if NULL).} + +\item{include_transient}{Whether the WHERE clause may match transient +(e.g. inferred) triples.} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$update()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_write.Rd b/maplibr/man/model_write.Rd new file mode 100644 index 00000000..3570c63e --- /dev/null +++ b/maplibr/man/model_write.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_write} +\alias{model_write} +\title{Serialize a Model's triples to a file} +\usage{ +model_write(m, path, format = NULL, graph = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{path}{Path to write the RDF file to (overwritten if it exists).} + +\item{format}{One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples".} + +\item{graph}{Optional named graph IRI to write (default graph if NULL).} +} +\value{ +\code{m}, invisibly. +} +\description{ +Pipe-friendly wrapper for \code{Model$write()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/man/model_writes.Rd b/maplibr/man/model_writes.Rd new file mode 100644 index 00000000..d63fb22f --- /dev/null +++ b/maplibr/man/model_writes.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/model-verbs.R +\name{model_writes} +\alias{model_writes} +\title{Serialize a Model's triples to a string} +\usage{ +model_writes(m, format = NULL, graph = NULL) +} +\arguments{ +\item{m}{A \code{Model}.} + +\item{format}{One of "ntriples", "turtle", "xml" (rdf/xml). Defaults to "ntriples".} + +\item{graph}{Optional named graph IRI to write (default graph if NULL).} +} +\value{ +A string. +} +\description{ +Pipe-friendly wrapper for \code{Model$writes()}. +} +\seealso{ +\link{Model} +} diff --git a/maplibr/tests/testthat/test-model-verbs.R b/maplibr/tests/testthat/test-model-verbs.R new file mode 100644 index 00000000..88fa6847 --- /dev/null +++ b/maplibr/tests/testthat/test-model-verbs.R @@ -0,0 +1,120 @@ +# Tests for the model_*() pipe-friendly functional wrappers (R/model-verbs.R). +# Each one is a thin passthrough to the matching Model R6 method, so these +# tests check the passthrough is correct (right args, right return value), +# not the underlying behavior itself (already covered by the R6-method +# tests in the other test-model-*.R files). + +test_that("mutating model_*() verbs return the model invisibly, and it stays chainable via |>", { + m <- Model$new() |> + model_reads(' .', format = "ntriples") |> + model_update("INSERT { ?s \"seen\" } WHERE { ?s ?p ?o }") |> + model_add_prefixes(list(ex = "http://ex/")) |> + model_create_index() + + expect_s3_class(m, "Model") + expect_equal(model_size(m), 2) +}) + +test_that("model_query() returns a data.frame, usable as the end of a pipe", { + m <- Model$new() |> model_reads(' .', format = "ntriples") + df <- m |> model_query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }") + expect_s3_class(df, "data.frame") + expect_equal(df$s, "http://ex/a") +}) + +test_that("model_query()'s bindings argument passes through correctly", { + m <- Model$new() + df <- m |> model_query( + 'SELECT * WHERE { VALUES ?a { "a" "b" } FILTER(?a = ?b) }', + bindings = list(b = Literal("b")) + ) + expect_equal(nrow(df), 1) +}) + +test_that("model_insert() adds a CONSTRUCT query's results", { + m <- Model$new() |> model_reads(' .', format = "ntriples") + m |> model_insert("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }", target_graph = "http://ex/g2") + expect_equal(model_size(m, graph = "http://ex/g2"), 1) +}) + +test_that("model_read()/model_write() round-trip through a file", { + m <- Model$new() |> model_reads(' .', format = "ntriples") + path <- tempfile(fileext = ".nt") + on.exit(unlink(path)) + m |> model_write(path) + + m2 <- Model$new() |> model_read(path) + expect_equal(model_size(m2), 1) +}) + +test_that("model_add_template() returns the template IRI (not chainable), model_map() expands it", { + ex <- Prefix("http://ex/", "ex") + var <- Variable("V") + template <- Template( + iri = suf(ex, "T"), + parameters = list(Parameter(var)), + instances = list(Triple(suf(ex, "a"), suf(ex, "b"), var)) + ) + m <- Model$new() + iri <- m |> model_add_template(template) + expect_equal(iri, "http://ex/T") + + m |> model_map(template, data.frame(V = "c")) + expect_equal(model_size(m), 1) +}) + +test_that("model_map_df()/model_map_triples()/model_map_json_string()/model_map_xml_string() all delegate correctly", { + m1 <- Model$new() + m1 |> model_map_df(data.frame(name = "Alice")) + expect_gt(model_size(m1), 0) + + m2 <- Model$new() + m2 |> model_map_triples( + data.frame(subject = "http://ex/a", object = "http://ex/b"), + predicate = "http://ex/knows" + ) + expect_equal(model_size(m2), 1) + + m3 <- Model$new() + m3 |> model_map_json_string('{"a": 1}') + expect_gt(model_size(m3), 0) + + m4 <- Model$new() + m4 |> model_map_xml_string("1") + expect_gt(model_size(m4), 0) +}) + +test_that("model_detach_graph() returns a new Model, and model_add_graph() copies into one", { + m <- Model$new() |> model_reads( + ' .', format = "ntriples", graph = "http://ex/g1" + ) + sprout <- m |> model_detach_graph(graph = "http://ex/g1") + expect_s3_class(sprout, "Model") + expect_equal(model_size(sprout), 1) + + m2 <- Model$new() |> model_add_graph(sprout) + expect_equal(model_size(m2), 1) +}) + +test_that("model_infer_rdfs() returns the inferred-triple count", { + m <- Model$new() |> model_reads(paste( + " .", + " .", + sep = "\n" + ), format = "turtle") + n <- m |> model_infer_rdfs() + expect_gt(n, 0) +}) + +test_that("model_writes() returns a string, and model_truncate_graph() empties a graph", { + m <- Model$new() |> model_reads(' .', format = "ntriples") + expect_type(model_writes(m), "character") + m |> model_truncate_graph() + expect_equal(model_size(m), 0) +}) + +test_that("model_deserialize() is a pipe source, not a target -- creates a fresh Model from a path", { + # No `m` argument at all -- unlike every other model_*() verb, it can only + # start a pipe, not receive one: model_deserialize(path) |> model_query(...). + expect_equal(names(formals(model_deserialize)), c("path", "storage_folder")) +}) diff --git a/maplibr/vignettes/maplibr.Rmd b/maplibr/vignettes/maplibr.Rmd index 6c45ee15..570ab1d4 100644 --- a/maplibr/vignettes/maplibr.Rmd +++ b/maplibr/vignettes/maplibr.Rmd @@ -264,6 +264,34 @@ if (requireNamespace("igraph", quietly = TRUE)) { } ``` +## Pipe-friendly usage + +Every `Model` method above is also available as a standalone `model_*()` +function taking the Model as its first argument, so `m |> model_query(...)` +works as an alternative to `m$query(...)` -- both are equivalent and stay +available side by side (the same convention `arrow` and `torch` use for +their own R6-based objects). This rewrites the "Creating a Model" example +from the top of this vignette as one pipeline: + +```{r pipe-example} +m_piped <- Model$new() |> + model_reads(' + . + "Alice" . + ', format = "ntriples") |> + model_update(' + PREFIX ex: + INSERT { ex:alice ex:greeting "Hello" } WHERE {} + ') + +m_piped |> model_query("SELECT ?p ?o WHERE { ?p ?o }") +``` + +The `model_` prefix (rather than bare `read()`/`write()`/`map()`/`update()`) +is deliberate: those names would mask `base::write`, `purrr::map`, and the +`stats::update` S3 generic if this package were attached alongside a normal +tidyverse session. + ## What doesn't work yet, and why | Method(s) | Status | From 30742ce233800250d43b9fbf0f636a78fdc46572 Mon Sep 17 00:00:00 2001 From: novica Date: Mon, 14 Sep 2026 21:52:15 +0200 Subject: [PATCH 44/46] docs(maplibr): switch getting-started vignette to pipe-first style - rewrites examples to use model_*() + |> instead of R6 $-methods; both styles remain supported - notes model_add_template()/model_infer_rdfs() return non-Model values, so aren't piped onward Co-Authored-By: Claude Sonnet 5 --- maplibr/vignettes/maplibr.Rmd | 192 ++++++++++++++++------------------ 1 file changed, 89 insertions(+), 103 deletions(-) diff --git a/maplibr/vignettes/maplibr.Rmd b/maplibr/vignettes/maplibr.Rmd index 570ab1d4..3f7ed45f 100644 --- a/maplibr/vignettes/maplibr.Rmd +++ b/maplibr/vignettes/maplibr.Rmd @@ -17,6 +17,15 @@ through what's implemented today: building a graph, querying and updating it with SPARQL, mapping data into RDF (both with OTTR templates and directly), RDFS inference, and managing named graphs. +`Model` is an R6 class, so every method is also available as `m$method(...)` +-- but this vignette uses the pipe-friendly `model_*()` functions throughout +instead (`m |> model_query(...)` rather than `m$query(...)`), since both are +fully equivalent and interchangeable, and the pipe form reads better across +a multi-step pipeline. `model_*()` uses that prefix rather than bare +`read()`/`write()`/`map()`/`update()` deliberately: those names would mask +`base::write`, `purrr::map`, and the `stats::update` S3 generic if this +package were attached alongside a normal tidyverse session. + ```{r setup} library(maplibr) ``` @@ -24,58 +33,58 @@ library(maplibr) ## Creating a Model and reading triples `Model` is the main entry point -- an in-memory RDF knowledge graph. +`model_reads()` mutates it in place and returns it invisibly, so it chains +straight off `Model$new()`: ```{r create-and-read} -m <- Model$new() - -m$reads(' - . - . - "Alice" . -', format = "ntriples") +m <- Model$new() |> + model_reads(' + . + . + "Alice" . + ', format = "ntriples") -m$size() +model_size(m) ``` ## Serializing back out ```{r writes} -cat(m$writes()) +m |> model_writes() |> cat() ``` Turtle works too: ```{r writes-turtle} -cat(m$writes(format = "turtle")) +m |> model_writes(format = "turtle") |> cat() ``` -`$read()`/`$write()` are the file-based counterparts to -`$reads()`/`$writes()` -- `$read()` guesses the format from the file -extension (`.ttl`/`.nt`/`.xml`/`.rdf`) when `format` isn't given: +`model_read()`/`model_write()` are the file-based counterparts to +`model_reads()`/`model_writes()` -- `model_read()` guesses the format from +the file extension (`.ttl`/`.nt`/`.xml`/`.rdf`) when `format` isn't given: ```{r read-write-file} path <- tempfile(fileext = ".ttl") -m$write(path, format = "turtle") +m |> model_write(path, format = "turtle") -m_from_file <- Model$new() -m_from_file$read(path) -m_from_file$size() +m_from_file <- Model$new() |> model_read(path) +model_size(m_from_file) unlink(path) ``` ## Querying with SPARQL -`$query()` runs a SPARQL query and returns the result as a data.frame. +`model_query()` runs a SPARQL query and returns the result as a data.frame. ```{r query-select} -m$query("SELECT ?s ?o WHERE { ?s ?o }") +m |> model_query("SELECT ?s ?o WHERE { ?s ?o }") ``` CONSTRUCT queries are supported too, returned as a combined subject/predicate/object data.frame: ```{r query-construct} -m$query(" +m |> model_query(" CONSTRUCT { ?s ?type } WHERE { ?s a ?type } ") @@ -86,7 +95,7 @@ the query runs -- useful inside a `FILTER`, though not as a `SELECT`-ed column itself: ```{r query-bindings} -m$query( +m |> model_query( 'SELECT * WHERE { VALUES ?name { "Alice" "Bob" } FILTER(?name = ?who) }', bindings = list(who = Literal("Bob")) ) @@ -94,34 +103,34 @@ m$query( ## Updating data with SPARQL UPDATE and INSERT -`$update()` runs a SPARQL UPDATE (a `DELETE`/`INSERT` ... `WHERE` modify -statement) against the Model in place. The bare `INSERT DATA { ... }` form -isn't supported yet by the underlying engine (see the table at the end) -- -use an empty `WHERE {}` instead: +`model_update()` runs a SPARQL UPDATE (a `DELETE`/`INSERT` ... `WHERE` +modify statement) against the Model in place. The bare `INSERT DATA { ... }` +form isn't supported yet by the underlying engine (see the table at the +end) -- use an empty `WHERE {}` instead: ```{r update} -m$update(' +m |> model_update(' PREFIX ex: INSERT { ex:alice ex:greeting "Hello" } WHERE {} ') -m$query("SELECT ?o WHERE { ?o }") +m |> model_query("SELECT ?o WHERE { ?o }") ``` -`$insert()` runs a SPARQL `CONSTRUCT` query and adds its results as new +`model_insert()` runs a SPARQL `CONSTRUCT` query and adds its results as new triples, optionally into a different graph than it queried: ```{r insert} -m$insert( +m |> model_insert( "CONSTRUCT { ?s ?type } WHERE { ?s a ?type }", target_graph = "urn:graph:types" ) -m$size("urn:graph:types") +model_size(m, "urn:graph:types") ``` ## Mapping tabular data into RDF with OTTR templates -`$map()` expands an OTTR template against a data.frame, adding the resulting -triples to the Model. Templates can be built programmatically... +`model_map()` expands an OTTR template against a data.frame, adding the +resulting triples to the Model. Templates can be built programmatically... ```{r map-programmatic} ex <- Prefix("http://example.org/", "ex") @@ -132,12 +141,15 @@ template <- Template( instances = list(Triple(suf(ex, "alice"), suf(ex, "nickname"), my_value)) ) -m$map(template, data.frame(MyValue = c("Ally", "Al"))) -m$query("SELECT ?o WHERE { ?o }") +m |> model_map(template, data.frame(MyValue = c("Ally", "Al"))) +m |> model_query("SELECT ?o WHERE { ?o }") ``` ...or from an stOTTR document string, matching `py_maplib`'s own syntax (see -the root project's README for the OTTR template language itself): +the root project's README for the OTTR template language itself). Since +`model_add_template()` returns the registered template's IRI rather than +`m`, it's not itself chained onward -- pipe into it, but capture its result +separately from the `m` you keep mutating: ```{r map-doc-string} doc <- ' @@ -146,64 +158,64 @@ ex:HasFriend [?Friend] :: { ottr:Triple(ex:alice, ex:knows, ?Friend) } . ' -m$add_template(doc) -m$map("http://example.org/HasFriend", data.frame(Friend = "http://example.org/bob")) -m$query("SELECT ?o WHERE { ?o }") +m |> model_add_template(doc) +m |> model_map("http://example.org/HasFriend", data.frame(Friend = "http://example.org/bob")) +m |> model_query("SELECT ?o WHERE { ?o }") ``` ## Mapping data directly to triples, without a template -For data that doesn't need OTTR's templating power, several `$map_*()` -methods add triples directly instead. `$map_df()` turns a data.frame's -columns into predicates, one row per subject: +For data that doesn't need OTTR's templating power, several `model_map_*()` +functions add triples directly instead. `model_map_df()` turns a +data.frame's columns into predicates, one row per subject: ```{r map-df} readings <- data.frame(city = c("Oslo", "Bergen"), temp_c = c(14, 16)) -m_direct <- Model$new() -m_direct$map_df(readings) -m_direct$query("SELECT ?p ?o WHERE { ?s ?p ?o }") +m_direct <- Model$new() |> model_map_df(readings) +m_direct |> model_query("SELECT ?p ?o WHERE { ?s ?p ?o }") ``` -`$map_triples()` maps subject/predicate/object columns directly (or just -subject/object, with a constant `predicate`): +`model_map_triples()` maps subject/predicate/object columns directly (or +just subject/object, with a constant `predicate`): ```{r map-triples} -m_triples <- Model$new() -m_triples$map_triples( +m_triples <- Model$new() |> model_map_triples( data.frame(subject = "http://example.org/alice", object = "http://example.org/bob"), predicate = "http://example.org/knows" ) -m_triples$query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }") +m_triples |> model_query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }") ``` -`$map_json_string()`/`$map_xml_string()` (and their file-based -`$map_json()`/`$map_xml()` counterparts) apply a fixed convention +`model_map_json_string()`/`model_map_xml_string()` (and their file-based +`model_map_json()`/`model_map_xml()` counterparts) apply a fixed convention (Facade-X) to a whole JSON or XML document instead: ```{r map-json} -m_json <- Model$new() -m_json$map_json_string('{"name": "Alice", "age": 30}') -m_json$query("SELECT ?p ?o WHERE { ?s ?p ?o }") +m_json <- Model$new() |> model_map_json_string('{"name": "Alice", "age": 30}') +m_json |> model_query("SELECT ?p ?o WHERE { ?s ?p ?o }") ``` ## RDFS inference `alice` is a `Person`, and `Person` is a subclass of `Agent` -- RDFS inference should let us derive that `alice` is also an `Agent`. +`model_infer_rdfs()` returns the number of newly-inferred triples, not `m`, +so this one's called directly rather than piped further: ```{r infer} -n <- m$infer_rdfs() +n <- m |> model_infer_rdfs() n ``` Inferred triples are *transient* -- kept separately from the main graph, and -only visible to `$query()` when asked for explicitly (`$writes()` doesn't -currently support this at all, a core-engine limitation, not an R-wrapper -one -- see the "Upstream issues" section of the package's development notes): +only visible to `model_query()` when asked for explicitly (`model_writes()` +doesn't currently support this at all, a core-engine limitation, not an +R-wrapper one -- see the "Upstream issues" section of the package's +development notes): ```{r infer-transient} -without <- m$query("SELECT * WHERE { ?s ?p ?o }") -with_transient <- m$query("SELECT * WHERE { ?s ?p ?o }", include_transient = TRUE) +without <- m |> model_query("SELECT * WHERE { ?s ?p ?o }") +with_transient <- m |> model_query("SELECT * WHERE { ?s ?p ?o }", include_transient = TRUE) nrow(without) nrow(with_transient) ``` @@ -214,28 +226,30 @@ Triples can be routed into named graphs, copied between Models, and split back out. ```{r graphs} -m$reads( +m |> model_reads( ' "Bob" .', format = "ntriples", graph = "urn:graph:extra" ) -m$size("urn:graph:extra") -m$size() # default graph, unaffected +model_size(m, "urn:graph:extra") +model_size(m) # default graph, unaffected -m2 <- Model$new() -m2$add_graph(m) -m2$size() +m2 <- Model$new() |> model_add_graph(m) +model_size(m2) -sprout <- m$detach_graph(graph = "urn:graph:extra") -sprout$size() -m$size("urn:graph:extra") # emptied by the detach +sprout <- m |> model_detach_graph(graph = "urn:graph:extra") +model_size(sprout) +model_size(m, "urn:graph:extra") # emptied by the detach ``` ## Prefixes and indexing +Both mutate in place, so a two-step setup pipeline chains naturally: + ```{r prefixes-index} -m$add_prefixes(list(ex = "http://example.org/")) -m$create_index() +m |> + model_add_prefixes(list(ex = "http://example.org/")) |> + model_create_index() ``` ## Visualizing a graph @@ -245,7 +259,7 @@ is a plain data.frame, so any R graph package works directly on it. ```{r graph-plot, fig.width=6, fig.height=4.5} if (requireNamespace("igraph", quietly = TRUE)) { - triples <- m$query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }") + triples <- m |> model_query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }") short_label <- function(iri) sub(".*[/#]", "", iri) edges <- data.frame(from = short_label(triples$s), to = short_label(triples$o)) @@ -264,43 +278,15 @@ if (requireNamespace("igraph", quietly = TRUE)) { } ``` -## Pipe-friendly usage - -Every `Model` method above is also available as a standalone `model_*()` -function taking the Model as its first argument, so `m |> model_query(...)` -works as an alternative to `m$query(...)` -- both are equivalent and stay -available side by side (the same convention `arrow` and `torch` use for -their own R6-based objects). This rewrites the "Creating a Model" example -from the top of this vignette as one pipeline: - -```{r pipe-example} -m_piped <- Model$new() |> - model_reads(' - . - "Alice" . - ', format = "ntriples") |> - model_update(' - PREFIX ex: - INSERT { ex:alice ex:greeting "Hello" } WHERE {} - ') - -m_piped |> model_query("SELECT ?p ?o WHERE { ?p ?o }") -``` - -The `model_` prefix (rather than bare `read()`/`write()`/`map()`/`update()`) -is deliberate: those names would mask `base::write`, `purrr::map`, and the -`stats::update` S3 generic if this package were attached alongside a normal -tidyverse session. - ## What doesn't work yet, and why | Method(s) | Status | |---|---| -| `$writes(include_transient = TRUE)` | Not supported for any format -- the underlying engine's triple-serialization code has no way to include transient/inferred triples, unlike its query path. A core-engine limitation, not specific to this package. | +| `model_writes(include_transient = TRUE)` | Not supported for any format -- the underlying engine's triple-serialization code has no way to include transient/inferred triples, unlike its query path. A core-engine limitation, not specific to this package. | | `$validate()` (SHACL) | Not implemented -- also blocked on a licensing question (the root project's README describes SHACL as proprietary, but its source is present and unrestricted in this checkout; not a decision this package can make unilaterally). | -| `$serialize()` / `$deserialize()` | Wired up correctly, but panic at runtime -- the on-disk storage engine they depend on is a closed-source stub in this checkout. | +| `model_serialize()` / `model_deserialize()` | Wired up correctly, but panic at runtime -- the on-disk storage engine they depend on is a closed-source stub in this checkout. | | `$add_udf()` | Not implemented -- the underlying engine's UDF callback mechanism is CPython-specific end to end (it calls into Python's own `io`/`polars` modules to invoke the callback), with no way for a non-Python binding to register one instead. A core-engine limitation. | -| bare `INSERT DATA { ... }` / `DELETE DATA { ... }` (in `$update()`) | Panics instead of raising a normal error -- unimplemented in the underlying engine's SPARQL UPDATE handling. Use `INSERT { ... } WHERE {}` / `DELETE { ... } WHERE {}` instead, which works correctly. A core-engine limitation. | +| bare `INSERT DATA { ... }` / `DELETE DATA { ... }` (in `model_update()`) | Panics instead of raising a normal error -- unimplemented in the underlying engine's SPARQL UPDATE handling. Use `INSERT { ... } WHERE {}` / `DELETE { ... } WHERE {}` instead, which works correctly. A core-engine limitation. | | CIM XML, HDT, JSON-LD | Not ported. | See the package's development notes for the full, current list of known From 11cd2a1ec3d17bc2c80e986f166bbea26b3de96a Mon Sep 17 00:00:00 2001 From: novica Date: Mon, 14 Sep 2026 22:02:46 +0200 Subject: [PATCH 45/46] docs(maplibr): drop the "what doesn't work yet" table from the vignette - limitations are already tracked separately; avoids two lists drifting out of sync Co-Authored-By: Claude Sonnet 5 --- maplibr/vignettes/maplibr.Rmd | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/maplibr/vignettes/maplibr.Rmd b/maplibr/vignettes/maplibr.Rmd index 3f7ed45f..5e9329d3 100644 --- a/maplibr/vignettes/maplibr.Rmd +++ b/maplibr/vignettes/maplibr.Rmd @@ -105,8 +105,8 @@ m |> model_query( `model_update()` runs a SPARQL UPDATE (a `DELETE`/`INSERT` ... `WHERE` modify statement) against the Model in place. The bare `INSERT DATA { ... }` -form isn't supported yet by the underlying engine (see the table at the -end) -- use an empty `WHERE {}` instead: +form isn't supported yet by the underlying engine -- use an empty `WHERE {}` +instead: ```{r update} m |> model_update(' @@ -277,17 +277,3 @@ if (requireNamespace("igraph", quietly = TRUE)) { ) } ``` - -## What doesn't work yet, and why - -| Method(s) | Status | -|---|---| -| `model_writes(include_transient = TRUE)` | Not supported for any format -- the underlying engine's triple-serialization code has no way to include transient/inferred triples, unlike its query path. A core-engine limitation, not specific to this package. | -| `$validate()` (SHACL) | Not implemented -- also blocked on a licensing question (the root project's README describes SHACL as proprietary, but its source is present and unrestricted in this checkout; not a decision this package can make unilaterally). | -| `model_serialize()` / `model_deserialize()` | Wired up correctly, but panic at runtime -- the on-disk storage engine they depend on is a closed-source stub in this checkout. | -| `$add_udf()` | Not implemented -- the underlying engine's UDF callback mechanism is CPython-specific end to end (it calls into Python's own `io`/`polars` modules to invoke the callback), with no way for a non-Python binding to register one instead. A core-engine limitation. | -| bare `INSERT DATA { ... }` / `DELETE DATA { ... }` (in `model_update()`) | Panics instead of raising a normal error -- unimplemented in the underlying engine's SPARQL UPDATE handling. Use `INSERT { ... } WHERE {}` / `DELETE { ... } WHERE {}` instead, which works correctly. A core-engine limitation. | -| CIM XML, HDT, JSON-LD | Not ported. | - -See the package's development notes for the full, current list of known -issues and their upstream/local status. From b1b96ff6a24fb55ae2e27badd0588efafbe65a86 Mon Sep 17 00:00:00 2001 From: novica Date: Wed, 16 Sep 2026 20:24:04 +0200 Subject: [PATCH 46/46] docs(maplibr): package readme --- maplibr/README.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 maplibr/README.md diff --git a/maplibr/README.md b/maplibr/README.md new file mode 100644 index 00000000..b87fea4b --- /dev/null +++ b/maplibr/README.md @@ -0,0 +1,43 @@ +# maplibr + +An R interface to the [maplib](https://github.com/DataTreehouse/maplib) knowledge +graph engine: build, query, and reason over RDF graphs from tabular data using +OTTR mapping templates, SPARQL, and the Arrow C Stream Interface for zero-copy +data frame results. + +## Installation + +```r +# from source, requires Cargo/rustc (see SystemRequirements in DESCRIPTION) +devtools::install() +``` + +## Known issue: `undefined symbol: _Py_Dealloc` when loading the package + +If `devtools::load_all()`, `devtools::check()`, or `library(maplibr)` fails +with something like: + +``` +Error in dyn.load(...) : + unable to load shared object '.../maplibr.so': + .../maplibr.so: undefined symbol: _Py_Dealloc +``` + +this is a known upstream issue in `maplib`, not a bug in this package. +`maplib`'s public API has an unconditional dependency on `pyo3` +(Python's Rust FFI crate), so `maplibr` is forced to compile against it +even though it never uses Python. On Linux, `pyo3-ffi` +never links `libpython` at build time — it expects the *loading process* to +supply those symbols, which works for Python but not for R's `dyn.load()`. + +**Workaround:** preload `libpython` into R's process before it loads the +package: + +```bash +LD_PRELOAD=$(python3-config --prefix)/lib/libpython3.$(python3 -c 'import sys;print(sys.version_info[1])').so.1.0 \ + Rscript -e 'devtools::load_all("maplibr")' +``` + +If that path doesn't resolve on your system, find the right one with +`ldconfig -p | grep libpython`. Needed for `load_all()`, `document()`, +`check()`, and any R session that actually calls into `Model`.