Skip to content

ManyToManyField

ManyToManyField is a virtual field backed by a through model.

It does not create columns on the owning model. Instead it either uses an explicit through model or generates one automatically and exposes a runtime relation descriptor for collection-style access.

Practical example

class Article(saffier.Model):
    id = saffier.IntegerField(primary_key=True, autoincrement=True)
    tags = saffier.ManyToManyField("Tag")

    class Meta:
        registry = models

Important behaviors

  • the through model must expose an integer id primary key
  • add(), add_many(), create(), remove(), and remove_many() operate through the junction model
  • embed_through can attach the through instance back onto the related object after relation operations
  • reverse names are generated from the owning model and field name when you do not declare one explicitly

saffier.ManyToManyField

ManyToManyField(
    to,
    through=None,
    through_tablename=NEW_M2M_NAMING,
    embed_through=False,
    to_foreign_key="",
    from_foreign_key="",
    **kwargs,
)

Bases: Field

Virtual field describing a many-to-many relation via a through model.

A many-to-many field never maps directly to columns on the owning model. Instead it manages or creates an intermediate through model, exposes a Relation descriptor for runtime access, and carries the metadata needed to build reverse relation names, embedded through objects, and auto-generated junction table definitions.

Source code in saffier/core/db/fields/base.py
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
def __init__(
    self,
    to: type["Model"] | str,
    through: type["Model"] | str | None = None,
    through_tablename: str | type[NEW_M2M_NAMING] | None = NEW_M2M_NAMING,
    embed_through: str | bool = False,
    to_foreign_key: str = "",
    from_foreign_key: str = "",
    **kwargs: typing.Any,
):
    if through_tablename is not None and (
        not isinstance(through_tablename, str) and through_tablename is not NEW_M2M_NAMING
    ):
        raise FieldDefinitionError(
            '"through_tablename" must be NEW_M2M_NAMING or a non-empty string.'
        )
    if isinstance(through_tablename, str) and not through_tablename.strip():
        raise FieldDefinitionError('"through_tablename" cannot be an empty string.')
    if embed_through and isinstance(embed_through, str) and "__" in embed_through:
        raise FieldDefinitionError('"embed_through" cannot contain "__".')

    if "null" in kwargs:
        terminal.write_warning("Declaring `null` on a ManyToMany relationship has no effect.")

    related_name = kwargs.pop("related_name", None)
    super().__init__(null=True, **kwargs)
    self.to = to
    self.through = through
    self.through_tablename = through_tablename
    self.embed_through = embed_through
    self.related_name = related_name
    self.from_foreign_key = from_foreign_key
    self.to_foreign_key = to_foreign_key
    self.reverse_name = ""

    if self.related_name not in (None, False):
        assert isinstance(self.related_name, str), "related_name must be a string."

    if isinstance(self.related_name, str):
        self.related_name = self.related_name.lower()

server_default instance-attribute

server_default = pop('server_default', None)

null instance-attribute

null = get('null', False)

default_value instance-attribute

default_value = get('default')

primary_key instance-attribute

primary_key = primary_key

index instance-attribute

index = index

unique instance-attribute

unique = unique

validator instance-attribute

validator = get_validator(**kwargs)

comment instance-attribute

comment = get('comment')

column_name instance-attribute

column_name = pop('column_name', None)

owner instance-attribute

owner = pop('owner', None)

registry instance-attribute

registry = pop('registry', None)

name instance-attribute

name = pop('name', '')

inherit instance-attribute

inherit = pop('inherit', True)

no_copy instance-attribute

no_copy = pop('no_copy', False)

exclude instance-attribute

exclude = pop('exclude', False)

inject_default_on_partial_update instance-attribute

inject_default_on_partial_update = get(
    "inject_default_on_partial_update", False
)

server_onupdate instance-attribute

server_onupdate = pop('server_onupdate', None)

autoincrement instance-attribute

autoincrement = pop('autoincrement', False)

secret instance-attribute

secret = pop('secret', False)

is_virtual class-attribute instance-attribute

is_virtual = True

to instance-attribute

to = to

through instance-attribute

through = through

through_tablename instance-attribute

through_tablename = through_tablename

embed_through instance-attribute

embed_through = embed_through

related_name instance-attribute

related_name = related_name

from_foreign_key instance-attribute

from_foreign_key = from_foreign_key

to_foreign_key instance-attribute

to_foreign_key = to_foreign_key

reverse_name instance-attribute

reverse_name = ''

target property

target

Resolve and cache the many-to-many target model class.

RETURNS DESCRIPTION
Any

typing.Any: Target model class for the relation.

