tbox: v1.5 - PhysicalRelation devient DataStructure et point d'extension declare pour le non-tabulaire, hasSource devient sourcedFrom vers un System instancie, storedIn sert deux granularites et deprecie materializedAs, references devient qualifiedBy, primaryLocation sous-propriete de storedIn, hasShortLabel sur 34 classes. CuratedObject devient DefinedObject et ObservedObject devient CapturedObject jusque dans les IRI, curationMode devient authoringMode. Correction de fond : PhysicalLayerObject ne porte plus la provenance, chaque classe de la couche la declare - un System est defined, tout ce qui est en dessous est captured. instances: 5 systemes crees, 151 sourcedFrom, 15 BO nettoyes de hasSource, labels courts, versions alignees

This commit is contained in:
Bastien Gourdon
2026-07-30 15:13:03 +02:00
parent 76e95de471
commit f83b7d7491
9 changed files with 2751 additions and 1504 deletions
+65 -32
View File
@@ -35,13 +35,13 @@ DOMAIN_STYLE = OrderedDict([
])
MULTI = {"composedOf", "usesConcept", "hasElement", "hasMetric", "measures",
"computedBy", "references", "hasGrainElement", "monitoredBy", "hasSynonym",
"computedBy", "qualifiedBy", "hasGrainElement", "monitoredBy", "hasSynonym",
"hasUnit", "hasSource", "packages", "exposes"}
SUBJECT_DOMAIN = "DD_06" # the hub owner: everything else is a dependency
# Every user-facing label in one place. Hyphenated throughout, and never the
# raw class name: "Physical Relation" and "Physicalized in" told a business
# raw class name: "Data Structure" and "Physicalized in" told a business
# reader nothing.
LABELS = {
"DataDomain": "Domain", "SubDomain": "Sub-domain",
@@ -49,14 +49,17 @@ LABELS = {
"Metric": "Metric", "DataObject": "Data object", "DataElement": "Data element",
"BaseTable": "Base table", "Field": "Field",
"DataProduct": "Data product", "DataContract": "Data contract",
"System": "System",
"DataInterface": "Data interface",
}
# Which ownership roles a card shows. The Steward section is gone: one section,
# different roles per object, so the reader always looks in the same place.
ROLES = {
"DataDomain": [("Domain owner", "owner"), ("Data-governance lead", "dgl")],
"SubDomain": [("Sub-domain owner", "subOwner"), ("Data-governance lead", "dgl_up")],
# role labels mirror the short labels the T-Box carries on the actor
# classes, so the two viewers name the same role the same way
"DataDomain": [("Domain owner", "owner"), ("Data governance lead", "dgl")],
"SubDomain": [("Sub-domain owner", "subOwner"), ("Data governance lead", "dgl_up")],
"BusinessObject": [("Steward", "steward")],
"BusinessConcept": [("Steward", "steward")],
"Metric": [("Steward", "steward")],
@@ -180,7 +183,10 @@ def build(subjects):
nodes[subj] = {
"id": subj,
"kind": t,
# the model carries both names now: hasShortLabel for screens,
# hasName for the record. The viewer stops keeping its own list.
"label": LABELS.get(t, t),
"shortName": rec.get("hasShortLabel", ""),
"ident": rec.get("hasIdentifier", ""),
"name": rec.get("hasName", subj.replace("ex:", "")),
"domain": domain_of(subj, rec, subjects),
@@ -193,6 +199,8 @@ def build(subjects):
"physical": rec.get("physicalName", ""),
"format": rec.get("hasFormat", ""),
"source": ", ".join(as_list(rec.get("hasSource"))),
"sourcedFrom": rec.get("sourcedFrom", ""),
"fqn": "",
"version": rec.get("hasVersion", ""),
"operatedBy": rec.get("operatedBy", ""),
"governedBy": rec.get("governedBy", ""),
@@ -208,7 +216,7 @@ def build(subjects):
"computedBy": as_list(rec.get("computedBy")),
"hasElement": as_list(rec.get("hasElement")),
"grain": as_list(rec.get("hasGrainElement")),
"references": as_list(rec.get("references")),
"qualifiedBy": as_list(rec.get("qualifiedBy")),
"steward": as_list(rec.get("monitoredBy")) or as_list(rec.get("ownedBy")),
"dgl": rec.get("hasDGL", ""),
"owner": rec.get("hasDomainOwner", ""),
@@ -239,6 +247,7 @@ def build(subjects):
phys.setdefault(pid, {"id": pid, "kind": "BaseTable",
"label": LABELS["BaseTable"],
"name": n["physical"], "domain": n["domain"],
"fqn": n["physical"],
"derived": True, "backs": []})
phys[pid]["backs"].append(n["id"])
n["materializedAs"] = pid
@@ -251,6 +260,7 @@ def build(subjects):
phys.setdefault(pid, {"id": pid, "kind": "Field",
"label": LABELS["Field"], "name": n["physical"],
"domain": n["domain"], "derived": True,
"fqn": (rel + "." if rel else "") + n["physical"],
"relation": "phys:" + rel if rel else "",
"backs": []})
phys[pid]["backs"].append(n["id"])
@@ -258,13 +268,14 @@ def build(subjects):
for p in phys.values():
p.setdefault("ident", ""); p.setdefault("arb", False)
p.setdefault("label", LABELS.get(p["kind"], p["kind"]))
p.setdefault("shortName", "")
for k in ("definition","rule","formula","physical","format","source","belongsTo",
"about","represents","status","dgl","owner","subOwner","activation",
"version","operatedBy","governedBy","productOwner","storedIn",
"materializedAs","relation","stewardFrom"):
"materializedAs","relation","stewardFrom","fqn","sourcedFrom"):
p.setdefault(k, "")
for k in ("uses","measures","hasMetric","computedBy","hasElement","grain",
"references","steward","units","computedFrom","packages","exposes",
"qualifiedBy","steward","units","computedFrom","packages","exposes",
"steward_inherited"):
p.setdefault(k, [])
p.setdefault("route", "")
@@ -777,7 +788,7 @@ var G = {mode:"strata", doms:[], sel:null, hot:null, labels:true,
show:{DataDomain:true, SubDomain:true, BusinessObject:true,
BusinessConcept:true, Metric:true, DataObject:true,
DataElement:false, DataProduct:true, DataContract:true,
DataInterface:true, BaseTable:true, Field:false},
DataInterface:true, System:true, BaseTable:true, Field:false},
view:{x:0,y:0,s:1}, nodes:[], edges:[]};
var NW=126, NH=32, LANEPAD=26, ROWGAP=15, BANDGAP=34, GUT=142;
@@ -787,12 +798,12 @@ var ROW = {DataDomain:0, SubDomain:1, BusinessObject:2,
BusinessConcept:0, Metric:1,
DataObject:0, DataElement:1,
DataProduct:0, DataContract:1, DataInterface:1,
BaseTable:0, Field:1};
System:0, BaseTable:1, Field:2};
var LAYER = {DataDomain:0, SubDomain:0, BusinessObject:0,
BusinessConcept:1, Metric:1,
DataObject:2, DataElement:2,
DataProduct:3, DataInterface:3, DataContract:3,
BaseTable:4, Field:4};
System:4, BaseTable:4, Field:4};
var BANDS = [
{t:"Ownership & Categorization", g:"who is accountable"},
{t:"Business", g:"what it means"},
@@ -805,7 +816,7 @@ var KINDS = [["DataDomain","Domains"],["SubDomain","Sub-domains"],
["Metric","Metrics"],["DataObject","Data objects"],
["DataElement","Data elements"],["DataProduct","Data products"],
["DataContract","Data contracts"],["DataInterface","Data interfaces"],
["BaseTable","Base tables"],["Field","Fields"]];
["System","Systems"],["BaseTable","Base tables"],["Field","Fields"]];
function subjectColour(dom){ return dom === M.subject ? "#1E4B9B" : "#8A6A28"; }
@@ -831,8 +842,8 @@ function buildGraph(){
n.measures.forEach(function(c){ edge(n.id, c, "measures"); });
n.computedBy.forEach(function(d){ edge(n.id, d, "computed by"); });
n.hasElement.forEach(function(d){ edge(n.id, d, "has element"); });
n.references.forEach(function(d){ edge(n.id, d, "references"); });
if(n.materializedAs) edge(n.id, n.materializedAs, "materialized as");
n.qualifiedBy.forEach(function(d){ edge(n.id, d, "qualified by"); });
if(n.sourcedFrom) edge(n.id, n.sourcedFrom, "sourced from");
if(n.storedIn) edge(n.id, n.storedIn, "stored in");
if(n.operatedBy) edge(n.id, n.operatedBy, "operated by");
if(n.governedBy) edge(n.id, n.governedBy, "governed by");
@@ -1113,10 +1124,12 @@ function renderGraphShell(){
}).join("")
+ '<div class="gsec">Options</div>'
+ '<label><input type="checkbox" id="g-lab" checked> Relation names</label>'
+ '<select id="g-dom"><option value="">All domains</option>'
+ M.domains.map(function(d){ return '<option value="'+d.code+'"'
+ (G.dom===d.code?" selected":"") + '>'+esc(d.label)+'</option>'; }).join("")
+ '</select>'
+ '<div class="gsec">Domains</div>'
+ M.domains.map(function(d){
return '<label><input type="checkbox" data-dm="' + d.code + '"'
+ (!G.doms.length || G.doms.indexOf(d.code) > -1 ? " checked" : "")
+ '> <i style="background:' + (d.code === M.subject ? "#1E4B9B" : "#8A6A28")
+ '"></i>' + esc(d.label) + '</label>'; }).join("")
+ '<div class="mono" id="g-count"></div></div>');
h.push('<div class="gzoom"><button id="g-in">+</button>'
+ '<button id="g-out">\u2212</button><button id="g-fit" style="font-size:12px">'
@@ -1222,7 +1235,7 @@ var HIST = [];
/* reverse indexes, built once */
var PARENT_DO = {}, SUBS_OF = {}, BOS_OF = {}, BACKS = {},
METRICS_OF = {}, REALISED_BY = {};
METRICS_OF = {}, REALISED_BY = {}, ABOUT_OF = {}, USES_OF = {};
M.nodes.forEach(function(n){
n.hasElement.forEach(function(de){ PARENT_DO[de] = n.id; });
if(n.kind === "SubDomain" && n.belongsTo) (SUBS_OF[n.belongsTo] = SUBS_OF[n.belongsTo]||[]).push(n.id);
@@ -1230,6 +1243,8 @@ M.nodes.forEach(function(n){
(n.backs||[]).forEach(function(b){ (BACKS[n.id] = BACKS[n.id]||[]).push(b); });
/* a concept needs to show the metrics that measure it, not only the reverse */
n.measures.forEach(function(bc){ (METRICS_OF[bc] = METRICS_OF[bc]||[]).push(n.id); });
if(n.about) (ABOUT_OF[n.about] = ABOUT_OF[n.about]||[]).push(n.id);
n.uses.forEach(function(u){ (USES_OF[u] = USES_OF[u]||[]).push(n.id); });
if(n.kind === "DataObject" && n.represents)
(REALISED_BY[n.represents] = REALISED_BY[n.represents]||[]).push(n.id);
});
@@ -1256,8 +1271,15 @@ function paint(id){
+ esc(byId[HIST[HIST.length-2]].name) + '</button>');
h.push('<div class="kind" style="color:' + subjectColour(n.domain) + '">'
+ esc(n.label || n.kind) + '</div>');
h.push('<h3>' + esc(n.name) + '</h3>');
h.push('<div class="iri">' + esc(n.ident || n.id) + '</div>');
h.push('<h3>' + esc(n.shortName || n.name) + '</h3>');
if(n.shortName && n.shortName !== n.name)
h.push('<div class="row" style="border:0;padding:0 0 8px"><span class="mono">'
+ esc(n.name) + '</span><span></span></div>');
/* a derived physical node has no governance identifier -- showing its
internal id as a subtitle read like one. The qualified name is an
attribute, and belongs in the attributes block with everything else. */
if(n.ident) h.push('<div class="iri">' + esc(n.ident) + '</div>');
else h.push('<div style="height:8px"></div>');
if(n.arb) h.push('<div class="note"><b>Awaiting arbitration.</b> Proposed by the '
+ 'Data Governance Office; ' + esc(domLabel(n.domain))
+ ' owns this perimeter and has not ratified it. Cannot be published (OW-007).</div>');
@@ -1270,15 +1292,20 @@ function paint(id){
var rows = [];
function at(label, v){ if(v) rows.push([label, v]); }
at("Status", n.status);
at("Source-of-truth", n.source);
if(n.kind !== "DataProduct") at("Source-of-truth", n.source);
if(n.kind === "DataElement"){
at("Base table", byId[n.storedIn] && byId[byId[n.storedIn].relation]
? byId[byId[n.storedIn].relation].name : "");
at("Table field", n.physical);
at("Unit", n.units.join(", "));
at("Format", n.format);
} else if(n.kind === "DataObject"){
at("Base table", n.physical);
} else if(n.kind === "BaseTable" || n.kind === "Field"){
at("Fully qualified name", n.fqn);
} else {
at("Unit" + (n.units.length > 1 ? "s" : ""), n.units.join(", "));
at("Format", n.format);
if(n.kind !== "DataElement") at("Physical name", n.physical);
}
at("Activation", n.activation.replace(/([A-Z])/g," $1").trim());
at("Version", n.version);
@@ -1287,7 +1314,8 @@ function paint(id){
+ esc(r[1])+'</span></div>'; }).join(""));
/* ---- 2. what it means ---- */
if(n.definition) h.push('<h4>Definition</h4><p>' + esc(n.definition) + '</p>');
h.push('<h4>What it is for</h4><p>' + (n.definition ? esc(n.definition)
: '<span class="mono">No definition yet.</span>') + '</p>');
if(n.formula) h.push('<h4>Calculation rule</h4><p class="mono">'
+ esc(n.formula) + '</p>');
if(n.rule) h.push('<h4>' + (n.kind === "DataContract" ? "Commitment" : "Open question")
@@ -1304,8 +1332,11 @@ function paint(id){
else if(r[1] === "productOwner") ids = n.productOwner ? [n.productOwner] : [];
else if(Array.isArray(n[r[1]])) ids = n[r[1]];
else if(n[r[1]]) ids = [n[r[1]]];
var names = ids.map(function(i){ return byId[i] ? esc(byId[i].name) : ""; })
.filter(Boolean);
/* actors carry a short label too; the ownership block shows it rather
than the full record name */
var names = ids.map(function(i){
return byId[i] ? esc(byId[i].shortName || byId[i].name) : ""; })
.filter(Boolean);
var inherited = (r[1] === "steward_inherited" && n.stewardFrom && names.length);
return '<div class="row"><span>' + r[0] + '</span><span class="mono">'
+ (names.length ? names.join(", ") : "to be defined")
@@ -1331,22 +1362,24 @@ function paint(id){
link("Business objects", BOS_OF[n.id]);
link("Is about", [n.about]);
link("Also uses", n.uses);
/* a concept is the subject of the objects that point at it: shown before
the metrics, because the objects are the coarser grain */
link("Defines", ABOUT_OF[n.id]);
link("Used by", USES_OF[n.id]);
link("Metrics", (n.hasMetric || []).concat(METRICS_OF[n.id] || []));
link("Measures", n.measures);
link("Represents", [n.represents]);
link("Implements metric", n.computedFrom);
link("Governed by", [n.governedBy]);
link("Exposes", n.exposes);
link("Implements", n.computedFrom);
link("Packages", n.packages);
link("Data objects", REALISED_BY[n.id]);
link("References", n.references);
link("Qualified by", n.qualifiedBy);
link("Grain", n.grain);
link("Held by data object", [PARENT_DO[n.id]]);
link("Held by", [PARENT_DO[n.id]]);
link("Computed by", n.computedBy);
if(n.hasElement.length) links.push(["Data elements", n.hasElement]);
link("Materialized as", [n.materializedAs]);
link("Stored in", [n.storedIn]);
link("Base table", [n.relation]);
link("Sourced from", [n.sourcedFrom]);
link("Held by", [n.relation]);
link("Provides data for", BACKS[n.id]);
links.forEach(function(l){
+147 -69
View File
@@ -13,7 +13,7 @@ Same visual language as the SODH viewer, with one substitution. Where SODH
lays its columns out by owning domain, the T-Box has no domains -- its
orthogonal axis is PROVENANCE: what governance asserts against what a
harvester observes. The two-colour code carries that instead, blue for
curated and dark gold for observed, and the seam is what crosses between them.
defined and dark gold for captured, and the seam is what crosses between them.
No dependency: parses the Turtle subset this file uses, so it runs on the DSM
system python without a venv.
@@ -33,8 +33,8 @@ WANTED = {
"rdfs:domain": "domain", "rdfs:range": "range",
"owl:inverseOf": "inverseOf", "owl:deprecated": "deprecated",
"dcterms:isReplacedBy": "isReplacedBy",
"pr:harvestSource": "harvestSource", "pr:curationMode": "curationMode",
"pr:hasAcronym": "acronym",
"pr:harvestSource": "harvestSource", "pr:authoringMode": "authoringMode",
"pr:hasAcronym": "acronym", "pr:hasShortLabel": "shortLabel",
}
LAYER_ROOT = OrderedDict([
@@ -43,8 +43,8 @@ LAYER_ROOT = OrderedDict([
("pr:BusinessLayerObject", ("business", "Business", "what it means")),
("pr:LogicalLayerObject", ("logical", "Logical",
"how it is structured")),
("pr:DeliveryLayerObject", ("delivery", "Delivery",
"what we promise, to whom")),
("pr:DeliveryLayerObject", ("delivery", "Delivery",
"what we promise, to whom, until when")),
("pr:PhysicalLayerObject", ("physical", "Physical",
"what actually runs")),
("pr:ConsumptionLayerObject", ("consumption", "Consumption",
@@ -213,19 +213,24 @@ def build(subjects):
layer = "vocab"
else:
layer = "roots"
prov = ("observed" if "pr:ObservedObject" in anc
else "curated" if "pr:CuratedObject" in anc else "structural")
# provenance is INFERRED from the type-of chain, never asserted:
# a Sub-domain is Defined because it is a kind of Ownership Layer
# Object, which is a kind of Defined Object. No separate edge exists
# because none is needed -- the subclass chain already says it.
prov = ("captured" if "pr:CapturedObject" in anc
else "defined" if "pr:DefinedObject" in anc else "structural")
depth, p, guard = 0, parents.get(term), 0
while p and p in classes and guard < 8:
depth += 1; guard += 1; p = parents.get(p)
nodes[term] = {
"id": term, "kind": "Class",
"label": first(rec, "label", term.split(":")[1]),
"shortLabel": first(rec, "shortLabel"),
"layer": layer, "prov": prov, "depth": depth,
"parent": parents.get(term, ""),
"comment": first(rec, "comment"),
"harvest": first(rec, "harvestSource"),
"curation": first(rec, "curationMode"),
"authoring": first(rec, "authoringMode"),
"acronym": first(rec, "acronym"),
"deprecated": first(rec, "deprecated") in ("true", "True"),
"replacedBy": first(rec, "isReplacedBy"),
@@ -240,11 +245,12 @@ def build(subjects):
return {
"id": term, "kind": kind,
"label": first(rec, "label", term.split(":")[1]),
"shortLabel": first(rec, "shortLabel"),
"comment": first(rec, "comment"),
"domain": first(rec, "domain"), "range": first(rec, "range"),
"inverseOf": first(rec, "inverseOf"),
"subPropertyOf": first(rec, "subPropertyOf"),
"curation": first(rec, "curationMode"),
"authoring": first(rec, "authoringMode"),
"harvest": first(rec, "harvestSource"),
"functional": "owl:FunctionalProperty" in rec.get("type", []),
"transitive": "owl:TransitiveProperty" in rec.get("type", []),
@@ -298,7 +304,7 @@ def main():
c = len([n for n in live_c if n["layer"] == l["id"]])
if c:
print(" %-28s %d" % (l["title"], c))
for p in ("curated", "observed", "structural"):
for p in ("defined", "captured", "structural"):
c = len([n for n in live_c if n["prov"] == p])
print(" %-28s %d" % (p, c))
@@ -528,10 +534,33 @@ var G = {mode:"strata", provs:[], sel:null, hot:null, labels:true,
var NW=126, NH=32, LANEPAD=26, ROWGAP=15, BANDGAP=34, GUT=142;
function provColour(p){
return p === "observed" ? "#8A6A28" : p === "curated" ? "#1E4B9B" : "#6B6B6B";
return p === "captured" ? "#8A6A28" : p === "defined" ? "#1E4B9B" : "#6B6B6B";
}
var LANES = [["curated","Curated \u00b7 asserted by governance","#1E4B9B"],
["observed","Observed \u00b7 harvested nightly","#8A6A28"],
/* Scoped relations: these carry no rdfs:domain or rdfs:range on purpose,
because each serves several classes with different targets and the scope
belongs in the SHACL shapes. Both the graph and the cards read this list,
so an edge drawn in one is never missing from the other. */
var SCOPED_PAIRS = [
["pr:belongsTo", "pr:SubDomain", "pr:DataDomain", "belongs to"],
["pr:belongsTo", "pr:BusinessObject", "pr:SubDomain", "belongs to"],
["pr:represents", "pr:DataObject", "pr:BusinessObject", "represents"],
["pr:represents", "pr:DataElement", "pr:BusinessConcept", "represents"],
["pr:monitoredBy", "pr:BusinessObject", "pr:DataSteward", "monitored by"],
["pr:owningDomain", "pr:DefinedObject", "pr:DataDomain", "owning domain"],
["pr:ownedBy", "pr:DefinedObject", "pr:Actor", "owned by"]
];
var ORDER = ["pr:MetaModelObject", "pr:DefinedObject", "pr:CapturedObject",
"pr:OwnershipLayerObject", "pr:DataDomain", "pr:SubDomain", "pr:BusinessObject",
"pr:BusinessLayerObject", "pr:BusinessConcept", "pr:Metric", "pr:KPI",
"pr:LogicalLayerObject", "pr:DataObject", "pr:DataElement",
"pr:DeliveryLayerObject", "pr:DataProduct", "pr:DataContract", "pr:DataInterface",
"pr:PhysicalLayerObject", "pr:System", "pr:Database", "pr:Schema",
"pr:DataStructure", "pr:BaseTable", "pr:View", "pr:ExternalTable", "pr:Field",
"pr:KeyConstraint", "pr:PrimaryKey", "pr:ForeignKeyConstraint", "pr:Transformation",
"pr:ConsumptionLayerObject", "pr:BIWorkspace", "pr:BIDataSource", "pr:BIField",
"pr:BIReport"];
var LANES = [["defined","Defined \u00b7 someone decided it","#1E4B9B"],
["captured","Captured \u00b7 read from a running system","#8A6A28"],
["structural","Structural","#6B6B6B"]];
function live(a){ return a.filter(function(x){ return !x.deprecated; }); }
function layerTitle(id){
@@ -544,18 +573,43 @@ function buildGraph(){
live(M.nodes).forEach(function(n){
if(!G.layers[n.layer]) return;
if(G.provs.length && G.provs.indexOf(n.prov) === -1) return;
/* row = rank inside the layer, so the hierarchy reads DOWNWARD:
layer root, then Domain, then Sub-domain, then Business object.
Depth alone put siblings side by side, which read left to right. */
var rank = ORDER.indexOf(n.id);
keep[n.id] = {n:n, x:0, y:0,
layer: M.layers.map(function(l){return l.id;}).indexOf(n.layer),
row: Math.min(n.depth, 2)};
row: rank > -1 ? rank : 90 + Math.min(n.depth, 5)};
});
var E = [];
/* Properties deliberately left without rdfs:domain/range serve several
classes with different targets, and their scope lives in the SHACL
shapes instead. The graph reads domain and range, so those edges were
simply absent -- Business Object to Sub-domain among them. The pairs are
declared here, mirroring the shapes, rather than added to the T-Box where
they would over-constrain the property. */
SCOPED_PAIRS.forEach(function(sc){
if(byId[sc[0]] && !byId[sc[0]].deprecated && keep[sc[1]] && keep[sc[2]]
&& sc[1] !== sc[2])
E.push({s:sc[1], t:sc[2], l:sc[3], cross:false});
});
var _unused = [
["pr:belongsTo", "pr:SubDomain", "pr:DataDomain", "belongs to"],
["pr:belongsTo", "pr:BusinessObject", "pr:SubDomain", "belongs to"],
["pr:represents", "pr:DataObject", "pr:BusinessObject", "represents"],
["pr:represents", "pr:DataElement", "pr:BusinessConcept", "represents"],
["pr:monitoredBy", "pr:BusinessObject", "pr:DataSteward", "monitored by"],
["pr:owningDomain", "pr:DefinedObject", "pr:DataDomain", "owning domain"],
["pr:ownedBy", "pr:DefinedObject", "pr:Actor", "owned by"]
];
live(M.relations).forEach(function(r){
if(keep[r.domain] && keep[r.range] && r.domain !== r.range)
E.push({s:r.domain, t:r.range, l:r.label, cross:r.seam});
});
live(M.nodes).forEach(function(n){
if(n.parent && keep[n.id] && keep[n.parent])
E.push({s:n.id, t:n.parent, l:"specialises", cross:false});
E.push({s:n.id, t:n.parent, l:"type of", cross:false});
});
G.nodes = Object.keys(keep).map(function(k){ return keep[k]; });
G.edges = E;
@@ -569,19 +623,15 @@ function layoutGraph(idx){
if(G.mode === "network"){ layoutForce(idx); return; }
var lanes = LANES.map(function(L){ return L[0]; })
.filter(function(c){ return G.nodes.some(function(n){ return n.n.prov === c; }); });
/* One class per row. A lane is one column wide, so a layer reads DOWNWARDS:
the layer class on top, then Domain, then Sub-domain, then Business
Object. That is how a hierarchy is read; laying them left to right made
the reader scan across for something that descends. */
var laneW = {}, x = GUT;
G._lanes = [];
lanes.forEach(function(code){
var members = G.nodes.filter(function(n){ return n.n.prov === code; });
var widest = 1;
M.layers.forEach(function(_, li){
[0,1,2].forEach(function(ri){
var c = members.filter(function(n){ return n.layer===li && n.row===ri; }).length;
if(c) widest = Math.max(widest, Math.min(c, Math.ceil(Math.sqrt(c*1.6))));
});
});
var w = widest*(NW+12) + LANEPAD*2;
laneW[code] = {x:x, w:w, per:widest};
var w = NW + LANEPAD*2;
laneW[code] = {x:x, w:w};
var L = LANES.filter(function(z){ return z[0] === code; })[0];
G._lanes.push({code:code, x:x, w:w, label:L[1], colour:L[2]});
x += w;
@@ -591,30 +641,23 @@ function layoutGraph(idx){
var y = 0;
M.layers.forEach(function(b, li){
b._y = y;
var maxRows = 0;
var rowTops = {};
var cursor = y + 34;
[0,1,2].forEach(function(ri){
var any = G.nodes.filter(function(n){ return n.layer===li && n.row===ri; });
if(!any.length){ rowTops[ri] = cursor; return; }
rowTops[ri] = cursor;
var deepest = 0;
lanes.forEach(function(code){
var g = any.filter(function(n){ return n.n.prov === code; });
if(!g.length) return;
var per = laneW[code].per;
deepest = Math.max(deepest, Math.ceil(g.length/per));
g.sort(function(p,q){ return p.n.label.localeCompare(q.n.label); });
g.forEach(function(n, i){
n.x = laneW[code].x + LANEPAD + (i%per)*(NW+12) + NW/2;
n.y = cursor + Math.floor(i/per)*(NH+ROWGAP) + NH/2;
});
var any = G.nodes.filter(function(n){ return n.layer === li; });
if(!any.length){ b._h = 0; return; }
var cursor = y + 36, rows = 0;
lanes.forEach(function(code){
var g = any.filter(function(n){ return n.n.prov === code; });
if(!g.length) return;
g.sort(function(p, q){
return ((ORDER.indexOf(p.n.id)+1)||99) - ((ORDER.indexOf(q.n.id)+1)||99)
|| p.n.label.localeCompare(q.n.label); });
g.forEach(function(n, i){
n.x = laneW[code].x + laneW[code].w/2;
n.y = cursor + i*(NH+ROWGAP) + NH/2;
});
cursor += deepest*(NH+ROWGAP) + ROWGAP;
maxRows += deepest;
rows = Math.max(rows, g.length);
});
b._h = maxRows ? (cursor - y + 12) : 0;
y += b._h + (b._h ? BANDGAP : 0);
b._h = rows*(NH+ROWGAP) + 48;
y += b._h + BANDGAP;
});
G._height = y;
}
@@ -676,7 +719,7 @@ function renderGraph(){
if(G.mode === "strata" && G.nodes.length){
/* domain lanes: alternating tint, so a column reads as one domain */
(G._lanes||[]).forEach(function(l, i){
var isSubject = l.code === "curated";
var isSubject = l.code === "defined";
bL.appendChild(el("rect", {x:l.x, y:-6, width:l.w, height:G._height+10,
fill:l.colour, opacity:isSubject ? .05 : .028}));
if(i) bL.appendChild(el("line", {x1:l.x, y1:-6, x2:l.x, y2:G._height+4,
@@ -733,7 +776,7 @@ function renderGraph(){
specialisations a shape, never instantiated */
g.appendChild(el("rect",{width:NW, height:NH, rx:3, fill:"#FFFFFF",
stroke:c, "stroke-dasharray": n.n.abstract ? "5 3" : "none"}));
var lines = wrapText(n.n.label, 21);
var lines = wrapText(n.n.shortLabel || n.n.label, 21);
lines.forEach(function(t,i){
var e = el("text",{x:NW/2, y:NH/2+3.5+(i-(lines.length-1)/2)*10, fill:"#1A1A1A"});
e.textContent = t; g.appendChild(e);
@@ -807,33 +850,33 @@ function zoomG(f){
function kpi(v,l,w){ return '<div class="kpi'+(w?' warn':'')+'"><b>'+v+'</b><span>'+l+'</span></div>'; }
function renderScope(){
var cls=live(M.nodes), rel=live(M.relations), att=live(M.attributes);
var cur=cls.filter(function(n){return n.prov==="curated";});
var obs=cls.filter(function(n){return n.prov==="observed";});
var cur=cls.filter(function(n){return n.prov==="defined";});
var obs=cls.filter(function(n){return n.prov==="captured";});
var str=cls.filter(function(n){return n.prov==="structural";});
var seam=rel.filter(function(r){return r.seam;});
var dep=M.nodes.filter(function(n){return n.deprecated;})
.concat(M.relations.concat(M.attributes).filter(function(r){return r.deprecated;}));
var h=[];
h.push('<p class="lede">The layer says what an object <b>is</b>. The provenance says <b>how it got here</b>.</p>');
h.push('<p class="sublede">Curated objects are asserted by governance and reviewed through a pull request. '
+ 'Observed objects are harvested from Snowflake, Tableau and Looker, rebuilt on every run and never edited '
h.push('<p class="sublede">Defined objects are written by governance and reviewed through a pull request. '
+ 'Captured objects are read from Snowflake, Tableau and Looker, rebuilt on every run and never edited '
+ 'by hand. Without that split the ownership rules would demand an owner, a status and a version on every '
+ 'one of some fifty thousand warehouse columns.</p>');
h.push('<div class="kpis">'+kpi(cls.length,"Classes")+kpi(rel.length,"Relations")
+kpi(att.length,"Attributes")+kpi(M.bridges.length,"Bridge properties")
+kpi(seam.length,"Seam edges",true)+kpi(dep.length,"Deprecated",true)+'</div>');
h.push('<h2 class="sec">Curated against observed</h2><div class="bar">'
h.push('<h2 class="sec">Defined against captured</h2><div class="bar">'
+'<div style="width:'+(cur.length/cls.length*100)+'%;background:#1E4B9B"></div>'
+'<div style="width:'+(obs.length/cls.length*100)+'%;background:#8A6A28"></div>'
+'<div style="width:'+(str.length/cls.length*100)+'%;background:#B9B4A7"></div></div>');
h.push('<div class="legend">'
+'<span><i style="background:#1E4B9B"></i>Curated \u2014 '+cur.length+'</span>'
+'<span><i style="background:#8A6A28"></i>Observed \u2014 '+obs.length+'</span>'
+'<span><i style="background:#1E4B9B"></i>Defined \u2014 '+cur.length+'</span>'
+'<span><i style="background:#8A6A28"></i>Captured \u2014 '+obs.length+'</span>'
+'<span><i style="background:#B9B4A7"></i>Structural \u2014 '+str.length+'</span></div>');
h.push('<div class="note" style="max-width:880px;margin-bottom:26px"><b>Only '+seam.length
+' relations cross between the two.</b> '+seam.map(function(r){return esc(r.label);}).join(" and ")
+' \u2014 both proposed by the reconciliation job and confirmed by a steward. Everywhere else the '
+'graph is either pure governance or pure machine, which is what keeps the curation cost bounded.</div>');
+'graph is either pure governance or pure machine, which is what keeps the mapping effort bounded.</div>');
h.push('<h2 class="sec">The layers</h2><div class="grid">');
M.layers.forEach(function(l){
var g=cls.filter(function(n){return n.layer===l.id;});
@@ -861,9 +904,12 @@ function renderModel(){
M.layers.forEach(function(l){
var g=live(M.nodes).filter(function(n){return n.layer===l.id;});
if(!g.length) return;
g.sort(function(a,b){ return a.depth-b.depth || a.label.localeCompare(b.label); });
g.sort(function(a,b){
return a.depth - b.depth
|| ((ORDER.indexOf(a.id)+1)||99) - ((ORDER.indexOf(b.id)+1)||99)
|| a.label.localeCompare(b.label); });
h.push('<h2 class="sec" style="color:'+provColour(g[0].prov)+'">'+esc(l.title)+' \u00b7 '+esc(l.tag)+'</h2>');
h.push('<table><thead><tr><th>Class</th><th>Provenance</th><th class="hide-s">Specialises</th>'
h.push('<table><thead><tr><th>Class</th><th>Provenance</th><th class="hide-s">Type of</th>'
+'<th class="hide-s">What it is for</th></tr></thead><tbody>');
g.forEach(function(n){
h.push('<tr data-id="'+n.id+'"><td><span class="dot" style="background:'+provColour(n.prov)
@@ -903,7 +949,7 @@ function propRows(){
if(p.functional) t.push("exactly one");
if(p.transitive) t.push("transitive");
if(p.seam) t.push("seam");
if(p.curation) t.push(p.curation.toLowerCase().replace(/_/g," "));
if(p.authoring) t.push(p.authoring.toLowerCase().replace(/_/g," "));
h.push('<tr data-id="'+p.id+'"><td><b>'+esc(p.label)+'</b><div class="mono">'+esc(p.id)+'</div></td>'
+'<td class="hide-s">'+(byId[p.domain]?esc(clean(byId[p.domain].label)):'<span class="mono">any</span>')+'</td>'
+'<td class="hide-s">'+(byId[p.range]?esc(clean(byId[p.range].label))
@@ -939,8 +985,8 @@ function renderDep(){
/* ------------------------------------------------------------ SHELL */
function renderGraphShell(){
var h=['<p class="lede">The meta model as a graph.</p>'
+'<p class="sublede">Columns are provenance: <b style="color:#1E4B9B">curated</b> on the left, '
+'<b style="color:#8A6A28">observed</b> on the right. Rows are layers. A <b>navy edge</b> crosses '
+'<p class="sublede">Columns are provenance: <b style="color:#1E4B9B">defined</b> on the left, '
+'<b style="color:#8A6A28">captured</b> on the right. Rows are layers. A <b>navy edge</b> crosses '
+'between the two \u2014 that is the seam, the only place a steward confirms anything. Dashed outlines '
+'are abstract classes, never instantiated.</p>'];
h.push('<div class="gwrap"><svg id="g-svg" xmlns="http://www.w3.org/2000/svg"><defs>'
@@ -1076,6 +1122,29 @@ M.relations.forEach(function(r){
if(r.deprecated) return;
if(r.domain) (FROM[r.domain]=FROM[r.domain]||[]).push(r.id);
if(r.range) (TO[r.range]=TO[r.range]||[]).push(r.id); });
/* The same scoped pairs the graph uses. Without them a Business Object shows
no incoming relation at all, because belongsTo and represents carry no
rdfs:domain -- their scope lives in the shapes. The card was silent about
edges the graph was drawing. */
SCOPED_PAIRS.forEach(function(sc){
if(!byId[sc[0]] || byId[sc[0]].deprecated) return;
(FROM[sc[1]] = FROM[sc[1]]||[]).push(sc[0]);
(TO[sc[2]] = TO[sc[2]]||[]).push(sc[0]);
});
/* the scoped properties again: without this a Sub-domain card showed no
incoming Business Object, and a Business Object card had no incoming list
at all, because neither relation declares a domain or a range */
[["pr:belongsTo","pr:SubDomain","pr:DataDomain"],
["pr:belongsTo","pr:BusinessObject","pr:SubDomain"],
["pr:represents","pr:DataObject","pr:BusinessObject"],
["pr:represents","pr:DataElement","pr:BusinessConcept"],
["pr:monitoredBy","pr:BusinessObject","pr:DataSteward"],
["pr:owningDomain","pr:DefinedObject","pr:DataDomain"],
["pr:ownedBy","pr:DefinedObject","pr:Actor"]].forEach(function(sc){
if(!byId[sc[0]] || byId[sc[0]].deprecated) return;
if((FROM[sc[1]]||[]).indexOf(sc[0])===-1) (FROM[sc[1]]=FROM[sc[1]]||[]).push(sc[0]);
if((TO[sc[2]]||[]).indexOf(sc[0])===-1) (TO[sc[2]]=TO[sc[2]]||[]).push(sc[0]);
});
M.attributes.forEach(function(a){
if(a.deprecated||!a.domain) return;
(ATTRS[a.domain]=ATTRS[a.domain]||[]).push(a.id); });
@@ -1104,7 +1173,10 @@ function paint(id){
: n.kind==="Attribute" ? "Attribute" : "Bridge property";
h.push('<div class="kind" style="color:'+provColour(n.prov||"structural")+'">'
+ kindLabel + (n.layer ? ' \u00b7 ' + esc(layerTitle(n.layer)) : '') + '</div>');
h.push('<h3>'+esc(clean(n.label))+'</h3>');
h.push('<h3>'+esc(n.shortLabel || clean(n.label))+'</h3>');
if(n.shortLabel && n.shortLabel !== clean(n.label))
h.push('<div class="row" style="border:0;padding:0 0 6px"><span class="mono">'
+ esc(clean(n.label)) + '</span><span></span></div>');
h.push('<div class="iri">'+esc(n.id)+'</div>');
if(n.deprecated) h.push('<div class="note"><b>Deprecated.</b> Kept so assertions written '
+ 'against it keep classifying (LC-002)'
@@ -1117,13 +1189,13 @@ function paint(id){
if(n.kind==="Class"){
at("Provenance", n.prov);
at("Instantiated", n.abstract ? "never \u2014 abstract" : "yes");
at("Curation mode", (n.curation||"").toLowerCase().replace(/_/g," "));
at("Authoring mode", (n.authoring||"").toLowerCase().replace(/_/g," "));
at("Acronym", n.acronym);
} else {
at("Cardinality", n.functional ? "exactly one" : "many");
at("Transitive", n.transitive ? "yes" : "");
at("Crosses the seam", n.seam ? "yes" : "");
at("Curation mode", (n.curation||"").toLowerCase().replace(/_/g," "));
at("Authoring mode", (n.authoring||"").toLowerCase().replace(/_/g," "));
if(!byId[n.range]) at("Range", n.range);
if(!n.domain) at("Domain", "any \u2014 scoped by SHACL per class");
}
@@ -1132,12 +1204,18 @@ function paint(id){
}).join(""));
/* 2. what it is for */
if(n.comment) h.push('<h4>What it is for</h4><p>'+esc(n.comment).replace(/\n/g,"<br>")+'</p>');
h.push('<h4>What it is for</h4><p>' + (n.comment
? esc(n.comment).replace(/\n/g,"<br>")
: '<em style="color:var(--muted)">No definition yet \u2014 this term needs one '
+ 'before it can be published.</em>') + '</p>');
if(n.harvest) h.push('<h4>Harvested from</h4><div class="harv">'+esc(n.harvest)+'</div>');
/* 3. related terms, from the coarsest down */
function link(label, ids){
ids=(ids||[]).filter(function(i){return byId[i];});
/* deprecated terms live on their own tab; showing them in a link list
invites someone to use one */
ids=(ids||[]).filter(function(i){
return byId[i] && (!byId[i].deprecated || label === "Replaced by"); });
if(!ids.length) return;
h.push('<h4>'+label+'</h4>');
ids.slice(0,16).forEach(function(i){
@@ -1148,8 +1226,8 @@ function paint(id){
if(ids.length>16) h.push('<div class="row"><span class="mono">\u2026 and '
+(ids.length-16)+' more</span><span></span></div>');
}
link("Specialises", [n.parent, n.subPropertyOf]);
link("Specialised by", CHILDREN[n.id]);
link("Type of", [n.parent, n.subPropertyOf]);
link("Types", CHILDREN[n.id]);
link("Replaced by", [n.replacedBy]);
link("From", [n.domain]);
link("To", [n.range]);
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""
SODH - migration v1.2 -> v1.3
==============================
USAGE
python3 scripts/migrate_instances_v1_3.py # dry run
python3 scripts/migrate_instances_v1_3.py --apply # rewrite, .bak kept
1. references -> qualifiedBy. The name now says why the link exists rather
than how it is implemented.
2. hasSource removed from every Business Object. Same argument as for the
Business Concepts in v1.2, and the values showed why it was needed: across
the 15 objects the property held physical locations ("SODH Gold /
F_SELL_OUT"), migration notes ("MDM Product -> SODH [concepts TBC by
DD-10]") and arbitration remarks ("DGO proposal, pending ratification"),
all under one name. A Business Object is a governance grouping; its data
lives in the Data Objects that represent it, and those carry the system.
3. Short labels where the long name is unwieldy on screen. The Data Product
was called "Sell Out Data Hub - Gold" on its own card and "SODH Gold"
everywhere else, because the viewers each kept a display dictionary.
4. Instance versions aligned on the model version. Business Objects were
still stamped 0.6 while the model had moved to 1.3.
"""
import os
import re
import shutil
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TTL = os.path.join(REPO, "instances", "sodh.ttl")
W = 78
SHORT_LABELS = {
"ex:DP_06_01_001": "SODH Gold",
"ex:DC_06_01_001": "SODH Gold contract",
"ex:DI_06_01_001": "Consumption schema",
"ex:DI_06_01_002": "Panel extract",
"ex:DO_06_01_001": "Sell Out Volume",
"ex:DO_06_01_003": "Sell Out Distribution",
"ex:DO_10_01_001": "Product",
"ex:DO_04_01_001": "Customer",
"ex:DO_16_01_001": "Currency",
"ex:DO_21_01_001": "Calendar",
}
MODEL_VERSION = "1.3"
def clauses(body):
out, buf, i, n = [], [], 0, len(body)
while i < n:
c = body[i]
if c == '"':
j = i + 1
while j < n and body[j] != '"':
j += 2 if body[j] == "\\" else 1
j = min(j + 1, n)
buf.append(body[i:j]); i = j; continue
if c == ";":
out.append("".join(buf)); buf = []; i += 1; continue
buf.append(c); i += 1
out.append("".join(buf))
return out
def set_prop(text, subject, prop, value):
pat = re.compile(r'(^%s a pr:\w+ ;)(.*?)(\.\s*\n)' % re.escape(subject), re.S | re.M)
m = pat.search(text)
if not m:
return text, False
kept = [c for c in clauses(m.group(2))
if c.strip() and not re.match(r'\s*%s\s' % re.escape(prop), c)]
if value is not None:
kept.insert(0, "\n %s %s " % (prop, value))
body = " ;".join(kept).rstrip() + "\n "
return text[:m.start()] + m.group(1) + body + m.group(3) + text[m.end():], True
def has_prop(text, subject, prop):
m = re.search(r'^%s a pr:\w+ ;(.*?)\.\s*\n' % re.escape(subject), text, re.S | re.M)
return bool(m) and any(re.match(r'\s*%s\s' % re.escape(prop), c)
for c in clauses(m.group(1)))
def main():
apply_changes = "--apply" in sys.argv
if not os.path.exists(TTL):
print("Not found: %s" % TTL); sys.exit(2)
text = original = open(TTL, encoding="utf-8").read()
report = []
text, n = re.subn(r'\bpr:references\b', 'pr:qualifiedBy', text)
report.append("references -> qualifiedBy: %d occurrence(s)" % n)
n_bo = 0
for m in list(re.finditer(r'^(ex:BO_\w+) a pr:BusinessObject ;', text, re.M)):
if has_prop(text, m.group(1), "pr:hasSource"):
text, _ = set_prop(text, m.group(1), "pr:hasSource", None)
n_bo += 1
report.append("hasSource removed from %d business objects" % n_bo)
n_sl = 0
for subj, label in SHORT_LABELS.items():
if re.search(r'^%s a ' % re.escape(subj), text, re.M):
text, ok = set_prop(text, subj, "pr:hasShortLabel", '"%s"' % label)
n_sl += ok
report.append("short label set on %d objects" % n_sl)
n_v = 0
for m in list(re.finditer(r'^(ex:\w+) a pr:\w+ ;', text, re.M)):
if has_prop(text, m.group(1), "pr:hasVersion"):
text, ok = set_prop(text, m.group(1), "pr:hasVersion",
'"%s"' % MODEL_VERSION)
n_v += ok
report.append("version aligned on %s for %d objects" % (MODEL_VERSION, n_v))
print()
print("SODH MIGRATION v1.2 -> v1.3 %s" % ("APPLY" if apply_changes else "DRY RUN"))
print("=" * W)
for line in report:
print(" " + line)
print("=" * W)
left_bo = len([1 for m in re.finditer(r'^(ex:BO_\w+) a pr:BusinessObject ;', text, re.M)
if has_prop(text, m.group(1), "pr:hasSource")])
doubled = []
for m in re.finditer(r'^(ex:\w+) a pr:\w+ ;(.*?)\.\s*\n', text, re.S | re.M):
for p in ("pr:hasName", "pr:hasShortLabel", "pr:hasVersion", "pr:hasSource"):
if len(re.findall(r'(?<![\w:])%s\s' % re.escape(p), m.group(2))) > 1:
doubled.append("%s/%s" % (m.group(1), p))
print(" business objects still carrying a source: %d (must be 0)" % left_bo)
print(" references left: %d (must be 0)" % len(re.findall(r'pr:references', text)))
print(" duplicated single-valued properties: %s"
% (", ".join(doubled) if doubled else "none"))
print("=" * W)
if text != original and apply_changes:
shutil.copy2(TTL, TTL + ".bak")
open(TTL, "w", encoding="utf-8").write(text)
print(" written, backup at %s.bak" % os.path.basename(TTL))
elif text != original:
print(" dry run -- re-run with --apply to write")
print()
if __name__ == "__main__":
main()
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""
SODH - migration v1.3 -> v1.4
==============================
USAGE
python3 scripts/migrate_instances_v1_4.py # dry run
python3 scripts/migrate_instances_v1_4.py --apply # rewrite, .bak kept
1. SYSTEMS BECOME NODES. hasSource held a string: "SODH Gold", "MDM Product",
"Group Finance". Six systems are created and sourcedFrom points at them.
"Which objects depend on MDM Product?" was unanswerable while MDM Product
was text.
The systems are seeded here by hand, once. Everything below them --
databases, schemas, tables, columns -- is meant to be harvested, and this
script does not invent any of it.
2. Three hasSource values were not systems at all but migration notes ("DGO
proposal, split of a compound sub-domain"). They are dropped rather than
turned into a system: provenance of a record is not a property of the thing
the record describes.
3. materializedAs -> storedIn on the Data Objects. Nothing carries it yet,
done for completeness so the term never reappears.
"""
import os
import re
import shutil
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TTL = os.path.join(REPO, "instances", "sodh.ttl")
W = 78
# (iri, identifier, name, short label, system type, the string it replaces)
SYSTEMS = [
("ex:SYS_SODH_GOLD", "SYS-001", "Sell Out Data Hub - Gold layer", "SODH Gold",
"pr:DataPlatform", "SODH Gold"),
("ex:SYS_MDM_PRODUCT", "SYS-002", "Master Data Management - Product", "MDM Product",
"pr:MasterDataSystem", "MDM Product"),
("ex:SYS_MDM_CUSTOMER", "SYS-003", "Master Data Management - Customer", "MDM Customer",
"pr:MasterDataSystem", "MDM Customer"),
("ex:SYS_GROUP_FINANCE", "SYS-004", "Group Finance consolidation", "Group Finance",
"pr:SourceApplication", "Group Finance"),
("ex:SYS_CORPORATE_CALENDAR", "SYS-005", "Corporate standard calendar", "Standard Calendar",
"pr:SourceApplication", "Standard Calendar"),
]
# strings that were never a system: provenance notes about the record itself
NOT_A_SYSTEM = ["DGO proposal"]
def clauses(body):
out, buf, i, n = [], [], 0, len(body)
while i < n:
c = body[i]
if c == '"':
j = i + 1
while j < n and body[j] != '"':
j += 2 if body[j] == "\\" else 1
j = min(j + 1, n)
buf.append(body[i:j]); i = j; continue
if c == ";":
out.append("".join(buf)); buf = []; i += 1; continue
buf.append(c); i += 1
out.append("".join(buf))
return out
def set_prop(text, subject, prop, value):
pat = re.compile(r'(^%s a pr:\w+ ;)(.*?)(\.\s*\n)' % re.escape(subject), re.S | re.M)
m = pat.search(text)
if not m:
return text, False
kept = [c for c in clauses(m.group(2))
if c.strip() and not re.match(r'\s*%s\s' % re.escape(prop), c)]
if value is not None:
kept.insert(0, "\n %s %s " % (prop, value))
body = " ;".join(kept).rstrip() + "\n "
return text[:m.start()] + m.group(1) + body + m.group(3) + text[m.end():], True
def get_source(text, subject):
m = re.search(r'^%s a pr:\w+ ;(.*?)\.\s*\n' % re.escape(subject), text, re.S | re.M)
if not m:
return None
for c in clauses(m.group(1)):
mm = re.match(r'\s*pr:hasSource\s+"([^"]*)"', c)
if mm:
return mm.group(1)
return None
def system_blocks(todo):
head = ("# --- systems -----------------------------------------------------------\n"
"# Seeded by hand, once. A system was a string until now, which is why\n"
"# 'what depends on MDM Product' had no answer. Everything below a system --\n"
"# databases, schemas, tables, columns -- is meant to be harvested, and\n"
"# nothing here invents any of it.\n\n")
out = []
for iri, ident, name, short, stype, _ in todo:
out.append('%s a pr:System ;\n'
' pr:hasIdentifier "%s" ; pr:hasName "%s" ;\n'
' pr:hasShortLabel "%s" ;\n'
' pr:hasSystemType %s ;\n'
' pr:hasStatus "DRAFT" ; pr:hasVersion "1.4" .'
% (iri, ident, name, short, stype))
return head + "\n".join(out) + "\n"
def main():
apply_changes = "--apply" in sys.argv
if not os.path.exists(TTL):
print("Not found: %s" % TTL); sys.exit(2)
text = original = open(TTL, encoding="utf-8").read()
report = []
todo = [sy for sy in SYSTEMS if not re.search(r'^%s a ' % re.escape(sy[0]), text, re.M)]
if todo:
anchor = re.search(r'^ex:DD_\w+ a pr:DataDomain', text, re.M)
pos = anchor.start() if anchor else 0
text = text[:pos] + system_blocks(todo) + "\n" + text[pos:]
report.append("%d systems created%s" % (len(todo), "" if todo else " (already present)"))
by_string = {sy[5]: sy[0] for sy in SYSTEMS}
n_ok = n_drop = 0
for m in list(re.finditer(r'^(ex:\w+) a pr:\w+ ;', text, re.M)):
subj = m.group(1)
src = get_source(text, subj)
if src is None:
continue
if src in by_string:
text, _ = set_prop(text, subj, "pr:hasSource", None)
text, _ = set_prop(text, subj, "pr:sourcedFrom", by_string[src])
n_ok += 1
elif any(k in src for k in NOT_A_SYSTEM):
text, _ = set_prop(text, subj, "pr:hasSource", None)
n_drop += 1
report.append("sourcedFrom set on %d objects" % n_ok)
report.append("%d provenance notes dropped rather than turned into a system" % n_drop)
text, n = re.subn(r'\bpr:materializedAs\b', 'pr:storedIn', text)
report.append("materializedAs -> storedIn: %d occurrence(s)" % n)
print()
print("SODH MIGRATION v1.3 -> v1.4 %s" % ("APPLY" if apply_changes else "DRY RUN"))
print("=" * W)
for line in report:
print(" " + line)
print("=" * W)
left = re.findall(r'pr:hasSource\s+"([^"]*)"', text)
defined = set(re.findall(r'^(ex:\w+) a ', text, re.M))
dangling = sorted(set(re.findall(r'\bex:\w+', text)) - defined)
doubled = []
for m in re.finditer(r'^(ex:\w+) a pr:\w+ ;(.*?)\.\s*\n', text, re.S | re.M):
for p in ("pr:sourcedFrom", "pr:hasName", "pr:hasShortLabel"):
if len(re.findall(r'(?<![\w:])%s\s' % re.escape(p), m.group(2))) > 1:
doubled.append("%s/%s" % (m.group(1), p))
print(" hasSource left: %d %s" % (len(left), sorted(set(left)) if left else "(must be 0)"))
print(" sourcedFrom references: %d" % len(re.findall(r'pr:sourcedFrom', text)))
print(" dangling references: %s" % (", ".join(dangling) if dangling else "none"))
print(" duplicated single-valued properties: %s"
% (", ".join(doubled) if doubled else "none"))
print("=" * W)
if text != original and apply_changes:
shutil.copy2(TTL, TTL + ".bak")
open(TTL, "w", encoding="utf-8").write(text)
print(" written, backup at %s.bak" % os.path.basename(TTL))
elif text != original:
print(" dry run -- re-run with --apply to write")
print()
if __name__ == "__main__":
main()