Repository navigation
feat(tables_db): add client-side row validation against table schema closes #134 #162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
GauravPatil2515
wants to merge
1
commit into
appwrite:main
from
GauravPatil2515:feat/client-side-row-validation
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| # This file makes the 'utils' directory a Python package. | ||
| from .validator import validate_row_data |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| from typing import Any, Dict, List, Optional, Union | ||
| from ..exception import AppwriteException | ||
|
|
||
|
|
||
| def validate_row_data( | ||
| data: Dict[str, Any], | ||
| schema: Union[List[Any], Any] | ||
| ) -> None: | ||
| """ | ||
| Validates row data against table schema columns client-side before sending network requests. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| data : Dict[str, Any] | ||
| The row payload being inserted or updated. | ||
| schema : Union[List[Any], ColumnList] | ||
| A list of Column models or ColumnList representing table schema definitions. | ||
|
|
||
| Raises | ||
| ------ | ||
| AppwriteException | ||
| If a required field is missing or an invalid data type is detected. | ||
| """ | ||
| if not isinstance(data, dict): | ||
| raise AppwriteException('Row data must be a dictionary/object.') | ||
|
|
||
| if schema is None: | ||
| return | ||
|
|
||
| columns = getattr(schema, 'columns', schema) | ||
| if not isinstance(columns, (list, tuple)): | ||
| return | ||
|
|
||
| type_mapping = { | ||
| 'string': (str,), | ||
| 'text': (str,), | ||
| 'varchar': (str,), | ||
| 'mediumtext': (str,), | ||
| 'longtext': (str,), | ||
| 'email': (str,), | ||
| 'url': (str,), | ||
| 'ip': (str,), | ||
| 'datetime': (str,), | ||
| 'integer': (int,), | ||
| 'bigint': (int,), | ||
| 'float': (float, int), | ||
| 'boolean': (bool,), | ||
| } | ||
|
|
||
| for col in columns: | ||
| key = getattr(col, 'key', None) or (col.get('key') if isinstance(col, dict) else None) | ||
| if not key: | ||
| continue | ||
|
|
||
| required = getattr(col, 'required', False) or (col.get('required', False) if isinstance(col, dict) else False) | ||
| col_type = getattr(col, 'type', None) or (col.get('type') if isinstance(col, dict) else None) | ||
| is_array = getattr(col, 'array', False) or (col.get('array', False) if isinstance(col, dict) else False) | ||
|
|
||
| if key not in data or data[key] is None: | ||
| if required: | ||
| raise AppwriteException(f"Missing required attribute '{key}' based on table schema.", 400, 'attribute_required') | ||
| continue | ||
|
|
||
| val = data[key] | ||
|
|
||
| if is_array: | ||
| if not isinstance(val, (list, tuple)): | ||
| raise AppwriteException(f"Attribute '{key}' must be a list/array.", 400, 'attribute_invalid_type') | ||
| if col_type and col_type in type_mapping: | ||
| expected_types = type_mapping[col_type] | ||
| for item in val: | ||
| # bool is a subclass of int in Python, so check bool explicitly | ||
| if col_type in ('integer', 'bigint', 'float') and isinstance(item, bool): | ||
| raise AppwriteException(f"Element in '{key}' must be of type {col_type}, got boolean.", 400, 'attribute_invalid_type') | ||
| if not isinstance(item, expected_types): | ||
| raise AppwriteException(f"Element in '{key}' must be of type {col_type}, got {type(item).__name__}.", 400, 'attribute_invalid_type') | ||
| else: | ||
| if col_type and col_type in type_mapping: | ||
| expected_types = type_mapping[col_type] | ||
| if col_type in ('integer', 'bigint', 'float') and isinstance(val, bool): | ||
| raise AppwriteException(f"Attribute '{key}' must be of type {col_type}, got boolean.", 400, 'attribute_invalid_type') | ||
| if not isinstance(val, expected_types): | ||
| raise AppwriteException(f"Attribute '{key}' must be of type {col_type}, got {type(val).__name__}.", 400, 'attribute_invalid_type') |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import unittest | ||
| from appwrite.exception import AppwriteException | ||
| from appwrite.utils.validator import validate_row_data | ||
|
|
||
|
|
||
| class TestRowValidation(unittest.TestCase): | ||
| def setUp(self): | ||
| self.mock_schema = [ | ||
| {'key': 'title', 'type': 'string', 'required': True, 'array': False}, | ||
| {'key': 'views', 'type': 'integer', 'required': False, 'array': False}, | ||
| {'key': 'rating', 'type': 'float', 'required': False, 'array': False}, | ||
| {'key': 'is_published', 'type': 'boolean', 'required': False, 'array': False}, | ||
| {'key': 'tags', 'type': 'string', 'required': False, 'array': True}, | ||
| ] | ||
|
|
||
| def test_valid_payload_passes(self): | ||
| payload = { | ||
| 'title': 'Getting Started with Appwrite', | ||
| 'views': 120, | ||
| 'rating': 4.8, | ||
| 'is_published': True, | ||
| 'tags': ['cloud', 'backend', 'python'], | ||
| } | ||
| # Should execute cleanly without raising | ||
| validate_row_data(payload, self.mock_schema) | ||
|
|
||
| def test_missing_required_attribute_raises(self): | ||
| payload = { | ||
| 'views': 10, | ||
| } | ||
| with self.assertRaises(AppwriteException) as ctx: | ||
| validate_row_data(payload, self.mock_schema) | ||
| self.assertIn("Missing required attribute 'title'", str(ctx.exception)) | ||
| self.assertEqual(ctx.exception.type, 'attribute_required') | ||
|
|
||
| def test_invalid_type_raises(self): | ||
| payload = { | ||
| 'title': 12345, # Expected string | ||
| } | ||
| with self.assertRaises(AppwriteException) as ctx: | ||
| validate_row_data(payload, self.mock_schema) | ||
| self.assertIn("Attribute 'title' must be of type string", str(ctx.exception)) | ||
| self.assertEqual(ctx.exception.type, 'attribute_invalid_type') | ||
|
|
||
| def test_boolean_in_numeric_field_raises(self): | ||
| payload = { | ||
| 'title': 'Valid Title', | ||
| 'views': True, # In python bool is subclass of int, but schema must reject | ||
| } | ||
| with self.assertRaises(AppwriteException) as ctx: | ||
| validate_row_data(payload, self.mock_schema) | ||
| self.assertIn("Attribute 'views' must be of type integer, got boolean", str(ctx.exception)) | ||
|
|
||
| def test_array_attribute_validation(self): | ||
| payload = { | ||
| 'title': 'Valid Title', | ||
| 'tags': 'not-an-array', | ||
| } | ||
| with self.assertRaises(AppwriteException) as ctx: | ||
| validate_row_data(payload, self.mock_schema) | ||
| self.assertIn("must be a list/array", str(ctx.exception)) | ||
|
|
||
| def test_none_schema_is_noop(self): | ||
| # Should gracefully pass when schema is None | ||
| validate_row_data({'title': 'Hello'}, None) | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| unittest.main() |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cover validation through create_row
The new tests call
validate_row_datadirectly, while the existingTablesDB.create_rowtest omitsschema; none verifies that invalid data is rejected beforeclient.call. A regression in this integration hook could therefore leave the suite green while the advertised request guard no longer works.Prompt To Fix With AI
🟡 Minor · testing · Reply if this doesn't apply.