mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
4fc3e9c386
commit
38efdf18e2
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,6 +11,8 @@ class Insights extends Base
|
||||
'reportId',
|
||||
'resourceType',
|
||||
'resourceId',
|
||||
'parentResourceType',
|
||||
'parentResourceId',
|
||||
'analyzedAt',
|
||||
'dismissedAt',
|
||||
'dismissedBy',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user