diff --git a/docs/LINGO_MAPP_SPEC.md b/docs/LINGO_MAPP_SPEC.md index e8c5d4b..5e203a5 100644 --- a/docs/LINGO_MAPP_SPEC.md +++ b/docs/LINGO_MAPP_SPEC.md @@ -109,6 +109,25 @@ models: - `name.lower_case`: The model name in lowercase with spaces, see [Name block](#name-block) for more information - `fields`: A collection of [field](#fields) definitions +**Optional subfields:** +- `auth`: Authentication and access-control settings for the model + +#### auth + +The `auth` block controls login requirements and creation limits: + +```yaml +auth: + require_login: true + max_models_per_user: 5 + max_models_by_field: + post_id: 1 +``` + +- `require_login` (bool): When `true`, the user must be authenticated to create, read, update, or delete models. Requires a `user_id` foreign-key field on the model. +- `max_models_per_user` (int, default `-1`): Maximum number of models a single user may create. `-1` means unlimited. +- `max_models_by_field` (dict, default `{}`): Per-field creation limits. Each key is a field name and the value is the maximum number of models a user may create where that field has the same value. For example, `post_id: 1` allows only one model per unique `post_id` value per user. Returns `MAX_MODELS_BY_FIELD_EXCEEDED` (HTTP 400) when the limit is reached. + ### fields Each [model](#models) must contain one or more fields that define the data structure: @@ -129,6 +148,7 @@ fields: **Optional subfields:** - `random`: Custom random generator name (overrides the default generator for the field type) +- `unique` (bool): When `true`, the database enforces that every row in the table has a distinct value for this column. Attempting to insert a duplicate value returns `UNIQUE_CONSTRAINT_VIOLATED` (HTTP 400). Only applies to non-list fields. ## Field Types and Examples diff --git a/src/mapp/module/model/db.py b/src/mapp/module/model/db.py index beccd09..85aa8cb 100644 --- a/src/mapp/module/model/db.py +++ b/src/mapp/module/model/db.py @@ -1,4 +1,5 @@ from datetime import datetime +import sqlite3 from mapp.auth import current_user from mapp.context import MappContext @@ -45,7 +46,12 @@ def db_model_create_table(ctx:MappContext, model_class: type) -> Acknowledgment: col_def = f"'{field_name}' INTEGER REFERENCES {ref_table}({ref_field})" case _: raise ValueError(f'Unsupported field type: {field_type}') - + + # unique constraint: foreign_key columns use quoted identifiers and REFERENCES syntax + # which does not require a separate UNIQUE clause (use a unique index instead if needed) + if field.get('unique') is True and field_type != 'foreign_key': + col_def += ' UNIQUE' + columns.append(col_def) if field_type == 'foreign_key': @@ -115,6 +121,17 @@ def db_model_create(ctx:MappContext, model_class: type, obj: object) -> object: if existing_count >= max_models: raise MappUserError('MAX_MODELS_EXCEEDED', f'Maximum number of models ({max_models}) for user exceeded.') + for field_name, max_count in model_spec['auth']['max_models_by_field'].items(): + if max_count >= 0: + field_value = getattr(obj, field_name) + count = ctx.db.cursor.execute( + f'SELECT COUNT(*) FROM {model_snake_case} WHERE user_id = ? AND "{field_name}" = ?', + (user['value']['id'], field_value) + ).fetchone()[0] + if count >= max_count: + raise MappUserError('MAX_MODELS_BY_FIELD_EXCEEDED', + f'Maximum models ({max_count}) for field {field_name} exceeded.') + # non list sql # fields = [] @@ -144,7 +161,10 @@ def db_model_create(ctx:MappContext, model_class: type, obj: object) -> object: # call db # - result = ctx.db.cursor.execute(non_list_sql, values) + try: + result = ctx.db.cursor.execute(non_list_sql, values) + except sqlite3.IntegrityError: + raise MappUserError('UNIQUE_CONSTRAINT_VIOLATED', 'A record with that value already exists.') assert result.rowcount == 1 assert result.lastrowid is not None obj = obj._replace(id=str(result.lastrowid)) diff --git a/src/mapp/test.py b/src/mapp/test.py index 60ca006..b16066a 100644 --- a/src/mapp/test.py +++ b/src/mapp/test.py @@ -350,6 +350,18 @@ def run_server_crud_for_model(module_name_kebab, model_name, model, base_ctx, lo # remaining tests not applicable return + max_models_by_field = model['auth'].get('max_models_by_field', {}) + if max_models_by_field and not hidden and require_login: + by_field_status, by_field_model = request(ctx, *create_args) + assert by_field_status == 400, f'Create {model_name} beyond max_models_by_field did not return 400 Bad Request, response: {by_field_model}' + assert by_field_model.get('error', {}).get('code') == 'MAX_MODELS_BY_FIELD_EXCEEDED', f'Expected MAX_MODELS_BY_FIELD_EXCEEDED error code, got: {by_field_model}' + + has_unique_fields = any(f.get('unique') for f in model.get('fields', {}).values()) + if has_unique_fields and not hidden: + unique_status, unique_model = request(ctx, *create_args) + assert unique_status == 400, f'Create {model_name} with duplicate unique field did not return 400 Bad Request, response: {unique_model}' + assert unique_model.get('error', {}).get('code') == 'UNIQUE_CONSTRAINT_VIOLATED', f'Expected UNIQUE_CONSTRAINT_VIOLATED error code, got: {unique_model}' + # # read # diff --git a/src/mspec/core.py b/src/mspec/core.py index 11b7b25..5f7d13a 100644 --- a/src/mspec/core.py +++ b/src/mspec/core.py @@ -375,10 +375,13 @@ def init_generator_spec(spec:dict, source_path:Path) -> dict: model['auth']['require_login'] = False if 'max_models_per_user' not in model['auth']: model['auth']['max_models_per_user'] = -1 + if 'max_models_by_field' not in model['auth']: + model['auth']['max_models_by_field'] = {} else: model['auth'] = { 'require_login': False, - 'max_models_per_user': -1 + 'max_models_per_user': -1, + 'max_models_by_field': {} } if user_id is not None and model['auth']['require_login'] is False and model['hidden'] is False: @@ -386,6 +389,9 @@ def init_generator_spec(spec:dict, source_path:Path) -> dict: if user_id is None and model['auth']['require_login'] is True: raise ValueError(f'model {model_path} has auth.require_login true, must have user_id field') + + if model['auth']['max_models_by_field'] and model['auth']['require_login'] is False: + raise ValueError(f'model {model_path} has auth.max_models_by_field, auth.require_login must be true') # # ops diff --git a/src/mspec/data/generator/model-type-testing.yaml b/src/mspec/data/generator/model-type-testing.yaml index dd1de16..f28d7fc 100644 --- a/src/mspec/data/generator/model-type-testing.yaml +++ b/src/mspec/data/generator/model-type-testing.yaml @@ -328,6 +328,57 @@ modules: - "example1" - "example2" + limit_d: + name: + lower_case: 'limit d' + + description: 'A model to test max_models_by_field limit of 1 per my_field value' + + auth: + require_login: true + max_models_by_field: + my_field: 1 + + fields: + + user_id: + name: + lower_case: 'user id' + type: foreign_key + references: + module: 'auth' + table: 'user' + field: 'id' + examples: + - "1" + - "2" + - "3" + + my_field: + name: + lower_case: 'my field' + type: str + examples: + - "example1" + - "example2" + + unique_test: + name: + lower_case: 'unique test' + + description: 'A model to test the unique field constraint' + + fields: + + unique_string: + name: + lower_case: 'unique string' + type: str + unique: true + examples: + - "unique_val_1" + - "unique_val_2" + single_choice_fk: name: lower_case: 'single choice fk'