From 82d983b95dce13feb95c1d2568affcc30feb58b7 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Mon, 23 Mar 2026 13:46:15 +0000 Subject: [PATCH 01/16] Create a ModelChangedListener This is the Jena facility for watching changes to a graph. Support is limited at the moment; specifically - Transactions are not respected. - While removals fire for inferred statements, additions do not. Some of this can be worked around. --- .../factoryplus/metadb/db/NotifyListener.java | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/NotifyListener.java diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/NotifyListener.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/NotifyListener.java new file mode 100644 index 00000000..cfd61b8e --- /dev/null +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/NotifyListener.java @@ -0,0 +1,111 @@ +/* + * Factory+ metadata database + * Change-notify model listener + * Copyright 2026 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.metadb.db; + +import java.util.HashSet; +import java.util.Set; + +import org.apache.jena.rdf.listeners.StatementListener; +import org.apache.jena.rdf.model.*; +import org.apache.jena.vocabulary.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class NotifyListener extends StatementListener +{ + /* Currently (with TDB2) it appears that Jena will only allow one + * write transaction to be active at a time. A subsequent parallel + * txn will block on the first write until the first commits or + * aborts. We rely on this to track which changes belong to which + * txn, and whether they committed or not. If this changes this + * class will need to track thread IDs and rely on the + * one-txn-per-thread restriction instead. */ + + /* Jena ModelChangedListeners do not appear to respect inference + * when reporting additions, but do when reporting removals. This is + * surely a bug but one we have to work with for now. For + * consistency we watch the direct model, and rely on the derived + * model not making use of any RDFS except rdfs:subClassOf. In + * particular: + * - No use of rdfs:subPropertyOf. + * - No use of rdfs:{domain,range} to infer rdf:type. + * + * We can optimise the class structure handling by notifying the + * rank of the changed class. With strict ranks it is not possible + * for a change in one rank to propagate into other ranks. If we + * introduced inference for powertypes this would change. + * + * Subproperties could probably be handled by maintaining a cache of + * the subproperty tree, and firing derived statements by hand. This + * might be difficult when removing statements as we won't know + * whether the derived statement is still present via another route. + * As long as our interface is 'there may have been a change in this + * property' this won't matter. Subproperties of rdf:type/ + * rdfs:subClassOf are likely to be the most important case, and + * could perhaps be handled specially. + * + * Handling derived config entries will be more difficult, even with + * inference restricted to the class structure. Probably it will be + * necessary to be rather crude and maintain a list of 'these + * predicates might affect this Application' and check all entries + * when any change. Restricting the domain of the Application may be + * important here to avoid needing to check every F+ object. + */ + + private record Config (Resource app, Resource obj) {} + + private static final Logger log = LoggerFactory.getLogger(NotifyListener.class); + + private boolean done = false; + private Set changed = new HashSet<>(); + + private void checkDone () + { + if (done) + throw new IllegalStateException("Listener is complete"); + } + + public void commit () + { + checkDone(); + done = true; + } + public void abort () + { + checkDone(); + changed = Set.of(); + done = true; + } + public void end () + { + if (!done) + throw new IllegalStateException("Listener is not complete"); + log.info("CHANGED:"); + for (var p: changed) + log.info(" {}", p); + } + + private void changed (Statement stmt) + { + checkDone(); + changed.add(stmt.getPredicate()); + } + + public void addedStatement (Statement stmt) + { + log.info("ADDED\n {}", stmt); + changed(stmt); + } + + public void removedStatement (Statement stmt) + { + log.info("REMOVED\n {}", stmt); + changed(stmt); + } +} + From f00cc63a09b3dfba8ac4617b869b4cd83a23db8c Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Mon, 23 Mar 2026 14:03:16 +0000 Subject: [PATCH 02/16] Avoid redundant config updates This will be particularly important when we are regenerating structured config entries. --- .../co/amrc/factoryplus/metadb/db/ConfigEntry.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java index fb74c969..cd0d5e98 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java @@ -89,8 +89,16 @@ public void removeValue () db.runUpdate(U_removeValue, "app", app, "obj", obj); } - public void putValue (JsonValue value) + public boolean putValue (JsonValue value) { + var existing = getValue() + .map(Value::value) + .filter(v -> v.equals(value)); + if (existing.isPresent()) { + log.info("Duplicate config update suppressed"); + return false; + } + removeValue(); var json = ResourceFactory.createTypedLiteral( @@ -104,6 +112,8 @@ public void putValue (JsonValue value) graph.add(entry, Vocab.forP, obj); graph.add(entry, Vocab.value, json); graph.add(entry, Vocab.start, inst); + + return true; } } From 52b20c13d5d3274440979c5cfc1e77e7ff21925a Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Mon, 23 Mar 2026 14:06:39 +0000 Subject: [PATCH 03/16] Update Object Registration after changes Currently this only updates _Object Registration_; it is also very crude and always recalculates all possible entries. This is due to the limitations of the ModelChangedListener interface. It may be possible to optimise in the future. --- .../factoryplus/metadb/db/AppUpdater.java | 81 +++++++++++++++++++ .../metadb/db/ObjectStructure.java | 6 +- .../amrc/factoryplus/metadb/db/RdfStore.java | 41 +++++++++- .../co/amrc/factoryplus/metadb/db/Vocab.java | 1 + acs-metadb/ttl/core.ttl | 22 +++-- 5 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java new file mode 100644 index 00000000..81837e24 --- /dev/null +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java @@ -0,0 +1,81 @@ +/* + * Factory+ metadata database + * Structured app update processor + * Copyright 2026 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.metadb.db; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.apache.jena.query.*; +import org.apache.jena.rdf.model.*; + +public class AppUpdater +{ + private static final Logger log = LoggerFactory.getLogger(AppUpdater.class); + + private record Config (UUID app, UUID obj) {} + + private RdfStore db; + private ObjectStructure objs; + private Set updated; + + public AppUpdater (RdfStore db) + { + this.db = db; + this.objs = db.objectStructure(); + this.updated = new HashSet<>(); + } + + private static final Query Q_findUpdates = Vocab.query(""" + select ?app ?obj ?appUUID ?objUUID + where { + ?app a ; + ?appUUID; + ?domain. + ?obj a ?domain; + ?objUUID. + } + """); + + public void update () + { + var updates = db.selectQuery(Q_findUpdates) + .materialise(); + + updates.forEachRemaining(update -> { + var app = update.getResource("app"); + var obj = update.getResource("obj"); + + if (!app.equals(Vocab.Registration)) { + log.info("Can't update app {}", app); + return; + } + log.info("Updating {} {}", app, obj); + var newVal = objs.objectRegistration(obj); + var changed = db.configEntry(app, obj).putValue(newVal); + + if (changed) { + var appUUID = UUID.fromString( + update.getLiteral("appUUID").getString()); + var objUUID = UUID.fromString( + update.getLiteral("objUUID").getString()); + + updated.add(new Config(appUUID, objUUID)); + } + }); + } + + public void publish () + { + log.info("Config updates:"); + for (var c : updated) + log.info(" {} {}", c.app(), c.obj()); + } +} diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java index da3434e0..9d90bcb8 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java @@ -106,9 +106,9 @@ public List listRanks () """); /* TXN */ - private JsonValue objectRegistration (FPObject obj) + public JsonValue objectRegistration (Resource obj) { - var rs = db.singleQuery(Q_objectRegistration, "obj", obj.node()); + var rs = db.singleQuery(Q_objectRegistration, "obj", obj); String uuid = Util.decodeLiteral(rs.get("uuid"), XSD.xstring, s -> s); String klass = Util.decodeLiteral(rs.get("class"), XSD.xstring, s -> s); @@ -166,7 +166,7 @@ public JsonValue createObject (UUID klass, Optional uuid) .orElseGet(() -> db.createObject(kres, u))) .orElseGet(() -> db.createObject(kres)); - return objectRegistration(obj); + return objectRegistration(obj.node()); }); } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java index cc30c730..a83a96ef 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java @@ -59,9 +59,42 @@ public RdfStore (String data) public InfModel derived () { return derived; } public void executeRead (Runnable r) { dataset.executeRead(r); } - public void executeWrite (Runnable r) { dataset.executeWrite(r); } public T calculateRead (Supplier s) { return dataset.calculateRead(s); } - public T calculateWrite (Supplier s) { return dataset.calculateWrite(s); } + + public T calculateWrite (Supplier s) + { + T rv; + + var updater = new AppUpdater(this); + dataset.begin(ReadWrite.WRITE); + //var listener = new NotifyListener(); + + try { + //direct.register(listener); + rv = s.get(); + //listener.commit(); + updater.update(); + dataset.commit(); + } + catch (Throwable e) { + //listener.abort(); + dataset.abort(); + throw e; + } + finally { + //direct.unregister(listener); + //listener.end(); + dataset.end(); + } + + updater.publish(); + return rv; + } + + public void executeWrite (Runnable r) + { + calculateWrite(() -> { r.run(); return 1; }); + } public ResultSet selectQuery (Query query, Object... substs) { @@ -179,4 +212,8 @@ public ConfigEntry configEntry (UUID app, UUID obj) { return ConfigEntry.create(this, app, obj); } + public ConfigEntry configEntry (Resource app, Resource obj) + { + return new ConfigEntry(this, app, obj); + } } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java index f8ba18a7..8bc5b49b 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java @@ -46,6 +46,7 @@ public static Property prop (String p) { public static final Property timestamp = prop("core/timestamp"); public static final Resource Application = res("core/Application"); + public static final Resource appStructured = res("app/Structured"); public static final Property forP = prop("app/for"); public static final Property value = prop("app/value"); public static final Resource Registration = res("app/Registration"); diff --git a/acs-metadb/ttl/core.ttl b/acs-metadb/ttl/core.ttl index acf4b15b..22994faa 100644 --- a/acs-metadb/ttl/core.ttl +++ b/acs-metadb/ttl/core.ttl @@ -97,6 +97,9 @@ app:ConfigEntryGroup a :R2Class; :primary :R2Class; rdfs:subClassOf :R1Class; :uuid "62314906-1c9f-11f1-9bf0-bb3086d1330d"; :powersetOf app:ConfigEntry. +app:for :from app:ConfigEntry; :to :Object. +app:value :from app:ConfigEntry; :to rdf:JSON. + # Currently we have Application a class of ConfigEntry. This means Apps # fit meaningfully into the well-founded class structure but makes them # a little harder to handle in RDF. We cannot have App a function from @@ -110,19 +113,26 @@ app:ConfigEntryGroup a :R2Class; :primary :R2Class; rdfs:subClassOf :R1Class; # as ranks outside the numerical system. :Application a :R2Class; :primary :R2Class; rdfs:subClassOf app:ConfigEntryGroup; :uuid "d319bd87-f42b-4b66-be4f-f82ff48b93f0". +app:AppGroup a :R3Class; :primary :R3Class; rdfs:subClassOf :R2Class; + :uuid "75b030ca-2463-11f1-8bc8-372c7503501b"; + :powersetOf :Application. +app:Structured a app:AppGroup; :primary app:AppGroup; rdfs:subClassOf :Application; + :uuid "912771e2-2463-11f1-b0e8-83e356c50fc8". -app:for :from app:ConfigEntry; :to :Object. -app:value :from app:ConfigEntry; :to rdf:JSON. +app:appliesTo :from :Application; :to :Class. -app:Registration a :Application; :primary :Application; +app:Registration a app:Structured; :primary :Application; rdfs:subClassOf app:ConfigEntry; - :uuid "cb40bed5-49ad-4443-a7f5-08c75009da8f". + :uuid "cb40bed5-49ad-4443-a7f5-08c75009da8f"; + app:appliesTo :Object. app:Info a :Application; :primary :Application; rdfs:subClassOf app:ConfigEntry; - :uuid "64a8bfa9-7772-45c4-9d1a-9e6290690957". + :uuid "64a8bfa9-7772-45c4-9d1a-9e6290690957"; + app:appliesTo :Object. app:ConfigSchema a :Application; :primary :Application; rdfs:subClassOf app:ConfigEntry; - :uuid "dbd8a535-52ba-4f6e-b4f8-9b71aefe09d3". + :uuid "dbd8a535-52ba-4f6e-b4f8-9b71aefe09d3"; + app:appliesTo :Application. # Properly an ACS release iss an Activity; in HQDM terms these Instants # should be Events which are caused by the release Activity. Whether the From 019ac5ca7ec65bda0d9929248fbb015e8cbc4baa Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Mon, 23 Mar 2026 12:09:35 +0000 Subject: [PATCH 04/16] Both class and rank must be optional in Reg In the current JS ConfigDB we store the rank of each object individually. In this RDF version we are deriving it from member relations, but this means the top rank class has no rank as well as no class. Given that nothing should be depending on the numerical ranks anyway I don't mind making this change. --- .../metadb/db/ObjectStructure.java | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java index 9d90bcb8..631a1539 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java @@ -99,9 +99,11 @@ public List listRanks () private static final Query Q_objectRegistration = Vocab.query(""" select ?uuid ?rank ?class where { - ?obj ?uuid; - rdf:type/ ?rank; - / ?class. + ?obj ?uuid. + optional { + ?obj rdf:type/ ?rank; + / ?class. + } } """); @@ -111,18 +113,24 @@ public JsonValue objectRegistration (Resource obj) var rs = db.singleQuery(Q_objectRegistration, "obj", obj); String uuid = Util.decodeLiteral(rs.get("uuid"), XSD.xstring, s -> s); - String klass = Util.decodeLiteral(rs.get("class"), XSD.xstring, s -> s); - int rank = Util.decodeLiteral(rs.get("rank"), XSD.xint, Integer::valueOf); - - return Json.createObjectBuilder() + var rank = Optional.ofNullable(rs.get("rank")) + .map(l -> Util.decodeLiteral(l, XSD.xint, Integer::valueOf)) + .map(Json::createValue) + .orElse(JsonValue.NULL); + var klass = Optional.ofNullable(rs.get("class")) + .map(l -> Util.decodeLiteral(rs.get("class"), XSD.xstring, s -> s)) + .map(Json::createValue) + .orElse(JsonValue.NULL); + + var rv = Json.createObjectBuilder() .add("uuid", uuid) .add("rank", rank) .add("class", klass) /* These entries are fake, for now */ .add("owner", Vocab.U_Unowned.toString()) .add("strict", true) - .add("deleted", false) - .build(); + .add("deleted", false); + return rv.build(); } /* TXN */ From 0367f5c73080b041aaae5a535c4e94b09e86f292 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Mon, 23 Mar 2026 16:16:01 +0000 Subject: [PATCH 05/16] Make sure we only create one Instant per txn All config changes which happen within a single transaction can and should share the same starting Instant. However we don't want to allocation an Instant to a txn unless it needs one. Refactor the RequestHandler classes to accomodate this. We need to special-case the Object Registration entry for the Instant: if the Instant is not created until we start processing config updates, it will not be caught by the initial pass. --- .../amrc/factoryplus/metadb/api/Sparql.java | 8 +- .../amrc/factoryplus/metadb/api/V2Config.java | 21 +- .../factoryplus/metadb/api/V2Objects.java | 27 +-- .../factoryplus/metadb/db/AppUpdater.java | 55 +++--- .../factoryplus/metadb/db/ConfigEntry.java | 22 +-- .../metadb/db/ObjectStructure.java | 179 ++++++++---------- .../amrc/factoryplus/metadb/db/RdfStore.java | 56 ++---- .../factoryplus/metadb/db/RequestHandler.java | 49 ++++- 8 files changed, 215 insertions(+), 202 deletions(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/Sparql.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/Sparql.java index ec3b1b7f..7c5189d9 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/Sparql.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/Sparql.java @@ -126,7 +126,7 @@ private static record QueryHandler ( @Consumes("application/sparql-update") public void update (String update) { - store.executeWrite(() -> + store.requestExecute(req -> UpdateAction.parseExecute(update, store.dataset())); } @@ -143,7 +143,7 @@ public StreamingOutput sparql (String queryString) throws WebApplicationExceptio var lang = handler.content().acceptLang(req); - return store.calculateRead(() -> { + return store.requestRead(req -> { try (var qexec = QueryExecutionFactory.create(query, store.dataset())) { return handler.handle().apply(qexec, lang); } @@ -226,7 +226,7 @@ public void graphPost ( var lang = RDF_HANDLER.contentLang(type); var graph = resolveGraph(true); - store.executeWrite(() -> { + store.requestExecute(req -> { readToGraph(graph, rdf, lang); /* This refreshes the inferences because we have been poking * around behind its back. Strictly this is only needed when @@ -244,7 +244,7 @@ public void graphPut ( var lang = RDF_HANDLER.contentLang(type); var graph = resolveGraph(true); - store.executeWrite(() -> { + store.requestExecute(req -> { graph.removeAll(); readToGraph(graph, rdf, lang); store.derived().rebind(); diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2Config.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2Config.java index 7fb8f07f..5cc6eb24 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2Config.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2Config.java @@ -35,17 +35,12 @@ public class V2Config { @PathParam("app") UUID app; @PathParam("object") UUID obj; - private ConfigEntry configEntry () - { - return store.configEntry(app, obj); - } - @GET public Response get () { log.info("Get config for {}/{}", app, obj); - var entry = store.calculateRead(() -> { - return configEntry().getValue(); + var entry = store.requestRead(req -> { + return req.configEntry(app, obj).getValue(); }); return entry .map(e -> Response.ok(e.value()) @@ -63,8 +58,8 @@ public Response get () public void put (JsonValue config) { log.info("Put config for {}/{}", app, obj); - store.executeWrite(() -> { - configEntry().putValue(config); + store.requestExecute(req -> { + req.configEntry(app, obj).putValue(config); }); } @@ -72,8 +67,8 @@ public void put (JsonValue config) public void delete () { log.info("Delete config for {}/{}", app, obj); - store.executeWrite(() -> { - configEntry().removeValue(); + store.requestExecute(req -> { + req.configEntry(app, obj).removeValue(); }); } @@ -81,8 +76,8 @@ public void delete () public void mergePatch (JsonValue json) { var patch = Json.createMergePatch(json); - store.executeWrite(() -> { - var entry = configEntry(); + store.requestExecute(req -> { + var entry = req.configEntry(app, obj); var o_conf = entry.getValue() .map(e -> e.value()) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2Objects.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2Objects.java index 49a7b237..0322b09a 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2Objects.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2Objects.java @@ -30,7 +30,8 @@ public class V2Objects { @GET @Path("object") public JsonArray listObjects () { - var objs = db.objectStructure().listObjects(); + var objs = db.requestRead(req -> + req.objectStructure().listObjects()); return Json.createArrayBuilder(objs).build(); } @@ -56,26 +57,28 @@ public JsonValue createObject (JsonObject spec) /* We don't accept any other parameters. The ServiceClient * doesn't pass any anyway. */ - return db.objectStructure().createObject(klass, uuid); + return db.requestWrite(req -> + req.objectStructure().createObject(klass, uuid)); } @DELETE @Path("object/{object}") public void deleteObject (@PathParam("object") UUID uuid) { - db.objectStructure().deleteObject(uuid); + db.requestExecute(req -> req.objectStructure().deleteObject(uuid)); } @GET @Path("object/rank") public JsonArray listRanks () { - var ranks = db.objectStructure().listRanks(); + var ranks = db.requestRead(req -> req.objectStructure().listRanks()); return Json.createArrayBuilder(ranks).build(); } private JsonArray listRelation (String graph, UUID uuid, String relation) { - var members = db.objectStructure() - .listRelation(graph, uuid, relation); + var members = db.requestRead(req -> + req.objectStructure() + .listRelation(graph, uuid, relation)); return Json.createArrayBuilder(members).build(); } @@ -101,8 +104,9 @@ public JsonArray listDirectRelation ( private Response testRelation (String graph, UUID klass, String relation, UUID object) { - var rv = db.objectStructure() - .testRelation(graph, klass, relation, object); + var rv = db.requestRead(req -> + req.objectStructure() + .testRelation(graph, klass, relation, object)); return Response.status(rv ? 204 : 404).build(); } @@ -131,7 +135,8 @@ public void putRelation ( @PathParam("relation") String relation, @PathParam("object") UUID object) { - db.objectStructure().putRelation(klass, relation, object); + db.requestExecute(req -> + req.objectStructure().putRelation(klass, relation, object)); } @DELETE @Path("class/{class}/direct/{relation}/{object}") @@ -140,8 +145,8 @@ public void delRelation ( @PathParam("relation") String relation, @PathParam("object") UUID object) { - db.objectStructure().delRelation(klass, relation, object); + db.requestExecute(req -> + req.objectStructure().delRelation(klass, relation, object)); } - } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java index 81837e24..78c95ec6 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java @@ -16,60 +16,65 @@ import org.apache.jena.query.*; import org.apache.jena.rdf.model.*; -public class AppUpdater +public class AppUpdater extends RequestHandler.Component { private static final Logger log = LoggerFactory.getLogger(AppUpdater.class); - private record Config (UUID app, UUID obj) {} + private record Config (Resource app, Resource obj) {} - private RdfStore db; private ObjectStructure objs; private Set updated; - public AppUpdater (RdfStore db) + public AppUpdater (RequestHandler req) { - this.db = db; - this.objs = db.objectStructure(); + super(req); + this.objs = request().objectStructure(); this.updated = new HashSet<>(); } private static final Query Q_findUpdates = Vocab.query(""" - select ?app ?obj ?appUUID ?objUUID + select ?app ?obj where { ?app a ; - ?appUUID; ?domain. ?obj a ?domain; - ?objUUID. + ?uuid. } """); public void update () { - var updates = db.selectQuery(Q_findUpdates) + var updates = db().selectQuery(Q_findUpdates) .materialise(); updates.forEachRemaining(update -> { var app = update.getResource("app"); var obj = update.getResource("obj"); + updateEntry(app, obj); + }); - if (!app.equals(Vocab.Registration)) { - log.info("Can't update app {}", app); - return; - } - log.info("Updating {} {}", app, obj); - var newVal = objs.objectRegistration(obj); - var changed = db.configEntry(app, obj).putValue(newVal); + /* We may have created the current instant in order to update a + * config entry. In this case it may not have been captured by + * the first pass through the changes. But we don't want to + * store an Instant if we don't need one. */ + if (!updated.isEmpty()) { + var now = request().getInstant(); + updateEntry(Vocab.Registration, now); + } + } - if (changed) { - var appUUID = UUID.fromString( - update.getLiteral("appUUID").getString()); - var objUUID = UUID.fromString( - update.getLiteral("objUUID").getString()); + private void updateEntry (Resource app, Resource obj) + { + if (!app.equals(Vocab.Registration)) { + log.info("Can't update app {}", app); + return; + } + log.info("Updating {} {}", app, obj); + var newVal = objs.objectRegistration(obj); + var changed = request().configEntry(app, obj).putValue(newVal); - updated.add(new Config(appUUID, objUUID)); - } - }); + if (changed) + updated.add(new Config(app, obj)); } public void publish () diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java index cd0d5e98..26071b87 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java @@ -21,26 +21,26 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class ConfigEntry extends RequestHandler +public class ConfigEntry extends RequestHandler.Component { private static final Logger log = LoggerFactory.getLogger(ConfigEntry.class); private Resource app; private Resource obj; - public ConfigEntry (RdfStore db, Resource app, Resource obj) + public ConfigEntry (RequestHandler req, Resource app, Resource obj) { - super(db); + super(req); this.app = app; this.obj = obj; } - public static ConfigEntry create (RdfStore db, UUID app, UUID obj) + public static ConfigEntry create (RequestHandler req, UUID app, UUID obj) { - var appO = db.findObjectOrError(app); - var objO = db.findObjectOrError(obj); + var appO = req.db().findObjectOrError(app); + var objO = req.db().findObjectOrError(obj); - return new ConfigEntry(db, appO.node(), objO.node()); + return new ConfigEntry(req, appO.node(), objO.node()); } public record Value (JsonValue value, String etag, Instant mtime) {} @@ -59,7 +59,7 @@ public record Value (JsonValue value, String etag, Instant mtime) {} public Optional getValue () { - return db.optionalQuery(Q_getValue, "app", app, "obj", obj) + return db().optionalQuery(Q_getValue, "app", app, "obj", obj) .map(binding -> { var val = Util.decodeLiteral(binding.get("value"), RDF.JSON, s -> Json.createReader(new StringReader(s)).readValue()); @@ -86,7 +86,7 @@ public Optional getValue () public void removeValue () { - db.runUpdate(U_removeValue, "app", app, "obj", obj); + db().runUpdate(U_removeValue, "app", app, "obj", obj); } public boolean putValue (JsonValue value) @@ -104,9 +104,9 @@ public boolean putValue (JsonValue value) var json = ResourceFactory.createTypedLiteral( value.toString(), RDF.dtRDFJSON); - var graph = db.derived(); + var graph = db().derived(); var entry = graph.createResource(); - var inst = db.createInstant(); + var inst = request().getInstant(); graph.add(entry, RDF.type, app); graph.add(entry, Vocab.forP, obj); diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java index 631a1539..ad8df520 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java @@ -23,18 +23,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class ObjectStructure extends RequestHandler +public class ObjectStructure extends RequestHandler.Component { private static final Logger log = LoggerFactory.getLogger(ObjectStructure.class); - private ObjectStructure (RdfStore db) + private ObjectStructure (RequestHandler req) { - super(db); + super(req); } - public static ObjectStructure create (RdfStore db) + public static ObjectStructure create (RequestHandler req) { - return new ObjectStructure(db); + return new ObjectStructure(req); } /* XXX Ideally both of these switch statements would become @@ -60,8 +60,8 @@ public static Relation of (String relation) private Model findGraph (String name) { switch (name) { - case "direct": return db.direct(); - case "derived": return db.derived(); + case "direct": return db().direct(); + case "derived": return db().derived(); default: throw new Err.NotFound("No such graph"); } } @@ -69,12 +69,11 @@ private Model findGraph (String name) /* These could return UUIDs, but it seems silly. */ public List listObjects () { - return db.calculateRead(() -> - db.derived() - .listObjectsOfProperty(Vocab.uuid) - .filterKeep(n -> n.isLiteral()) - .mapWith(o -> o.asLiteral().getString()) - .toList()); + return db().derived() + .listObjectsOfProperty(Vocab.uuid) + .filterKeep(n -> n.isLiteral()) + .mapWith(o -> o.asLiteral().getString()) + .toList(); } private static final Query Q_listRanks = Vocab.query(""" @@ -88,12 +87,10 @@ order by asc(?rank) public List listRanks () { - return db.calculateRead(() -> { - var rs = db.selectQuery(Q_listRanks); - return Iterator.ofAll(rs) - .map(s -> s.getLiteral("uuid").getString()) - .toJavaList(); - }); + var rs = db().selectQuery(Q_listRanks); + return Iterator.ofAll(rs) + .map(s -> s.getLiteral("uuid").getString()) + .toJavaList(); } private static final Query Q_objectRegistration = Vocab.query(""" @@ -110,7 +107,7 @@ public List listRanks () /* TXN */ public JsonValue objectRegistration (Resource obj) { - var rs = db.singleQuery(Q_objectRegistration, "obj", obj); + var rs = db().singleQuery(Q_objectRegistration, "obj", obj); String uuid = Util.decodeLiteral(rs.get("uuid"), XSD.xstring, s -> s); var rank = Optional.ofNullable(rs.get("rank")) @@ -138,13 +135,13 @@ private FPObject updateRegistration (FPObject obj, Resource klass) { log.info("Update registration: {}, {}", obj, klass); - var orank = db.findRank(obj.node()); - var krank = db.findRank(klass); + var orank = db().findRank(obj.node()); + var krank = db().findRank(klass); if (krank != orank + 1) throw new Err.RankMismatch(); - var model = db.derived(); + var model = db().derived(); model.removeAll(obj.node(), Vocab.primary, null); model.add(obj.node(), Vocab.primary, klass); if (!model.contains(obj.node(), RDF.type, klass)) @@ -164,18 +161,16 @@ private FPObject updateRegistration (FPObject obj, Resource klass) * a Jakarta Response, which is a clear layer violation. */ public JsonValue createObject (UUID klass, Optional uuid) { - return db.calculateWrite(() -> { - var kres = db.findObjectOrError(klass).node(); - - var obj = uuid - .map(u -> db.findObject(u) - .map(o -> { log.info("Found object {}", o); return o; }) - .map(o -> updateRegistration(o, kres)) - .orElseGet(() -> db.createObject(kres, u))) - .orElseGet(() -> db.createObject(kres)); - - return objectRegistration(obj.node()); - }); + var kres = db().findObjectOrError(klass).node(); + + var obj = uuid + .map(u -> db().findObject(u) + .map(o -> { log.info("Found object {}", o); return o; }) + .map(o -> updateRegistration(o, kres)) + .orElseGet(() -> db().createObject(kres, u))) + .orElseGet(() -> db().createObject(kres)); + + return objectRegistration(obj.node()); } private static UpdateRequest U_deleteConfigs = Vocab.update(""" @@ -190,39 +185,37 @@ public JsonValue createObject (UUID klass, Optional uuid) public void deleteObject (UUID uuid) { - db.executeWrite(() -> { - log.info("Delete object {}", uuid); - var obj = db.findObjectOrError(uuid).node(); - log.info("Found node {}", obj); - if (IMMUTABLE.contains(obj)) { - log.info("Object {} is immutable", obj); - throw new Err.Immutable(); - } + log.info("Delete object {}", uuid); + var obj = db().findObjectOrError(uuid).node(); + log.info("Found node {}", obj); + if (IMMUTABLE.contains(obj)) { + log.info("Object {} is immutable", obj); + throw new Err.Immutable(); + } - /* We only check for direct dependents. If we have no direct - * dependents we should also have no indirect. This relies - * on no use of rdfs:domain etc. to infer memberships. */ - var model = db.direct(); - - /* This will also catch configs-of-app. If we want to return - * UUIDs in the 409 we will need to handle these separately. */ - var members = model.listResourcesWithProperty(RDF.type, obj); - if (members.hasNext()) { - log.info("Object {} has members:", uuid); - members.forEachRemaining(m -> log.info(" {}", m)); - throw new Err.InUse(); - } + /* We only check for direct dependents. If we have no direct + * dependents we should also have no indirect. This relies + * on no use of rdfs:domain etc. to infer memberships. */ + var model = db().direct(); + + /* This will also catch configs-of-app. If we want to return + * UUIDs in the 409 we will need to handle these separately. */ + var members = model.listResourcesWithProperty(RDF.type, obj); + if (members.hasNext()) { + log.info("Object {} has members:", uuid); + members.forEachRemaining(m -> log.info(" {}", m)); + throw new Err.InUse(); + } - var subclasses = model.listResourcesWithProperty(RDFS.subClassOf, obj); - if (subclasses.hasNext()) { - log.info("Object {} has subclasses:", uuid); - subclasses.forEachRemaining(m -> log.info(" {}", m)); - throw new Err.InUse(); - } + var subclasses = model.listResourcesWithProperty(RDFS.subClassOf, obj); + if (subclasses.hasNext()) { + log.info("Object {} has subclasses:", uuid); + subclasses.forEachRemaining(m -> log.info(" {}", m)); + throw new Err.InUse(); + } - db.runUpdate(U_deleteConfigs, "obj", obj); - model.removeAll(obj, null, null); - }); + db().runUpdate(U_deleteConfigs, "obj", obj); + model.removeAll(obj, null, null); } public List listRelation (String gname, UUID uuid, String relation) @@ -230,15 +223,13 @@ public List listRelation (String gname, UUID uuid, String relation) var graph = findGraph(gname); var rel = Relation.of(relation); - return db.calculateRead(() -> { - var klass = db.findObjectOrError(uuid); - return graph - .listResourcesWithProperty(rel.prop(), klass.node()) - .mapWith(r -> r.getProperty(Vocab.uuid)) - .filterKeep(s -> s != null) - .mapWith(s -> s.getString()) - .toList(); - }); + var klass = db().findObjectOrError(uuid); + return graph + .listResourcesWithProperty(rel.prop(), klass.node()) + .mapWith(r -> r.getProperty(Vocab.uuid)) + .filterKeep(s -> s != null) + .mapWith(s -> s.getString()) + .toList(); } public boolean testRelation (String gname, UUID klass, String relation, UUID object) @@ -246,40 +237,34 @@ public boolean testRelation (String gname, UUID klass, String relation, UUID obj var graph = findGraph(gname); var rel = Relation.of(relation); - return db.calculateRead(() -> { - var kres = db.findObjectOrError(klass).node(); - var ores = db.findObjectOrError(object).node(); - /* We cannot use methods on ores as this is always from the - * direct graph. */ - return !graph.listStatements(ores, rel.prop(), kres) - .toList() - .isEmpty(); - }); + var kres = db().findObjectOrError(klass).node(); + var ores = db().findObjectOrError(object).node(); + /* We cannot use methods on ores as this is always from the + * direct graph. */ + return !graph.listStatements(ores, rel.prop(), kres) + .toList() + .isEmpty(); } public void putRelation (UUID klass, String relation, UUID object) { var rel = Relation.of(relation); - db.executeWrite(() -> { - var kres = db.findObjectOrError(klass).node(); - var ores = db.findObjectOrError(object).node(); + var kres = db().findObjectOrError(klass).node(); + var ores = db().findObjectOrError(object).node(); - var krank = db.findRank(kres); - var orank = db.findRank(ores); - if (krank != orank + rel.offset()) - throw new Err.RankMismatch(); + var krank = db().findRank(kres); + var orank = db().findRank(ores); + if (krank != orank + rel.offset()) + throw new Err.RankMismatch(); - db.direct().add(ores, rel.prop(), kres); - }); + db().direct().add(ores, rel.prop(), kres); } public void delRelation (UUID klass, String relation, UUID object) { var rel = Relation.of(relation); - db.executeWrite(() -> { - var kres = db.findObjectOrError(klass).node(); - var ores = db.findObjectOrError(object).node(); - db.direct().remove(ores, rel.prop(), kres); - }); + var kres = db().findObjectOrError(klass).node(); + var ores = db().findObjectOrError(object).node(); + db().direct().remove(ores, rel.prop(), kres); } } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java index a83a96ef..c3d2226c 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java @@ -7,6 +7,8 @@ package uk.co.amrc.factoryplus.metadb.db; import java.time.Instant; +import java.util.function.Consumer; +import java.util.function.Function; import java.util.function.Supplier; import java.util.Map; import java.util.Optional; @@ -61,39 +63,30 @@ public RdfStore (String data) public void executeRead (Runnable r) { dataset.executeRead(r); } public T calculateRead (Supplier s) { return dataset.calculateRead(s); } - public T calculateWrite (Supplier s) - { - T rv; + public void executeWrite (Runnable r) { dataset.executeWrite(r); } + public T calculateWrite (Supplier s) { return dataset.calculateWrite(s); } - var updater = new AppUpdater(this); - dataset.begin(ReadWrite.WRITE); - //var listener = new NotifyListener(); + public T requestRead (Function cb) + { + return calculateRead(() -> cb.apply(new RequestHandler(this))); + } + public T requestWrite (Function cb) + { + var req = new RequestHandler(this); + var updater = req.appUpdater(); - try { - //direct.register(listener); - rv = s.get(); - //listener.commit(); + T rv = calculateWrite(() -> { + var rv2 = cb.apply(req); updater.update(); - dataset.commit(); - } - catch (Throwable e) { - //listener.abort(); - dataset.abort(); - throw e; - } - finally { - //direct.unregister(listener); - //listener.end(); - dataset.end(); - } + return rv2; + }); updater.publish(); return rv; } - - public void executeWrite (Runnable r) + public void requestExecute (Consumer cb) { - calculateWrite(() -> { r.run(); return 1; }); + requestWrite(req -> { cb.accept(req); return 1; }); } public ResultSet selectQuery (Query query, Object... substs) @@ -203,17 +196,4 @@ public Resource createInstant () return inst; } - public ObjectStructure objectStructure () - { - return ObjectStructure.create(this); - } - - public ConfigEntry configEntry (UUID app, UUID obj) - { - return ConfigEntry.create(this, app, obj); - } - public ConfigEntry configEntry (Resource app, Resource obj) - { - return new ConfigEntry(this, app, obj); - } } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RequestHandler.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RequestHandler.java index 8b1902cc..16104af8 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RequestHandler.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RequestHandler.java @@ -6,16 +6,59 @@ package uk.co.amrc.factoryplus.metadb.db; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.apache.jena.rdf.model.*; + +import io.vavr.Lazy; + /* Eventually these objects will need to be associated with a txn, and a * request, and contain the request UPN and the current txn Instant and * other per-request information. Possibly at this point this will * become a second object rather than a superclass. */ -public abstract class RequestHandler +public class RequestHandler { - protected RdfStore db; + public static abstract class Component + { + private RequestHandler req; + + protected Component (RequestHandler req) + { + this.req = req; + } - protected RequestHandler (RdfStore db) + protected RequestHandler request () { return req; } + protected RdfStore db () { return req.db(); } + } + + private RdfStore db; + private Lazy now; + + public RequestHandler (RdfStore db) { this.db = db; + this.now = Lazy.of(db::createInstant); + } + + public RdfStore db () { return db; } + public Resource getInstant () { return now.get(); } + + public ObjectStructure objectStructure () + { + return ObjectStructure.create(this); + } + public ConfigEntry configEntry (UUID app, UUID obj) + { + return ConfigEntry.create(this, app, obj); + } + public ConfigEntry configEntry (Resource app, Resource obj) + { + return new ConfigEntry(this, app, obj); + } + public AppUpdater appUpdater () + { + return new AppUpdater(this); } } From 4bc78fdad803ea5b2355d4dacffbb0a6b4296200 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Tue, 24 Mar 2026 09:03:58 +0000 Subject: [PATCH 06/16] Generalise the structured app mapping Generate _General info_ entries as well. For the moment these are still two hardcoded functions; it should be possible to generalise them. --- .../factoryplus/metadb/db/AppUpdater.java | 89 ++++++++++++++++-- .../metadb/db/ObjectStructure.java | 41 +-------- .../co/amrc/factoryplus/metadb/db/Vocab.java | 1 + acs-metadb/ttl/core.ttl | 90 ++++++++----------- 4 files changed, 124 insertions(+), 97 deletions(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java index 78c95ec6..59d92ab6 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java @@ -7,19 +7,32 @@ package uk.co.amrc.factoryplus.metadb.db; import java.util.HashSet; +import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.function.BiFunction; + +import jakarta.json.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.apache.jena.query.*; import org.apache.jena.rdf.model.*; +import org.apache.jena.vocabulary.*; public class AppUpdater extends RequestHandler.Component { private static final Logger log = LoggerFactory.getLogger(AppUpdater.class); + /* XXX This is hardcoded for now. */ + private static final Map>> generators = + Map.of( + Vocab.Registration, AppUpdater::objectRegistration, + Vocab.Info, AppUpdater::generalInfo); + private record Config (Resource app, Resource obj) {} private ObjectStructure objs; @@ -65,22 +78,84 @@ public void update () private void updateEntry (Resource app, Resource obj) { - if (!app.equals(Vocab.Registration)) { - log.info("Can't update app {}", app); - return; - } log.info("Updating {} {}", app, obj); - var newVal = objs.objectRegistration(obj); - var changed = request().configEntry(app, obj).putValue(newVal); + var changed = generateConfig(app, obj) + .map(v -> request().configEntry(app, obj).putValue(v)) + .orElse(false); if (changed) updated.add(new Config(app, obj)); } - + + public Optional generateConfig (Resource app, Resource obj) + { + return Optional.of(generators.get(app)) + .flatMap(gen -> gen.apply(this, obj)); + } + public void publish () { log.info("Config updates:"); for (var c : updated) log.info(" {} {}", c.app(), c.obj()); } + + /* Specific updaters for individual Apps. These should be replaced + * with queries generated from schema entries. */ + + private static final Query Q_objectRegistration = Vocab.query(""" + select ?uuid ?rank ?class + where { + ?obj ?uuid. + optional { + ?obj rdf:type/ ?rank; + / ?class. + } + } + """); + + private Optional objectRegistration (Resource obj) + { + return db().optionalQuery(Q_objectRegistration, "obj", obj) + .map(rs -> { + String uuid = Util.decodeLiteral(rs.get("uuid"), XSD.xstring, s -> s); + var rank = Optional.ofNullable(rs.get("rank")) + .map(l -> Util.decodeLiteral(l, XSD.xint, Integer::valueOf)) + .map(Json::createValue) + .orElse(JsonValue.NULL); + var klass = Optional.ofNullable(rs.get("class")) + .map(l -> Util.decodeLiteral(rs.get("class"), XSD.xstring, s -> s)) + .map(Json::createValue) + .orElse(JsonValue.NULL); + + var rv = Json.createObjectBuilder() + .add("uuid", uuid) + .add("rank", rank) + .add("class", klass) + /* These entries are fake, for now */ + .add("owner", Vocab.U_Unowned.toString()) + .add("strict", true) + .add("deleted", false); + return rv.build(); + }); + } + + private static final Query Q_generalInfo = Vocab.query(""" + select ?name + where { + ?obj ?name. + } + """); + + private Optional generalInfo (Resource obj) + { + return db().optionalQuery(Q_generalInfo, "obj", obj) + .map(rs -> { + String name = Util.decodeLiteral(rs.get("name"), XSD.xstring, s -> s); + + return Json.createObjectBuilder() + .add("name", name) + .build(); + }); + } } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java index ad8df520..b32dcfed 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java @@ -93,43 +93,6 @@ public List listRanks () .toJavaList(); } - private static final Query Q_objectRegistration = Vocab.query(""" - select ?uuid ?rank ?class - where { - ?obj ?uuid. - optional { - ?obj rdf:type/ ?rank; - / ?class. - } - } - """); - - /* TXN */ - public JsonValue objectRegistration (Resource obj) - { - var rs = db().singleQuery(Q_objectRegistration, "obj", obj); - - String uuid = Util.decodeLiteral(rs.get("uuid"), XSD.xstring, s -> s); - var rank = Optional.ofNullable(rs.get("rank")) - .map(l -> Util.decodeLiteral(l, XSD.xint, Integer::valueOf)) - .map(Json::createValue) - .orElse(JsonValue.NULL); - var klass = Optional.ofNullable(rs.get("class")) - .map(l -> Util.decodeLiteral(rs.get("class"), XSD.xstring, s -> s)) - .map(Json::createValue) - .orElse(JsonValue.NULL); - - var rv = Json.createObjectBuilder() - .add("uuid", uuid) - .add("rank", rank) - .add("class", klass) - /* These entries are fake, for now */ - .add("owner", Vocab.U_Unowned.toString()) - .add("strict", true) - .add("deleted", false); - return rv.build(); - } - /* TXN */ private FPObject updateRegistration (FPObject obj, Resource klass) { @@ -170,7 +133,9 @@ public JsonValue createObject (UUID klass, Optional uuid) .orElseGet(() -> db().createObject(kres, u))) .orElseGet(() -> db().createObject(kres)); - return objectRegistration(obj.node()); + return request().appUpdater() + .generateConfig(Vocab.Registration, obj.node()) + .orElseThrow(() -> new Err.CorruptRDF("Cannot find object registration")); } private static UpdateRequest U_deleteConfigs = Vocab.update(""" diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java index 8bc5b49b..c324b52b 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java @@ -51,6 +51,7 @@ public static Property prop (String p) { public static final Property value = prop("app/value"); public static final Resource Registration = res("app/Registration"); public static final Resource ConfigSchema = res("app/ConfigSchema"); + public static final Resource Info = res("app/Info"); public static final Resource G_direct = res("graph/direct"); public static final Resource G_derived = res("graph/derived"); diff --git a/acs-metadb/ttl/core.ttl b/acs-metadb/ttl/core.ttl index 22994faa..3cdf2ea9 100644 --- a/acs-metadb/ttl/core.ttl +++ b/acs-metadb/ttl/core.ttl @@ -22,6 +22,7 @@ :uuid :from :Object; :to xsd:string. :rank :from :Object; :to xsd:integer. +:name :from :Object; :to xsd:string. # Factory+ primary class. This is not a subproperty of rdf:type as we # need to track all memberships in the direct graph. Enforcement that @@ -51,50 +52,61 @@ :Individual a :R1Class; :primary :R1Class; rdfs:subClassOf :Object; :uuid "2494ae9b-cd87-4c01-98db-437a303b43e9"; + :name "Individual"; :rank "0"^^xsd:int. :R1Class a :R2Class; :primary :R2Class; rdfs:subClassOf :Class; :uuid "04a1c90d-2295-4cbe-b33a-74eded62cbf1"; + :name "Rank 1 class"; :rank "1"^^xsd:int; :powersetOf :Individual. :R2Class a :R3Class; :primary :R3Class; rdfs:subClassOf :Class; :uuid "705888ce-53fa-434d-afee-274b331d4642"; + :name "Rank 2 class"; :rank "2"^^xsd:int; :powersetOf :R1Class. # The top rank class has no primary class :R3Class a :Class; rdfs:subClassOf :Class; :uuid "52b80183-6998-4bf9-9b30-132755e7dede"; + :name "Rank 3 class"; :rank "3"^^xsd:int; :powersetOf :R2Class. # XXX These are definitely not R0. They are perhaps names, which are R1? :Special a :R1Class; :primary :R1Class; rdfs:subClassOf :Individual; - :uuid "ddb132e4-5cdd-49c8-b9b1-2f35879eab6d". + :uuid "ddb132e4-5cdd-49c8-b9b1-2f35879eab6d"; + :name "Special UUID". # There is some logic in saying this should be :Object. :Wildcard a :Special; :primary :Special; - :uuid "00000000-0000-0000-0000-000000000000". + :uuid "00000000-0000-0000-0000-000000000000"; + :name "Wildcard". # This makes no logical sense. We should omit the owner instead. :Unowned a :Special; :primary :Special; - :uuid "091e796a-65c0-4080-adff-c3ce01a65b2e". + :uuid "091e796a-65c0-4080-adff-c3ce01a65b2e"; + :name "Unownded". :Instant a :R1Class; :primary :R1Class; rdfs:subClassOf :Individual; - :uuid "600bc688-1d35-11f1-80e6-d7c35e61c3ce". + :uuid "600bc688-1d35-11f1-80e6-d7c35e61c3ce"; + :name "Instant". # XXX Long-term this needs to go in favour of measurements against a # particular clock. But for now it's a useful simplification. :timestamp :from :Instant; :to xsd:datetime. :State a :R1Class; :primary :R1Class; rdfs:subClassOf :Individual; - :uuid "c73d94e4-1d35-11f1-88e0-bf4ed4b22d09". + :uuid "c73d94e4-1d35-11f1-88e0-bf4ed4b22d09"; + :name "Temporal state". # We don't do full 4D state modelling yet. We only record the current # state and the starting Instant. :start :from :State; :to :Instant. app:ConfigEntry a :R1Class; :primary :R1Class; rdfs:subClassOf :State; - :uuid "86d6f3d0-1c9c-11f1-9d4d-3b787922d771". + :uuid "86d6f3d0-1c9c-11f1-9d4d-3b787922d771"; + :name "Config entry". app:ConfigEntryGroup a :R2Class; :primary :R2Class; rdfs:subClassOf :R1Class; :uuid "62314906-1c9f-11f1-9bf0-bb3086d1330d"; + :name "Config entry group"; :powersetOf app:ConfigEntry. app:for :from app:ConfigEntry; :to :Object. @@ -112,71 +124,45 @@ app:value :from app:ConfigEntry; :to rdf:JSON. # pragmatic alternative would be to allow 'proper class' and 'function' # as ranks outside the numerical system. :Application a :R2Class; :primary :R2Class; rdfs:subClassOf app:ConfigEntryGroup; - :uuid "d319bd87-f42b-4b66-be4f-f82ff48b93f0". + :uuid "d319bd87-f42b-4b66-be4f-f82ff48b93f0"; + :name "Application". app:AppGroup a :R3Class; :primary :R3Class; rdfs:subClassOf :R2Class; :uuid "75b030ca-2463-11f1-8bc8-372c7503501b"; + :name "Application group"; :powersetOf :Application. app:Structured a app:AppGroup; :primary app:AppGroup; rdfs:subClassOf :Application; - :uuid "912771e2-2463-11f1-b0e8-83e356c50fc8". + :uuid "912771e2-2463-11f1-b0e8-83e356c50fc8"; + :name "Structured application". app:appliesTo :from :Application; :to :Class. app:Registration a app:Structured; :primary :Application; rdfs:subClassOf app:ConfigEntry; :uuid "cb40bed5-49ad-4443-a7f5-08c75009da8f"; + :name "Object registration"; app:appliesTo :Object. -app:Info a :Application; :primary :Application; +app:Info a app:Structured; :primary :Application; rdfs:subClassOf app:ConfigEntry; :uuid "64a8bfa9-7772-45c4-9d1a-9e6290690957"; + :name "General information"; app:appliesTo :Object. app:ConfigSchema a :Application; :primary :Application; rdfs:subClassOf app:ConfigEntry; :uuid "dbd8a535-52ba-4f6e-b4f8-9b71aefe09d3"; + :name "Application config schema"; app:appliesTo :Application. -# Properly an ACS release iss an Activity; in HQDM terms these Instants -# should be Events which are caused by the release Activity. Whether the -# objects below should share one Event or have individual Events all -# caused by ACS 5.0.2 is an open question. It's also not really true -# that the new config entries below began with ACS 5.0.2; nor even that -# they will begin with whichever release includes this code. - a :Instant; :primary :Instant; - :uuid "948ad49c-2444-11f1-a870-df20b1e545e4"; - :timestamp "2025-05-07T13:27:48Z"^^xsd:dateTime. - a :Instant; :primary :Instant; - :uuid "09aa4958-1d36-11f1-a9a6-bff44d0575de"; - :timestamp "2026-03-10T17:21:05Z"^^xsd:dateTime. - -# ConfigEntries are not F+ objects for the moment; they don't have -# UUIDs. This is to avoid infinite regress with the _Object -# registration_ entries. -[] a app:Info; app:for :Individual; - app:value """{"name": "Individual"}"""^^rdf:JSON; - :start . -[] a app:Info; app:for :R1Class; - app:value """{"name": "Rank 1 class"}"""^^rdf:JSON; - :start . -[] a app:Info; app:for :R2Class; - app:value """{"name": "Rank 2 class"}"""^^rdf:JSON; - :start . -[] a app:Info; app:for :R3Class; - app:value """{"name": "Rank 3 class"}"""^^rdf:JSON; - :start . - -[] a app:Info; app:for app:ConfigEntry; - app:value """{"name": "Config entry"}"""^^rdf:JSON; - :start . -[] a app:Info; app:for app:ConfigEntryGroup; - app:value """{"name": "Config entry group"}"""^^rdf:JSON; - :start . -[] a app:Info; app:for :Application; - app:value """{"name": "Application"}"""^^rdf:JSON; - :start . - :Service a :R1Class; :primary :R1Class; rdfs:subClassOf :Individual; - :uuid "265d481f-87a7-4f93-8fc6-53fa64dc11bb". -srv:Directory a :Service; :primary :Service; :uuid "af4a1d66-e6f7-43c4-8a67-0fa3be2b1cf9". -srv:ConfigDB a :Service; :primary :Service; :uuid "af15f175-78a0-4e05-97c0-2a0bb82b9f3b". -srv:Auth a :Service; :primary :Service; :uuid "cab2642a-f7d9-42e5-8845-8f35affe1fd4". + :uuid "265d481f-87a7-4f93-8fc6-53fa64dc11bb"; + :name "F+ service function". +srv:Directory a :Service; :primary :Service; + :uuid "af4a1d66-e6f7-43c4-8a67-0fa3be2b1cf9"; + :name "Directory service". +srv:ConfigDB a :Service; :primary :Service; + :uuid "af15f175-78a0-4e05-97c0-2a0bb82b9f3b"; + :name "ConfigDB service". +srv:Auth a :Service; :primary :Service; + :uuid "cab2642a-f7d9-42e5-8845-8f35affe1fd4"; + :name "Auth service". From c57e582a4be1316774938470ff7b541a2189e30d Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Tue, 24 Mar 2026 12:29:46 +0000 Subject: [PATCH 07/16] Map structured entries directly from a Query Map the results of a Query into JSON, using the typing information from the RDF to decide how to decode. This will only map to a single flat JSON object at this point. The mapping between Application and Query is still hardcoded for Registration and Info for now. In order to generate the correct Registration output we need some facilities for defaulting optional or nonexistent values. This can be handled fairly cleanly in the SPARQL provided we can identify a namespace of variable names which are not valid as JSON object keys. --- .../factoryplus/metadb/db/AppUpdater.java | 119 ++++++++++-------- .../uk/co/amrc/factoryplus/metadb/db/Err.java | 16 +++ .../co/amrc/factoryplus/metadb/db/Util.java | 36 +++++- 3 files changed, 114 insertions(+), 57 deletions(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java index 59d92ab6..c168f423 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java @@ -12,6 +12,7 @@ import java.util.Set; import java.util.UUID; import java.util.function.BiFunction; +import java.util.function.Function; import jakarta.json.*; @@ -22,17 +23,12 @@ import org.apache.jena.rdf.model.*; import org.apache.jena.vocabulary.*; +import io.vavr.control.Try; + public class AppUpdater extends RequestHandler.Component { private static final Logger log = LoggerFactory.getLogger(AppUpdater.class); - /* XXX This is hardcoded for now. */ - private static final Map>> generators = - Map.of( - Vocab.Registration, AppUpdater::objectRegistration, - Vocab.Info, AppUpdater::generalInfo); - private record Config (Resource app, Resource obj) {} private ObjectStructure objs; @@ -76,6 +72,20 @@ public void update () } } + public void publish () + { + log.info("Config updates:"); + for (var c : updated) + log.info(" {} {}", c.app(), c.obj()); + } + + public Optional generateConfig (Resource app, Resource obj) + { + return Optional.of(generators.get(app)) + .flatMap(q -> db().optionalQuery(q, "obj", obj)) + .map(this::solutionToJson); + } + private void updateEntry (Resource app, Resource obj) { log.info("Updating {} {}", app, obj); @@ -87,59 +97,71 @@ private void updateEntry (Resource app, Resource obj) updated.add(new Config(app, obj)); } - public Optional generateConfig (Resource app, Resource obj) + /* We have to bypass Jena's RDFDatatype system here and provide our + * own mappings. We cannot map arbitrary objects to JSON, and the + * JSON creation functions will not accept Object. */ + private static final Map>> toJson = Map.of( + XSD.xstring, s -> Optional.of(Json.createValue(s)), + XSD.xint, s -> Try.of(() -> Integer.parseInt(s)) + .toJavaOptional() + .map(Json::createValue), + XSD.xboolean, s -> s.equals("true") ? Optional.of(JsonValue.TRUE) + : s.equals("false") ? Optional.of(JsonValue.FALSE) + : Optional.empty(), + RDF.JSON, Util::readJson); + + /* Currently this does dynamic decoding based on what was returned + * from the RDF. Once we have query generation we should be able to + * use the properties to know what we are expecting. */ + private JsonValue literalToJson (RDFNode node) { - return Optional.of(generators.get(app)) - .flatMap(gen -> gen.apply(this, obj)); + /* This should probably be selected based on optional/nullable + * properties in the schema. */ + if (node == null) return JsonValue.NULL; + + if (!node.isLiteral()) + throw new Err.NotLiteral(node); + + var lit = node.asLiteral(); + var typ = ResourceFactory.createResource(lit.getDatatypeURI()); + + return Optional.ofNullable(toJson.get(typ)) + .flatMap(d -> d.apply(lit.getLexicalForm())) + .orElseThrow(() -> new Err.BadLiteral(lit)); } - public void publish () + private JsonValue solutionToJson (QuerySolution rs) { - log.info("Config updates:"); - for (var c : updated) - log.info(" {} {}", c.app(), c.obj()); + var jobj = Json.createObjectBuilder(); + rs.varNames().forEachRemaining(n -> + jobj.add(n, literalToJson(rs.get(n)))); + + return jobj.build(); } /* Specific updaters for individual Apps. These should be replaced * with queries generated from schema entries. */ private static final Query Q_objectRegistration = Vocab.query(""" - select ?uuid ?rank ?class + select ?uuid ?rank ?class ?owner ?strict ?deleted where { ?obj ?uuid. optional { ?obj rdf:type/ ?rank; / ?class. } + + optional { ?obj / ?_1. } + bind(coalesce(?_1, "091e796a-65c0-4080-adff-c3ce01a65b2e") as ?owner) + + optional { ?obj ?_2. } + bind(coalesce(?_2, false) as ?deleted) + + bind(true as ?strict) } """); - private Optional objectRegistration (Resource obj) - { - return db().optionalQuery(Q_objectRegistration, "obj", obj) - .map(rs -> { - String uuid = Util.decodeLiteral(rs.get("uuid"), XSD.xstring, s -> s); - var rank = Optional.ofNullable(rs.get("rank")) - .map(l -> Util.decodeLiteral(l, XSD.xint, Integer::valueOf)) - .map(Json::createValue) - .orElse(JsonValue.NULL); - var klass = Optional.ofNullable(rs.get("class")) - .map(l -> Util.decodeLiteral(rs.get("class"), XSD.xstring, s -> s)) - .map(Json::createValue) - .orElse(JsonValue.NULL); - - var rv = Json.createObjectBuilder() - .add("uuid", uuid) - .add("rank", rank) - .add("class", klass) - /* These entries are fake, for now */ - .add("owner", Vocab.U_Unowned.toString()) - .add("strict", true) - .add("deleted", false); - return rv.build(); - }); - } - private static final Query Q_generalInfo = Vocab.query(""" select ?name where { @@ -147,15 +169,8 @@ private Optional objectRegistration (Resource obj) } """); - private Optional generalInfo (Resource obj) - { - return db().optionalQuery(Q_generalInfo, "obj", obj) - .map(rs -> { - String name = Util.decodeLiteral(rs.get("name"), XSD.xstring, s -> s); - - return Json.createObjectBuilder() - .add("name", name) - .build(); - }); - } + /* XXX This is hardcoded for now. */ + private static final Map generators = Map.of( + Vocab.Registration, Q_objectRegistration, + Vocab.Info, Q_generalInfo); } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Err.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Err.java index 986b7270..cbd62ad3 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Err.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Err.java @@ -8,6 +8,8 @@ import java.util.UUID; +import org.apache.jena.rdf.model.*; + public class Err extends Error { public Err (String msg) @@ -37,6 +39,20 @@ public Config (String msg, UUID app, UUID obj) public UUID app () { return app; } public UUID obj () { return obj; } } + public static class NotLiteral extends CorruptRDF + { + public NotLiteral (RDFNode n) + { + super("Expecting literal, not " + n.toString()); + } + } + public static class BadLiteral extends CorruptRDF + { + public BadLiteral (Literal l) + { + super("Cannot decode literal: " + l.toString()); + } + } public static abstract class ClientError extends Err { diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Util.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Util.java index 89a434a4..bd2a400b 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Util.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Util.java @@ -6,23 +6,45 @@ package uk.co.amrc.factoryplus.metadb.db; +import java.io.StringReader; import java.util.Iterator; import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; + +import jakarta.json.*; import org.apache.jena.query.ResultSet; import org.apache.jena.rdf.model.*; import org.apache.jena.util.iterator.ClosableIterator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import io.vavr.*; import io.vavr.control.*; final class Util { + private static final Logger log = LoggerFactory.getLogger(Util.class); + private static Throwable error (String fmt, Object... args) { String msg = String.format(fmt, args); return new Err.CorruptRDF(msg); } + /* This will silently ignore trailing garbage. I think this could be + * cured by using JsonParser instead but it's not straightforward. */ + public static Optional readJson (String json) + { + var sr = new StringReader(json); + var jr = Json.createReader(sr); + + return Try.of(jr::readValue) + .andFinally(jr::close) + .toJavaOptional(); + } + public static T decodeLiteral (RDFNode node, Resource type, CheckedFunction1 extract) { @@ -46,11 +68,15 @@ public static Optional single (Iterator it) var rv = it.next(); if (it.hasNext()) { - if (it instanceof ClosableIterator) - ((ClosableIterator)it).close(); - /* Annoying failure to implement the appropriate interface… */ - if (it instanceof ResultSet) - ((ResultSet)it).close(); +// if (it instanceof ClosableIterator) +// ((ClosableIterator)it).close(); +// /* Annoying failure to implement the appropriate interface… */ +// if (it instanceof ResultSet) +// ((ResultSet)it).close(); + + /* rather than closing early, log the results */ + log.info("Expected single result: {}", rv); + it.forEachRemaining(v -> log.info("Unexpected extra result: {}", v)); throw new Err.CorruptRDF("Expected single result, found multiple"); } From 212c83d8217b1b53e1a327cd6529533ba4145bfc Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Tue, 24 Mar 2026 15:12:11 +0000 Subject: [PATCH 08/16] Move structure app definitions into a new object We are going to need to maintain a permanent but changing list of mappings for the structured apps. The AppUpdater object lives for a single request so it is not a suitable place to hold this information. --- .../amrc/factoryplus/metadb/db/AppMapper.java | 117 ++++++++++++++++++ .../factoryplus/metadb/db/AppUpdater.java | 89 +------------ .../metadb/db/ObjectStructure.java | 2 +- .../amrc/factoryplus/metadb/db/RdfStore.java | 6 + 4 files changed, 127 insertions(+), 87 deletions(-) create mode 100644 acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java new file mode 100644 index 00000000..a509649c --- /dev/null +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java @@ -0,0 +1,117 @@ +/* + * Factory+ metadata database + * Structured application mapper + * Copyright 2026 University of Sheffield AMRC + */ + +package uk.co.amrc.factoryplus.metadb.db; + +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import jakarta.json.*; + +import org.apache.jena.query.*; +import org.apache.jena.rdf.model.*; +import org.apache.jena.update.*; +import org.apache.jena.vocabulary.*; + +import io.vavr.control.Try; + +public class AppMapper { + /* Specific updaters for individual Apps. These should be replaced + * with queries generated from schema entries. */ + + private static final Query Q_objectRegistration = Vocab.query(""" + select ?uuid ?rank ?class ?owner ?strict ?deleted + where { + ?obj ?uuid. + optional { + ?obj rdf:type/ ?rank; + / ?class. + } + + optional { ?obj / ?1. } + bind(coalesce(?1, "091e796a-65c0-4080-adff-c3ce01a65b2e") as ?owner) + + optional { ?obj ?2. } + bind(coalesce(?2, false) as ?deleted) + + bind(true as ?strict) + } + """); + + private static final Query Q_generalInfo = Vocab.query(""" + select ?name + where { + ?obj ?name. + } + """); + + private static final UpdateRequest U_generalInfo = Vocab.update(""" + delete { ?obj ?1. } + insert { ?obj ?name. } + where {} + """); + + /* We have to bypass Jena's RDFDatatype system here and provide our + * own mappings. We cannot map arbitrary objects to JSON, and the + * JSON creation functions will not accept Object. */ + private static final Map>> toJson = Map.of( + XSD.xstring, s -> Optional.of(Json.createValue(s)), + XSD.xint, s -> Try.of(() -> Integer.parseInt(s)) + .toJavaOptional() + .map(Json::createValue), + XSD.xboolean, s -> s.equals("true") ? Optional.of(JsonValue.TRUE) + : s.equals("false") ? Optional.of(JsonValue.FALSE) + : Optional.empty(), + RDF.JSON, Util::readJson); + + private RdfStore db; + private Map generators = Map.of( + Vocab.Registration, Q_objectRegistration, + Vocab.Info, Q_generalInfo); + + public AppMapper (RdfStore db) + { + this.db = db; + } + + public Optional generateConfig (Resource app, Resource obj) + { + return Optional.of(generators.get(app)) + .flatMap(q -> db.optionalQuery(q, "obj", obj)) + .map(AppMapper::solutionToJson); + } + + /* Currently this does dynamic decoding based on what was returned + * from the RDF. Once we have query generation we should be able to + * use the properties to know what we are expecting. */ + private static JsonValue literalToJson (RDFNode node) + { + /* This should probably be selected based on optional/nullable + * properties in the schema. */ + if (node == null) return JsonValue.NULL; + + if (!node.isLiteral()) + throw new Err.NotLiteral(node); + + var lit = node.asLiteral(); + var typ = ResourceFactory.createResource(lit.getDatatypeURI()); + + return Optional.ofNullable(toJson.get(typ)) + .flatMap(d -> d.apply(lit.getLexicalForm())) + .orElseThrow(() -> new Err.BadLiteral(lit)); + } + + private static JsonValue solutionToJson (QuerySolution rs) + { + var jobj = Json.createObjectBuilder(); + rs.varNames().forEachRemaining(n -> + jobj.add(n, literalToJson(rs.get(n)))); + + return jobj.build(); + } +} diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java index c168f423..82739806 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java @@ -23,8 +23,6 @@ import org.apache.jena.rdf.model.*; import org.apache.jena.vocabulary.*; -import io.vavr.control.Try; - public class AppUpdater extends RequestHandler.Component { private static final Logger log = LoggerFactory.getLogger(AppUpdater.class); @@ -32,12 +30,14 @@ public class AppUpdater extends RequestHandler.Component private record Config (Resource app, Resource obj) {} private ObjectStructure objs; + private AppMapper mapper; private Set updated; public AppUpdater (RequestHandler req) { super(req); this.objs = request().objectStructure(); + this.mapper = db().appMapper(); this.updated = new HashSet<>(); } @@ -79,17 +79,10 @@ public void publish () log.info(" {} {}", c.app(), c.obj()); } - public Optional generateConfig (Resource app, Resource obj) - { - return Optional.of(generators.get(app)) - .flatMap(q -> db().optionalQuery(q, "obj", obj)) - .map(this::solutionToJson); - } - private void updateEntry (Resource app, Resource obj) { log.info("Updating {} {}", app, obj); - var changed = generateConfig(app, obj) + var changed = mapper.generateConfig(app, obj) .map(v -> request().configEntry(app, obj).putValue(v)) .orElse(false); @@ -97,80 +90,4 @@ private void updateEntry (Resource app, Resource obj) updated.add(new Config(app, obj)); } - /* We have to bypass Jena's RDFDatatype system here and provide our - * own mappings. We cannot map arbitrary objects to JSON, and the - * JSON creation functions will not accept Object. */ - private static final Map>> toJson = Map.of( - XSD.xstring, s -> Optional.of(Json.createValue(s)), - XSD.xint, s -> Try.of(() -> Integer.parseInt(s)) - .toJavaOptional() - .map(Json::createValue), - XSD.xboolean, s -> s.equals("true") ? Optional.of(JsonValue.TRUE) - : s.equals("false") ? Optional.of(JsonValue.FALSE) - : Optional.empty(), - RDF.JSON, Util::readJson); - - /* Currently this does dynamic decoding based on what was returned - * from the RDF. Once we have query generation we should be able to - * use the properties to know what we are expecting. */ - private JsonValue literalToJson (RDFNode node) - { - /* This should probably be selected based on optional/nullable - * properties in the schema. */ - if (node == null) return JsonValue.NULL; - - if (!node.isLiteral()) - throw new Err.NotLiteral(node); - - var lit = node.asLiteral(); - var typ = ResourceFactory.createResource(lit.getDatatypeURI()); - - return Optional.ofNullable(toJson.get(typ)) - .flatMap(d -> d.apply(lit.getLexicalForm())) - .orElseThrow(() -> new Err.BadLiteral(lit)); - } - - private JsonValue solutionToJson (QuerySolution rs) - { - var jobj = Json.createObjectBuilder(); - rs.varNames().forEachRemaining(n -> - jobj.add(n, literalToJson(rs.get(n)))); - - return jobj.build(); - } - - /* Specific updaters for individual Apps. These should be replaced - * with queries generated from schema entries. */ - - private static final Query Q_objectRegistration = Vocab.query(""" - select ?uuid ?rank ?class ?owner ?strict ?deleted - where { - ?obj ?uuid. - optional { - ?obj rdf:type/ ?rank; - / ?class. - } - - optional { ?obj / ?_1. } - bind(coalesce(?_1, "091e796a-65c0-4080-adff-c3ce01a65b2e") as ?owner) - - optional { ?obj ?_2. } - bind(coalesce(?_2, false) as ?deleted) - - bind(true as ?strict) - } - """); - - private static final Query Q_generalInfo = Vocab.query(""" - select ?name - where { - ?obj ?name. - } - """); - - /* XXX This is hardcoded for now. */ - private static final Map generators = Map.of( - Vocab.Registration, Q_objectRegistration, - Vocab.Info, Q_generalInfo); } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java index b32dcfed..b4e07eb5 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java @@ -133,7 +133,7 @@ public JsonValue createObject (UUID klass, Optional uuid) .orElseGet(() -> db().createObject(kres, u))) .orElseGet(() -> db().createObject(kres)); - return request().appUpdater() + return db().appMapper() .generateConfig(Vocab.Registration, obj.node()) .orElseThrow(() -> new Err.CorruptRDF("Cannot find object registration")); } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java index c3d2226c..46b5e662 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java @@ -40,6 +40,8 @@ public class RdfStore private InfModel derived; private Dataset dataset; + private AppMapper appMapper; + /* We build a Dataset out of these named graphs: * - G_direct: this is G_direct from the TDB. * - G_derived: this is RDFS(G_direct). @@ -54,12 +56,16 @@ public RdfStore (String data) dataset.addNamedModel(Vocab.G_direct, direct); dataset.addNamedModel(Vocab.G_derived, derived); + + appMapper = new AppMapper(this); } public Dataset dataset () { return dataset; } public Model direct () { return direct; } public InfModel derived () { return derived; } + public AppMapper appMapper () { return appMapper; } + public void executeRead (Runnable r) { dataset.executeRead(r); } public T calculateRead (Supplier s) { return dataset.calculateRead(s); } From 1b003c16f1dcae655246da5d2750704877536bf4 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Tue, 24 Mar 2026 15:16:45 +0000 Subject: [PATCH 09/16] Define something with a Sparkplug address I'm trying not to pull too much into the RDF rather than using the existing dump files; there is a lot of information in there and I don't really want to just rewrite it all in Turtle. But I want a structured app other than Info, and I can't use Registration because that will need to be special-cased on set in any case. --- .../amrc/factoryplus/metadb/db/AppMapper.java | 6 +- acs-metadb/ttl/core.ttl | 62 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java index a509649c..b8a8ce86 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java @@ -52,9 +52,11 @@ public class AppMapper { private static final UpdateRequest U_generalInfo = Vocab.update(""" delete { ?obj ?1. } insert { ?obj ?name. } - where {} + where { ?obj ?1. } """); + + /* We have to bypass Jena's RDFDatatype system here and provide our * own mappings. We cannot map arbitrary objects to JSON, and the * JSON creation functions will not accept Object. */ @@ -81,7 +83,7 @@ public AppMapper (RdfStore db) public Optional generateConfig (Resource app, Resource obj) { - return Optional.of(generators.get(app)) + return Optional.ofNullable(generators.get(app)) .flatMap(q -> db.optionalQuery(q, "obj", obj)) .map(AppMapper::solutionToJson); } diff --git a/acs-metadb/ttl/core.ttl b/acs-metadb/ttl/core.ttl index 3cdf2ea9..3c455cf4 100644 --- a/acs-metadb/ttl/core.ttl +++ b/acs-metadb/ttl/core.ttl @@ -8,6 +8,8 @@ @prefix : . @prefix app: . @prefix srv: . +@prefix sp: . +@prefix usr: . # These are proper classes and sit outside the rank structure. :Object a rdfs:Class. @@ -151,6 +153,35 @@ app:ConfigSchema a :Application; :primary :Application; :uuid "dbd8a535-52ba-4f6e-b4f8-9b71aefe09d3"; :name "Application config schema"; app:appliesTo :Application. +app:SparkplugAddress a app:Structured; :primary :Application; + rdfs:subClassOf app:ConfigEntry; + :uuid "8e32801b-f35a-4cbf-a5c3-2af64d3debd7"; + :name "Sparkplug address"; + app:appliesTo sp:Entity. + +# These are things which can have a Sparkplug address. _Edge cluster_, +# _Sparkplug Node_ and _Sparkplug Device_ are all subclasses. Properly +# we should have Group/Node subclasses here as well, because: +# * _Edge cluster_ != _Sparkplug Group_ because `*-Service-Core` is not +# an _Edge cluster_. +# * _Sparkplug Node_ is currently a _Client role_. Not all entities with +# a Node address should currently be permitted to use it. cf _Edge +# Agent_ vs _Active Edge Agent_. +sp:Entity a :R1Class; :primary :R1Class; + rdfs:subClassOf :Individual; + :uuid "3ef49486-278d-11f1-b52f-f742bd9bbc94"; + :name "Sparkplug entity". +sp:Device a :R1Class; :primary :R1Class; + rdfs:subClassOf sp:Entity; + :uuid "18773d6d-a70d-443a-b29a-3f1583195290"; + :name "Sparkplug Device". +# This is very crude for now and represents group/node/device separately +# and redundantly. Properly we should link Device→Node→Group and derive +# the address from that. Probably we could use only a single sp:name +# property for the actual names. +sp:groupId :from sp:Entity; :to xsd:string. +sp:nodeId :from sp:Entity; :to xsd:string. +sp:deviceId :from sp:Entity; :to xsd:string. :Service a :R1Class; :primary :R1Class; rdfs:subClassOf :Individual; @@ -165,4 +196,35 @@ srv:ConfigDB a :Service; :primary :Service; srv:Auth a :Service; :primary :Service; :uuid "cab2642a-f7d9-42e5-8845-8f35affe1fd4"; :name "Auth service". +# These are for representing Directory information. +srv:Provider a :R1Class; :primary :R1Class; + rdfs:subClassOf :Individual; + :uuid "0fdf17ce-2793-11f1-a869-6fd7319ec1a3"; + :name "Service provider". +srv:provides :from srv:Provider; :to :Service. +srv:url :from srv:Provider; :to xsd:string. +:Principal a :R1Class; :primary :R1Class; + rdfs:subClassOf :Individual; + :uuid "11614546-b6d7-11ef-aebd-8fbb45451d7c"; + :name "Principal". +usr:PrincipalGroup a :R2Class; :primary :R2Class; + rdfs:subClassOf :R1Class; :powersetOf :Principal; + :uuid "c0157038-ccff-11ef-a4db-63c6212e998f"; + :name "Principal group". +usr:PrincipalType a :R2Class; :primary :R2Class; + rdfs:subClassOf usr:PrincipalGroup; + :uuid "ae17afe0-ccff-11ef-bb70-67807cb4a9df"; + :name "Principal type". +usr:CentralService a usr:PrincipalType; :primary usr:PrincipalType; + rdfs:subClassOf :Principal; + :uuid "e463b4ae-a322-46cc-8976-4ba76838e908"; + :name "Central service". + +usr:ConfigDB a usr:CentralService, sp:Entity, srv:Provider; + :primary usr:CentralService; + :uuid "36861e8d-9152-40c4-8f08-f51c2d7e3c25"; + :name "ConfigDB"; + srv:provides srv:ConfigDB; + sp:groupId "AMRC-Service-Core"; + sp:nodeId "ConfigDB". From efd80aae2dba54f5c9c76e8a9c59c9c42d623451 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Wed, 25 Mar 2026 09:49:13 +0000 Subject: [PATCH 10/16] Update structured config entries This is still limited to a few hardcoded Apps with handwritten SPARQL queries. Updates to Registration will have to be handled specially. --- .../amrc/factoryplus/metadb/db/AppMapper.java | 90 +++++++++++++++++-- .../factoryplus/metadb/db/AppUpdater.java | 4 +- .../factoryplus/metadb/db/ConfigEntry.java | 25 +++++- .../uk/co/amrc/factoryplus/metadb/db/Err.java | 11 +++ .../co/amrc/factoryplus/metadb/db/Vocab.java | 1 + 5 files changed, 122 insertions(+), 9 deletions(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java index b8a8ce86..b0fb5b3d 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java @@ -48,14 +48,33 @@ public class AppMapper { ?obj ?name. } """); - + private static final UpdateRequest D_generalInfo = Vocab.update(""" + delete where { ?obj ?1. }; + """); private static final UpdateRequest U_generalInfo = Vocab.update(""" - delete { ?obj ?1. } - insert { ?obj ?name. } - where { ?obj ?1. } + insert { ?obj ?name. } where {}; """); - + private static final Query Q_sparkplugAddress = Vocab.query(""" + select ?group_id ?node_id ?device_id + where { + ?obj ?group_id. + optional { ?obj ?node_id. } + optional { ?obj ?device_id. } + } + """); + private static final UpdateRequest D_sparkplugAddress = Vocab.update(""" + delete where { ?obj ?1. }; + delete where { ?obj ?2. }; + delete where { ?obj ?3. }; + """); + private static final UpdateRequest U_sparkplugAddress = Vocab.update(""" + insert { + ?obj ?group_id; + ?node_id; + ?device_id. + } where {}; + """); /* We have to bypass Jena's RDFDatatype system here and provide our * own mappings. We cannot map arbitrary objects to JSON, and the @@ -72,9 +91,17 @@ public class AppMapper { RDF.JSON, Util::readJson); private RdfStore db; + private Map generators = Map.of( Vocab.Registration, Q_objectRegistration, - Vocab.Info, Q_generalInfo); + Vocab.Info, Q_generalInfo, + Vocab.SparkplugAddr, Q_sparkplugAddress); + private Map deleters = Map.of( + Vocab.Info, D_generalInfo, + Vocab.SparkplugAddr, D_sparkplugAddress); + private Map updaters = Map.of( + Vocab.Info, U_generalInfo, + Vocab.SparkplugAddr, U_sparkplugAddress); public AppMapper (RdfStore db) { @@ -88,6 +115,32 @@ public Optional generateConfig (Resource app, Resource obj) .map(AppMapper::solutionToJson); } + public void deleteConfig (Resource app, Resource obj) + { + if (app.equals(Vocab.Registration)) + throw new Err.Immutable(); + + Optional.ofNullable(deleters.get(app)) + .ifPresent(d -> db.runUpdate(d, "obj", obj)); + } + + public void updateConfig (Resource app, Resource obj, JsonValue config) + { + /* XXX Reg can be updated but needs special handling */ + if (app.equals(Vocab.Registration)) + throw new Err.Immutable(); + + var sol = jsonToSolution(config); + + deleteConfig(app, obj); + Optional.ofNullable(updaters.get(app)) + .ifPresent(u -> UpdateExecution.dataset(db.dataset()) + .update(u) + .substitution(sol) + .substitution("obj", obj) + .execute()); + } + /* Currently this does dynamic decoding based on what was returned * from the RDF. Once we have query generation we should be able to * use the properties to know what we are expecting. */ @@ -116,4 +169,29 @@ private static JsonValue solutionToJson (QuerySolution rs) return jobj.build(); } + + /* XXX This can only convert strings for now. In general we will + * need expected type information as RDF has a richer type system + * than JSON. */ + private static Literal jsonToLiteral (JsonValue val) + { + if (!(val instanceof JsonString)) + throw new Err.BadJson(val); + + return ResourceFactory.createPlainLiteral( + ((JsonString)val).getString()); + } + + /* This can only decode flat objects for now */ + private static QuerySolution jsonToSolution (JsonValue val) + { + if (!(val instanceof JsonObject)) + throw new Err.BadJson(val); + + var qsol = new QuerySolutionMap(); + ((JsonObject)val).forEach( + (k, v) -> qsol.add(k, jsonToLiteral(v))); + + return qsol; + } } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java index 82739806..8cff2d40 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java @@ -81,9 +81,11 @@ public void publish () private void updateEntry (Resource app, Resource obj) { + /* XXX We need to remove redundant entries */ + log.info("Updating {} {}", app, obj); var changed = mapper.generateConfig(app, obj) - .map(v -> request().configEntry(app, obj).putValue(v)) + .map(v -> request().configEntry(app, obj).putRawValue(v)) .orElse(false); if (changed) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java index 26071b87..a60484dd 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java @@ -45,6 +45,11 @@ public static ConfigEntry create (RequestHandler req, UUID app, UUID obj) public record Value (JsonValue value, String etag, Instant mtime) {} + private boolean isStructured () + { + return db().derived().contains(app, RDF.type, Vocab.appStructured); + } + private static final Query Q_getValue = Vocab.query(""" select ?value ?etag ?mtime where { @@ -85,11 +90,27 @@ public Optional getValue () """); public void removeValue () + { + if (isStructured()) + db().appMapper().deleteConfig(app, obj); + else + removeRawValue(); + } + + public void removeRawValue () { db().runUpdate(U_removeValue, "app", app, "obj", obj); } - public boolean putValue (JsonValue value) + public void putValue (JsonValue value) + { + if (isStructured()) + db().appMapper().updateConfig(app, obj, value); + else + putRawValue(value); + } + + public boolean putRawValue (JsonValue value) { var existing = getValue() .map(Value::value) @@ -99,7 +120,7 @@ public boolean putValue (JsonValue value) return false; } - removeValue(); + removeRawValue(); var json = ResourceFactory.createTypedLiteral( value.toString(), RDF.dtRDFJSON); diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Err.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Err.java index cbd62ad3..40fb15d6 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Err.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Err.java @@ -8,6 +8,8 @@ import java.util.UUID; +import jakarta.json.*; + import org.apache.jena.rdf.model.*; public class Err extends Error @@ -81,6 +83,15 @@ public InvalidName (String name) public int statusCode () { return 410; } } + public static class BadJson extends ClientError + { + public BadJson (JsonValue val) + { + super("Unexpected JSON value: " + val.toString()); + } + + public int statusCode () { return 422; } + } public static class RankMismatch extends ClientError { public RankMismatch () diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java index c324b52b..6d0de661 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java @@ -52,6 +52,7 @@ public static Property prop (String p) { public static final Resource Registration = res("app/Registration"); public static final Resource ConfigSchema = res("app/ConfigSchema"); public static final Resource Info = res("app/Info"); + public static final Resource SparkplugAddr = res("app/SparkplugAddress"); public static final Resource G_direct = res("graph/direct"); public static final Resource G_derived = res("graph/derived"); From 4df85e415c008527221b757b6eaab4fedd2b44e0 Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Thu, 26 Mar 2026 14:54:15 +0000 Subject: [PATCH 11/16] Implement updates to Object Registration This mostly consists of validation; only the class and deleted fields can be changed for now. --- .../amrc/factoryplus/metadb/db/AppMapper.java | 63 ++++++++++++++++++- .../uk/co/amrc/factoryplus/metadb/db/Err.java | 32 +++++----- .../co/amrc/factoryplus/metadb/db/Vocab.java | 1 + 3 files changed, 79 insertions(+), 17 deletions(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java index b0fb5b3d..29bab24f 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java @@ -17,9 +17,14 @@ import org.apache.jena.update.*; import org.apache.jena.vocabulary.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import io.vavr.control.Try; public class AppMapper { + private static final Logger log = LoggerFactory.getLogger(AppMapper.class); + /* Specific updaters for individual Apps. These should be replaced * with queries generated from schema entries. */ @@ -127,8 +132,10 @@ public void deleteConfig (Resource app, Resource obj) public void updateConfig (Resource app, Resource obj, JsonValue config) { /* XXX Reg can be updated but needs special handling */ - if (app.equals(Vocab.Registration)) - throw new Err.Immutable(); + if (app.equals(Vocab.Registration)) { + updateRegistration(obj, (JsonObject)config); + return; + } var sol = jsonToSolution(config); @@ -141,6 +148,58 @@ public void updateConfig (Resource app, Resource obj, JsonValue config) .execute()); } + /* XXX This assumes JSON schema validation has been performed. + * Currently this is not implemented nor is a schema installed for + * the Registration app. */ + private void updateRegistration (Resource obj, JsonObject spec) + { + if (!spec.getBoolean("strict")) { + log.info("Objects must be strict"); + throw new Err.BadJson(spec.get("strict")); + } + if (!spec.getString("owner").equals(Vocab.U_Unowned.toString())) { + log.info("Owners not implemented yet"); + throw new Err.Forbidden(); + } + + var rank = db.findRank(obj); + if (spec.getInt("rank") != rank) { + log.info("Rank changes can only be made via dumps"); + throw new Err.RankMismatch(); + } + + var model = db.derived(); + var uuid = Util.single(model.listObjectsOfProperty(obj, Vocab.uuid)) + .map(AppMapper::literalToJson) + .orElseThrow(() -> new Err.CorruptRDF("Cannot find object UUID")); + if (!spec.get("uuid").equals(uuid)) { + log.info("UUIDs cannot be changed: {} vs {}", uuid, spec.get("uuid")); + throw new Err.BadJson(spec.get("uuid")); + } + + var klass = Vocab.parseUUID(spec.getString("class")) + .flatMap(db::findObject) + .map(FPObject::node) + .orElseThrow(() -> { + log.info("Cannot find new primary class"); + return new Err.BadJson(spec.get("class")); + }); + /* This is a change from the JS implementation; here we require + * the object to already be a member of the new primary class. + * The ConfigDB will create a new direct membership if needed, + * but will not remove it again if the primary class changes. + * This is inconsistent and so has been changed. */ + if (!model.contains(obj, RDF.type, klass)) { + log.info("Object not a member of new primary class"); + throw new Err.NotMember(); + } + model.removeAll(obj, Vocab.primary, null); + model.add(obj, Vocab.primary, klass); + + model.removeAll(obj, Vocab.deleted, null); + model.addLiteral(obj, Vocab.deleted, spec.getBoolean("deleted")); + } + /* Currently this does dynamic decoding based on what was returned * from the RDF. Once we have query generation we should be able to * use the properties to know what we are expecting. */ diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Err.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Err.java index 40fb15d6..e774c849 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Err.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Err.java @@ -65,6 +65,15 @@ public ClientError (String msg) public abstract int statusCode (); } + public static class Forbidden extends ClientError + { + public Forbidden () + { + super("Access denied"); + } + + public int statusCode () { return 403; } + } public static class NotFound extends ClientError { public NotFound (String res) @@ -94,29 +103,22 @@ public BadJson (JsonValue val) } public static class RankMismatch extends ClientError { - public RankMismatch () - { - super("Rank mismatch"); - } - + public RankMismatch () { super("Rank mismatch"); } + public int statusCode () { return 409; } + } + public static class NotMember extends ClientError + { + public NotMember () { super("Not a member of a required class"); } public int statusCode () { return 409; } } public static class InUse extends ClientError { - public InUse () - { - super("Object in use"); - } - + public InUse () { super("Object in use"); } public int statusCode () { return 409; } } public static class Immutable extends ClientError { - public Immutable () - { - super("Object is immutable"); - } - + public Immutable () { super("Object is immutable"); } public int statusCode () { return 405; } } } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java index 6d0de661..90af79f5 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java @@ -36,6 +36,7 @@ public static Property prop (String p) { public static final Property uuid = prop("core/uuid"); public static final Property rank = prop("core/rank"); public static final Property primary = prop("core/primary"); + public static final Property deleted = prop("core/deleted"); public static final Property start = prop("core/start"); public static final Resource Special = res("core/Special"); From 8e0872fea6dbd79a8ae253aa5d2ad71e2028419f Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Mon, 30 Mar 2026 09:06:49 +0100 Subject: [PATCH 12/16] Namespace within Vocab --- .../factoryplus/metadb/api/ErrorMapper.java | 6 ++++++ .../amrc/factoryplus/metadb/db/AppMapper.java | 19 +++++++++---------- .../factoryplus/metadb/db/AppUpdater.java | 2 +- .../factoryplus/metadb/db/ConfigEntry.java | 6 +++--- .../metadb/db/ObjectStructure.java | 4 ++-- .../co/amrc/factoryplus/metadb/db/Vocab.java | 18 +++++++++++------- 6 files changed, 32 insertions(+), 23 deletions(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/ErrorMapper.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/ErrorMapper.java index 487a25c0..ea4acc83 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/ErrorMapper.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/ErrorMapper.java @@ -14,13 +14,19 @@ import org.glassfish.hk2.api.MultiException; import org.glassfish.jersey.server.spi.ResponseErrorMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import uk.co.amrc.factoryplus.metadb.db.Err; public class ErrorMapper { + public static final Logger log = LoggerFactory.getLogger(ErrorMapper.class); + public static Response clientError (Err.ClientError err) { /* XXX we should allow the error to include more fields here */ + log.info("Returning error: {}", err); var json = Json.createValue(err.getMessage()); return Response.status(err.statusCode()) .entity(json) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java index 29bab24f..58cac487 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppMapper.java @@ -98,15 +98,15 @@ public class AppMapper { private RdfStore db; private Map generators = Map.of( - Vocab.Registration, Q_objectRegistration, - Vocab.Info, Q_generalInfo, - Vocab.SparkplugAddr, Q_sparkplugAddress); + Vocab.App.Registration, Q_objectRegistration, + Vocab.App.Info, Q_generalInfo, + Vocab.App.SparkplugAddr, Q_sparkplugAddress); private Map deleters = Map.of( - Vocab.Info, D_generalInfo, - Vocab.SparkplugAddr, D_sparkplugAddress); + Vocab.App.Info, D_generalInfo, + Vocab.App.SparkplugAddr, D_sparkplugAddress); private Map updaters = Map.of( - Vocab.Info, U_generalInfo, - Vocab.SparkplugAddr, U_sparkplugAddress); + Vocab.App.Info, U_generalInfo, + Vocab.App.SparkplugAddr, U_sparkplugAddress); public AppMapper (RdfStore db) { @@ -122,7 +122,7 @@ public Optional generateConfig (Resource app, Resource obj) public void deleteConfig (Resource app, Resource obj) { - if (app.equals(Vocab.Registration)) + if (app.equals(Vocab.App.Registration)) throw new Err.Immutable(); Optional.ofNullable(deleters.get(app)) @@ -131,8 +131,7 @@ public void deleteConfig (Resource app, Resource obj) public void updateConfig (Resource app, Resource obj, JsonValue config) { - /* XXX Reg can be updated but needs special handling */ - if (app.equals(Vocab.Registration)) { + if (app.equals(Vocab.App.Registration)) { updateRegistration(obj, (JsonObject)config); return; } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java index 8cff2d40..a1044d50 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java @@ -68,7 +68,7 @@ public void update () * store an Instant if we don't need one. */ if (!updated.isEmpty()) { var now = request().getInstant(); - updateEntry(Vocab.Registration, now); + updateEntry(Vocab.App.Registration, now); } } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java index a60484dd..c1110c1c 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java @@ -47,7 +47,7 @@ public record Value (JsonValue value, String etag, Instant mtime) {} private boolean isStructured () { - return db().derived().contains(app, RDF.type, Vocab.appStructured); + return db().derived().contains(app, RDF.type, Vocab.App.Structured); } private static final Query Q_getValue = Vocab.query(""" @@ -130,8 +130,8 @@ public boolean putRawValue (JsonValue value) var inst = request().getInstant(); graph.add(entry, RDF.type, app); - graph.add(entry, Vocab.forP, obj); - graph.add(entry, Vocab.value, json); + graph.add(entry, Vocab.App.forP, obj); + graph.add(entry, Vocab.App.value, json); graph.add(entry, Vocab.start, inst); return true; diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java index b4e07eb5..0fd59c65 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java @@ -54,7 +54,7 @@ public static Relation of (String relation) private static final Set IMMUTABLE = Set.of( Vocab.Application, Vocab.Special, - Vocab.Registration, Vocab.ConfigSchema, + Vocab.App.Registration, Vocab.App.ConfigSchema, Vocab.Wildcard, Vocab.Unowned); private Model findGraph (String name) @@ -134,7 +134,7 @@ public JsonValue createObject (UUID klass, Optional uuid) .orElseGet(() -> db().createObject(kres)); return db().appMapper() - .generateConfig(Vocab.Registration, obj.node()) + .generateConfig(Vocab.App.Registration, obj.node()) .orElseThrow(() -> new Err.CorruptRDF("Cannot find object registration")); } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java index 90af79f5..702dc8c2 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java @@ -47,13 +47,17 @@ public static Property prop (String p) { public static final Property timestamp = prop("core/timestamp"); public static final Resource Application = res("core/Application"); - public static final Resource appStructured = res("app/Structured"); - public static final Property forP = prop("app/for"); - public static final Property value = prop("app/value"); - public static final Resource Registration = res("app/Registration"); - public static final Resource ConfigSchema = res("app/ConfigSchema"); - public static final Resource Info = res("app/Info"); - public static final Resource SparkplugAddr = res("app/SparkplugAddress"); + + public static class App { + public static final Resource Structured = res("app/Structured"); + public static final Resource Registration = res("app/Registration"); + public static final Resource ConfigSchema = res("app/ConfigSchema"); + public static final Resource Info = res("app/Info"); + public static final Resource SparkplugAddr = res("app/SparkplugAddress"); + + public static final Property forP = prop("app/for"); + public static final Property value = prop("app/value"); + } public static final Resource G_direct = res("graph/direct"); public static final Resource G_derived = res("graph/derived"); From 36189fb1ed29053e71b8df265379af18865b0fcc Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Mon, 30 Mar 2026 09:18:33 +0100 Subject: [PATCH 13/16] Don't use blank nodes for F+ objects Blank nodes behave strangely within SPARQL INSERTS: the spec says that blank nodes in inserted triples must be new blank nodes distinct from any other. Jena's mechanism for substitution into queries does not override this, so if an existing blank is substituted into an INSERT the inserted triples reference a new blank instead. This means we cannot practically use blank nodes at all. For now, create FP nodes under an ACS namespace. There are three sensible options here: urn:uuid, an ACS namespace, and a namespace local to the installation. Using urn:uuid causes issues with relative URL resolution rules. Using a local namespace may be a better idea in the long run, although ideally we would want to keep ACS objects under the ACS prefix. --- .../main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java index 46b5e662..cae36b9d 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java @@ -179,7 +179,7 @@ public FPObject createObject (Resource klass) /* TXN */ public FPObject createObject (Resource klass, UUID uuid) { - var obj = derived.createResource(); + var obj = Vocab.uuidResource(uuid); derived.add(obj, Vocab.uuid, uuid.toString()); derived.add(obj, RDF.type, klass); derived.add(obj, Vocab.primary, klass); From e56d40cd43c7b74c3d1d5defba61ad75aa85471c Mon Sep 17 00:00:00 2001 From: Ben Morrow Date: Mon, 30 Mar 2026 12:08:30 +0100 Subject: [PATCH 14/16] Create config entries as full F+ objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If we are not going to create config entries as blanks node, they need IRIs; if they are going to have IRIs they need UUIDs generating and might as well be F+ objects. Create a class Document ⊂ Individual and make ConfigEntry a subclass. * Use the ConfigEntry's own UUID for the etag. * Remove the Instant handling for now. Ideally it would come back later applied to all Individuals but we don't need it for now. * Config entries are now F+ objects so they have Registration entries. We must not generate these for Registration entries or we have an infinite loop. These could be supplied virtually as they are immutable. * Delete orphaned structured config entries. * Adjust some of the namespacing and use relative URLs within the Turtle rather than a plethora of namespaces. --- .../amrc/factoryplus/metadb/api/V2Config.java | 11 +- .../factoryplus/metadb/db/AppUpdater.java | 62 +++++-- .../factoryplus/metadb/db/ConfigEntry.java | 25 ++- .../metadb/db/ObjectStructure.java | 2 +- .../amrc/factoryplus/metadb/db/RdfStore.java | 18 +- .../co/amrc/factoryplus/metadb/db/Vocab.java | 16 +- acs-metadb/ttl/core.ttl | 175 ++++++++++-------- 7 files changed, 189 insertions(+), 120 deletions(-) diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2Config.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2Config.java index 5cc6eb24..90317249 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2Config.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/api/V2Config.java @@ -43,10 +43,13 @@ public Response get () return req.configEntry(app, obj).getValue(); }); return entry - .map(e -> Response.ok(e.value()) - .tag(e.etag()) - .lastModified(Date.from(e.mtime())) - .build()) + .map(e -> { + var res = Response.ok(e.value()) + .tag(e.etag()); + e.mtime().ifPresent(t -> + res.lastModified(Date.from(t))); + return res.build(); + }) .orElseThrow(() -> new WebApplicationException(404)); } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java index a1044d50..5976b4d4 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/AppUpdater.java @@ -48,6 +48,35 @@ public AppUpdater (RequestHandler req) ?domain. ?obj a ?domain; ?uuid. + + # We do not generate Reg entries here, they must be done in + # a second pass. + filter(?app != ) + } + """); + private static Query Q_findObjects = Vocab.query(""" + select ?obj + where { + ?obj ?uuid. + + # We don't generate Reg entries for Reg entries. + filter not exists { + ?obj a . + } + } + """); + /* This query assumes we do not have sub-apps, so that a given + * ConfigEntry is a member of only one App. If SPARQL had DELETE + * RETURNING this could be an UpdateRequest: we must know what was + * removed to send notify updates. + */ + private static Query Q_orphanConfigs = Vocab.query(""" + select ?conf ?app ?obj + where { + ?conf a ?app; ?obj. + ?app ?domain. + + filter not exists { ?obj a ?domain. } } """); @@ -55,21 +84,30 @@ public void update () { var updates = db().selectQuery(Q_findUpdates) .materialise(); - - updates.forEachRemaining(update -> { - var app = update.getResource("app"); - var obj = update.getResource("obj"); + updates.forEachRemaining(row -> { + var app = row.getResource("app"); + var obj = row.getResource("obj"); updateEntry(app, obj); }); - /* We may have created the current instant in order to update a - * config entry. In this case it may not have been captured by - * the first pass through the changes. But we don't want to - * store an Instant if we don't need one. */ - if (!updated.isEmpty()) { - var now = request().getInstant(); - updateEntry(Vocab.App.Registration, now); - } + /* We update Reg entries in a second pass, as updating the other + * entries will have created registered objects. */ + var objects = db().selectQuery(Q_findObjects) + .materialise(); + objects.forEachRemaining(row -> { + var obj = row.getResource("obj"); + updateEntry(Vocab.App.Registration, obj); + }); + + var orphans = db().selectQuery(Q_orphanConfigs) + .materialise(); + orphans.forEachRemaining(row -> { + var conf = row.getResource("conf"); + db().removeResource(conf); + var app = row.getResource("app"); + var obj = row.getResource("obj"); + updated.add(new Config(app, obj)); + }); } public void publish () diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java index c1110c1c..30f6b84f 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ConfigEntry.java @@ -43,7 +43,7 @@ public static ConfigEntry create (RequestHandler req, UUID app, UUID obj) return new ConfigEntry(req, appO.node(), objO.node()); } - public record Value (JsonValue value, String etag, Instant mtime) {} + public record Value (JsonValue value, String etag, Optional mtime) {} private boolean isStructured () { @@ -51,14 +51,12 @@ private boolean isStructured () } private static final Query Q_getValue = Vocab.query(""" - select ?value ?etag ?mtime + select ?value ?etag where { ?config a ?app; ?obj; - ?value. - ?config ?instant. - ?instant ?etag; - ?mtime. + ?etag; + ?value. } """); @@ -69,10 +67,10 @@ public Optional getValue () var val = Util.decodeLiteral(binding.get("value"), RDF.JSON, s -> Json.createReader(new StringReader(s)).readValue()); var etag = Util.decodeLiteral(binding.get("etag"), XSD.xstring, s -> s); - var mtime = Util.decodeLiteral(binding.get("mtime"), XSD.dateTime, - Instant::parse); + //var mtime = Util.decodeLiteral(binding.get("mtime"), XSD.dateTime, + // Instant::parse); - return new Value(val, etag, mtime); + return new Value(val, etag, Optional.empty()); }); } @@ -126,13 +124,12 @@ public boolean putRawValue (JsonValue value) value.toString(), RDF.dtRDFJSON); var graph = db().derived(); - var entry = graph.createResource(); - var inst = request().getInstant(); + var entry = db().createObject(app).node(); + //var inst = request().getInstant(); - graph.add(entry, RDF.type, app); graph.add(entry, Vocab.App.forP, obj); - graph.add(entry, Vocab.App.value, json); - graph.add(entry, Vocab.start, inst); + graph.add(entry, Vocab.Doc.content, json); + //graph.add(entry, Vocab.Time.start, inst); return true; } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java index 0fd59c65..201dbca0 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/ObjectStructure.java @@ -53,7 +53,7 @@ public static Relation of (String relation) } private static final Set IMMUTABLE = Set.of( - Vocab.Application, Vocab.Special, + Vocab.App.Application, Vocab.Special, Vocab.App.Registration, Vocab.App.ConfigSchema, Vocab.Wildcard, Vocab.Unowned); diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java index cae36b9d..cf1e2e55 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/RdfStore.java @@ -132,7 +132,16 @@ public Optional findResource (Property pred, RDFNode obj) direct.listResourcesWithProperty(pred, obj)); } - /* TXN */ + public void removeResource (Resource node) + { + derived.removeAll(node, null, null); + derived.removeAll(null, null, node); + + /* We don't do this yet but we may in the future. */ + if (node.canAs(Property.class)) + derived.removeAll(null, node.as(Property.class), null); + } + public Optional findObject (UUID uuid) { return findResource(Vocab.uuid, Vocab.uuidLiteral(uuid)) @@ -162,7 +171,6 @@ public int findRank (Resource obj) return Util.decodeLiteral(binding.get("rank"), XSD.xint, Integer::parseInt); } - /* TXN */ public FPObject createObject (Resource klass) { UUID uuid; @@ -176,7 +184,6 @@ public FPObject createObject (Resource klass) return createObject(klass, uuid); } - /* TXN */ public FPObject createObject (Resource klass, UUID uuid) { var obj = Vocab.uuidResource(uuid); @@ -193,12 +200,11 @@ public FPObject createObject (Resource klass, UUID uuid) return new FPObject(obj, uuid); } - /* TXN */ public Resource createInstant () { - var inst = createObject(Vocab.Instant).node(); + var inst = createObject(Vocab.Time.Instant).node(); var stamp = derived.createTypedLiteral(Instant.now(), XSDDatatype.XSDdateTime); - derived.add(inst, Vocab.timestamp, stamp); + derived.add(inst, Vocab.Time.timestamp, stamp); return inst; } diff --git a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java index 702dc8c2..f80ad5cc 100644 --- a/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java +++ b/acs-metadb/src/main/java/uk/co/amrc/factoryplus/metadb/db/Vocab.java @@ -37,18 +37,26 @@ public static Property prop (String p) { public static final Property rank = prop("core/rank"); public static final Property primary = prop("core/primary"); public static final Property deleted = prop("core/deleted"); - public static final Property start = prop("core/start"); public static final Resource Special = res("core/Special"); public static final Resource Wildcard = res("core/Wildcard"); public static final Resource Unowned = res("core/Unowned"); - public static final Resource Instant = res("core/Instant"); - public static final Property timestamp = prop("core/timestamp"); + public static class Time { + public static final Resource Instant = res("time/Instant"); - public static final Resource Application = res("core/Application"); + public static final Property start = prop("time/start"); + public static final Property timestamp = prop("time/timestamp"); + } + + public static class Doc { + public static final Resource Document = res("doc/Document"); + + public static final Property content = prop("doc/content"); + } public static class App { + public static final Resource Application = res("app/Application"); public static final Resource Structured = res("app/Structured"); public static final Resource Registration = res("app/Registration"); public static final Resource ConfigSchema = res("app/ConfigSchema"); diff --git a/acs-metadb/ttl/core.ttl b/acs-metadb/ttl/core.ttl index 3c455cf4..f2d6b532 100644 --- a/acs-metadb/ttl/core.ttl +++ b/acs-metadb/ttl/core.ttl @@ -6,10 +6,6 @@ @base . @prefix uuid: . @prefix : . -@prefix app: . -@prefix srv: . -@prefix sp: . -@prefix usr: . # These are proper classes and sit outside the rank structure. :Object a rdfs:Class. @@ -24,6 +20,8 @@ :uuid :from :Object; :to xsd:string. :rank :from :Object; :to xsd:integer. + +# This should become some form of name-in-naming-scheme. :name :from :Object; :to xsd:string. # Factory+ primary class. This is not a subproperty of rdf:type as we @@ -34,22 +32,29 @@ # This is purely informational at the moment. :powersetOf :from :Class; :to :Class. -## Implementation of subsets in terms of powersets. -## These need to be forward rules, I think. -# a ∈ b, b ⊂ c ⇒ a ∈ c -# a ∈ b, b ∈ p, p ℙ c ⇒ a ∈ c +# Implementation of subsets in terms of powersets. +# These need to be forward rules, I think. +# +## a ∈ b, b ⊂ c ⇒ a ∈ c +## a ∈ b, b ∈ p, p ℙ c ⇒ a ∈ c +# +## x ⊂ y, y ⊂ z ⇒ x ⊂ z +## x ∈ m, m ℙ y, y ∈ n, n ℙ z ⇒ x ∈ r, r ℙ z +# r = n as powersets are unique +## x ∈ m, m ℙ y, y ∈ n, n ℙ z ⇒ x ∈ n # -# x ⊂ y, y ⊂ z ⇒ x ⊂ z -# x ∈ m, m ℙ y, y ∈ n, n ℙ z ⇒ x ∈ r, r ℙ z -## r = n as powersets are unique -# x ∈ m, m ℙ y, y ∈ n, n ℙ z ⇒ x ∈ n -## z is not needed -# x ∈ m, m ℙ y, y ∈ n ⇒ x ∈ n +# We cannot remove the condition n ℙ z without adding an assertion that +# n is a powerset, e.g. n ∈ PowerSet where PowerSet is a proper class. +# Without this we might have e.g. n = { y } and so cannot derive x ∈ n. +# Possibly we could replace m ℙ x with m ∈ PowerSet, x ∈ m throughout, +# and instead of referencing 'the powerset of x', reference 'a powerset +# containing x', where a powerset m obeys +## x ∈ m, (∀a: a ∈ y ⇒ a ∈ x) ⇒ y ∈ m # -## then also (backward rules?): -# p ℙ a ⇒ a ∈ p -# a ∈ p, p ℙ b ⇒ a ⊂ p -# a R b, R ∈ p, p ℙ S ⇒ a S b +# then also (backward rules?): +## p ℙ a ⇒ a ∈ p +## a ∈ p, p ℙ b ⇒ a ⊂ p +## a R b, R ∈ p, p ℙ S ⇒ a S b :Individual a :R1Class; :primary :R1Class; rdfs:subClassOf :Object; @@ -81,38 +86,48 @@ :Wildcard a :Special; :primary :Special; :uuid "00000000-0000-0000-0000-000000000000"; :name "Wildcard". -# This makes no logical sense. We should omit the owner instead. +# This makes no logical sense. We should omit the owner instead, or +# replace the owner link with a link to the set of objects belonging to +# that owner, where the Unowned set has no owner reference. :Unowned a :Special; :primary :Special; :uuid "091e796a-65c0-4080-adff-c3ce01a65b2e"; :name "Unownded". -:Instant a :R1Class; :primary :R1Class; +