Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
80ad740
docs: update the directory structure in CONTRIBUTING.md
tejas-raskar Aug 20, 2025
44ec8de
Merge pull request #10946 from appwrite/1.8.x
stnguyen90 Dec 19, 2025
f019120
Merge pull request #10342 from tejas-raskar/doc-10339-fix-inconsisten…
stnguyen90 Jan 2, 2026
aed9816
fix: validate relationship document ID
premtsd-code Jan 26, 2026
f66e0c2
refactor: separate validation from normalization in validateRelationship
premtsd-code Jan 26, 2026
cb32dc4
Merge branch 'main' into fix-10612-validate-relationship-document-id
premtsd-code Jan 26, 2026
1ee2539
fix: generate unique ID before validation per coderabbit suggestion
premtsd-code Jan 26, 2026
63e6a51
test: add assertion for relationship column polling
premtsd-code Jan 26, 2026
00d0915
refactor: simplify relationship validation code
premtsd-code Jan 27, 2026
d792d3b
refactor: use getId() instead of getAttribute('$id')
premtsd-code Jan 27, 2026
d182c85
fix: reject unsupported relationship value types
premtsd-code Jan 27, 2026
7f3ea98
refactor: use array_is_list() and assertEventually helper
premtsd-code Jan 27, 2026
aef7b8d
fix: use RELATIONSHIP_VALUE_INVALID exception for validation errors
premtsd-code Jan 28, 2026
cbe2d23
chore: update phpunit to 9.6.34 (security fix)
premtsd-code Jan 28, 2026
71670f8
Merge branch '1.8.x' into fix-10612-validate-relationship-document-id
premtsd-code Jan 28, 2026
2f3fa9e
sync CONTRIBUTING.md with 1.8.x
premtsd-code Jan 28, 2026
23c1f8f
Merge branch '1.8.x' into fix-10612-validate-relationship-document-id
premtsd-code Jan 28, 2026
23dae85
Sync composer.lock with 1.8.x
premtsd-code Jan 28, 2026
845aa5c
Merge branch '1.8.x' into fix-10612-validate-relationship-document-id
premtsd-code Jan 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Appwrite\Extend\Exception;
use Appwrite\Functions\EventProcessor;
use Appwrite\Platform\Modules\Databases\Http\Databases\Action as DatabasesAction;
use Appwrite\Utopia\Database\Validator\CustomId;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Validator\Authorization;
Expand Down Expand Up @@ -250,6 +251,35 @@ protected function removeReadonlyAttributes(
return $document;
}

/**
* Validate relationship values.
* Handles Document objects, ID strings, and associative arrays.
*/
protected function validateRelationship(mixed $relation): void
{
$relationId = null;

if ($relation instanceof Document) {
$relationId = $relation->getId();
} elseif (\is_string($relation)) {
$relationId = $relation;
} elseif (\is_array($relation) && !\array_is_list($relation)) {
$relationId = $relation['$id'] ?? null;
} else {
throw new Exception(Exception::RELATIONSHIP_VALUE_INVALID, 'Relationship value must be an object, document ID string, or associative array');
}

if ($relationId !== null) {
if (!\is_string($relationId)) {
throw new Exception(Exception::RELATIONSHIP_VALUE_INVALID, 'Relationship $id must be a string');
}
$validator = new CustomId();
if (!$validator->isValid($relationId)) {
throw new Exception(Exception::RELATIONSHIP_VALUE_INVALID, $validator->getDescription());
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Resolves relationships in a document and attaches metadata.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ public function action(string $databaseId, string $documentId, string $collectio
$relation['$id'] = ID::unique();
$relation = new Document($relation);
}

$this->validateRelationship($relation);

if ($relation instanceof Document) {
$relation = $this->removeReadonlyAttributes($relation, $isAPIKey || $isPrivilegedUser);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ public function action(string $databaseId, string $collectionId, string $documen
$relation['$id'] = ID::unique();
$relation = new Document($relation);
}

$this->validateRelationship($relation);

if ($relation instanceof Document) {
$relation = $this->removeReadonlyAttributes($relation, $isAPIKey || $isPrivilegedUser);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ public function action(string $databaseId, string $collectionId, string $documen
$relation['$id'] = ID::unique();
$relation = new Document($relation);
}

$this->validateRelationship($relation);

if ($relation instanceof Document) {
$relation = $this->removeReadonlyAttributes($relation, $isAPIKey || $isPrivilegedUser);

Expand Down
261 changes: 261 additions & 0 deletions tests/e2e/Services/Databases/TablesDB/DatabasesBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -7626,6 +7626,267 @@ public function testUpdateWithExistingRelationships(array $data): void
$this->assertEquals(200, $update['headers']['status-code']);
}

/**
* @depends testCreateDatabase
*/
public function testInvalidRelationshipDocumentId(array $data): void
{
$databaseId = $data['databaseId'];

// Create parent table
$parentTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'ParentTable',
]);
$this->assertEquals(201, $parentTable['headers']['status-code']);
$parentTableId = $parentTable['body']['$id'];

// Create child table
$childTable = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'tableId' => ID::unique(),
'name' => 'ChildTable',
]);
$this->assertEquals(201, $childTable['headers']['status-code']);
$childTableId = $childTable['body']['$id'];

// Add string column to parent
$this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/columns/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'name',
'size' => 255,
'required' => false,
]);

