Skip to content

Commit 2e25236

Browse files
Eric Prud'hommeauxclaude
andcommitted
+ ShExMap: schema-to-schema mapping via Map/# semantic actions
Implement http://shex.io/extensions/Map/# (lib/ShExMap.{hpp,cpp}), ported from shex.js's @shexjs/extension-map: validating data against a source schema whose triple constraints carry %Map:{ pfx:var %} actions binds each matched value to the (prefix-expanded) variable, and materialization walks a target schema emitting those bindings as a new graph. Lift/lower functions are supported on both sides: %Map:{ regex(/(?<bp:family>[a-zA-Z]+), (?<bp:given>[a-zA-Z]+)/) %} %Map:{ hashmap(bp:code, {"D": "Divorced", ...}) %} with named-group regexes rewritten for Boost.Regex and hashmaps read via MiniJSON. The validator gains a SemActHandler interface (in addition to the built-in Test language): handlers see each action with its focus node and the triples the accepted matching assigned to its constraint, and mark()/rollback() scope handler state to successful validation subtrees -- without that, probing a sibling shape (e.g. a systolic component against the diastolic shape) leaks spurious bindings. Bindings are an ordered log frozen into per-variable FIFO queues: variables bound once (and manifest staticVars) stay available, while multi-valued queues pop, and only pops count as "consumption". The materializer uses that to decide repetition: a repeated triple constraint (@<Shape>* etc.) keeps emitting fresh bnodes only while each repetition consumes bindings, which reproduces shex.js's multi-bindings behavior without its solution-tree binder. Manifest entries (JSON or YAML) drive mapping with outputSchema[URL], outputShape, createRoot and staticVars, plus optional expectedBindingsURL and outputDataURL checks (materialized graphs compared bnode-isomorphically); bin/sparql --shex-manifest prints the materialized graph. tests/ShExMap/ carries the BP FHIR<->DAM round trip and the multi-bindings case from shex.js's examples; the ShEx suite is now 1736 cases, all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 19a38cf commit 2e25236

21 files changed

Lines changed: 1305 additions & 30 deletions

‎CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ add_library(SWObjects STATIC
165165
lib/ShExSchema.cpp
166166
lib/ShExShapeMap.cpp
167167
lib/ShExManifest.cpp
168+
lib/ShExMap.cpp
168169
lib/SWObjects.cpp
169170
${SWOBJ_GENERATED_SOURCES}
170171
)

‎README‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ see lib/ShExManifest.hpp and tests/ShExManifest/):
6060
--shex-run selects 0-based entries: ranges like "1", "2-5", "5-2"
6161
(descending) or "*" (everything), comma-separated.
6262

63+
lib/ShExMap.{hpp,cpp} implements the http://shex.io/extensions/Map/#
64+
semantic-action language for mapping between two schemas: validating
65+
against a source schema whose constraints carry %Map:{ var %} (or
66+
regex(...)/hashmap(...) lift functions) collects variable bindings,
67+
and materialization walks a target schema emitting the bound (or
68+
lowered) values. Manifest entries drive it with outputSchema[URL],
69+
outputShape, createRoot, staticVars and optional expectedBindingsURL /
70+
outputDataURL comparisons (see tests/ShExMap/, ported from shex.js's
71+
packages/extension-map examples):
72+
73+
bin/sparql --shex-manifest tests/ShExMap/manifest.yaml
74+
6375
# More info
6476

6577
To learn more about SWObjects, checkout some tutorials given at SWAT4LS in 2010 and 2011:

