Skip to content

Commit 179fd77

Browse files
committed
Linked and ingested sessions in a V2 dataset
V_eta_linked_session_plan.md (signed 2026-10-09): a session is in a dataset when its `session` entity is part_of the dataset or a study of it; an ingested session's documents are in the dataset's database, a linked one's in its own folder, which a `linked_session` document (path, entity_id) names. - ndi.v2.datasetSessions lists a V2 dataset's sessions that way, the denominator first, and says what it left out and why (a session not part_of the dataset; a linked folder missing, unopenable, or holding another session). ndi.dataset/build_session_info uses it for a did2 dataset with no session_in_a_dataset document; linked sessions are opened to list them and searched by database_search as before. session_notes() returns the listing's notes. - add_linked_session / add_ingested_session ('PartOf': a study of the dataset; default the dataset), convertLinkedSessionToIngested, the new convertIngestedSessionToLinked, and unlink_session work on a V2 dataset. Documents move with ndi.v2.copyDocuments, which carries the files they ingested; files recorded by location stay where they are. A membership the session's own relations already state is not written again. - makeSelfContained copies every by-location file of the dataset's documents into its file store ('DryRun' reports first). - ndi.dataset.dir's constructor reads its own database for its lookups, rather than searching the linked sessions before it knows its own id. Tests: TestLinkedSessions (the linking ones return early on a schema without linked_session). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxKLA8Tbf9cpvDe7fuVeKf
1 parent 65fe6b5 commit 179fd77

9 files changed

Lines changed: 1088 additions & 14 deletions

File tree

‎.github/workflows/test-import-v2.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ on:
3434
- 'src/ndi/+ndi/+vintage/**'
3535
- 'src/ndi/+ndi/+v2/**'
3636
- 'src/ndi/+ndi/entity.m'
37+
- 'src/ndi/+ndi/dataset.m'
38+
- 'src/ndi/+ndi/+dataset/dir.m'
3739
- 'src/ndi/+ndi/subject.m'
3840
- 'src/ndi/+ndi/statement.m'
3941
- 'src/ndi/+ndi/assertion.m'
@@ -65,6 +67,8 @@ on:
6567
- 'src/ndi/+ndi/+vintage/**'
6668
- 'src/ndi/+ndi/+v2/**'
6769
- 'src/ndi/+ndi/entity.m'
70+
- 'src/ndi/+ndi/dataset.m'
71+
- 'src/ndi/+ndi/+dataset/dir.m'
6872
- 'src/ndi/+ndi/subject.m'
6973
- 'src/ndi/+ndi/statement.m'
7074
- 'src/ndi/+ndi/assertion.m'

‎src/ndi/+ndi/+dataset/dir.m‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,14 @@
4343
% Use the session.dir's path as the path for this object
4444
ndi_dataset_dir_obj.path = ndi_dataset_dir_obj.session.path;
4545

46-
dataset_session_info_docs = ndi_dataset_dir_obj.database_search(ndi.query('','isa','dataset_session_info'));
46+
dataset_session_info_docs = ndi_dataset_dir_obj.session.database.search(ndi.query('','isa','dataset_session_info'));
4747

4848
correctSessionId = '';
4949
if ~isempty(dataset_session_info_docs)
5050
correctSessionId = dataset_session_info_docs{1}.document_properties.base.session_id;
5151
else
5252
q = ndi.query('','isa','session_in_a_dataset');
53-
session_in_a_dataset_docs = ndi_dataset_dir_obj.database_search(q);
53+
session_in_a_dataset_docs = ndi_dataset_dir_obj.session.database.search(q);
5454
if ~isempty(session_in_a_dataset_docs)
5555
correctSessionId = session_in_a_dataset_docs{1}.document_properties.base.session_id;
5656
else
@@ -68,7 +68,7 @@
6868
correctSessionId = datasetDocs{1}.document_properties.base.session_id;
6969
else
7070
q_session = ndi.v2.isaQuery('session');
71-
candidate_session_doc = ndi_dataset_dir_obj.database_search(q_session);
71+
candidate_session_doc = ndi_dataset_dir_obj.session.database.search(q_session);
7272
if isscalar(candidate_session_doc)
7373
correctSessionId = candidate_session_doc{1}.document_properties.base.session_id;
7474
end
@@ -78,7 +78,7 @@
7878

