Skip to content

Commit fc178d3

Browse files
committed
Added m2m support
1 parent 9e48712 commit fc178d3

11 files changed

Lines changed: 234 additions & 13 deletions

File tree

‎examples/falcon_example/admin.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"slug",
3939
"author_id",
4040
"category_id",
41+
"tags",
4142
"is_published",
4243
"views",
4344
"created_at",

‎examples/fastapi_example/admin.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"slug",
3939
"author_id",
4040
"category_id",
41+
"tags",
4142
"is_published",
4243
"views",
4344
"created_at",

‎examples/litestar_example/admin.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"slug",
3838
"author_id",
3939
"category_id",
40+
"tags",
4041
"is_published",
4142
"views",
4243
"created_at",

‎examples/quart_example/admin.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"slug",
3939
"author_id",
4040
"category_id",
41+
"tags",
4142
"is_published",
4243
"views",
4344
"created_at",

‎examples/sanic_example/admin.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"slug",
3939
"author_id",
4040
"category_id",
41+
"tags",
4142
"is_published",
4243
"views",
4344
"created_at",

‎frontend/src/views/ModelDetail.vue‎

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,25 @@ function buildFields(schemaData, labels, roFields) {
8686
const result = [];
8787
8888
for (const [name, prop] of Object.entries(props)) {
89+
// M2M field
90+
if (prop['x-db-m2m']) {
91+
result.push({
92+
name,
93+
label: (labels && labels[name]) || prop.title || name,
94+
type: 'array',
95+
format: null,
96+
dbType: null,
97+
isPk: false,
98+
isReadonly: roSet.has(name),
99+
isRequired: false,
100+
default: [],
101+
maxLength: null,
102+
fk: null,
103+
m2m: { target: prop['x-db-target'], through: prop['x-db-through'] },
104+
});
105+
continue;
106+
}
107+
89108
if (hasRef(prop)) continue;
90109
91110
const fk = fkMap[name] || null;
@@ -102,6 +121,7 @@ function buildFields(schemaData, labels, roFields) {
102121
default: prop.default ?? null,
103122
maxLength: prop.maxLength || prop['x-db-max-length'] || null,
104123
fk,
124+
m2m: null,
105125
});
106126
}
107127
@@ -125,6 +145,7 @@ const dlgVisibleFields = computed(() => {
125145
});
126146
127147
function componentType(field) {
148+
if (field.m2m) return 'multiselect';
128149
if (field.fk) return 'select';
129150
if (field.type === 'boolean') return 'boolean';
130151
if (field.type === 'integer' || field.type === 'number') return 'number';
@@ -138,7 +159,13 @@ function initFormData(fieldList, record) {
138159
const data = {};
139160
for (const f of fieldList) {
140161
let val = record ? (record[f.name] ?? f.default) : f.default;
141-
if (val && (f.format === 'date-time' || f.format === 'date')) {
162+
if (f.m2m && Array.isArray(val)) {
163+
// M2M values come as [{value, label}] from fkOptions or as objects from API
164+
val = val.map((item) => {
165+
if (typeof item !== 'object') return item;
166+
return item.value ?? item.id ?? Object.values(item)[0];
167+
}).filter((v) => v != null);
168+
} else if (val && (f.format === 'date-time' || f.format === 'date')) {
142169
val = new Date(val);
143170
}
144171
data[f.name] = val;
@@ -165,6 +192,10 @@ function buildPayload() {
165192
if (f.isReadonly) continue;
166193
if (f.isPk && isCreate.value) continue;
167194
let val = formData.value[f.name];
195+
if (f.m2m) {
196+
payload[f.name] = Array.isArray(val) ? val : [];
197+
continue;
198+
}
168199
if (val instanceof Date) {
169200
val = val.toISOString();
170201
}
@@ -190,6 +221,7 @@ async function loadSchema() {
190221
191222
async function loadFkOptions() {
192223
const fkFields = fields.value.filter((f) => f.fk);
224+
const m2mFields = fields.value.filter((f) => f.m2m);
193225
const promises = fkFields.map(async (f) => {
194226
let url = `/api/${f.fk.model}/options`;
195227
const currentVal = formData.value?.[f.name];
@@ -199,7 +231,17 @@ async function loadFkOptions() {
199231
const res = await api(url);
200232
fkOptions.value[f.name] = await res.json();
201233
});
202-
await Promise.all(promises);
234+
const m2mPromises = m2mFields.map(async (f) => {
235+
let url = `/api/${f.m2m.target}/options`;
236+
const currentVals = formData.value?.[f.name];
237+
if (Array.isArray(currentVals) && currentVals.length > 0) {
238+
const params = currentVals.map((v) => `include=${encodeURIComponent(v)}`).join('&');
239+
url += `?${params}`;
240+
}
241+
const res = await api(url);
242+
fkOptions.value[f.name] = await res.json();
243+
});
244+
await Promise.all([...promises, ...m2mPromises]);
203245
}
204246
205247
let fkSearchTimeout = null;
@@ -215,6 +257,19 @@ function onFkSearch(field, event) {
215257
}, 300);
216258
}
217259
260+
let m2mFilterTimeout = null;
261+
function onM2mFilter(field, event) {
262+
clearTimeout(m2mFilterTimeout);
263+
const query = event.value;
264+
m2mFilterTimeout = setTimeout(async () => {
265+
const url = query
266+
? `/api/${field.m2m.target}/options?search=${encodeURIComponent(query)}`
267+
: `/api/${field.m2m.target}/options`;
268+
const res = await api(url);
269+
fkOptions.value[field.name] = await res.json();
270+
}, 300);
271+
}
272+
218273
async function loadRecord() {
219274
loading.value = true;
220275
try {
@@ -474,8 +529,24 @@ onMounted(async () => {
474529
<span v-if="field.isRequired" class="text-red-500">*</span>
475530
</label>
476531
532+
<!-- M2M MultiSelect -->
533+
<MultiSelect
534+
v-if="componentType(field) === 'multiselect'"
535+
:id="field.name"
536+
v-model="formData[field.name]"
537+
:options="fkOptions[field.name] || []"
538+
optionLabel="label"
539+
optionValue="value"
540+
:disabled="field.isReadonly"
541+
display="chip"
542+
filter
543+
@filter="onM2mFilter(field, $event)"
544+
placeholder="Select..."
545+
fluid
546+
/>
547+
477548
<!-- FK Select -->
478-
<div v-if="componentType(field) === 'select'" class="flex gap-2 items-center">
549+
<div v-else-if="componentType(field) === 'select'" class="flex gap-2 items-center">
479550
<Select
480551
:id="field.name"
481552
v-model="formData[field.name]"

‎frontend/src/views/ModelList.vue‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ function extractFkColumns(schema) {
8080
function colType(col) {
8181
const prop = colProps.value[col];
8282
if (!prop) return 'default';
83+
if (prop['x-db-m2m']) return 'm2m';
8384
if (prop['x-db-primary-key']) return 'pk';
8485
if (fkModels.value[col]) return 'fk';
8586
if (prop.type === 'boolean') return 'boolean';
@@ -146,6 +147,7 @@ function buildBulkFields(schema, labels, roFields) {
146147
const result = [];
147148
148149
for (const [name, prop] of Object.entries(props)) {
150+
if (prop['x-db-m2m']) continue;
149151
if (hasRef(prop)) continue;
150152
const isPk = !!prop['x-db-primary-key'];
151153
if (isPk) continue;
@@ -582,6 +584,17 @@ onMounted(async () => {
582584
<!-- null -->
583585
<span v-if="data[col] == null" class="text-surface-400 italic">NULL</span>
584586

587+
<!-- M2M -->
588+
<div v-else-if="colType(col) === 'm2m'" class="flex flex-wrap gap-1">
589+
<Tag
590+
v-for="(item, idx) in (Array.isArray(data[col]) ? data[col] : [])"
591+
:key="idx"
592+
:value="typeof item === 'object' ? (item.name || item.title || item.label || Object.values(item)[1] || Object.values(item)[0]) : item"
593+
severity="info"
594+
class="text-xs"
595+
/>
596+
</div>
597+
585598
<!-- PK -->
586599
<router-link
587600
v-else-if="colType(col) === 'pk'"

‎oxyde_admin/api/routes.py‎

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from dataclasses import dataclass
44
from typing import Any, TYPE_CHECKING
55

6+
from oxyde.models import registered_tables
7+
68
if TYPE_CHECKING:
79
from oxyde.models import Model
810

@@ -43,6 +45,10 @@ async def list_records(
4345
if ordering:
4446
query = query.order_by(*ordering)
4547

48+
m2m_fields = _get_m2m_fields(model)
49+
if m2m_fields:
50+
query = query.prefetch(*m2m_fields)
51+
4652
offset = (page - 1) * per_page
4753
items = await query.limit(per_page).offset(offset).all()
4854

@@ -54,30 +60,56 @@ async def get_record(
5460
pk: Any,
5561
) -> Model:
5662
name, pk_type = _get_pk_field(model)
57-
return await model.objects.get(**{name: pk_type(pk)})
63+
m2m_fields = _get_m2m_fields(model)
64+
query = model.objects
65+
if m2m_fields:
66+
query = query.prefetch(*m2m_fields)
67+
return await query.filter(**{name: pk_type(pk)}).get()
5868

5969

6070
async def create_record(
6171
model: type[Model],
6272
data: dict[str, Any],
73+
m2m_data: dict[str, list] | None = None,
6374
) -> Model:
64-
return await model.objects.create(**data)
75+
record = await model.objects.create(**data)
76+
if m2m_data:
77+
await _sync_m2m(model, record, m2m_data)
78+
m2m_fields = list(m2m_data.keys())
79+
pk_name = _get_pk_field(model)[0]
80+
record = (
81+
await model.objects.prefetch(*m2m_fields)
82+
.filter(**{pk_name: getattr(record, pk_name)})
83+
.get()
84+
)
85+
return record
6586

6687

6788
async def update_record(
6889
model: type[Model],
6990
pk: Any,
7091
data: dict[str, Any],
7192
readonly_fields: list[str] | None = None,
93+
m2m_data: dict[str, list] | None = None,
7294
) -> Model:
7395
name, pk_type = _get_pk_field(model)
7496
record = await model.objects.get(**{name: pk_type(pk)})
7597
blocked = set(readonly_fields or [])
7698
blocked.add(name)
7799
clean = {k: v for k, v in data.items() if k not in blocked}
78-
for key, value in clean.items():
79-
setattr(record, key, value)
80-
await record.save(update_fields=set(clean.keys()))
100+
if clean:
101+
for key, value in clean.items():
102+
setattr(record, key, value)
103+
await record.save(update_fields=set(clean.keys()))
104+
if m2m_data:
105+
await _sync_m2m(model, record, m2m_data)
106+
m2m_fields = _get_m2m_fields(model)
107+
if m2m_fields:
108+
record = (
109+
await model.objects.prefetch(*m2m_fields)
110+
.filter(**{name: pk_type(pk)})
111+
.get()
112+
)
81113
return record
82114

83115

@@ -170,8 +202,6 @@ async def resolve_fk_labels(
170202
"""
171203
import asyncio
172204

173-
from oxyde.models.registry import registered_tables
174-
175205
fk_fields = {}
176206
for name, col in model._db_meta.field_metadata.items():
177207
if col.foreign_key:
@@ -227,3 +257,61 @@ def _guess_display_field(model: type[Model]) -> str:
227257
if col.python_type is str:
228258
return name
229259
return _get_pk_field(model)[0]
260+
261+
262+
def _get_m2m_fields(model: type[Model]) -> list[str]:
263+
"""Return M2M relation names for the model."""
264+
return [
265+
name
266+
for name, rel in model._db_meta.relations.items()
267+
if rel.kind == "many_to_many"
268+
]
269+
270+
271+
def _resolve_model_by_name(name: str) -> type[Model]:
272+
"""Find a model by class name in the registry."""
273+
tables = registered_tables()
274+
for key, model in tables.items():
275+
if key.endswith(f".{name}") or model.__name__ == name:
276+
return model
277+
raise ValueError(f"Model '{name}' not found in registry")
278+
279+
280+
async def _sync_m2m(
281+
model: type[Model],
282+
record: Any,
283+
m2m_data: dict[str, list],
284+
) -> None:
285+
"""Sync M2M relations: delete existing junction rows and re-create."""
286+
pk_name, _ = _get_pk_field(model)
287+
record_pk = getattr(record, pk_name)
288+
289+
for field_name, target_ids in m2m_data.items():
290+
rel = model._db_meta.relations.get(field_name)
291+
if rel is None or rel.kind != "many_to_many" or rel.through is None:
292+
continue
293+
294+
through_model = _resolve_model_by_name(rel.through)
295+
296+
# Find source_fk and target_fk columns in the through model
297+
source_fk = None
298+
target_fk = None
299+
source_key = f".{model.__name__}"
300+
target_key = f".{rel.target}"
301+
302+
for meta in through_model._db_meta.field_metadata.values():
303+
if meta.foreign_key:
304+
if meta.foreign_key.target.endswith(source_key):
305+
source_fk = meta.name
306+
elif meta.foreign_key.target.endswith(target_key):
307+
target_fk = meta.name
308+
309+
if source_fk is None or target_fk is None:
310+
continue
311+
312+
# Delete existing junction rows for this record
313+
await through_model.objects.filter(**{source_fk: record_pk}).delete()
314+
315+
# Re-create junction rows
316+
for tid in target_ids:
317+
await through_model.objects.create(**{source_fk: record_pk, target_fk: tid})

‎oxyde_admin/schema.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ def build_schema(model: type[Model]) -> dict[str, Any]:
5454
if col.comment is not None:
5555
prop["x-db-comment"] = col.comment
5656

57+
# M2M relations
58+
fk_table_map_rev = {
59+
model.__name__: model._db_meta.table_name
60+
for model in registered_tables().values()
61+
if model._db_meta.table_name
62+
}
63+
for rel_name, rel in model._db_meta.relations.items():
64+
if rel.kind != "many_to_many":
65+
continue
66+
prop = properties.get(rel_name)
67+
if prop is None:
68+
continue
69+
prop["x-db-m2m"] = True
70+
prop["x-db-target"] = fk_table_map_rev.get(rel.target, rel.target)
71+
prop["x-db-through"] = rel.through
72+
5773
return schema
5874

5975

0 commit comments

Comments
 (0)