get_validator

get_validator(**kwargs)
Source code in saffier/core/db/fields/base.py
127
128
def get_validator(self, **kwargs: typing.Any) -> SaffierField:
    return SaffierField(**kwargs)  # pragma: no cover

get_column_type

get_column_type()
Source code in saffier/core/db/fields/base.py
130
131
def get_column_type(self) -> sqlalchemy.types.TypeEngine:
    raise NotImplementedError()  # pragma: no cover

get_constraints

get_constraints()
Source code in saffier/core/db/fields/base.py
133
134
def get_constraints(self) -> typing.Any:
    return []

get_columns

get_columns(name)
Source code in saffier/core/db/fields/base.py
136
137
def get_columns(self, name: str) -> typing.Sequence[sqlalchemy.Column]:
    return [self.get_column(name)]

get_global_constraints

get_global_constraints(name, columns, *, schema=None)
Source code in saffier/core/db/fields/base.py
139
140
141
142
143
144
145
146
147
def get_global_constraints(
    self,
    name: str,
    columns: typing.Sequence[sqlalchemy.Column],
    *,
    schema: str | None = None,
) -> typing.Sequence[sqlalchemy.Constraint | sqlalchemy.Index]:
    del name, columns, schema
    return []

has_column

has_column()
Source code in saffier/core/db/fields/base.py
149
150
def has_column(self) -> bool:
    return not self.is_virtual

get_embedded_fields

get_embedded_fields(field_name, existing_fields)
Source code in saffier/core/db/fields/base.py
152
153
154
155
156
157
158
def get_embedded_fields(
    self,
    field_name: str,
    existing_fields: dict[str, "Field"],
) -> dict[str, "Field"]:
    del field_name, existing_fields
    return {}

expand_relationship

expand_relationship(value)
Source code in saffier/core/db/fields/base.py
160
161
def expand_relationship(self, value: typing.Any) -> typing.Any:
    return value

clean

clean(name, value, *, for_query=False)

Normalize one logical field value into column-value pairs.

PARAMETER DESCRIPTION
name

Logical field name.

TYPE: str

value

User-facing field value.

TYPE: Any

for_query

Reserved for field implementations that need distinct query-time normalization.

TYPE: bool DEFAULT: False

RETURNS DESCRIPTION
dict[str, Any]

dict[str, Any]: Database payload keyed by column name.

Source code in saffier/core/db/fields/base.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
def clean(
    self, name: str, value: typing.Any, *, for_query: bool = False
) -> dict[str, typing.Any]:
    """Normalize one logical field value into column-value pairs.

    Args:
        name: Logical field name.
        value: User-facing field value.
        for_query: Reserved for field implementations that need distinct
            query-time normalization.

    Returns:
        dict[str, Any]: Database payload keyed by column name.
    """
    del for_query
    if not self.has_column():
        return {}
    return {name: value}

modify_input

modify_input(name, kwargs)
Source code in saffier/core/db/fields/base.py
182
183
def modify_input(self, name: str, kwargs: dict[str, typing.Any]) -> None:
    del name, kwargs

raise_for_non_default

raise_for_non_default(default, server_default)
Source code in saffier/core/db/fields/base.py
185
186
def raise_for_non_default(self, default: typing.Any, server_default: typing.Any) -> typing.Any:
    del default, server_default

get_default_value

get_default_value()
Source code in saffier/core/db/fields/base.py
188
189
def get_default_value(self) -> typing.Any:
    return self.validator.get_default_value()

has_default

has_default()
Source code in saffier/core/db/fields/base.py
191
192
def has_default(self) -> bool:
    return self.validator.has_default()

get_default_values

get_default_values(field_name, cleaned_data)
Source code in saffier/core/db/fields/base.py
194
195
196
197
198
199
200
201
def get_default_values(
    self,
    field_name: str,
    cleaned_data: dict[str, typing.Any],
) -> dict[str, typing.Any]:
    if field_name in cleaned_data or not self.has_column():
        return {}
    return {field_name: self.get_default_value()}

is_required

is_required()
Source code in saffier/core/db/fields/base.py
203
204
205
206
def is_required(self) -> bool:
    if self.primary_key and self.autoincrement:
        return False
    return not (self.null or self.server_default is not None or self.has_default())

get_is_null_clause

get_is_null_clause(column)
Source code in saffier/core/db/fields/base.py
208
209
def get_is_null_clause(self, column: typing.Any) -> typing.Any:
    return column == None  # noqa: E711

get_is_empty_clause