7979
if ~isempty(correctSessionId)
8080
q_session = ndi.v2.isaQuery('session') & ndi.query('base.session_id','exact_string',correctSessionId);
81-
candidate_session_doc = ndi_dataset_dir_obj.database_search(q_session);
81+
candidate_session_doc = ndi_dataset_dir_obj.session.database.search(q_session);
8282
if isscalar(candidate_session_doc)
8383
% BOTH VINTAGES. V_eta renamed session.reference ->
8484
% session.local_identifier (did-schema, signed 2026-08-13).
@@ -110,7 +110,7 @@
110110
if ~isempty(dataset_session_info_docs)
111111
%disp('updating dataset to new form');
112112
% double-check we still need to do it
113-
dataset_session_info_docs2 = ndi_dataset_dir_obj.database_search(ndi.query('','isa','dataset_session_info'));
113+
dataset_session_info_docs2 = ndi_dataset_dir_obj.session.database.search(ndi.query('','isa','dataset_session_info'));
114114
if ~isempty(dataset_session_info_docs2)
115115
ndi_dataset_dir_obj.repairDatasetSessionInfo(ndi_dataset_dir_obj,dataset_session_info_docs2);
116116
end

‎src/ndi/+ndi/+v2/copyDocuments.m‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
function report = copyDocuments(sourceDb, targetDb, docs, options)
2+
%COPYDOCUMENTS Copy V2 documents, and the files they ingested, to another database.
3+
%
4+
% REPORT = ndi.v2.copyDocuments(SOURCEDB, TARGETDB, DOCS) adds DOCS (a cell
5+
% of document property structs, read from SOURCEDB) to TARGETDB. Both are
6+
% did2.database.sqlitedb objects (an ndi session's is its
7+
% database.db). Each file a document INGESTED into SOURCEDB is ingested
8+
% into TARGETDB from SOURCEDB's copy (<its folder>/files/<uid>), which is
9+
% left in place; a file recorded BY LOCATION (`ingest` 0) stays where it is
10+
% and is recorded the same way. Ids, edges and session ids are unchanged.
11+
%
12+
% Nothing is written when a file a document ingested is missing from
13+
% SOURCEDB: the copy would lose it.
14+
%
15+
% REPORT: documents (how many copied), ingested (files copied into
16+
% TARGETDB's store) and byLocation (files still recorded by location).
17+
%
18+
% Options:
19+
% 'BatchSize' documents per write (default 2000)
20+
% 'Validate' default false: the documents were validated when written
21+
% to SOURCEDB
22+
%
23+
% See also ndi.dataset, did2.database.sqlitedb.
24+
25+
arguments
26+
sourceDb
27+
targetDb
28+
docs cell
29+
options.BatchSize (1,1) double {mustBePositive, mustBeInteger} = 2000
30+
options.Validate (1,1) logical = false
31+
end
32+
33+
report = struct('documents', numel(docs), 'ingested', 0, 'byLocation', 0);
34+
missing = {};
35+
for i = 1:numel(docs)
36+
[docs{i}, nIn, nBy, miss] = fromStore(docs{i}, sourceDb.fileDir);
37+
report.ingested = report.ingested + nIn;
38+
report.byLocation = report.byLocation + nBy;
39+
missing = [missing, miss]; %#ok<AGROW>
40+
end
41+
if ~isempty(missing)
42+
error('ndi:v2:copyDocuments:fileMissing', ...
43+
['%d ingested file(s) are missing from %s, so nothing was copied (the copy ' ...
44+
'would lose them):\n %s'], numel(missing), sourceDb.fileDir, ...
45+
strjoin(missing(1:min(end, 20)), '\n '));
46+
end
47+
for b = 1:options.BatchSize:numel(docs)
48+
part = docs(b:min(b + options.BatchSize - 1, numel(docs)));
49+
targetDb.add(cellfun(@(d) did2.document(d), part, 'UniformOutput', false), ...
50+
'Validate', options.Validate);
51+
end
52+
end
53+
54+
% -----------------------------------------------------------------------------
55+
function [d, nIn, nBy, missing] = fromStore(d, fileDir)
56+
% D with every ingested location pointed at FILEDIR's copy, kept there
57+
nIn = 0; nBy = 0; missing = {};
58+
if ~isfield(d, 'files') || ~isstruct(d.files) || ~isfield(d.files, 'file_info') ...
59+
|| isempty(d.files.file_info)
60+
return;
61+
end
62+
fi = d.files.file_info;
63+
if iscell(fi), fi = [fi{:}]; end
64+
for a = 1:numel(fi)
65+
if ~isfield(fi(a), 'locations'), continue; end
66+
if iscell(fi(a).locations), fi(a).locations = [fi(a).locations{:}]; end
67+
for c = 1:numel(fi(a).locations)
68+
L = fi(a).locations(c);
69+
if isfield(L, 'ingest') && logical(L.ingest)
70+
src = fullfile(fileDir, char(L.uid));
71+
if ~isfile(src)
72+
missing{end+1} = sprintf('%s of document %s (%s)', char(fi(a).name), ...
73+
d.base.id, src); %#ok<AGROW>
74+
end
75+
fi(a).locations(c).location = src;
76+
fi(a).locations(c).delete_original = 0;
77+
nIn = nIn + 1;
78+
else
79+
nBy = nBy + 1;
80+
end
81+
end
82+
end
83+
d.files.file_info = fi;
84+
end

‎src/ndi/+ndi/+v2/datasetSessions.m‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
function [sessions, notes] = datasetSessions(database, datasetSessionId, datasetPath, options)
2+
%DATASETSESSIONS The sessions of a V2 dataset: ingested and linked.
3+
%
4+
% [SESSIONS, NOTES] = ndi.v2.datasetSessions(DATABASE, DATASETSESSIONID,
5+
% DATASETPATH) reads a V2 dataset's own database (DATABASE, an ndi.database
6+
% searched without a session scope) and returns its sessions, one row of a
7+
% struct array each:
8+
%
9+
% session_id the session's id (its documents' base.session_id)
10+
% reference its local_identifier
11+
% entity_id the id of its `session` entity document
12+
% is_linked false: ingested (its documents are in DATABASE)
13+
% true: linked (they are in the folder a `linked_session`
14+
% document names)
15+
% path a linked session's folder, absolute ('' when ingested)
16+
% link_id the `linked_session` document's id ('' when ingested)
17+
% session the linked session, opened (an ndi.session.dir; [] when
18+
% ingested)
19+
%
20+
% The rule (V_eta_linked_session_plan.md, signed 2026-10-09): a session is
21+
% in the dataset when its entity document is `part_of` the dataset, or
22+
% `part_of` a study that is `part_of` the dataset; the relation is in the
23+
% dataset's database for both kinds. A session is LINKED when a
24+
% `linked_session` document names it: its `path` is the session's folder
25+
% (relative to DATASETPATH when inside it) and its `entity_id` the
26+
% session's entity document, which must be in that folder.
27+
%
28+
% NOTES is a cellstr of what was left out and why, the denominator first:
29+
% - a `session` document in DATABASE that is not part_of the dataset;
30+
% - a linked folder that is missing, cannot be opened, or holds another
31+
% session;
32+
% - a linked session that is not part_of the dataset.
33+
% When DATABASE holds no `dataset` document (an import whose metadata stage
34+
% did not run), membership cannot be read, so every session is listed and
35+
% NOTES says so.
36+
%
37+
% Options:
38+
% 'OpenLinked' default true. false lists linked sessions without opening
39+
% them (session_id and reference are then '' and
40+
% `session` is []), e.g. to find a link whose folder moved.
41+
%
42+
% See also ndi.dataset, ndi.dataset.dir.
43+
44+
arguments
45+
database
46+
datasetSessionId (1,:) char
47+
datasetPath (1,:) char
48+
options.OpenLinked (1,1) logical = true
49+
end
50+
51+
sessions = struct('session_id', {}, 'reference', {}, 'entity_id', {}, ...
52+
'is_linked', {}, 'path', {}, 'link_id', {}, 'session', {});
53+
notes = {};
54+
55+
% --- membership: part_of the dataset, directly or through a study ---
56+
datasetDocs = database.search(ndi.v2.isaQuery('dataset'));
57+
datasetDocs = datasetDocs(cellfun(@(d) strcmp(ndi.v2.kindOf(d.document_properties), ...
58+
'dataset'), datasetDocs));
59+
knowMembership = ~isempty(datasetDocs);
60+
% level 1: part_of the dataset (studies, or sessions directly); level 2:
61+
% part_of one of those (the sessions of a study)
62+
level1 = {};
63+
for k = 1:numel(datasetDocs)
64+
level1 = [level1, partOfChildren(database, datasetDocs{k}.id())]; %#ok<AGROW>
65+
end
66+
level1 = unique(level1);
67+
members = level1;
68+
for k = 1:numel(level1)
69+
members = [members, partOfChildren(database, level1{k})]; %#ok<AGROW>
70+
end
71+
members = unique(members);
72+
73+
% --- ingested: `session` documents in the dataset's own database ---
74+
sessionDocs = database.search(ndi.v2.isaQuery('session'));
75+
nIngested = 0;
76+
left = {};
77+
for i = 1:numel(sessionDocs)
78+
p = sessionDocs{i}.document_properties;
79+
if ~strcmp(ndi.v2.kindOf(p), 'session') || strcmp(p.base.session_id, datasetSessionId)
80+
continue;
81+
end
82+
ref = char(ndi.v2.blockOf(p, 'session', 'local_identifier', ''));
83+
if knowMembership && ~ismember(p.base.id, members)
84+
left{end+1} = ref; %#ok<AGROW>
85+
continue;
86+
end
87+
nIngested = nIngested + 1;
88+
sessions(end+1) = struct('session_id', p.base.session_id, 'reference', ref, ...
89+
'entity_id', p.base.id, 'is_linked', false, 'path', '', 'link_id', '', ...
90+
'session', []); %#ok<AGROW>
91+
end
92+
93+
% --- linked: `linked_session` documents ---
94+
links = {};
95+
try
96+
links = database.search(ndi.query('', 'isa', 'linked_session', ''));
97+
catch
98+
% a schema without the class: no linked sessions
99+
end
100+
nLinked = 0;
101+
problems = {};
102+
for i = 1:numel(links)
103+
p = links{i}.document_properties;
104+
rel = char(ndi.v2.blockOf(p, 'linked_session', 'path', ''));
105+
e = ndi.v2.edgeIds(p, 'entity_id');
106+
if isempty(e)
107+
problems{end+1} = sprintf('linked_session %s names no session entity', p.base.id); %#ok<AGROW>
108+
continue;
109+
end
110+
entityId = e{1};
111+
folder = ndi.v2.resolveLinkPath(rel, datasetPath);
112+
if knowMembership && ~ismember(entityId, members)
113+
problems{end+1} = sprintf('%s (linked) is not part_of the dataset', folder); %#ok<AGROW>
114+
continue;
115+
end
116+
row = struct('session_id', '', 'reference', '', 'entity_id', entityId, ...
117+
'is_linked', true, 'path', folder, 'link_id', p.base.id, 'session', []);
118+
if options.OpenLinked
119+
[S, sp, why] = openLinked(folder, entityId);
120+
if isempty(S)
121+
problems{end+1} = why; %#ok<AGROW>
122+
continue;
123+
end
124+
row.session = S;
125+
row.session_id = S.id();
126+
row.reference = char(ndi.v2.blockOf(sp, 'session', 'local_identifier', ''));
127+
end
128+
nLinked = nLinked + 1;
129+
sessions(end+1) = row; %#ok<AGROW>
130+
end
131+
132+
notes{end+1} = sprintf(['DENOMINATOR: %d session document(s) and %d linked_session ' ...
133+
'document(s) read; %d ingested and %d linked session(s) listed'], ...
134+
sum(cellfun(@(d) strcmp(ndi.v2.kindOf(d.document_properties), 'session') && ...
135+
~strcmp(d.document_properties.base.session_id, datasetSessionId), sessionDocs)), ...
136+
numel(links), nIngested, nLinked);
137+
if ~knowMembership
138+
notes{end+1} = ['no dataset document: membership (part_of) cannot be read, so every ' ...
139+
'session is listed'];
140+
end
141+
if ~isempty(left)
142+
notes{end+1} = sprintf('%d session document(s) not part_of the dataset, not listed: %s', ...
143+
numel(left), strjoin(left, ', '));
144+
end
145+
for k = 1:numel(problems)
146+
notes{end+1} = ['not listed: ' problems{k}]; %#ok<AGROW>
147+
end
148+
end
149+
150+
% -----------------------------------------------------------------------------
151+
function children = partOfChildren(database, parentId)
152+
% the ids of the documents `part_of` PARENTID, read from relations in DATABASE
153+
children = {};
154+
q = ndi.query('', 'isa', 'directed_relation', '') & ...
155+
ndi.query('', 'depends_on', 'parent_id', parentId);
156+
rels = database.search(q);
157+
for k = 1:numel(rels)
158+
p = rels{k}.document_properties;
159+
if ~strcmp(ndi.v2.termName(ndi.v2.blockOf(p, 'directed_relation', 'relation', '')), 'part_of')
160+
continue;
161+
end
162+
c = ndi.v2.edgeIds(p, 'child_id');
163+
children = [children, c(:)']; %#ok<AGROW>
164+
end
165+
end
166+
167+
function [S, sp, why] = openLinked(folder, entityId)
168+
% the session in FOLDER, checked to hold the session entity ENTITYID
169+
S = []; sp = []; why = '';
170+
if ~isfolder(folder)
171+
why = sprintf('%s (linked): no such folder', folder);
172+
return;
173+
end
174+
try
175+
S0 = ndi.session.dir(folder);
176+
catch err
177+
why = sprintf('%s (linked) cannot be opened: %s', folder, err.message);
178+
return;
179+
end
180+
d = S0.database_search(ndi.query('base.id', 'exact_string', entityId, ''));
181+
if isempty(d)
182+
why = sprintf(['%s (linked) holds another session (%s): its session document %s ' ...
183+
'is not there'], folder, S0.id(), entityId);
184+
return;
185+
end
186+
sp = d{1}.document_properties;
187+
if ~strcmp(ndi.v2.kindOf(sp), 'session')
188+
why = sprintf('%s (linked): document %s is a %s, not a session', folder, entityId, ...
189+
ndi.v2.kindOf(sp));
190+
return;
191+
end
192+
S = S0;
193+
end

‎src/ndi/+ndi/+v2/linkPath.m‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
function path = linkPath(folder, datasetPath)
2+
%LINKPATH The `path` a linked_session document records for FOLDER.
3+
%
4+
% PATH = ndi.v2.linkPath(FOLDER, DATASETPATH): FOLDER relative to the
5+
% dataset's folder DATASETPATH when FOLDER is inside it (written with '/',
6+
% so it reads the same on any system), else FOLDER absolute
7+
% (V_eta_linked_session_plan.md). A dataset moved with its sessions inside
8+
% it then still finds them.
9+
%
10+
% See also ndi.v2.resolveLinkPath.
11+
12+
arguments
13+
folder (1,:) char
14+
datasetPath (1,:) char
15+
end
16+
f = char(java.io.File(folder).getCanonicalPath());
17+
d = char(java.io.File(datasetPath).getCanonicalPath());
18+
if startsWith(f, [d filesep])
19+
path = strrep(f(numel(d)+2:end), filesep, '/');
20+
else
21+
path = f;
22+
end
23+
end

‎src/ndi/+ndi/+v2/resolveLinkPath.m‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
function folder = resolveLinkPath(path, datasetPath)
2+
%RESOLVELINKPATH A linked session's folder, as an absolute path.
3+
%
4+
% FOLDER = ndi.v2.resolveLinkPath(PATH, DATASETPATH): a `linked_session`
5+
% PATH is relative to the dataset's folder DATASETPATH when the session is
6+
% inside it, else absolute. Either way FOLDER is absolute. '/' and '\' are
7+
% both read as separators, so a dataset written on one system opens on
8+
% another.
9+
%
10+
% See also ndi.v2.linkPath, ndi.v2.datasetSessions.
11+
12+
arguments
13+
path (1,:) char
14+
datasetPath (1,:) char
15+
end
16+
p = strrep(strrep(path, '\', filesep), '/', filesep);
17+
if isAbsolute(p)
18+
folder = p;
19+
else
20+
folder = fullfile(datasetPath, p);
21+
end
22+
end
23+
24+
function tf = isAbsolute(p)
25+
tf = startsWith(p, filesep) || ~isempty(regexp(p, '^[A-Za-z]:', 'once'));
26+
end

0 commit comments

Comments
 (0)