Skip to content

Commit eecdde8

Browse files
committed
[jdict] new feature: schema-guarded assignment, jd.key <= value, with tests
1 parent 2de0680 commit eecdde8

File tree

3 files changed

+751
-29
lines changed

3 files changed

+751
-29
lines changed

jdict.m

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@
166166
properties (Access = private)
167167
flags__ % additional options, will be passed to jsonlab utility functions such as savejson/loadjson
168168
currentpath__ % internal variable tracking the current path when lookup embedded data at current depth
169+
root__ % reference to root jdict object for validated assignment
169170
end
170171
methods
171172

@@ -175,6 +176,7 @@
175176
obj.attr = containers.Map();
176177
obj.schema = [];
177178
obj.currentpath__ = char(36);
179+
obj.root__ = obj;
178180
if (nargin >= 1)
179181
if (~isempty(varargin))
180182
allflags = [varargin(1:2:end); varargin(2:2:end)];
@@ -321,6 +323,7 @@
321323
newobj.attr = obj.attr;
322324
newobj.schema = obj.schema;
323325
newobj.currentpath__ = trackpath;
326+
newobj.root__ = obj.root__;
324327
val = newobj;
325328
i = i + 2;
326329
continue
@@ -381,7 +384,8 @@
381384
end
382385
end
383386
newobj.schema = obj.schema;
384-
newobj.currentpath__ = char(36);
387+
newobj.currentpath__ = trackpath;
388+
newobj.root__ = obj.root__;
385389
val = newobj;
386390
end
387391
varargout{1} = val;
@@ -576,7 +580,11 @@
576580
opcell{i} = obj.call_('jsonpath', opcell{i}, idx.subs, opcell{i + 1});
577581
else
578582
try
579-
opcell{i} = subsasgn(opcell{i}, idx, opcell{i + 1});
583+
if (exist('OCTAVE_VERSION', 'builtin') ~= 0) && (isa(opcell{i}, 'containers.Map') || isa(opcell{i}, 'dictionary'))
584+
opcell{i}(idx.subs) = opcell{i + 1};
585+
else
586+
opcell{i} = subsasgn(opcell{i}, idx, opcell{i + 1});
587+
end
580588
catch
581589
opcell{i}.(idx.subs) = opcell{i + 1};
582590
end
@@ -793,19 +801,22 @@
793801

794802
% validate data against JSON Schema
795803
function errors = validate(obj, schemadata)
796-
% determine which schema to use
797-
if (nargin >= 2 && ~isempty(schemadata))
798-
useschema = schemadata;
799-
else
800-
useschema = obj.schema;
804+
if nargin >= 2 && ~isempty(schemadata)
805+
obj.setschema(schemadata);
801806
end
802807

803-
if (isempty(useschema))
808+
if isempty(obj.schema)
804809
error('No schema available. Use setschema() first or provide schema as argument.');
805810
end
806811

807-
% use standalone jsonschema function
808-
[valid, errors] = jsonschema(obj.data, useschema);
812+
subschema = jsonschema(obj.schema, [], 'getsubschema', obj.currentpath__);
813+
814+
if isempty(subschema)
815+
errors = {};
816+
return
817+
end
818+
819+
[temp, errors] = jsonschema(obj.data, subschema, 'rootschema', obj.schema);
809820
end
810821

811822
% convert attributes to JSON Schema
@@ -922,5 +933,35 @@
922933
end
923934
end
924935

936+
% overload <= operator for schema-validated assignment
937+
function result = le(obj, value)
938+
% validate against schema if defined
939+
if ~isempty(obj.schema)
940+
subschema = jsonschema(obj.schema, [], 'getsubschema', obj.currentpath__);
941+
942+
% if subschema found for this path, validate
943+
if ~isempty(subschema)
944+
[valid, errs] = jsonschema(value, subschema, 'rootschema', obj.schema);
945+
if ~valid
946+
errmsg = sprintf('Schema validation failed for "%s":', obj.currentpath__);
947+
for i = 1:length(errs)
948+
errmsg = [errmsg ' ' errs{i} ';'];
949+
end
950+
error(errmsg);
951+
end
952+
end
953+
end
954+
955+
% assign via root object using JSONPath
956+
if strcmp(obj.currentpath__, char(36))
957+
obj.root__.data = value;
958+
else
959+
idx.type = '.';
960+
idx.subs = obj.currentpath__;
961+
subsasgn(obj.root__, idx, value);
962+
end
963+
result = obj;
964+
end
965+
925966
end
926967
end

jsonschema.m

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@
3030

3131
opt = varargin2struct(varargin{:});
3232

33+
% Expose resolveref for external use
34+
if isfield(opt, 'resolveref')
35+
valid = resolveref(opt.resolveref, data);
36+
errors = {};
37+
return
38+
end
39+
40+
% Expose getsubschema for external use: jsonschema(schema, [], 'getsubschema', '$.path')
41+
if isfield(opt, 'getsubschema')
42+
valid = getsubschema(data, opt.getsubschema);
43+
errors = {};
44+
return
45+
end
46+
3347
% Generation mode: jsonschema(schema) or jsonschema(schema, [])
3448
if nargin == 1 || (nargin >= 2 && isempty(schema))
3549
schemaarg = data;
@@ -818,3 +832,60 @@
818832
val.(genvarname(reqfields{i})) = [];
819833
end
820834
end
835+
836+
%%
837+
function subschema = getsubschema(schema, jsonpath)
838+
839+
subschema = schema;
840+
if isempty(schema) || isempty(jsonpath) || strcmp(jsonpath, '$')
841+
return
842+
end
843+
844+
% Parse path after $.
845+
path = regexprep(jsonpath, '^\$\.?', '');
846+
if isempty(path)
847+
return
848+
end
849+
850+
% Tokenize: split by unescaped dots and array indices
851+
tokens = regexp(path, '(?:\\.|[^\.\[]+|\[\d+\])', 'match');
852+
853+
for i = 1:length(tokens)
854+
tok = tokens{i};
855+
856+
% Resolve $ref if present
857+
while isa(subschema, 'containers.Map') && isKey(subschema, '$ref')
858+
subschema = resolveref(subschema('$ref'), schema);
859+
if isempty(subschema)
860+
return
861+
end
862+
end
863+
864+
if tok(1) == '['
865+
% Array index -> use items schema
866+
if isa(subschema, 'containers.Map') && isKey(subschema, 'items')
867+
subschema = subschema('items');
868+
if iscell(subschema) && ~isempty(subschema)
869+
subschema = subschema{1};
870+
end
871+
else
872+
subschema = [];
873+
return
874+
end
875+
else
876+
% Property name (unescape \.)
877+
prop = strrep(tok, '\.', '.');
878+
if isa(subschema, 'containers.Map') && isKey(subschema, 'properties')
879+
props = subschema('properties');
880+
if isa(props, 'containers.Map') && isKey(props, prop)
881+
subschema = props(prop);
882+
else
883+
subschema = [];
884+
return
885+
end
886+
else
887+
subschema = [];
888+
return
889+
end
890+
end
891+
end

0 commit comments

Comments
 (0)