get_is_empty_clause(column)
Source code in saffier/core/db/fields/base.py
211
212
def get_is_empty_clause(self, column: typing.Any) -> typing.Any:
    return self.get_is_null_clause(column)

operator_to_clause

operator_to_clause(field_name, operator, table, value)

Translate one lookup operator into a SQLAlchemy clause.

PARAMETER DESCRIPTION
field_name

Logical field name being filtered.

TYPE: str

operator

Saffier lookup suffix such as exact or isnull.

TYPE: str

table

SQLAlchemy table containing the target column.

TYPE: Table

value

Lookup value supplied by the caller.

TYPE: Any

RETURNS DESCRIPTION
Any

SQLAlchemy boolean clause implementing the lookup.

TYPE: Any

Source code in saffier/core/db/fields/base.py
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
def operator_to_clause(
    self,
    field_name: str,
    operator: str,
    table: sqlalchemy.Table,
    value: typing.Any,
) -> typing.Any:
    """Translate one lookup operator into a SQLAlchemy clause.

    Args:
        field_name: Logical field name being filtered.
        operator: Saffier lookup suffix such as `exact` or `isnull`.
        table: SQLAlchemy table containing the target column.
        value: Lookup value supplied by the caller.

    Returns:
        Any: SQLAlchemy boolean clause implementing the lookup.
    """
    column = table.columns[field_name]
    mapped_operator = settings.filter_operators.get(operator, operator)

    if mapped_operator == "isnull":
        is_null = self.get_is_null_clause(column)
        return is_null if value else sqlalchemy.not_(is_null)

    if mapped_operator == "isempty":
        is_empty = self.get_is_empty_clause(column)
        return is_empty if value else sqlalchemy.not_(is_empty)

    return getattr(column, mapped_operator)(value)

get_column

get_column(name)
Source code in saffier/core/db/fields/base.py
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
def get_column(self, name: str) -> sqlalchemy.Column:
    target = self.target
    to_field = target.fields[target.pkname]

    column_type = to_field.get_column_type()
    constraints = [
        sqlalchemy.schema.ForeignKey(
            f"{target.meta.tablename}.{target.pkname}",
            ondelete=saffier.CASCADE,
            onupdate=saffier.CASCADE,
            name=self.get_fk_name(name=name),
        )
    ]
    return sqlalchemy.Column(name, column_type, *constraints, nullable=self.null)

add_model_to_register

add_model_to_register(model)

Register an auto-generated through model on the owning registry.

PARAMETER DESCRIPTION
model

Through model created for the relation.

TYPE: type[Model]

Source code in saffier/core/db/fields/base.py
1241
1242
1243
1244
1245
1246
1247
def add_model_to_register(self, model: type["Model"]) -> None:
    """Register an auto-generated through model on the owning registry.

    Args:
        model: Through model created for the relation.
    """
    self.registry.models[model.__name__] = model

get_fk_name

get_fk_name(name)

Build a stable FK constraint name for the through table.

PARAMETER DESCRIPTION
name

Local field or column name participating in the constraint.

TYPE: str

RETURNS DESCRIPTION
str

Constraint name truncated to common identifier limits.

TYPE: str

Source code in saffier/core/db/fields/base.py
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
def get_fk_name(self, name: str) -> str:
    """Build a stable FK constraint name for the through table.

    Args:
        name: Local field or column name participating in the constraint.

    Returns:
        str: Constraint name truncated to common identifier limits.
    """
    fk_name = f"fk_{self.owner.meta.tablename}_{self.target.meta.tablename}_{self.target.pkname}_{name}"
    if not len(fk_name) > CHAR_LIMIT:
        return fk_name
    return fk_name[:CHAR_LIMIT]

create_through_model

create_through_model()

Create or normalize the through model used to persist the relation.

RETURNS DESCRIPTION
Any

typing.Any: Concrete through model class used by the relation.

RAISES DESCRIPTION
ImproperlyConfigured

If an explicit through model does not expose a valid integer id primary key.

