From 38ab8fa3df562bb9e82db48d36427002fccef585 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Mon, 31 Aug 2026 12:20:18 +0200 Subject: [PATCH] fix: decode controlled instance files through the shared JSON-LD path Three readers load controlled instance files from the local library. One of them decoded through the shared JSON-LD utility; the other two, InstanceResolver.resolveNode and HasControlledInstance.fromName, used raw jsondecode, which mangles @id into x_id. Everything downstream of those two readers therefore leaned on x_id fallback branches rather than the at_-form keywords every other decoded document carries. All three now decode through openminds.internal.utility.json.decode, so no code inside this repository produces x_-form structs any more. The offline reader also doubled every apostrophe inside the JSON text before decoding, a leftover from quoting for some earlier evaluation context. A definition written as 'associative array' came back as ''associative array'', and because the online reader did not do this, the same instance decoded differently depending on where the file came from. The replacement is removed. The x_id fallback branches themselves are deliberately kept. They are not dead compatibility code: openminds-kg-sync converts Knowledge Graph payloads with raw jsondecode and hands x_-form structs into openMINDS types, so the branches are the contract that integration relies on. They can only be removed after kg-sync normalizes its payloads to at_-form, which belongs to the kg-sync migration, not to this stack. Co-Authored-By: Claude Opus 5 --- .../+internal/+mixin/HasControlledInstance.m | 5 ++- .../+internal/+resolver/InstanceResolver.m | 7 +++- .../+internal/getControlledInstance.m | 1 - .../tests/unitTests/ControlledInstanceTest.m | 42 +++++++++++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/code/internal/+openminds/+internal/+mixin/HasControlledInstance.m b/code/internal/+openminds/+internal/+mixin/HasControlledInstance.m index 1da371e10..256107fc3 100644 --- a/code/internal/+openminds/+internal/+mixin/HasControlledInstance.m +++ b/code/internal/+openminds/+internal/+mixin/HasControlledInstance.m @@ -16,7 +16,10 @@ instances = openminds.internal.listControlledInstances(type); isMatch = instances.InstanceName==string(name); if any(isMatch) - data = jsondecode(fileread(instances.Filepath(isMatch))); + % Shared JSON-LD decode, so the struct carries at_-form + % keywords; raw jsondecode would mangle @id into x_id. + data = openminds.internal.utility.json.decode( ... + fileread(instances.Filepath(isMatch))); instance = feval(type.ClassName, data); else error(['Could not find data for instance of type "%s" ', ... diff --git a/code/internal/+openminds/+internal/+resolver/InstanceResolver.m b/code/internal/+openminds/+internal/+resolver/InstanceResolver.m index 765bc8dd4..6fea68c95 100644 --- a/code/internal/+openminds/+internal/+resolver/InstanceResolver.m +++ b/code/internal/+openminds/+internal/+resolver/InstanceResolver.m @@ -21,8 +21,11 @@ 'Could not find data for instance with IRI "%s"', instance.id) end - % Todo: use the JSON-LD deserializer once it exists - data = jsondecode(fileread(instances.Filepath(isMatch))); + % Decode through the shared JSON-LD utility, so the struct + % carries at_-form keywords like every other decoded document. + % Raw jsondecode would mangle @id into x_id. + data = openminds.internal.utility.json.decode( ... + fileread(instances.Filepath(isMatch))); instance.fromStruct(data); end diff --git a/code/internal/+openminds/+internal/getControlledInstance.m b/code/internal/+openminds/+internal/getControlledInstance.m index 0a8d2b497..8b1d20089 100644 --- a/code/internal/+openminds/+internal/getControlledInstance.m +++ b/code/internal/+openminds/+internal/getControlledInstance.m @@ -60,7 +60,6 @@ end jsonStr = fileread(filePath); - jsonStr = strrep(jsonStr, '''', ''''''); %If character array contains ', need to replace with '' data = openminds.internal.utility.json.decode(jsonStr); end diff --git a/tools/tests/unitTests/ControlledInstanceTest.m b/tools/tests/unitTests/ControlledInstanceTest.m index c659db282..dfb0b9911 100644 --- a/tools/tests/unitTests/ControlledInstanceTest.m +++ b/tools/tests/unitTests/ControlledInstanceTest.m @@ -20,6 +20,48 @@ function testGetControlledInstanceLocal(testCase, instanceSpecification, version testCase.assertTrue(contains(jsonStr.at_id, expectedIdUriPrefix)); end + function testApostrophesInDefinitionsAreNotDoubled(testCase) + % Apostrophes inside JSON values survive decoding unchanged, so a + % definition written as 'associative array' reads back with single + % apostrophes. The offline and online readers must agree here, so + % an instance decodes the same way wherever its file came from. + + term = openminds.controlledterms.DataType("associativeArray"); + + testCase.verifyTrue(contains(term.definition, "'associative array'"), ... + 'The definition should contain the apostrophes as written in the file.') + testCase.verifyFalse(contains(term.definition, "''"), ... + 'Apostrophes inside values must not be doubled by the reader.') + end + + function testResolvedInstanceCarriesFileContent(testCase) + % Resolving a controlled instance IRI populates the instance from + % the library file, keeping the identifier and enriching values. + + IRI = "https://openminds.om-i.org/instances/dataType/associativeArray"; + instance = openminds.instanceFromIRI(IRI); + + testCase.verifyEqual(string(instance.id), IRI) + testCase.verifyEqual(instance.name, "associative array") + testCase.verifyFalse(contains(instance.definition, "''")) + end + + function testFromNameCarriesFileIdentifier(testCase) + % A controlled instance built by name through the mixin gets its + % identifier from the library file rather than a generated one. + + instanceNames = openminds.core.data.ContentType.listInstances(); + testCase.assumeNotEmpty(instanceNames, ... + 'No controlled ContentType instances available locally.') + + instance = openminds.core.data.ContentType.fromName(instanceNames(1)); + + testCase.verifyTrue(startsWith(string(instance.id), ... + "https://openminds.om-i.org/instances/contentTypes/"), ... + 'The identifier should come from the library file.') + testCase.verifyNotEqual(string(instance.name), "") + end + function testGetControlledInstanceRemote(testCase, instanceSpecification, versionNumber) jsonStr = openminds.internal.getControlledInstance(... instanceSpecification{:}, versionNumber, "FileSource", "github");