‎bin/sparql.cpp‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1075,7 +1075,7 @@ int main(int ac, char* av[])
10751075
std::cout << (outcome.allAsAsserted ? "conformant" : "nonconformant")
10761076
<< ", expected "
10771077
<< (entry.expectConformant ? "conformant" : "nonconformant")
1078-
<< (outcome.statusMatched ? " -- PASS" : " -- FAIL") << "\n";
1078+
<< (outcome.passed() ? " -- PASS" : " -- FAIL") << "\n";
10791079
for (std::vector<sw::ShEx::AssociationResult>::const_iterator r
10801080
= outcome.results.begin(); r != outcome.results.end(); ++r)
10811081
std::cout << " " << r->assoc.node->toString()
@@ -1084,7 +1084,13 @@ int main(int ac, char* av[])
10841084
: std::string("START"))
10851085
<< ": " << (r->conformant ? "conformant" : "nonconformant")
10861086
<< "\n";
1087-
if (!outcome.statusMatched)
1087+
for (std::vector<std::string>::const_iterator p = outcome.problems.begin();
1088+
p != outcome.problems.end(); ++p)
1089+
std::cout << " ! " << *p << "\n";
1090+
if (entry.isMapTest() && !outcome.outputGraph.empty()
1091+
&& outcome.problems.empty())
1092+
std::cout << outcome.outputGraph;
1093+
if (!outcome.passed())
10881094
++failures;
10891095
}
10901096
return failures > 125 ? 125 : failures;

‎lib/SWObjects.hpp‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4487,6 +4487,7 @@ class NamespaceMap {
44874487
return vi->second;
44884488
}
44894489
virtual void set (std::string prefix, const URI* uri) { ns[prefix] = uri; }
4490+
const std::map<std::string, const URI*>& raw () const { return ns; }
44904491
virtual std::string unmap (std::string mapped);
44914492

44924493
typedef std::map<std::string, const URI*>::iterator iterator;

‎lib/ShExCParser.ypp‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,6 +1425,12 @@ void ShExDriver::parse (IStreamContext& in, ShEx::Schema* intoSchema) {
14251425
parser.set_debug_level(trace_parsing);
14261426
parser.parse();
14271427
checkErrors();
1428+
1429+
// keep the prefixes for semantic-action languages (e.g. ShExMap)
1430+
const std::map<std::string, const URI*>& ns = getNamespaceMap()->raw();
1431+
for (std::map<std::string, const URI*>::const_iterator it = ns.begin();
1432+
it != ns.end(); ++it)
1433+
intoSchema->prefixes[it->first] = it->second->getLexicalValue();
14281434
}
14291435

