Skip to content

Commit b0f433c

Browse files
calmacxclaude
andcommitted
feat(GAT-9018): free-text linkage fallback + delta-row indexing fixes
Squashed net contribution of feat/GAT-9018-2-2 over dev (which already carries the GWDM version-context + handler hierarchy from #1699/#1704). - Free-text linkage fallback: persist raw_url/raw_pid/raw_title/raw_doi for unresolved dataset/publication linkages (new add_unresolved_linkage_columns migration) and reconstruct them on read. Layered onto dev's resolveDataset/ PublicationLinkages helpers so resolved-title consistency and partial-update handling are preserved; getLinkages now lives on Gwdm2xHandler. - IndexElastic + ExtractTools/PublicationsFromMetadata: correct delta-row handling so reduced (patch) versions reconstruct the full GWDM envelope before indexing/extraction instead of reading the stripped metadata column. - Gate Elasticsearch indexing per GWDM version via handler->supportsElasticIndexing() and toSearchableFields(). - DatasetService: add getMetadataOnly() fast path and validateAllVersions(); spatial-coverage mapping now prunes stale pivot rows on edit-down. - Tests: linkage free-text roundtrip, delta-row index/extract, GWDM index gating, and spatial-coverage pruning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 248c5de commit b0f433c

18 files changed

Lines changed: 1152 additions & 137 deletions

app/Http/Controllers/Api/V2/DatasetController.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,15 @@ public function showActive(GetDataset $request, int $id): JsonResponse|BinaryFil
187187
return $this->streamStructuralMetadataExport($dataset, $id);
188188
}
189189

