From 38efdf18e2082d32c7d7036584cfd3619392800a Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 6 May 2026 17:25:34 +1200 Subject: [PATCH] feat(insights): add parent resource pointer Eldad's review comment: insights about nested resources need a pointer to the containing parent (the file-in-bucket pattern). Add three optional fields: - parentResourceType (plural noun, e.g. `tables`, `collections`) - parentResourceId - parentResourceInternalId so an insight whose `resourceType=indexes` / `resourceId=_idx_status` can also carry `parentResourceType=tables` / `parentResourceId=orders` to identify the table that owns the index. All three are nullable for top-level resources (e.g. a project-wide audit finding). Schema, response model, manager Create endpoint, and the listInsights query validator (parent fields are filterable). New compound index `_key_project_parent_resource(projectInternalId, parentResourceType, parentResourceId, $sequence)` to support the parent lookup pattern the console will use ("show all insights for table X"). E2E factory generates a parent by default (engine-aware: tables for tablesDB, collections for the others). New testCreateWithoutParentResource exercises the top-level case; testList gains a parent-resource filter assertion; testUpdate's preserved-fields check picks up the new attributes. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/config/collections/platform.php | 43 ++++++++++++++ .../Insights/Http/Manager/Insights/Create.php | 9 +++ .../Database/Validator/Queries/Insights.php | 2 + .../Utopia/Response/Model/Insight.php | 18 ++++++ tests/e2e/Services/Insights/InsightsBase.php | 59 ++++++++++++++++--- 5 files changed, 122 insertions(+), 9 deletions(-) diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index 8196189197..7c54da0f0a 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -2191,6 +2191,42 @@ $platformCollections = [ 'array' => false, 'filters' => [], ], + [ + // Plural noun for the parent (containing) resource. Optional. + // e.g. an insight about a column index → resourceType=indexes, + // parentResourceType=tables. Mirrors the file-in-bucket pointer. + '$id' => ID::custom('parentResourceType'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 64, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('parentResourceId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('parentResourceInternalId'), + 'type' => Database::VAR_ID, + 'format' => '', + 'size' => 0, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('title'), 'type' => Database::VAR_STRING, @@ -2291,6 +2327,13 @@ $platformCollections = [ 'lengths' => [Database::LENGTH_KEY, 64, Database::LENGTH_KEY], 'orders' => [], ], + [ + '$id' => ID::custom('_key_project_parent_resource'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'parentResourceType', 'parentResourceId', '$sequence'], + 'lengths' => [Database::LENGTH_KEY, 64, Database::LENGTH_KEY], + 'orders' => [], + ], [ '$id' => ID::custom('_key_project_type'), 'type' => Database::INDEX_KEY, diff --git a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php index 8ff67a9370..5e909ab25e 100644 --- a/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php +++ b/src/Appwrite/Platform/Modules/Insights/Http/Manager/Insights/Create.php @@ -79,6 +79,9 @@ class Create extends Action ->param('resourceType', '', new Text(64), 'Plural resource type the insight is about, e.g. `databases`, `sites`, `functions`.') ->param('resourceId', '', new Text(36), 'ID of the resource the insight is about.') ->param('resourceInternalId', '', new Text(36), 'Internal ID of the resource the insight is about.', true) + ->param('parentResourceType', '', new Text(64), 'Plural noun for the parent (containing) resource, e.g. `tables` for an insight about a column index. Optional.', true) + ->param('parentResourceId', '', new Text(36), 'ID of the parent resource.', true) + ->param('parentResourceInternalId', '', new Text(36), 'Internal ID of the parent resource.', true) ->param('title', '', new Text(256), 'Short, human-readable title.') ->param('summary', '', new Text(4096, 0), 'Markdown summary describing the insight.', true) ->param('payload', null, new Nullable(new JSON()), 'Type-specific structured payload.', true) @@ -99,6 +102,9 @@ class Create extends Action string $resourceType, string $resourceId, string $resourceInternalId, + string $parentResourceType, + string $parentResourceId, + string $parentResourceInternalId, string $title, string $summary, ?array $payload, @@ -155,6 +161,9 @@ class Create extends Action 'resourceType' => $resourceType, 'resourceId' => $resourceId, 'resourceInternalId' => $resourceInternalId, + 'parentResourceType' => $parentResourceType, + 'parentResourceId' => $parentResourceId, + 'parentResourceInternalId' => $parentResourceInternalId, 'title' => $title, 'summary' => $summary, 'payload' => $payload, diff --git a/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php b/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php index b7e2cadf03..c0afd56134 100644 --- a/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php +++ b/src/Appwrite/Utopia/Database/Validator/Queries/Insights.php @@ -11,6 +11,8 @@ class Insights extends Base 'reportId', 'resourceType', 'resourceId', + 'parentResourceType', + 'parentResourceId', 'analyzedAt', 'dismissedAt', 'dismissedBy', diff --git a/src/Appwrite/Utopia/Response/Model/Insight.php b/src/Appwrite/Utopia/Response/Model/Insight.php index d81c5ef330..151301df41 100644 --- a/src/Appwrite/Utopia/Response/Model/Insight.php +++ b/src/Appwrite/Utopia/Response/Model/Insight.php @@ -77,6 +77,24 @@ class Insight extends Model 'default' => '', 'example' => '5e5ea5c16897e', ]) + ->addRule('parentResourceType', [ + 'type' => self::TYPE_STRING, + 'description' => 'Plural noun for the parent resource that contains the insight\'s resource, e.g. an insight about a column index on a table → resourceType=indexes, parentResourceType=tables. Empty when the resource has no parent.', + 'default' => '', + 'example' => 'tables', + ]) + ->addRule('parentResourceId', [ + 'type' => self::TYPE_STRING, + 'description' => 'ID of the parent resource. Empty when the resource has no parent.', + 'default' => '', + 'example' => 'orders', + ]) + ->addRule('parentResourceInternalId', [ + 'type' => self::TYPE_STRING, + 'description' => 'Internal ID of the parent resource. Empty when the resource has no parent.', + 'default' => '', + 'example' => '5e5ea5c16897e', + ]) ->addRule('title', [ 'type' => self::TYPE_STRING, 'description' => 'Insight title.', diff --git a/tests/e2e/Services/Insights/InsightsBase.php b/tests/e2e/Services/Insights/InsightsBase.php index cb59df7fd5..a42fda8e6c 100644 --- a/tests/e2e/Services/Insights/InsightsBase.php +++ b/tests/e2e/Services/Insights/InsightsBase.php @@ -147,20 +147,25 @@ trait InsightsBase default => throw new \InvalidArgumentException("Unknown engine: {$engine}"), }; - $resourceType = match ($engine) { - 'databases' => 'databases', + // The insight is *about* a missing index, contained within a table/collection. + // resourceType=indexes points at the index that should exist; the parent + // points at the table/collection that owns it. + $parentResourceType = match ($engine) { + 'databases' => 'collections', 'tablesDB' => 'tables', 'documentsDB' => 'collections', 'vectorsDB' => 'collections', - default => 'databases', + default => 'collections', }; $body = [ 'insightId' => $insightId ?? ID::unique(), 'type' => $type, 'severity' => 'warning', - 'resourceType' => $resourceType, - 'resourceId' => 'main', + 'resourceType' => 'indexes', + 'resourceId' => '_idx_status', + 'parentResourceType' => $parentResourceType, + 'parentResourceId' => 'orders', 'title' => 'Missing index on collection orders', 'summary' => 'Queries against `orders.status` are scanning the full collection.', 'payload' => ['databaseId' => 'main', 'engine' => $engine], @@ -338,8 +343,10 @@ trait InsightsBase $this->assertSame('tablesDBIndex', $insight['body']['type']); $this->assertSame('warning', $insight['body']['severity']); $this->assertSame('active', $insight['body']['status']); - $this->assertSame('tables', $insight['body']['resourceType']); - $this->assertSame('main', $insight['body']['resourceId']); + $this->assertSame('indexes', $insight['body']['resourceType']); + $this->assertSame('_idx_status', $insight['body']['resourceId']); + $this->assertSame('tables', $insight['body']['parentResourceType']); + $this->assertSame('orders', $insight['body']['parentResourceId']); $this->assertSame('Missing index on collection orders', $insight['body']['title']); $this->assertCount(1, $insight['body']['ctas']); $this->assertSame('createIndex', $insight['body']['ctas'][0]['id']); @@ -397,6 +404,26 @@ trait InsightsBase $this->deleteInsight($insightId); } + public function testCreateWithoutParentResource(): void + { + // Top-level resource (no parent) — e.g. a project-wide audit finding. + $insightId = ID::unique(); + $body = $this->sampleInsight($insightId); + unset($body['parentResourceType'], $body['parentResourceId']); + $body['resourceType'] = 'projects'; + $body['resourceId'] = $this->getProject()['$id']; + + $insight = $this->createInsight($body); + + $this->assertSame(201, $insight['headers']['status-code']); + $this->assertSame('projects', $insight['body']['resourceType']); + $this->assertEmpty($insight['body']['parentResourceType']); + $this->assertEmpty($insight['body']['parentResourceId']); + $this->assertEmpty($insight['body']['parentResourceInternalId']); + + $this->deleteInsight($insightId); + } + public function testCreateRejectsInvalidType(): void { $insight = $this->createInsight([ @@ -563,11 +590,23 @@ trait InsightsBase $this->assertNotEmpty($list['body']['insights']); $byResourceType = $this->listInsights([ - 'queries' => ['equal("resourceType", "tables")'], + 'queries' => ['equal("resourceType", "indexes")'], ]); $this->assertSame(200, $byResourceType['headers']['status-code']); foreach ($byResourceType['body']['insights'] as $insight) { - $this->assertSame('tables', $insight['resourceType']); + $this->assertSame('indexes', $insight['resourceType']); + } + + $byParentResource = $this->listInsights([ + 'queries' => [ + 'equal("parentResourceType", "tables")', + 'equal("parentResourceId", "orders")', + ], + ]); + $this->assertSame(200, $byParentResource['headers']['status-code']); + foreach ($byParentResource['body']['insights'] as $insight) { + $this->assertSame('tables', $insight['parentResourceType']); + $this->assertSame('orders', $insight['parentResourceId']); } $byStatus = $this->listInsights([ @@ -676,6 +715,8 @@ trait InsightsBase $this->assertSame($original['type'], $updated['body']['type']); $this->assertSame($original['resourceType'], $updated['body']['resourceType']); $this->assertSame($original['resourceId'], $updated['body']['resourceId']); + $this->assertSame($original['parentResourceType'], $updated['body']['parentResourceType']); + $this->assertSame($original['parentResourceId'], $updated['body']['parentResourceId']); $this->assertSame($original['reportId'], $updated['body']['reportId']); $this->assertSame($original['ctas'], $updated['body']['ctas']); $this->assertSame($original['payload'], $updated['body']['payload']);