From 6bdcfa84d20aaa1ca4340b2f72dfa0ce188f668c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:04:39 +0000 Subject: [PATCH 1/3] Initial plan From 0ec6d889c37809d093ee388a5eeb4160ff720331 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:13:34 +0000 Subject: [PATCH 2/3] Implement max_models_by_field and unique field constraints Agent-Logs-Url: https://github.com/medium-tech/mspec/sessions/b14cb5cf-a10a-4577-a667-9dcc9cb85103 Co-authored-by: b-rad-c <25362581+b-rad-c@users.noreply.github.com> --- docs/LINGO_MAPP_SPEC.md | 20 ++++++++ src/mapp/module/model/db.py | 22 +++++++- src/mapp/test.py | 12 +++++ src/mspec/core.py | 5 +- .../data/generator/model-type-testing.yaml | 51 +++++++++++++++++++ 5 files changed, 107 insertions(+), 3 deletions(-) diff --git a/docs/LINGO_MAPP_SPEC.md b/docs/LINGO_MAPP_SPEC.md index e8c5d4b5..5e203a52 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 beccd099..c9d273e4 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,10 @@ 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}') - + + 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 +119,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 +159,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 as e: + raise MappUserError('UNIQUE_CONSTRAINT_VIOLATED', f'A record with that value already exists: {e}') 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 60ca0062..b16066a5 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 11b7b25d..782d5c38 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: diff --git a/src/mspec/data/generator/model-type-testing.yaml b/src/mspec/data/generator/model-type-testing.yaml index dd1de16d..f28d7fc0 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' From c98137e818483dfaf293a991222cdbe4d918a8af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:15:52 +0000 Subject: [PATCH 3/3] Address code review: hide IntegrityError details, validate max_models_by_field requires login, add comment for FK UNIQUE exclusion Agent-Logs-Url: https://github.com/medium-tech/mspec/sessions/b14cb5cf-a10a-4577-a667-9dcc9cb85103 Co-authored-by: b-rad-c <25362581+b-rad-c@users.noreply.github.com> --- src/mapp/module/model/db.py | 6 ++++-- src/mspec/core.py | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/mapp/module/model/db.py b/src/mapp/module/model/db.py index c9d273e4..85aa8cb3 100644 --- a/src/mapp/module/model/db.py +++ b/src/mapp/module/model/db.py @@ -47,6 +47,8 @@ def db_model_create_table(ctx:MappContext, model_class: type) -> Acknowledgment: 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' @@ -161,8 +163,8 @@ def db_model_create(ctx:MappContext, model_class: type, obj: object) -> object: try: result = ctx.db.cursor.execute(non_list_sql, values) - except sqlite3.IntegrityError as e: - raise MappUserError('UNIQUE_CONSTRAINT_VIOLATED', f'A record with that value already exists: {e}') + 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/mspec/core.py b/src/mspec/core.py index 782d5c38..5f7d13a1 100644 --- a/src/mspec/core.py +++ b/src/mspec/core.py @@ -389,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