// Add string column to child
$this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $childTableId . '/columns/string', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'key' => 'title',
'size' => 255,
'required' => false,
]);

// Create one-to-many relationship
$relationship = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/columns/relationship', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'relatedTableId' => $childTableId,
'type' => Database::RELATION_ONE_TO_MANY,
'twoWay' => false,
'key' => 'children',
]);
$this->assertEquals(202, $relationship['headers']['status-code']);

// Wait for relationship column to be available
$this->assertEventually(function () use ($databaseId, $parentTableId) {
$columns = $this->client->call(Client::METHOD_GET, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/columns', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]));
$columnKeys = array_column($columns['body']['columns'], 'key');
$this->assertContains('children', $columnKeys, "Relationship column 'children' not found in table {$parentTableId} of database {$databaseId}");
}, 2000, 200);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// ID too long (>36 chars) should fail
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 1',
'children' => [
[
'$id' => 'this_id_is_way_too_long_and_should_fail_validation_check',
'title' => 'Child 1',
],
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);

// ID with invalid characters should fail
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 2',
'children' => [
[
'$id' => 'invalid@id#with$special%chars',
'title' => 'Child 2',
],
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);

// ID starting with underscore should fail
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 3',
'children' => [
[
'$id' => '_startsWithUnderscore',
'title' => 'Child 3',
],
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);

// Valid ID should succeed
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 4',
'children' => [
[
'$id' => 'valid-id-123',
'title' => 'Child 4',
],
],
],
]);
$this->assertEquals(201, $response['headers']['status-code']);
$parentRowId = $response['body']['$id'];

// Update with invalid relationship ID should fail
$response = $this->client->call(Client::METHOD_PATCH, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows/' . $parentRowId, array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'data' => [
'children' => [
[
'$id' => 'another@invalid#id',
'title' => 'Child 5',
],
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);

// Invalid string relation ID should fail
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 6',
'children' => [
'invalid@string#id',
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);

// Integer as relation value should fail
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 7',
'children' => [
12345,
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);

// unique() as $id should succeed
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 8',
'children' => [
[
'$id' => 'unique()',
'title' => 'Child 8',
],
],
],
]);
$this->assertEquals(201, $response['headers']['status-code']);

// Empty string as $id should fail
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 9',
'children' => [
[
'$id' => '',
'title' => 'Child 9',
],
],
],
]);
$this->assertEquals(400, $response['headers']['status-code']);

// Valid ID with allowed special chars (hyphen, period) should succeed
$response = $this->client->call(Client::METHOD_POST, '/tablesdb/' . $databaseId . '/tables/' . $parentTableId . '/rows', array_merge([
'content-type' => 'application/json',
'x-appwrite-project' => $this->getProject()['$id'],
'x-appwrite-key' => $this->getProject()['apiKey']
]), [
'rowId' => ID::unique(),
'data' => [
'name' => 'Parent 10',
'children' => [
[
'$id' => 'valid.id-with_chars',
'title' => 'Child 10',
],
],
],
]);
$this->assertEquals(201, $response['headers']['status-code']);
}

/**
* @depends testCreateDatabase
*/
Expand Down