190+
if ($request->query('view') === 'metadataOnly') {
191+
$metadata = $this->datasetService->getMetadataOnly(
192+
$dataset,
193+
$request->query('schema_model') ?? $this->outputSchemaContext->schemaModel(),
194+
$request->query('schema_version') ?? $this->outputSchemaContext->schemaVersion(),
195+
);
196+
return response()->json(['message' => 'success', 'data' => $metadata]);
197+
}
198+
190199
$dataset = $this->datasetService->prepareForShow(
191200
$dataset,
192201
$request->query('schema_model') ?? $this->outputSchemaContext->schemaModel(),
@@ -209,7 +218,7 @@ public function showActive(GetDataset $request, int $id): JsonResponse|BinaryFil
209218

210219
} catch (\InvalidArgumentException $e) {
211220
return response()->json(['message' => $e->getMessage()], 400);
212-
} catch (ModelNotFoundException $e) {
221+
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
213222
return response()->json(['message' => $e->getMessage()], 404);
214223
} catch (\RuntimeException $e) {
215224
return response()->json(['message' => 'failed to translate', 'details' => $e->getMessage()], 400);

app/Http/Traits/IndexElastic.php

Lines changed: 106 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
use App\Models\ToolHasTag;
3232
use App\Models\ToolHasTypeCategory;
3333
use App\Models\TypeCategory;
34+
use App\Services\DatasetService;
35+
use App\Services\Gwdm\GwdmHandlerFactory;
3436
use ElasticClientController as ECC;
3537

3638
trait IndexElastic
@@ -43,6 +45,59 @@ trait IndexElastic
4345
private $publications = [];
4446
private $collections = [];
4547

48+
/**
49+
* Single named resolution point for DatasetService within this trait.
50+
*
51+
* Constructor injection isn't viable here: IndexElastic is a trait consumed
52+
* by ~20 unrelated controllers/jobs/observers, so there is no single
53+
* constructor to inject into without a much larger refactor.
54+
*/
55+
private function datasetService(): DatasetService
56+
{
57+
return app(DatasetService::class);
58+
}
59+
60+
/**
61+
* Reconstruct the full GWDM envelope for a dataset's latest version.
62+
*
63+
* DatasetVersion->metadata cannot be read directly for indexing: delta rows
64+
* store an empty metadata blob, and GWDM 3.0 rows omit the `metadata` key
65+
* entirely (their data lives in dedicated SQL tables). Funnelling through
66+
* DatasetService::getReconstructedMetadataEnvelope() yields a complete
67+
* {gwdmVersion, metadata, original_metadata} envelope for every schema
68+
* version and every snapshot/delta storage shape. Validation is skipped —
69+
* data is validated at write time.
70+
*
71+
* @return array The reconstructed envelope, or [] when the dataset has no versions.
72+
*/
73+
private function reconstructedLatestEnvelope(Dataset $dataset): array
74+
{
75+
$version = $dataset->latestVersion();
76+
77+
if (!$version) {
78+
return [];
79+
}
80+
81+
return $this->datasetService()->getReconstructedMetadataEnvelope(
82+
$dataset->id,
83+
$version->version,
84+
false,
85+
$version,
86+
);
87+
}
88+
89+
// Whether this envelope's GWDM version should be indexed into Elasticsearch.
90+
private function isElasticIndexable(array $envelope): bool
91+
{
92+
$version = $envelope['gwdmVersion'] ?? null;
93+
94+
if ($version === null) {
95+
return true; // no version info available — preserve current behaviour
96+
}
97+
98+
return app(GwdmHandlerFactory::class)->resolve($version)->supportsElasticIndexing();
99+
}
100+
46101
/**
47102
* Calls a re-indexing of Elastic search when a dataset is created, updated or added to a collection.
48103
*
@@ -71,7 +126,14 @@ public function reindexElastic(string $datasetId, bool $returnParams = false, bo
71126
// throw new \Exception("Error: DatasetVersion is missing for dataset ID=$datasetId.");
72127
}
73128

74-
$metadata = $datasetMatch->latestVersion()->metadata;
129+
$metadata = $this->reconstructedLatestEnvelope($datasetMatch);
130+
131+
if (!$this->isElasticIndexable($metadata)) {
132+
$this->deleteDatasetFromElastic($datasetId);
133+
return null;
134+
}
135+
136+
$latestVersion = $datasetMatch->latestVersion();
75137

76138
// inject relationships via Local functions
77139
$materialTypes = $this->getMaterialTypes($metadata);
@@ -82,8 +144,10 @@ public function reindexElastic(string $datasetId, bool $returnParams = false, bo
82144
'abstract' => $this->getValueByPossibleKeys($metadata, ['metadata.summary.abstract'], ''),
83145
'keywords' => $this->normalizeDelimitedList($this->getValueByPossibleKeys($metadata, ['metadata.summary.keywords'], '')),
84146
'description' => $this->getValueByPossibleKeys($metadata, ['metadata.summary.description'], ''),
85-
'shortTitle' => $this->getValueByPossibleKeys($metadata, ['metadata.summary.shortTitle'], ''),
86-
'title' => $this->getValueByPossibleKeys($metadata, ['metadata.summary.title'], ''),
147+
// short_title/title are populated columns on every write (see DatasetVersion);
148+
// only fall back to the envelope for legacy pre-migration rows where they're null.
149+
'shortTitle' => $latestVersion->short_title ?? $this->getValueByPossibleKeys($metadata, ['metadata.summary.shortTitle'], ''),
150+
'title' => $latestVersion->title ?? $this->getValueByPossibleKeys($metadata, ['metadata.summary.title'], ''),
87151
'populationSize' => $this->getValueByPossibleKeys($metadata, ['metadata.summary.populationSize'], -1),
88152
'publisherName' => $this->getValueByPossibleKeys($metadata, ['metadata.summary.publisher.name', 'metadata.summary.publisher.publisherName'], ''),
89153
'startDate' => $this->getValueByPossibleKeys($metadata, ['metadata.provenance.temporal.startDate'], null),
@@ -288,13 +352,16 @@ public function reindexElasticDataProvider(string $teamId, bool $returnParams =
288352

289353
$latestVersion = $dataset->latestVersion();
290354
if ($latestVersion) {
291-
$datasetVersionIds[] = $latestVersion->id;
292-
$metadata = $latestVersion->metadata;
293-
$datasetTitles[] = $metadata['metadata']['summary']['shortTitle'];
294-
$types = explode(';,;', $metadata['metadata']['summary']['datasetType']);
295-
foreach ($types as $t) {
296-
if (!in_array($t, $dataTypes)) {
297-
$dataTypes[] = $t;
355+
$metadata = $this->reconstructedLatestEnvelope($dataset);
356+
357+
if ($this->isElasticIndexable($metadata)) {
358+
$datasetVersionIds[] = $latestVersion->id;
359+
$datasetTitles[] = $latestVersion->short_title ?? $this->getValueByPossibleKeys($metadata, ['metadata.summary.shortTitle'], '');
360+
$types = $this->normalizeDelimitedList($this->getValueByPossibleKeys($metadata, ['metadata.summary.datasetType'], ''));
361+
foreach ($types as $t) {
362+
if (!in_array($t, $dataTypes)) {
363+
$dataTypes[] = $t;
364+
}
298365
}
299366
}
300367
}
@@ -460,12 +527,19 @@ public function indexElasticCollections(int $collectionId, bool $returnParams =
460527
$datasetTitles = array();
461528
$datasetAbstracts = array();
462529
foreach ($datasetIds as $d) {
463-
$metadata = Dataset::where(['id' => $d])
464-
->first()
465-
->latestVersion()
466-
->metadata;
467-
$datasetTitles[] = $metadata['metadata']['summary']['shortTitle'];
468-
$datasetAbstracts[] = $metadata['metadata']['summary']['abstract'];
530+
$datasetRow = Dataset::where(['id' => $d])->first();
531+
$latestVersion = $datasetRow?->latestVersion();
532+
if (!$latestVersion) {
533+
continue;
534+
}
535+
536+
$metadata = $this->reconstructedLatestEnvelope($datasetRow);
537+
if (!$this->isElasticIndexable($metadata)) {
538+
continue;
539+
}
540+
541+
$datasetTitles[] = $latestVersion->short_title ?? $this->getValueByPossibleKeys($metadata, ['metadata.summary.shortTitle'], '');
542+
$datasetAbstracts[] = $this->getValueByPossibleKeys($metadata, ['metadata.summary.abstract'], '');
469543
}
470544

471545
$keywords = array();
@@ -535,8 +609,7 @@ private function indexElasticDataProviderColl(int $id): void
535609

536610
foreach ($datasets as $dataset) {
537611
$dataset->setAttribute('spatialCoverage', $dataset->allSpatialCoverages ?? []);
538-
$metadata = $dataset['versions'][0];
539-
$datasetTitles[] = $metadata['metadata']['metadata']['summary']['shortTitle'];
612+
$datasetTitles[] = $dataset->latestVersion()?->short_title ?? '';
540613
foreach ($dataset['spatialCoverage'] as $loc) {
541614
if (!in_array($loc['region'], $locations)) {
542615
$locations[] = $loc['region'];
@@ -598,11 +671,7 @@ public function indexElasticDur(string $id, bool $returnParams = false): null|ar
598671

599672
$datasetTitles = array();
600673
foreach ($datasetIds as $d) {
601-
$metadata = Dataset::where(['id' => $d])
602-
->first()
603-
->latestVersion()
604-
->metadata;
605-
$datasetTitles[] = $metadata['metadata']['summary']['shortTitle'];
674+
$datasetTitles[] = Dataset::where(['id' => $d])->first()->latestVersion()->short_title ?? '';
606675
}
607676

608677
$keywords = array();
@@ -698,9 +767,10 @@ public function indexElasticPublication(string $id, bool $returnParams = false):
698767
];
699768

700769
foreach ($datasets as $dataset) {
701-
$metadata = Dataset::where(['id' => $dataset['id']])->first()->latestVersion()->metadata;
702-
$latestVersionID = Dataset::where(['id' => $dataset['id']])->first()->latestVersion()->id;
703-
$datasetTitles[] = $metadata['metadata']['summary']['shortTitle'];
770+
$datasetModel = Dataset::where(['id' => $dataset['id']])->first();
771+
$latestVersion = $datasetModel->latestVersion();
772+
$latestVersionID = $latestVersion->id;
773+
$datasetTitles[] = $latestVersion->short_title ?? '';
704774

705775
// change from 'UNKNOWN' to 'USING' - temp
706776
$linkType = PublicationHasDatasetVersion::where([
@@ -838,8 +908,7 @@ public function indexElasticTools(int $toolId, bool $returnParams = false): null
838908
$datasetTitles[] = '_Can be used with any dataset';
839909
} else {
840910
foreach ($datasets as $dataset) {
841-
$dataset_version = $dataset['versions'][0];
842-
$datasetTitles[] = $dataset_version['metadata']['metadata']['summary']['shortTitle'];
911+
$datasetTitles[] = $dataset->latestVersion()?->short_title ?? '';
843912
}
844913
usort($datasetTitles, 'strcasecmp');
845914
}
@@ -971,7 +1040,10 @@ public function deleteDataProviderCollFromElastic(string $id)
9711040
public function getMaterialTypes(array $metadata): array|null
9721041
{
9731042
$materialTypes = null;
974-
if (version_compare(Config::get('metadata.GWDM.version'), "2.0", "<")) {
1043+
// Check the reconstructed envelope's own gwdmVersion, not the global config
1044+
// default — a dataset's actual version can diverge from that default.
1045+
$rowGwdmVersion = $metadata['gwdmVersion'] ?? Config::get('metadata.GWDM.version');
1046+
if (version_compare($rowGwdmVersion, "2.0", "<")) {
9751047
$containsTissue = !empty($this->getValueByPossibleKeys($metadata, [
9761048
'metadata.coverage.biologicalsamples',
9771049
'metadata.coverage.physicalSampleAvailability',
@@ -1110,16 +1182,12 @@ public function checkingDataset(int $datasetId)
11101182
$publicationIds = array_column($dataset->allActivePublications, 'id') ?? [];
11111183
$toolIds = array_column($dataset->allActiveTools, 'id') ?? [];
11121184

1113-
$version = $dataset->latestVersion();
1114-
$withLinks = DatasetVersion::where('id', $version['id'])
1115-
->with(['linkedDatasetVersions'])
1116-
->first();
1117-
1118-
$dataset->setAttribute('versions', [$withLinks]);
1119-
1120-
$metadataSummary = $dataset['versions'][0]['metadata']['metadata']['summary'] ?? [];
1185+
$metadataSummary = $this->reconstructedLatestEnvelope($dataset)['metadata']['summary'] ?? [];
11211186

1122-
$title = $this->getValueByPossibleKeys($metadataSummary, ['title'], '');
1187+
// title/short_title are populated columns on every write; only fall back
1188+
// to the reconstructed envelope for legacy pre-migration rows.
1189+
$title = $dataset->latestVersion()?->title
1190+
?? $this->getValueByPossibleKeys($metadataSummary, ['title'], '');
11231191
$populationSize = $this->getValueByPossibleKeys($metadataSummary, ['populationSize'], -1);
11241192
$datasetType = $this->getValueByPossibleKeys($metadataSummary, ['datasetType'], '');
11251193

app/Jobs/ExtractPublicationsFromMetadata.php

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
use App\Models\Team;
77
use App\Models\User;
88
use App\Models\Dataset;
9+
use App\Models\DatasetVersion;
910
use App\Models\Publication;
11+
use App\Services\DatasetService;
1012
use Illuminate\Support\Arr;
1113
use Illuminate\Bus\Queueable;
1214
use Illuminate\Support\Facades\Http;
@@ -62,17 +64,15 @@ public function publication($datasetVersionId)
6264
$linkagePublicationAboutDataset = 'metadata.linkage.publicationAboutDataset';
6365
$linkagePublicationUsingDataset = 'metadata.linkage.publicationUsingDataset';
6466

65-
$metadata = \DB::table('dataset_versions')
66-
->where('id', $datasetVersionId)
67-
->select('id', 'dataset_id', 'metadata', \DB::raw('JSON_TYPE(metadata) as metadata_type'))
68-
->first();
67+
// Reconstruct via DatasetService — the raw `metadata` column is empty on delta rows.
68+
$dv = DatasetVersion::where('id', $datasetVersionId)->first();
6969

70-
if (is_null($metadata)) {
70+
if (is_null($dv)) {
7171
\Log::warning('ExtractPublicationsFromMetadata :: Metadata not found.', $this->loggingContext);
7272
return;
7373
}
7474

75-
$dataset = Dataset::where('id', $metadata->dataset_id)->select(['id', 'user_id', 'team_id'])->first();
75+
$dataset = Dataset::where('id', $dv->dataset_id)->select(['id', 'user_id', 'team_id'])->first();
7676
if (is_null($dataset)) {
7777
\Log::warning('ExtractPublicationsFromMetadata :: Dataset not found.', $this->loggingContext);
7878
return;
@@ -93,16 +93,9 @@ public function publication($datasetVersionId)
9393
$datasetUserId = (int) $dataset->user_id;
9494
$datasetTeamId = (int) $dataset->team_id;
9595

96-
$data = null;
97-
if ($metadata->metadata_type === 'OBJECT') {
98-
$data = json_decode($metadata->metadata, true);
99-
}
100-
101-
if ($metadata->metadata_type === 'STRING') {
102-
$data = json_decode(json_decode($metadata->metadata), true);
103-
}
96+
$data = app(DatasetService::class)->getReconstructedMetadataEnvelope($dv->dataset_id, $dv->version, false, $dv);
10497

105-
if (count($data ?: []) === 0) {
98+
if (empty($data['metadata'])) {
10699
return;
107100
}
108101

app/Jobs/ExtractToolsFromMetadata.php

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
use App\Models\Tool;
77
use App\Models\User;
88
use App\Models\Dataset;
9+
use App\Models\DatasetVersion;
10+
use App\Services\DatasetService;
911
use Illuminate\Support\Arr;
1012
use Illuminate\Bus\Queueable;
1113
use App\Http\Traits\IndexElastic;
@@ -59,17 +61,15 @@ public function tool(int $datasetVersionId)
5961
$linkageToolsDataset = 'metadata.linkage.tools';
6062
$type = 'Used on';
6163

62-
$metadata = \DB::table('dataset_versions')
63-
->where('id', $datasetVersionId)
64-
->select('id', 'dataset_id', 'metadata', \DB::raw('JSON_TYPE(metadata) as metadata_type'))
65-
->first();
64+
// Reconstruct via DatasetService — the raw `metadata` column is empty on delta rows.
65+
$dv = DatasetVersion::where('id', $datasetVersionId)->first();
6666

67-
if (is_null($metadata)) {
67+
if (is_null($dv)) {
6868
\Log::info('ExtractToolsFromMetadata :: Metadata not found.', $this->loggingContext);
6969
return;
7070
}
7171

72-
$dataset = Dataset::where('id', $metadata->dataset_id)->select(['id', 'user_id', 'team_id'])->first();
72+
$dataset = Dataset::where('id', $dv->dataset_id)->select(['id', 'user_id', 'team_id'])->first();
7373
if (is_null($dataset)) {
7474
\Log::info('ExtractToolsFromMetadata :: Dataset not found.', $this->loggingContext);
7575
return;
@@ -92,16 +92,9 @@ public function tool(int $datasetVersionId)
9292

9393
$this->cleanToolDatasetVersion($datasetVersionId, $type, (int)$dataset->id);
9494

95-
$data = null;
96-
if ($metadata->metadata_type === 'OBJECT') {
97-
$data = json_decode($metadata->metadata, true);
98-
}
99-
100-
if ($metadata->metadata_type === 'STRING') {
101-
$data = json_decode(json_decode($metadata->metadata), true);
102-
}
95+
$data = app(DatasetService::class)->getReconstructedMetadataEnvelope($dv->dataset_id, $dv->version, false, $dv);
10396

104-
if (count($data ?: []) === 0) {
97+
if (empty($data['metadata'])) {
10598
return;
10699
}
107100

app/MetadataManagementController/MetadataManagementController.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,39 @@ public function validateDataModelType(string &$dataset, string $input_schema, st
171171
}
172172
}
173173

174+
/**
175+
* Validate a metadata JSON string against the given schema and version, returning
176+
* structured error details from TRASER rather than a plain boolean.
177+
*
178+
* @return array{valid: bool, errors: mixed}
179+
*/
180+
public function validateDataModelTypeWithErrors(string &$dataset, string $input_schema, string $input_version): array
181+
{
182+
$loggingContext = $this->getLoggingContext(\request());
183+
$loggingContext['method_name'] = class_basename($this) . '@' . __FUNCTION__;
184+
185+
try {
186+
$urlString = sprintf(
187+
'%s/validate?input_schema=%s&input_version=%s',
188+
config('services.traser.url'),
189+
$input_schema,
190+
$input_version
191+
);
192+
193+
$response = Http::withBody($dataset, 'application/json')->post($urlString);
194+
195+
$valid = $response->status() === 200;
196+
197+
return [
198+
'valid' => $valid,
199+
'errors' => $valid ? null : $response->json(),
200+
];
201+
} catch (Exception $e) {
202+
\Log::info($e->getMessage(), $loggingContext);
203+
throw new MMCException($e->getMessage());
204+
}
205+
}
206+
174207
/**
175208
* Finds the matching data model schema for a given dataset by querying the TRASER service.
176209
*

0 commit comments

Comments
 (0)