14301436
std::string ShExDriver::facetKey (const ShEx::NCComponent* c) {

‎lib/ShExManifest.cpp‎

Lines changed: 164 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
#include "ShExManifest.hpp"
44
#include "MiniJSON.hpp"
5+
#include "ShExMap.hpp"
56
#include "ShExCParser.hpp"
67
#include "TurtleParser.hpp"
78
#include "RdfDB.hpp"
@@ -137,6 +138,32 @@ namespace ShEx {
137138
std::string key = strip(cur.substr(indent, colon - indent));
138139
std::string value = strip(cur.substr(colon + 1));
139140
++i;
141+
if (value.empty()) {
142+
// a one-level nested mapping (e.g. staticVars:)
143+
while (i < yl.lines.size()) {
144+
std::string nl = yl.lines[i];
145+
if (strip(nl).empty()) {
146+
++i;
147+
continue;
148+
}
149+
size_t nIndent = YamlLines::indentOf(nl);
150+
if (nIndent <= indent || nl.compare(0, 2, "- ") == 0)
151+
break;
152+
size_t nColon = nl.find(':', nIndent);
153+
if (nColon == std::string::npos)
154+
throw ManifestError("expected nested \"key: value\": " + nl);
155+
std::string nKey = strip(nl.substr(nIndent, nColon - nIndent));
156+
std::string nValue = strip(nl.substr(nColon + 1));
157+
if (!nValue.empty() && nValue[0] == '\'')
158+
nValue = nValue.size() >= 2 && nValue[nValue.size()-1] == '\''
159+
? nValue.substr(1, nValue.size()-2) : nValue;
160+
else if (!nValue.empty() && nValue[0] == '"')
161+
nValue = unescapeDoubleQuoted(nValue.substr(1, nValue.size()-2), i);
162+
item[key + "\t" + nKey] = nValue;
163+
++i;
164+
}
165+
continue;
166+
}
140167
if (value == "|" || value == "|-") {
141168
bool clip = value == "|-";
142169
// literal block scalar: lines indented past the key
@@ -201,6 +228,15 @@ namespace ShEx {
201228
if ((it = kv.find("dataURL")) != kv.end()) e.dataURL = it->second;
202229
if ((it = kv.find("queryMap")) != kv.end()) e.queryMap = it->second;
203230
if ((it = kv.find("queryMapURL")) != kv.end()) e.queryMapURL = it->second;
231+
if ((it = kv.find("outputSchema")) != kv.end()) e.outputSchema = it->second;
232+
if ((it = kv.find("outputSchemaURL")) != kv.end()) e.outputSchemaURL = it->second;
233+
if ((it = kv.find("outputShape")) != kv.end()) e.outputShape = it->second;
234+
if ((it = kv.find("createRoot")) != kv.end()) e.createRoot = it->second;
235+
if ((it = kv.find("expectedBindingsURL")) != kv.end()) e.expectedBindingsURL = it->second;
236+
if ((it = kv.find("outputDataURL")) != kv.end()) e.outputDataURL = it->second;
237+
for (it = kv.begin(); it != kv.end(); ++it)
238+
if (it->first.compare(0, 11, "staticVars\t") == 0)
239+
e.staticVars[it->first.substr(11)] = it->second;
204240
std::string status;
205241
if ((it = kv.find("status")) != kv.end()) status = it->second;
206242
std::stringstream where;
@@ -222,6 +258,114 @@ namespace ShEx {
222258
}
223259
} // namespace
224260

261+
namespace {
262+
263+
/** Parse a term from a manifest string: "<iri>", a quoted literal, or
264+
* a bare string (taken as a plain literal). */
265+
const TTerm* parseTermString (const std::string& s, AtomFactory& atomFactory) {
266+
std::string t = s;
267+
if (!t.empty() && t[0] == '<' && t[t.size()-1] == '>')
268+
return atomFactory.getURI(t.substr(1, t.size() - 2));
269+
if (t.size() >= 2 && t[0] == '"' && t[t.size()-1] == '"')
270+
return atomFactory.getRDFLiteral(t.substr(1, t.size() - 2), NULL, NULL);
271+
return atomFactory.getRDFLiteral(t, NULL, NULL);
272+
}
273+
274+
/** JSON binding value {value, type?, language?} -> a term. */
275+
const TTerm* bindingValueTerm (const MiniJSON::Value& v, AtomFactory& atomFactory) {
276+
std::string value = v.getString("value");
277+
std::string type = v.getString("type");
278+
std::string language = v.getString("language");
279+
if (!language.empty())
280+
return atomFactory.getRDFLiteral(value, NULL, new LANGTAG(language));
281+
if (!type.empty())
282+
return atomFactory.getRDFLiteral(value, atomFactory.getURI(type), NULL);
283+
return atomFactory.getRDFLiteral(value, NULL, NULL);
284+
}
285+
286+
void compareBindings (const ManifestEntry& entry, const Manifest& manifest,
287+
const ShExMap::Bindings& bindings, AtomFactory& atomFactory,
288+
EntryOutcome& outcome) {
289+
MiniJSON::Value expected
290+
= MiniJSON::parse(readFileOrThrow(manifest.directory + entry.expectedBindingsURL));
291+
if (!expected.isObject())
292+
throw ManifestError(entry.expectedBindingsURL + " should be a JSON object");
293+
for (std::vector<std::pair<std::string, MiniJSON::Value> >::const_iterator it
294+
= expected.object.begin(); it != expected.object.end(); ++it) {
295+
const TTerm* want = bindingValueTerm(it->second, atomFactory);
296+
std::map<std::string, std::deque<const TTerm*> >::const_iterator q
297+
= bindings.queues.find(it->first);
298+
bool found = false;
299+
if (q != bindings.queues.end())
300+
for (std::deque<const TTerm*>::const_iterator b = q->second.begin();
301+
!found && b != q->second.end(); ++b)
302+
found = *b == want;
303+
if (!found)
304+
outcome.problems.push_back("expected binding " + it->first + " = "
305+
+ want->toString());
306+
}
307+
}
308+
309+
void materializeEntry (const ManifestEntry& entry, const Manifest& manifest,
310+
ShExMap::Bindings& bindings, AtomFactory& atomFactory,
311+
EntryOutcome& outcome) {
312+
// target schema
313+
std::string schemaText = entry.outputSchema;
314+
std::string schemaBase = manifest.baseURI;
315+
if (!entry.outputSchemaURL.empty()) {
316+
schemaText = readFileOrThrow(manifest.directory + entry.outputSchemaURL);
317+
schemaBase = libwww::GetAbsoluteURIstring(entry.outputSchemaURL, manifest.baseURI);
318+
}
319+
ShEx::Schema target;
320+
ShExDriver targetDriver(schemaBase, &atomFactory);
321+
{
322+
IStreamContext istr(schemaText, IStreamContext::STRING);
323+
istr.nameStr = schemaBase;
324+
targetDriver.parse(istr, &target);
325+
}
326+
target.checkStructure();
327+
328+
const TTerm* root = NULL;
329+
if (!entry.createRoot.empty()) {
330+
std::string t = entry.createRoot;
331+
if (!t.empty() && t[0] == '<' && t[t.size()-1] == '>')
332+
root = atomFactory.getURI(
333+
libwww::GetAbsoluteURIstring(t.substr(1, t.size()-2), schemaBase));
334+
else
335+
root = parseTermString(t, atomFactory);
336+
}
337+
const TTerm* shapeLabel = NULL;
338+
if (!entry.outputShape.empty()) {
339+
std::string t = entry.outputShape;
340+
if (!t.empty() && t[0] == '<' && t[t.size()-1] == '>')
341+
shapeLabel = atomFactory.getURI(
342+
libwww::GetAbsoluteURIstring(t.substr(1, t.size()-2), schemaBase));
343+
else
344+
shapeLabel = atomFactory.getURI(t);
345+
}
346+
347+
RdfDB outDB;
348+
BasicGraphPattern* outGraph = outDB.ensureGraph(DefaultGraph);
349+
ShExMap::materialize(target, bindings, atomFactory, outGraph, root, shapeLabel);
350+
outcome.outputGraph = outDB.toString(MediaType("text/turtle"));
351+
352+
if (!entry.outputDataURL.empty()) {
353+
std::string expectedText = readFileOrThrow(manifest.directory + entry.outputDataURL);
354+
std::string expectedBase
355+
= libwww::GetAbsoluteURIstring(entry.outputDataURL, manifest.baseURI);
356+
RdfDB expectedDB;
357+
TurtleDriver expectedParser(expectedBase, &atomFactory);
358+
IStreamContext istr(expectedText, IStreamContext::STRING);
359+
istr.nameStr = expectedBase;
360+
expectedParser.parse(istr, expectedDB.ensureGraph(DefaultGraph));
361+
if (!(outDB == expectedDB))
362+
outcome.problems.push_back("materialized graph differs from "
363+
+ entry.outputDataURL + ":\n"
364+
+ outcome.outputGraph);
365+
}
366+
}
367+
} // namespace
368+
225369
Manifest Manifest::parse (const std::string& text, const std::string& path) {
226370
Manifest m;
227371
size_t slash = path.find_last_of('/');
@@ -253,6 +397,11 @@ namespace ShEx {
253397
f = o.object.begin(); f != o.object.end(); ++f)
254398
if (f->second.isString())
255399
kv[f->first] = f->second.string;
400+
else if (f->second.isObject()) // e.g. staticVars
401+
for (std::vector<std::pair<std::string, MiniJSON::Value> >::const_iterator
402+
n = f->second.object.begin(); n != f->second.object.end(); ++n)
403+
if (n->second.isString())
404+
kv[f->first + "\t" + n->first] = n->second.string;
256405
m.entries.push_back(entryFromKeyValues(kv, i));
257406
}
258407
} else {
@@ -312,14 +461,28 @@ namespace ShEx {
312461
std::vector<Association> associations
313462
= parseQueryMap(mapText, &atomFactory, nodeEnv, shapeEnv, *graph);
314463

315-
// ---- validate
464+
// ---- validate, collecting ShExMap bindings
465+
ShExMap::Bindings bindings;
466+
for (std::map<std::string, std::string>::const_iterator sv = entry.staticVars.begin();
467+
sv != entry.staticVars.end(); ++sv)
468+
bindings.setStatic(sv->first, parseTermString(sv->second, atomFactory));
469+
ShExMap::BindingCollector collector(bindings, &atomFactory, schema.prefixes);
316470
Validator validator(schema, *graph);
471+
validator.setSemActHandler(&collector);
317472
outcome.results = evaluate(validator, associations);
318473
outcome.allAsAsserted = true;
319474
for (std::vector<AssociationResult>::const_iterator it = outcome.results.begin();
320475
it != outcome.results.end(); ++it)
321476
outcome.allAsAsserted = outcome.allAsAsserted && it->asAsserted;
322477
outcome.statusMatched = outcome.allAsAsserted == entry.expectConformant;
478+
bindings.freeze();
479+
outcome.bindings = bindings.str();
480+
481+
// ---- ShExMap: check bindings, materialize into the target schema
482+
if (!entry.expectedBindingsURL.empty())
483+
compareBindings(entry, manifest, bindings, atomFactory, outcome);
484+
if (entry.isMapTest() && outcome.statusMatched && outcome.allAsAsserted)
485+
materializeEntry(entry, manifest, bindings, atomFactory, outcome);
323486
} catch (std::exception& e) {
324487
outcome.error = e.what();
325488
} catch (std::string& e) {

‎lib/ShExManifest.hpp‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,17 @@ namespace ShEx {
4444
std::string data, dataURL;
4545
std::string queryMap, queryMapURL;
4646
bool expectConformant;
47+
48+
/* ShExMap entries additionally name a target schema to materialize
49+
* into (see lib/ShExMap.hpp): */
50+
std::string outputSchema, outputSchemaURL;
51+
std::string outputShape; // "<label>" in the output schema; "" = start
52+
std::string createRoot; // subject of the materialized root; "" = bnode
53+
std::map<std::string, std::string> staticVars; // var IRI -> value
54+
std::string expectedBindingsURL; // JSON var -> {value, type?, language?}
55+
std::string outputDataURL; // expected materialized graph (Turtle)
56+
57+
bool isMapTest () const { return !outputSchema.empty() || !outputSchemaURL.empty(); }
4758
};
4859

4960
struct Manifest {
@@ -62,7 +73,11 @@ namespace ShEx {
6273
std::vector<AssociationResult> results;
6374
bool allAsAsserted;
6475
bool statusMatched; // allAsAsserted == entry.expectConformant
76+
std::string bindings; // collected ShExMap bindings (diagnostic)
77+
std::string outputGraph; // materialized graph as Turtle, when mapping
78+
std::vector<std::string> problems; // bindings/output mismatches
6579
EntryOutcome () : allAsAsserted(true), statusMatched(false) { }
80+
bool passed () const { return error.empty() && statusMatched && problems.empty(); }
6681
};
6782

6883
/** Parse the entry's schema, data and query map, validate every

0 commit comments

Comments
 (0)