Source code in saffier/core/db/fields/base.py
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
def create_through_model(self) -> typing.Any:
    """Create or normalize the through model used to persist the relation.

    Returns:
        typing.Any: Concrete through model class used by the relation.

    Raises:
        ImproperlyConfigured: If an explicit through model does not expose a
            valid integer `id` primary key.
    """
    self.to = typing.cast(type["Model"], self.target)

    if self.through:
        if isinstance(self.through, str):
            registry = self.owner.meta.registry
            self.through = (
                registry.models.get(self.through) or registry.reflected[self.through]
            )

        if not self.from_foreign_key:
            candidates = [
                field_name
                for field_name, field in self.through.fields.items()
                if isinstance(field, ForeignKey) and field.target is self.owner
            ]
            if len(candidates) == 1:
                self.from_foreign_key = candidates[0]
        if not self.to_foreign_key:
            candidates = [
                field_name
                for field_name, field in self.through.fields.items()
                if isinstance(field, ForeignKey) and field.target is self.to
            ]
            if len(candidates) == 1:
                self.to_foreign_key = candidates[0]

        # M2M through models in Saffier are always required to expose an
        # auto-incrementing integer "id" primary key.
        id_field = self.through.fields.get("id")
        if id_field is None:
            has_non_id_pk = any(
                field.primary_key
                for field_name, field in self.through.fields.items()
                if field_name != "id"
            )
            if has_non_id_pk:
                raise ImproperlyConfigured(
                    "ManyToMany through models must use an auto-incrementing 'id' primary key."
                )
            id_field = saffier.IntegerField(primary_key=True, autoincrement=True)
            id_field.owner = self.through
            id_field.registry = self.through.meta.registry
            id_field.name = "id"
            self.through.fields["id"] = id_field
            self.through.meta.fields["id"] = id_field
            self.through.meta.fields_mapping["id"] = id_field
            self.through.meta.pk = id_field
            self.through.meta.pk_attribute = "id"
            self.through.pkname = "id"
            self.through._table = None
            self.through.__proxy_model__ = None
        elif not id_field.primary_key:
            raise ImproperlyConfigured(
                "ManyToMany through models must define 'id' as the primary key."
            )
        elif not isinstance(id_field, (IntegerField, SmallIntegerField, BigIntegerField)):
            raise ImproperlyConfigured(
                "ManyToMany through model 'id' primary key must be an integer type."
            )
        else:
            id_field.autoincrement = True

        self.through.meta.is_multi = True
        if not self.from_foreign_key:
            self.from_foreign_key = self.owner.__name__.lower()
        if not self.to_foreign_key:
            self.to_foreign_key = self.to.__name__.lower()
        self.through.meta.multi_related = [self.to_foreign_key]
        return self.through

    owner_name = self.owner.__name__
    to_name = self.to.__name__
    if not self.from_foreign_key:
        self.from_foreign_key = owner_name.lower()
    if not self.to_foreign_key:
        self.to_foreign_key = to_name.lower()
    class_name = f"{owner_name}{self.name.capitalize()}Through"
    if self.through_tablename is None or self.through_tablename is NEW_M2M_NAMING:
        tablename = class_name.lower()
    else:
        tablename = self.through_tablename.format(field=self).lower()
    if self.owner.meta.table_prefix:
        tablename = f"{self.owner.meta.table_prefix}_{tablename}"

    new_meta_namespace = {
        "tablename": tablename,
        "registry": self.owner.meta.registry,
        "is_multi": True,
        "multi_related": [self.to_foreign_key],
        "unique_together": [(self.from_foreign_key, self.to_foreign_key)],
    }

    new_meta = type("MetaInfo", (), new_meta_namespace)

    to_related_name = (
        f"{self.related_name}"
        if self.related_name
        else (
            f"{to_name.lower()}_{owner_name.lower()}{to_name.lower()}"
            if self.unique
            else f"{to_name.lower()}_{owner_name.lower()}{to_name.lower()}s_set"
        )
    )
    self.reverse_name = to_related_name if to_related_name is not False else ""

    through_model = type(
        class_name,
        (saffier.Model,),
        {
            "Meta": new_meta,
            "id": saffier.IntegerField(primary_key=True, autoincrement=True),
            f"{self.from_foreign_key}": ForeignKey(
                self.owner,
                on_delete=saffier.CASCADE,
                index=self.index,
                null=True,
                related_name=False,
            ),
            f"{self.to_foreign_key}": ForeignKey(
                self.to,
                on_delete=saffier.CASCADE,
                unique=self.unique,
                index=self.index,
                null=True,
                embed_parent=(
                    (self.from_foreign_key, self.embed_through or "")
                    if self.embed_through is not False
                    else None
                ),
                related_name=(False if self.related_name is False else to_related_name),
            ),
        },
    )
    self.through = typing.cast(type["Model"], through_model)

    self.add_model_to_register(self.through)
    tenant_models = getattr(self.registry, "tenant_models", None)
    if tenant_models is not None and (
        getattr(self.owner.meta, "is_tenant", False)
        or getattr(self.to.meta, "is_tenant", False)
    ):
        tenant_models[self.through.__name__] = self.through
        if getattr(self.owner.meta, "register_default", None) is False:
            self.registry.models.pop(self.through.__name__, None)