diff --git a/app/Actions/Project/ArchiveProject.php b/app/Actions/Project/ArchiveProject.php index 6d3c2fa0..b944b991 100644 --- a/app/Actions/Project/ArchiveProject.php +++ b/app/Actions/Project/ArchiveProject.php @@ -3,7 +3,7 @@ namespace App\Actions\Project; use App\Models\Project; -use App\Support\Public\PublicMoleculeAggregates; +use App\Support\Public\PublicMoleculeCatalogIndexer; class ArchiveProject { @@ -36,7 +36,7 @@ public function toggleArchive($project) } $project->save(); - PublicMoleculeAggregates::forgetPublicCatalogTotalCache(); + app(PublicMoleculeCatalogIndexer::class)->refreshForProject($project); } /** diff --git a/app/Actions/Project/PublishProject.php b/app/Actions/Project/PublishProject.php index 88c72a75..ee0353a6 100644 --- a/app/Actions/Project/PublishProject.php +++ b/app/Actions/Project/PublishProject.php @@ -4,7 +4,7 @@ use App\Jobs\ProcessMetadataExtractionBagitGenerationJob; use App\Models\Project; -use App\Support\Public\PublicMoleculeAggregates; +use App\Support\Public\PublicMoleculeCatalogIndexer; class PublishProject { @@ -32,6 +32,6 @@ public function publish($project) } } - PublicMoleculeAggregates::forgetPublicCatalogTotalCache(); + app(PublicMoleculeCatalogIndexer::class)->refreshForProject($project); } } diff --git a/app/Actions/Project/UnPublishProject.php b/app/Actions/Project/UnPublishProject.php index bd17df48..4dcb9929 100644 --- a/app/Actions/Project/UnPublishProject.php +++ b/app/Actions/Project/UnPublishProject.php @@ -3,7 +3,7 @@ namespace App\Actions\Project; use App\Models\Project; -use App\Support\Public\PublicMoleculeAggregates; +use App\Support\Public\PublicMoleculeCatalogIndexer; class UnPublishProject { @@ -29,6 +29,6 @@ public function unPublish($project) } } - PublicMoleculeAggregates::forgetPublicCatalogTotalCache(); + app(PublicMoleculeCatalogIndexer::class)->refreshForProject($project); } } diff --git a/app/Actions/Study/PublishStudy.php b/app/Actions/Study/PublishStudy.php index edc4def6..c15a3356 100644 --- a/app/Actions/Study/PublishStudy.php +++ b/app/Actions/Study/PublishStudy.php @@ -5,7 +5,7 @@ use App\Jobs\ProcessMetadataExtractionBagitGenerationJob; use App\Models\Study; use App\Services\ChemotionRepositoryTrackerService; -use App\Support\Public\PublicMoleculeAggregates; +use App\Support\Public\PublicMoleculeCatalogIndexer; use Illuminate\Support\Facades\Log; class PublishStudy @@ -26,6 +26,7 @@ public function publish($study) $dataset->save(); } + app(PublicMoleculeCatalogIndexer::class)->refreshForStudy($study); if ($study->is_public && $study->has_nmrium && filled($study->download_url)) { ProcessMetadataExtractionBagitGenerationJob::dispatch($study->id); } diff --git a/app/Console/Commands/IndexPublicMoleculeCatalogCommand.php b/app/Console/Commands/IndexPublicMoleculeCatalogCommand.php new file mode 100644 index 00000000..f4fea0e8 --- /dev/null +++ b/app/Console/Commands/IndexPublicMoleculeCatalogCommand.php @@ -0,0 +1,30 @@ +option('chunk')); + $moleculeOption = $this->option('molecule'); + $moleculeIds = filled($moleculeOption) ? [(int) $moleculeOption] : null; + + $result = $indexer->refresh($moleculeIds, $chunk); + + $this->info(sprintf( + 'Indexed public molecule catalog (%s in catalog, %s cleared).', + number_format($result['indexed']), + number_format($result['cleared']), + )); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/API/SearchController.php b/app/Http/Controllers/API/SearchController.php index b7533bec..d7967784 100644 --- a/app/Http/Controllers/API/SearchController.php +++ b/app/Http/Controllers/API/SearchController.php @@ -764,7 +764,7 @@ public function search(Request $request, ?string $smiles = null) $queryType = strtolower($queryType); - $publicSpectraExists = PublicMoleculeAggregates::hasPublicSpectraExistsSql('molecules.id'); + $publicSpectraFilter = PublicMoleculeAggregates::hasPublicSpectraColumnSql(); $orderByRecentSql = $sort === 'recent' ? 'ORDER BY molecules.created_at DESC' : ''; $ids = []; @@ -774,7 +774,7 @@ public function search(Request $request, ?string $smiles = null) ['ids' => $ids, 'total' => $count] = PublicMoleculeAggregates::paginateIds( [ 'from' => 'FROM molecules', - 'where' => "WHERE identifier IS NOT NULL AND (smiles LIKE ? OR absolute_smiles LIKE ? OR canonical_smiles LIKE ?) AND {$publicSpectraExists}", + 'where' => "WHERE identifier IS NOT NULL AND (smiles LIKE ? OR absolute_smiles LIKE ? OR canonical_smiles LIKE ?) AND {$publicSpectraFilter}", 'order' => $orderByRecentSql, ], ['%'.$query.'%', '%'.$query.'%', '%'.$query.'%'], @@ -786,7 +786,7 @@ public function search(Request $request, ?string $smiles = null) ['ids' => $ids, 'total' => $count] = PublicMoleculeAggregates::paginateIds( [ 'from' => 'FROM mols INNER JOIN molecules ON molecules.id = mols.id', - 'where' => "WHERE m@>? AND molecules.identifier IS NOT NULL AND {$publicSpectraExists}", + 'where' => "WHERE m@>? AND molecules.identifier IS NOT NULL AND {$publicSpectraFilter}", 'id' => 'mols.id', 'order' => $orderByRecentSql, ], @@ -803,7 +803,7 @@ public function search(Request $request, ?string $smiles = null) ['ids' => $ids, 'total' => $count] = PublicMoleculeAggregates::paginateIds( [ 'from' => 'FROM molecules', - 'where' => "WHERE identifier IS NOT NULL AND (inchi LIKE ? OR standard_inchi LIKE ?) AND {$publicSpectraExists}", + 'where' => "WHERE identifier IS NOT NULL AND (inchi LIKE ? OR standard_inchi LIKE ?) AND {$publicSpectraFilter}", 'order' => $orderByRecentSql, ], ['%'.$query.'%', '%'.$query.'%'], @@ -814,7 +814,7 @@ public function search(Request $request, ?string $smiles = null) ['ids' => $ids, 'total' => $count] = PublicMoleculeAggregates::paginateIds( [ 'from' => 'FROM molecules', - 'where' => "WHERE identifier IS NOT NULL AND (inchi_key LIKE ? OR standard_inchi_key LIKE ?) AND {$publicSpectraExists}", + 'where' => "WHERE identifier IS NOT NULL AND (inchi_key LIKE ? OR standard_inchi_key LIKE ?) AND {$publicSpectraFilter}", 'order' => $orderByRecentSql, ], ['%'.$query.'%', '%'.$query.'%'], @@ -826,7 +826,7 @@ public function search(Request $request, ?string $smiles = null) ['ids' => $ids, 'total' => $count] = PublicMoleculeAggregates::paginateIds( [ 'from' => 'FROM mols INNER JOIN molecules ON molecules.id = mols.id', - 'where' => "WHERE m@=? AND molecules.identifier IS NOT NULL AND {$publicSpectraExists}", + 'where' => "WHERE m@=? AND molecules.identifier IS NOT NULL AND {$publicSpectraFilter}", 'id' => 'mols.id', 'order' => $orderByRecentSql, ], @@ -844,7 +844,7 @@ public function search(Request $request, ?string $smiles = null) ['ids' => $ids, 'total' => $count] = PublicMoleculeAggregates::paginateIds( [ 'from' => 'FROM fps INNER JOIN molecules ON molecules.id = fps.id', - 'where' => "WHERE mfp2%morganbv_fp(?) AND molecules.identifier IS NOT NULL AND {$publicSpectraExists}", + 'where' => "WHERE mfp2%morganbv_fp(?) AND molecules.identifier IS NOT NULL AND {$publicSpectraFilter}", 'id' => 'fps.id', 'order' => $orderByRecentSql, ], @@ -884,7 +884,7 @@ public function search(Request $request, ?string $smiles = null) ['ids' => $ids, 'total' => $count] = PublicMoleculeAggregates::paginateIds( [ 'from' => 'FROM molecules', - 'where' => "WHERE identifier IS NOT NULL AND (name::TEXT ILIKE ? OR iupac_name ILIKE ? OR synonyms::TEXT ILIKE ? OR identifier::TEXT ILIKE ?) AND {$publicSpectraExists}", + 'where' => "WHERE identifier IS NOT NULL AND (name::TEXT ILIKE ? OR iupac_name ILIKE ? OR synonyms::TEXT ILIKE ? OR identifier::TEXT ILIKE ?) AND {$publicSpectraFilter}", 'order' => $orderByRecentSql, ], ['%'.$query.'%', '%'.$query.'%', '%'.$query.'%', '%'.$query.'%'], @@ -1135,7 +1135,7 @@ private function buildSecureFilterQuery(string $query, array $filterMap, int $li } $whereClause = implode(' OR ', $whereConditions); - $publicSpectraFilter = PublicMoleculeAggregates::hasPublicSpectraExistsSql('molecules.id'); + $publicSpectraFilter = PublicMoleculeAggregates::hasPublicSpectraColumnSql(); ['ids' => $ids, 'total' => $count] = PublicMoleculeAggregates::paginateIds( [ diff --git a/app/Models/Molecule.php b/app/Models/Molecule.php index c4edec15..59676ea0 100644 --- a/app/Models/Molecule.php +++ b/app/Models/Molecule.php @@ -38,6 +38,10 @@ protected function casts(): array { return [ 'workspace_experiment_type_counts' => 'array', + 'has_public_spectra' => 'boolean', + 'public_samples_count' => 'integer', + 'public_experiment_type_counts' => 'array', + 'public_catalog_indexed_at' => 'datetime', ]; } diff --git a/app/Support/Public/PublicMoleculeAggregates.php b/app/Support/Public/PublicMoleculeAggregates.php index 8c880652..415d08be 100644 --- a/app/Support/Public/PublicMoleculeAggregates.php +++ b/app/Support/Public/PublicMoleculeAggregates.php @@ -21,8 +21,17 @@ final class PublicMoleculeAggregates private const PUBLIC_CATALOG_TOTAL_CACHE_SECONDS = 300; + /** + * Indexed public-catalog membership written by PublicMoleculeCatalogIndexer. + */ + public static function hasPublicSpectraColumnSql(string $table = 'molecules'): string + { + return "{$table}.has_public_spectra = true"; + } + /** * Correlated EXISTS: molecule has ≥1 spectrum in the public catalog. + * Used by the catalog indexer, not by request-path search. */ public static function hasPublicSpectraExistsSql(string $moleculeIdColumn = 'molecules.id'): string { @@ -133,12 +142,12 @@ public static function paginatePublicCatalog( int $offset, bool $orderByRecent = true, ): array { - $exists = self::hasPublicSpectraExistsSql('molecules.id'); + $public = self::hasPublicSpectraColumnSql(); return self::paginateIds( [ 'from' => 'FROM molecules', - 'where' => "WHERE molecules.identifier IS NOT NULL AND {$exists}", + 'where' => "WHERE molecules.identifier IS NOT NULL AND {$public}", 'id' => 'molecules.id', 'order' => $orderByRecent ? 'ORDER BY molecules.created_at DESC' : '', ], @@ -192,11 +201,11 @@ public static function forgetPublicCatalogTotalCache(): void */ public static function publicCatalogTotal(): int { - $exists = self::hasPublicSpectraExistsSql('molecules.id'); + $public = self::hasPublicSpectraColumnSql(); return self::countIds( 'FROM molecules', - "WHERE molecules.identifier IS NOT NULL AND {$exists}", + "WHERE molecules.identifier IS NOT NULL AND {$public}", 'molecules.id', [], self::PUBLIC_CATALOG_TOTAL_CACHE_KEY, @@ -211,7 +220,7 @@ public static function scopePublicCatalog(Builder $query): Builder { return $query ->whereNotNull('identifier') - ->whereRaw(self::hasPublicSpectraExistsSql($query->getModel()->getTable().'.id')); + ->where('has_public_spectra', true); } /** @@ -224,19 +233,9 @@ public static function enrich(array $molecules): array return $molecules; } - $ids = array_values(array_unique(array_map( - fn ($molecule) => $molecule instanceof Molecule ? (int) $molecule->id : (int) $molecule->id, - $molecules, - ))); - - $sampleCounts = self::sampleCountsByMoleculeId($ids); - $experimentCounts = (new MoleculeExperimentTypeCounts)->forPublicCatalog($ids); - foreach ($molecules as $molecule) { - $id = $molecule instanceof Molecule ? (int) $molecule->id : (int) $molecule->id; - - $samples = $sampleCounts[$id] ?? 0; - $experiments = $experimentCounts[$id] ?? []; + $samples = self::publicSamplesCountFromRow($molecule); + $experiments = self::publicExperimentTypeCountsFromRow($molecule); if ($molecule instanceof Molecule) { $molecule->setAttribute('workspace_samples_count', $samples); @@ -250,6 +249,20 @@ public static function enrich(array $molecules): array return $molecules; } + /** + * Sample and experiment-type badge payload for the catalog indexer. + * + * @param list $moleculeIds + * @return array{sample_counts: array, experiment_counts: array>} + */ + public static function catalogCardPayload(array $moleculeIds): array + { + return [ + 'sample_counts' => self::sampleCountsByMoleculeId($moleculeIds), + 'experiment_counts' => (new MoleculeExperimentTypeCounts)->forPublicCatalog($moleculeIds), + ]; + } + /** * @param list $bindings */ @@ -277,6 +290,29 @@ private static function countIds( return (int) Cache::remember($totalCacheKey, $totalCacheSeconds, $resolve); } + private static function publicSamplesCountFromRow(object|Molecule $molecule): int + { + return (int) ($molecule->public_samples_count ?? 0); + } + + /** + * @return array + */ + private static function publicExperimentTypeCountsFromRow(object|Molecule $molecule): array + { + $value = $molecule->public_experiment_type_counts ?? []; + + if (is_string($value)) { + $value = json_decode($value, true); + } + + if (is_object($value)) { + $value = json_decode(json_encode($value), true); + } + + return is_array($value) ? $value : []; + } + /** * @param array $moleculeIds * @return array diff --git a/app/Support/Public/PublicMoleculeCatalogIndexer.php b/app/Support/Public/PublicMoleculeCatalogIndexer.php new file mode 100644 index 00000000..8ad53025 --- /dev/null +++ b/app/Support/Public/PublicMoleculeCatalogIndexer.php @@ -0,0 +1,178 @@ +|null $moleculeIds Null refreshes the full catalog. + * @return array{indexed: int, cleared: int} + */ + public function refresh(?array $moleculeIds = null, int $chunkSize = 100): array + { + $chunkSize = max(1, $chunkSize); + + if ($moleculeIds !== null) { + $moleculeIds = array_values(array_unique(array_map( + static fn (mixed $id): int => (int) $id, + $moleculeIds, + ))); + + if ($moleculeIds === []) { + PublicMoleculeAggregates::forgetPublicCatalogTotalCache(); + + return ['indexed' => 0, 'cleared' => 0]; + } + } + + $publicIds = $this->publicMoleculeIds($moleculeIds); + $publicLookup = array_flip($publicIds); + + $previouslyPublic = Molecule::query() + ->where('has_public_spectra', true) + ->when($moleculeIds !== null, fn ($query) => $query->whereIn('id', $moleculeIds)) + ->pluck('id') + ->map(static fn (mixed $id): int => (int) $id) + ->all(); + + $toClear = array_values(array_filter( + $previouslyPublic, + static fn (int $id): bool => ! isset($publicLookup[$id]), + )); + + $this->clear($toClear); + + $indexed = 0; + foreach (array_chunk($publicIds, $chunkSize) as $chunk) { + $this->writeChunk($chunk); + $indexed += count($chunk); + } + + PublicMoleculeAggregates::forgetPublicCatalogTotalCache(); + + return [ + 'indexed' => $indexed, + 'cleared' => count($toClear), + ]; + } + + /** + * @return array{indexed: int, cleared: int} + */ + public function refreshForProject(Project $project): array + { + return $this->refresh($this->moleculeIdsForProject($project->id)); + } + + /** + * @return array{indexed: int, cleared: int} + */ + public function refreshForStudy(Study $study): array + { + return $this->refresh($this->moleculeIdsForStudy($study->id)); + } + + /** + * @param list|null $moleculeIds + * @return list + */ + private function publicMoleculeIds(?array $moleculeIds): array + { + $exists = PublicMoleculeAggregates::hasPublicSpectraExistsSql('molecules.id'); + $sql = "SELECT molecules.id FROM molecules WHERE molecules.identifier IS NOT NULL AND {$exists}"; + $bindings = []; + + if ($moleculeIds !== null) { + $placeholders = implode(',', array_fill(0, count($moleculeIds), '?')); + $sql .= " AND molecules.id IN ({$placeholders})"; + $bindings = $moleculeIds; + } + + return array_map( + static fn (object $row): int => (int) $row->id, + DB::select($sql, $bindings), + ); + } + + /** + * @param list $moleculeIds + */ + private function writeChunk(array $moleculeIds): void + { + $payload = PublicMoleculeAggregates::catalogCardPayload($moleculeIds); + $indexedAt = now(); + + foreach (Molecule::query()->whereIn('id', $moleculeIds)->get() as $molecule) { + $id = (int) $molecule->id; + + $molecule->forceFill([ + 'has_public_spectra' => true, + 'public_samples_count' => $payload['sample_counts'][$id] ?? 0, + 'public_experiment_type_counts' => $payload['experiment_counts'][$id] ?? [], + 'public_catalog_indexed_at' => $indexedAt, + ])->saveQuietly(); + } + } + + /** + * @param list $moleculeIds + */ + private function clear(array $moleculeIds): void + { + if ($moleculeIds === []) { + return; + } + + $indexedAt = now(); + + foreach (array_chunk($moleculeIds, 500) as $chunk) { + foreach (Molecule::query()->whereIn('id', $chunk)->get() as $molecule) { + $molecule->forceFill([ + 'has_public_spectra' => false, + 'public_samples_count' => 0, + 'public_experiment_type_counts' => [], + 'public_catalog_indexed_at' => $indexedAt, + ])->saveQuietly(); + } + } + } + + /** + * @return list + */ + private function moleculeIdsForProject(int $projectId): array + { + return DB::table('molecule_sample') + ->join('samples', 'samples.id', '=', 'molecule_sample.sample_id') + ->join('studies', 'studies.id', '=', 'samples.study_id') + ->where('studies.project_id', $projectId) + ->distinct() + ->pluck('molecule_sample.molecule_id') + ->map(static fn (mixed $id): int => (int) $id) + ->all(); + } + + /** + * @return list + */ + private function moleculeIdsForStudy(int $studyId): array + { + return DB::table('molecule_sample') + ->join('samples', 'samples.id', '=', 'molecule_sample.sample_id') + ->where('samples.study_id', $studyId) + ->distinct() + ->pluck('molecule_sample.molecule_id') + ->map(static fn (mixed $id): int => (int) $id) + ->all(); + } +} diff --git a/database/migrations/2026_08_20_092738_add_public_catalog_columns_to_molecules_table.php b/database/migrations/2026_08_20_092738_add_public_catalog_columns_to_molecules_table.php new file mode 100644 index 00000000..196c92c9 --- /dev/null +++ b/database/migrations/2026_08_20_092738_add_public_catalog_columns_to_molecules_table.php @@ -0,0 +1,36 @@ +boolean('has_public_spectra')->default(false); + $table->unsignedInteger('public_samples_count')->default(0); + $table->json('public_experiment_type_counts')->nullable(); + $table->timestamp('public_catalog_indexed_at')->nullable(); + $table->index(['has_public_spectra', 'created_at'], 'molecules_public_catalog_recent_index'); + }); + } + + public function down(): void + { + Schema::table('molecules', function (Blueprint $table) { + $table->dropIndex('molecules_public_catalog_recent_index'); + $table->dropColumn([ + 'has_public_spectra', + 'public_samples_count', + 'public_experiment_type_counts', + 'public_catalog_indexed_at', + ]); + }); + } +}; diff --git a/routes/console.php b/routes/console.php index f8ff664e..ec2ed919 100644 --- a/routes/console.php +++ b/routes/console.php @@ -23,6 +23,10 @@ // Staggered away from the backup dump (both defaulted to ->daily(), i.e. midnight) since this rebuilds the mols/fps RDKit tables via DROP/CREATE DDL Schedule::command('nmrxiv:index-molecules')->dailyAt('02:00'); Schedule::command('nmrxiv:index-spectra-metadata-stats')->daily(); +Schedule::command('nmrxiv:index-public-molecule-catalog') + ->daily() + ->withoutOverlapping() + ->onOneServer(); Schedule::command('nmrxiv:delete-citations')->weekly(); Schedule::command('nmrxiv:delete-authors')->weekly(); if (App::environment('production')) { diff --git a/tests/API/SearchControllerTest.php b/tests/API/SearchControllerTest.php index bab6a766..5c7910c3 100644 --- a/tests/API/SearchControllerTest.php +++ b/tests/API/SearchControllerTest.php @@ -63,6 +63,8 @@ private function createMoleculeInPublicCatalog(array $attributes = [], ?\Closure 'has_nmrium' => true, ]); + $this->indexPublicMoleculeCatalog([$molecule->id]); + return $molecule; } @@ -348,6 +350,8 @@ public function test_search_includes_public_sample_and_experiment_counts(): void 'has_nmrium' => true, ]); + $this->indexPublicMoleculeCatalog([$molecule->id]); + $response = $this->postJson('/api/v1/search/compounds', [ 'query' => '', ]); @@ -401,6 +405,8 @@ public function test_search_counts_each_nmrium_spectrum_on_compound_card(): void ], ]); + $this->indexPublicMoleculeCatalog([$molecule->id]); + $response = $this->postJson('/api/v1/search/compounds', [ 'query' => '', ]); @@ -445,6 +451,8 @@ public function test_search_response_includes_iupac_name_when_present(): void 'has_nmrium' => true, ]); + $this->indexPublicMoleculeCatalog([$molecule->id]); + $response = $this->postJson('/api/v1/search/compounds', [ 'query' => '', ]); @@ -510,6 +518,8 @@ public function test_search_excludes_compounds_without_public_spectra(): void 'has_nmrium' => true, ]); + $this->indexPublicMoleculeCatalog([$withSpectra->id, $withoutSpectra->id]); + $response = $this->postJson('/api/v1/search/compounds', [ 'query' => '', ]); @@ -548,6 +558,8 @@ public function test_search_includes_compound_with_study_level_public_nmrium_spe ], ]); + $this->indexPublicMoleculeCatalog([$molecule->id]); + $response = $this->postJson('/api/v1/search/compounds', [ 'query' => '', ]); diff --git a/tests/Feature/Commands/IndexPublicMoleculeCatalogCommandTest.php b/tests/Feature/Commands/IndexPublicMoleculeCatalogCommandTest.php new file mode 100644 index 00000000..2ef02778 --- /dev/null +++ b/tests/Feature/Commands/IndexPublicMoleculeCatalogCommandTest.php @@ -0,0 +1,70 @@ +createPublicCatalogMolecule(); + + $this->artisan('nmrxiv:index-public-molecule-catalog') + ->expectsOutputToContain('Indexed public molecule catalog (1 in catalog, 0 cleared).') + ->assertSuccessful(); + + $this->assertTrue($molecule->fresh()->has_public_spectra); + $this->assertSame(1, $molecule->fresh()->public_samples_count); + } + + public function test_command_is_scheduled_daily(): void + { + $event = collect(Schedule::events())->first( + fn ($scheduled) => str_contains((string) $scheduled->command, 'nmrxiv:index-public-molecule-catalog') + ); + + $this->assertNotNull($event); + $this->assertSame('0 0 * * *', $event->expression); + $this->assertTrue($event->withoutOverlapping); + } + + private function createPublicCatalogMolecule(): Molecule + { + $project = Project::factory()->create(); + + $study = Study::factory()->create([ + 'project_id' => $project->id, + 'is_public' => true, + 'is_archived' => false, + 'is_deleted' => false, + ]); + + $molecule = Molecule::factory()->create(); + $sample = Sample::factory()->create(['study_id' => $study->id]); + $molecule->samples()->attach($sample->id, ['percentage_composition' => '100']); + + Dataset::factory()->create([ + 'study_id' => $study->id, + 'team_id' => $study->team_id, + 'owner_id' => $study->owner_id, + 'project_id' => $study->project_id, + 'type' => '1H NMR - 1D', + 'is_public' => true, + 'is_archived' => false, + 'is_deleted' => false, + 'has_nmrium' => true, + ]); + + return $molecule; + } +} diff --git a/tests/Feature/StatsPageTest.php b/tests/Feature/StatsPageTest.php index e2922178..05fbc83b 100644 --- a/tests/Feature/StatsPageTest.php +++ b/tests/Feature/StatsPageTest.php @@ -117,6 +117,8 @@ private function createMoleculeInPublicCatalog(): Molecule 'has_nmrium' => true, ]); + $this->indexPublicMoleculeCatalog([$molecule->id]); + return $molecule; } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 8679cc5a..818ae131 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,12 +2,21 @@ namespace Tests; +use App\Support\Public\PublicMoleculeCatalogIndexer; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Testing\TestResponse; use JsonException; abstract class TestCase extends BaseTestCase { + /** + * @param list|null $moleculeIds + */ + protected function indexPublicMoleculeCatalog(?array $moleculeIds = null): void + { + app(PublicMoleculeCatalogIndexer::class)->refresh($moleculeIds); + } + protected function inertiaPageFromResponse(TestResponse $response): array { $content = $response->getContent(); diff --git a/tests/Unit/Support/Public/PublicMoleculeAggregatesTest.php b/tests/Unit/Support/Public/PublicMoleculeAggregatesTest.php index e54488f2..307edbce 100644 --- a/tests/Unit/Support/Public/PublicMoleculeAggregatesTest.php +++ b/tests/Unit/Support/Public/PublicMoleculeAggregatesTest.php @@ -118,6 +118,8 @@ private function createMoleculeInPublicCatalog(array $attributes = []): Molecule 'has_nmrium' => true, ]); + $this->indexPublicMoleculeCatalog([$molecule->id]); + return $molecule; } } diff --git a/tests/Unit/Support/Public/PublicMoleculeCatalogIndexerTest.php b/tests/Unit/Support/Public/PublicMoleculeCatalogIndexerTest.php new file mode 100644 index 00000000..59624cce --- /dev/null +++ b/tests/Unit/Support/Public/PublicMoleculeCatalogIndexerTest.php @@ -0,0 +1,121 @@ +createPublicCatalogMolecule(); + + $this->assertFalse($molecule->fresh()->has_public_spectra); + + $result = app(PublicMoleculeCatalogIndexer::class)->refresh([$molecule->id]); + + $this->assertSame(['indexed' => 1, 'cleared' => 0], $result); + + $molecule->refresh(); + + $this->assertTrue($molecule->has_public_spectra); + $this->assertSame(1, $molecule->public_samples_count); + $this->assertSame(1, $molecule->public_experiment_type_counts['1H NMR - 1D']); + $this->assertSame(1, $molecule->public_experiment_type_counts['13C-1H NMR - 2D']); + $this->assertNotNull($molecule->public_catalog_indexed_at); + } + + public function test_refresh_clears_molecules_that_left_the_public_catalog(): void + { + $molecule = $this->createPublicCatalogMolecule(); + app(PublicMoleculeCatalogIndexer::class)->refresh([$molecule->id]); + + $this->assertTrue($molecule->fresh()->has_public_spectra); + + Dataset::query()->update(['is_public' => false]); + + $result = app(PublicMoleculeCatalogIndexer::class)->refresh([$molecule->id]); + + $this->assertSame(['indexed' => 0, 'cleared' => 1], $result); + $this->assertFalse($molecule->fresh()->has_public_spectra); + $this->assertSame(0, $molecule->fresh()->public_samples_count); + $this->assertSame([], $molecule->fresh()->public_experiment_type_counts); + } + + public function test_browse_uses_indexed_column_instead_of_live_exists(): void + { + $molecule = $this->createPublicCatalogMolecule(); + + $before = PublicMoleculeAggregates::paginatePublicCatalog(limit: 24, offset: 0, orderByRecent: true); + $this->assertSame([], $before['ids']); + $this->assertSame(0, $before['total']); + + app(PublicMoleculeCatalogIndexer::class)->refresh([$molecule->id]); + + $after = PublicMoleculeAggregates::paginatePublicCatalog(limit: 24, offset: 0, orderByRecent: true); + $this->assertSame([$molecule->id], $after['ids']); + $this->assertSame(1, $after['total']); + } + + public function test_refresh_for_project_indexes_linked_molecules(): void + { + $molecule = $this->createPublicCatalogMolecule(); + $project = $molecule->samples()->first()->study->project; + + app(PublicMoleculeCatalogIndexer::class)->refreshForProject($project); + + $this->assertTrue($molecule->fresh()->has_public_spectra); + } + + private function createPublicCatalogMolecule(): Molecule + { + $project = Project::factory()->create(); + + $study = Study::factory()->create([ + 'project_id' => $project->id, + 'is_public' => true, + 'is_archived' => false, + 'is_deleted' => false, + ]); + + $molecule = Molecule::factory()->create(); + $sample = Sample::factory()->create(['study_id' => $study->id]); + $molecule->samples()->attach($sample->id, ['percentage_composition' => '100']); + + $dataset = Dataset::factory()->create([ + 'study_id' => $study->id, + 'team_id' => $study->team_id, + 'owner_id' => $study->owner_id, + 'project_id' => $study->project_id, + 'type' => '1H NMR - 1D', + 'is_public' => true, + 'is_archived' => false, + 'is_deleted' => false, + 'has_nmrium' => true, + ]); + + NMRium::factory()->forDataset($dataset)->create([ + 'nmrium_info' => [ + 'data' => [ + 'spectra' => [ + ['info' => ['experiment' => '1D', 'nucleus' => '1H']], + ['info' => ['experiment' => '2D', 'nucleus' => ['13C', '1H']]], + ], + ], + ], + ]); + + return $molecule; + } +}