Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions docs/LINGO_MAPP_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
24 changes: 22 additions & 2 deletions src/mapp/module/model/db.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from datetime import datetime
import sqlite3

from mapp.auth import current_user
from mapp.context import MappContext
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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))
Expand Down
12 changes: 12 additions & 0 deletions src/mapp/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
8 changes: 7 additions & 1 deletion src/mspec/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,17 +375,23 @@ 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:
raise ValueError(f'model {model_path} has user_id field, auth.require_login must be true')

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
Expand Down
51 changes: 51 additions & 0 deletions src/mspec/data/generator/model-type-testing.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down