1use crate::emit::{CommandRunner, DjangoConfig};
4use alembic_adapter_sdk::{AppliedOp, ApplyReport, Op};
5use alembic_core::{key_string, FieldFormat, FieldType, Inventory, Object, Schema, TypeSchema};
6use alembic_engine::{pluralize, Emitter, StateStore};
7use anyhow::{anyhow, bail, Context, Result};
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10use serde_json::{json, Map, Value};
11use std::collections::BTreeMap;
12use std::fs;
13use std::path::Path;
14
15pub mod emit;
16
17pub struct DjangoAdapter {
18 config: DjangoConfig,
19}
20
21impl DjangoAdapter {
22 pub fn new(config: DjangoConfig) -> Self {
23 Self { config }
24 }
25}
26
27#[async_trait]
28impl Emitter for DjangoAdapter {
29 async fn write(&self, schema: &Schema, ops: &[Op], _state: &StateStore) -> Result<ApplyReport> {
30 let mut inventory = Inventory {
31 scope: None,
32 schema: schema.clone(),
33 objects: Vec::new(),
34 };
35 let mut apply_report = ApplyReport::default();
36
37 for op in ops {
38 match op {
39 Op::Create {
40 uid,
41 type_name,
42 desired,
43 } => {
44 inventory.objects.push(Object {
45 uid: *uid,
46 type_name: type_name.clone(),
47 key: desired.key.clone(),
48 attrs: desired.attrs.clone(),
49 source: desired.source.clone(),
50 });
51 apply_report.applied.push(AppliedOp {
52 uid: *uid,
53 type_name: type_name.clone(),
54 backend_id: None,
55 })
56 }
57 Op::Update { .. } => {
58 return Err(anyhow!("unsupported operation, cannot update object"))
59 }
60 Op::Delete { .. } => {
61 return Err(anyhow!("unsupported operation, cannot delete object"))
62 }
63 }
64 }
65
66 let runner = CommandRunner::new();
67 emit::run_emit(&runner, &inventory, &self.config)?;
68 Ok(apply_report)
69 }
70}
71
72const GENERATED_MODELS: &str = "generated_models.py";
73const GENERATED_ADMIN: &str = "generated_admin.py";
74const GENERATED_SERIALIZERS: &str = "generated_serializers.py";
75const GENERATED_VIEWS: &str = "generated_views.py";
76const GENERATED_URLS: &str = "generated_urls.py";
77const USER_MODELS: &str = "models.py";
78const USER_ADMIN: &str = "admin.py";
79const USER_SERIALIZERS: &str = "serializers.py";
80const USER_VIEWS: &str = "views.py";
81const USER_URLS: &str = "urls.py";
82const USER_EXTENSIONS: &str = "extensions.py";
83const FIXTURES_DIR: &str = "fixtures";
84pub const FIXTURE_LABEL: &str = "alembic";
86const FIXTURE_FILE: &str = "alembic.json";
87
88const MODELS_TEMPLATE: &str = include_str!("../templates/models.py.tpl");
89const ADMIN_TEMPLATE: &str = include_str!("../templates/admin.py.tpl");
90const SERIALIZERS_TEMPLATE: &str = include_str!("../templates/serializers.py.tpl");
91const VIEWS_TEMPLATE: &str = include_str!("../templates/views.py.tpl");
92const URLS_TEMPLATE: &str = include_str!("../templates/urls.py.tpl");
93
94const BLOCK_SEPARATOR: &str = "\n\n\n";
96
97const MEMBER_VALIDATOR_CLASS: &str = r#"_MEMBER_KINDS = {
101 "str": lambda member: isinstance(member, str),
102 # a python bool is an int, and alembic's int is not a bool.
103 "int": lambda member: isinstance(member, int) and not isinstance(member, bool),
104 "float": lambda member: isinstance(member, (int, float))
105 and not isinstance(member, bool),
106 "bool": lambda member: isinstance(member, bool),
107 "list": lambda member: isinstance(member, (list, tuple)),
108 "map": lambda member: isinstance(member, dict),
109}
110
111
112@deconstructible
113class _ListMembers:
114 """checks every member of a list against its declared element type."""
115
116 def __init__(self, choices=None, regex=None, kind=None):
117 self.choices = choices
118 self.regex = regex
119 self.kind = kind
120
121 def __call__(self, value):
122 if not isinstance(value, (list, tuple)):
123 raise ValidationError(
124 "expected a list, got %(got)s", params={"got": type(value).__name__}
125 )
126 for member in value:
127 if self.choices is not None and member not in self.choices:
128 raise ValidationError(
129 "%(member)s is not one of %(choices)s",
130 params={"member": member, "choices": ", ".join(self.choices)},
131 )
132 if self.kind is not None and not _MEMBER_KINDS[self.kind](member):
133 raise ValidationError(
134 "%(member)s is not a %(kind)s",
135 params={"member": member, "kind": self.kind},
136 )
137 # every pattern here is one of core's anchored format regexes, and
138 # python's `$` also matches before a trailing newline: `fullmatch`
139 # is what core's own `is_match` makes of an anchored pattern.
140 if self.regex is not None and not (
141 isinstance(member, str) and re.fullmatch(self.regex, member)
142 ):
143 raise ValidationError(
144 "%(member)s does not match %(regex)s",
145 params={"member": member, "regex": self.regex},
146 )"#;
147
148#[derive(Debug)]
149struct ModelSpec {
150 type_name: String,
151 class_name: String,
152 fields: Vec<FieldSpec>,
153 key_fields: Vec<String>,
154}
155
156impl ModelSpec {
157 fn relation_fields(&self) -> impl Iterator<Item = &FieldSpec> {
158 self.fields
159 .iter()
160 .filter(|field| matches!(field.field_type, DjangoFieldType::ForeignKey { .. }))
161 }
162
163 fn many_to_many_fields(&self) -> impl Iterator<Item = &FieldSpec> {
164 self.fields
165 .iter()
166 .filter(|field| matches!(field.field_type, DjangoFieldType::ManyToMany { .. }))
167 }
168}
169
170#[derive(Debug, Clone)]
171struct FieldSpec {
172 name: String,
173 field_type: DjangoFieldType,
174 required: bool,
175 nullable: bool,
176 choices: Option<Vec<String>>,
177 validators: Vec<String>,
178 member_check: Option<MemberCheck>,
181 help_text: Option<String>,
182}
183
184#[derive(Debug, Clone)]
185enum DjangoFieldType {
186 Char,
187 Text,
188 Integer,
189 Float,
190 Boolean,
191 Uuid,
192 Date,
193 DateTime,
194 Time,
195 Json {
197 list: bool,
198 },
199 Slug,
200 IpAddress,
201 ForeignKey {
202 target: String,
203 },
204 ManyToMany {
205 target: String,
206 },
207}
208
209impl DjangoFieldType {
210 fn is_textual(&self) -> bool {
213 matches!(
214 self,
215 DjangoFieldType::Char | DjangoFieldType::Text | DjangoFieldType::Slug
216 )
217 }
218
219 fn is_json(&self) -> bool {
220 matches!(self, DjangoFieldType::Json { .. })
221 }
222
223 fn is_list(&self) -> bool {
224 matches!(self, DjangoFieldType::Json { list: true })
225 }
226
227 fn is_many_to_many(&self) -> bool {
228 matches!(self, DjangoFieldType::ManyToMany { .. })
229 }
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct DjangoEmitOptions {
234 pub emit_admin: bool,
235 pub filter_backend: bool,
238 pub schema_view: bool,
241}
242
243impl Default for DjangoEmitOptions {
244 fn default() -> Self {
245 Self {
246 emit_admin: true,
247 filter_backend: false,
248 schema_view: false,
249 }
250 }
251}
252
253const PYTHON_KEYWORDS: &[&str] = &[
256 "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class", "continue",
257 "def", "del", "elif", "else", "except", "finally", "for", "from", "global", "if", "import",
258 "in", "is", "lambda", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while",
259 "with", "yield",
260];
261
262const RESERVED_FIELD_NAMES: &[&str] = &["uid", "key", "attrs"];
266
267const DJANGO_RESERVED_FIELD_NAMES: &[&str] =
270 &["pk", "id", "objects", "save", "delete", "clean", "_state"];
271
272fn is_python_identifier(name: &str) -> bool {
273 let mut chars = name.chars();
274 let Some(first) = chars.next() else {
275 return false;
276 };
277 if !(first.is_ascii_alphabetic() || first == '_') {
278 return false;
279 }
280 if !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_') {
281 return false;
282 }
283 !PYTHON_KEYWORDS.contains(&name)
284}
285
286fn validate_schema_names(schema: &Schema) -> Result<()> {
291 let mut problems = Vec::new();
292 for (type_name, type_schema) in &schema.types {
293 let class_name = class_name_for_type(type_name);
294 if !is_python_identifier(&class_name) {
295 problems.push(format!(
296 "type '{type_name}' renders to class name '{class_name}', which is not a valid python identifier"
297 ));
298 }
299 let field_names = type_schema.key.keys().chain(
300 type_schema
301 .fields
302 .keys()
303 .filter(|field| !type_schema.key.contains_key(*field)),
304 );
305 for field in field_names {
306 if RESERVED_FIELD_NAMES.contains(&field.as_str()) {
307 problems.push(format!(
308 "field '{field}' of type '{type_name}' is reserved: it would shadow the generated model attribute of the same name"
309 ));
310 } else if DJANGO_RESERVED_FIELD_NAMES.contains(&field.as_str()) {
311 problems.push(format!(
312 "field '{field}' of type '{type_name}' is reserved: django already gives that name a meaning on a model"
313 ));
314 } else if !is_python_identifier(field) {
315 problems.push(format!(
316 "field '{field}' of type '{type_name}' is not a valid python identifier"
317 ));
318 } else if field.ends_with('_') {
319 problems.push(format!(
321 "field '{field}' of type '{type_name}' ends with an underscore, which django rejects"
322 ));
323 } else if field.contains("__") {
324 problems.push(format!(
325 "field '{field}' of type '{type_name}' contains a double underscore, which django rejects"
326 ));
327 }
328 }
329 }
330 if problems.is_empty() {
331 Ok(())
332 } else {
333 Err(anyhow!(
334 "cannot emit django app, invalid names in schema:\n {}",
335 problems.join("\n ")
336 ))
337 }
338}
339
340pub fn emit_django_app(
341 app_dir: &Path,
342 inventory: &Inventory,
343 options: DjangoEmitOptions,
344) -> Result<()> {
345 validate_schema_names(&inventory.schema)?;
346 fs::create_dir_all(app_dir)?;
347 let app_name = app_dir
348 .file_name()
349 .and_then(|name| name.to_str())
350 .unwrap_or("alembic_app");
351
352 let models = build_models(&inventory.schema)?;
353
354 let rendered = render_files(&models, &options);
355 fs::write(app_dir.join(GENERATED_MODELS), rendered.models)?;
356 if let Some(admin) = rendered.admin {
357 fs::write(app_dir.join(GENERATED_ADMIN), admin)?;
358 }
359 fs::write(app_dir.join(GENERATED_SERIALIZERS), rendered.serializers)?;
360 fs::write(app_dir.join(GENERATED_VIEWS), rendered.views)?;
361 fs::write(app_dir.join(GENERATED_URLS), rendered.urls)?;
362
363 let fixtures_dir = app_dir.join(FIXTURES_DIR);
364 fs::create_dir_all(&fixtures_dir)?;
365 let entries = fixture_entries(app_name, &models, &inventory.objects)?;
366 fs::write(
367 fixtures_dir.join(FIXTURE_FILE),
368 format!("{}\n", serde_json::to_string_pretty(&entries)?),
369 )?;
370
371 write_user_file(
372 app_dir.join(USER_MODELS),
373 user_models_stub(),
374 &[default_models_stub()],
375 )?;
376 if options.emit_admin {
377 write_user_file(
378 app_dir.join(USER_ADMIN),
379 user_admin_stub(),
380 &[default_admin_stub()],
381 )?;
382 }
383 write_user_file(app_dir.join(USER_SERIALIZERS), user_serializers_stub(), &[])?;
384 write_user_file(
385 app_dir.join(USER_VIEWS),
386 user_views_stub(),
387 &[default_views_stub()],
388 )?;
389 write_user_file(app_dir.join(USER_URLS), user_urls_stub(), &[])?;
390 write_if_missing(app_dir.join(USER_EXTENSIONS), user_extensions_stub())?;
391
392 Ok(())
393}
394
395fn build_models(schema: &Schema) -> Result<Vec<ModelSpec>> {
399 let mut by_class: BTreeMap<String, String> = BTreeMap::new();
400 for type_name in schema.types.keys() {
401 let class_name = class_name_for_type(type_name);
402 if class_name.is_empty() {
403 bail!("type '{type_name}' does not map to a usable django model name");
404 }
405 if let Some(previous) = by_class.insert(class_name.clone(), type_name.clone()) {
406 bail!(
407 "types '{previous}' and '{type_name}' both map to the django model \
408 '{class_name}'; rename one of them"
409 );
410 }
411 }
412
413 for (type_name, type_schema) in schema.types.iter() {
414 for (field, field_schema) in type_schema.key.iter().chain(type_schema.fields.iter()) {
415 let target = match &field_schema.r#type {
416 FieldType::Ref { target } | FieldType::ListRef { target } => target,
417 _ => continue,
418 };
419 if !schema.types.contains_key(target) {
420 bail!(
421 "{type_name}.{field} references unknown type '{target}'; \
422 the django backend can only relate to types in the same model"
423 );
424 }
425 }
426 }
427
428 let mut models = Vec::with_capacity(schema.types.len());
429 let mut endpoints: BTreeMap<String, String> = BTreeMap::new();
430 for (type_name, type_schema) in schema.types.iter() {
431 let model = model_spec_from_schema(type_name, type_schema);
432 if let Some(previous) = endpoints.insert(endpoint_for(&model), model.type_name.clone()) {
433 bail!(
434 "types '{previous}' and '{}' both map to the api route '{}'; rename one of them",
435 model.type_name,
436 endpoint_for(&model)
437 );
438 }
439 models.push(model);
440 }
441 Ok(models)
442}
443
444fn model_spec_from_schema(type_name: &str, schema: &TypeSchema) -> ModelSpec {
445 let class_name = class_name_for_type(type_name);
446 let mut fields = Vec::new();
447 let mut key_fields = Vec::new();
448
449 for (field, field_schema) in schema.key.iter() {
450 key_fields.push(field.to_string());
451 fields.push(field_spec_from_schema(field, field_schema, true));
452 }
453
454 for (field, field_schema) in schema.fields.iter() {
455 if schema.key.contains_key(field) {
456 continue;
457 }
458 fields.push(field_spec_from_schema(field, field_schema, false));
459 }
460
461 ModelSpec {
462 type_name: type_name.to_string(),
463 class_name,
464 fields,
465 key_fields,
466 }
467}
468
469struct DjangoFiles {
470 models: String,
471 admin: Option<String>,
472 serializers: String,
473 views: String,
474 urls: String,
475}
476
477fn render_files(models: &[ModelSpec], options: &DjangoEmitOptions) -> DjangoFiles {
478 let model_names: Vec<String> = models.iter().map(|m| m.class_name.clone()).collect();
479 let model_import = import_line("from .generated_models import ", &model_names);
480 let serializer_names: Vec<String> = model_names
481 .iter()
482 .map(|name| format!("{name}Serializer"))
483 .collect();
484 let serializer_import = import_line("from .generated_serializers import ", &serializer_names);
485 let view_names: Vec<String> = model_names
486 .iter()
487 .map(|name| format!("{name}ViewSet"))
488 .collect();
489 let view_import = import_line("from .generated_views import ", &view_names);
490
491 let has_field = |predicate: fn(&FieldSpec) -> bool| {
492 models
493 .iter()
494 .any(|model| model.fields.iter().any(&predicate))
495 };
496 let member_validators = has_field(|field| field.field_type.is_list());
497
498 let mut model_imports = Vec::new();
499 if has_field(|field| matches!(field.member_check, Some(MemberCheck::Regex(_)))) {
502 model_imports.push("import re".to_string());
503 }
504 model_imports.push("import uuid".to_string());
505 model_imports.push(String::new());
506 model_imports.push("from django.db import models".to_string());
507 if has_field(|field| !field.validators.is_empty()) {
508 model_imports.push("from django.core.validators import RegexValidator".to_string());
509 }
510 if member_validators {
511 model_imports.push("from django.core.exceptions import ValidationError".to_string());
512 model_imports.push("from django.utils.deconstruct import deconstructible".to_string());
513 }
514
515 let mut model_blocks = render_blocks(models, render_model_block);
516 if member_validators {
517 model_blocks = format!("{MEMBER_VALIDATOR_CLASS}{BLOCK_SEPARATOR}{model_blocks}");
518 }
519 let models_file = render_template(
520 MODELS_TEMPLATE,
521 &[
522 ("imports", model_imports.join("\n")),
523 ("models", model_blocks),
524 ],
525 );
526 let admin = if options.emit_admin {
527 Some(render_template(
528 ADMIN_TEMPLATE,
529 &[
530 ("model_import", model_import.clone()),
531 ("admins", render_blocks(models, render_admin_block)),
532 ],
533 ))
534 } else {
535 None
536 };
537 let serializers = render_template(
538 SERIALIZERS_TEMPLATE,
539 &[
540 ("model_import", model_import.clone()),
541 (
542 "serializers",
543 render_blocks(models, render_serializer_block),
544 ),
545 ],
546 );
547 let views = render_template(
548 VIEWS_TEMPLATE,
549 &[
550 ("model_import", model_import),
551 ("serializer_import", serializer_import),
552 (
553 "views",
554 render_blocks(models, |model| render_view_block(model, options)),
555 ),
556 ],
557 );
558 let (schema_import, schema_routes) = if options.schema_view {
562 (
563 "from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView\n"
564 .to_string(),
565 concat!(
566 " path(\"schema/\", SpectacularAPIView.as_view(), name=\"schema\"),\n",
567 " path(\"docs/\", SpectacularSwaggerView.as_view(url_name=\"schema\"), name=\"docs\"),\n",
568 )
569 .to_string(),
570 )
571 } else {
572 (String::new(), String::new())
573 };
574 let urls = render_template(
575 URLS_TEMPLATE,
576 &[
577 ("schema_import", schema_import),
578 ("view_import", view_import),
579 ("routes", render_routes_block(models)),
580 ("schema_routes", schema_routes),
581 ],
582 );
583
584 DjangoFiles {
585 models: models_file,
586 admin,
587 serializers,
588 views,
589 urls,
590 }
591}
592
593fn render_blocks(models: &[ModelSpec], render: impl Fn(&ModelSpec) -> String) -> String {
594 models
595 .iter()
596 .map(render)
597 .collect::<Vec<String>>()
598 .join(BLOCK_SEPARATOR)
599}
600
601fn render_field(model: &ModelSpec, field: &FieldSpec) -> String {
602 let mut args = Vec::new();
603 if let Some(choices) = &field.choices {
604 let choice_items = choices
605 .iter()
606 .map(|value| format!("({}, {})", py_str(value), py_str(value)))
607 .collect::<Vec<_>>()
608 .join(", ");
609 args.push(format!("choices=[{choice_items}]"));
610 }
611 let validators: Vec<String> = field
612 .validators
613 .iter()
614 .cloned()
615 .chain(
616 field
617 .field_type
618 .is_list()
619 .then(|| render_member_check(field.member_check.as_ref())),
620 )
621 .collect();
622 if !validators.is_empty() {
623 args.push(format!("validators=[{}]", validators.join(", ")));
624 }
625 if let Some(help_text) = &field.help_text {
626 args.push(format!("help_text={}", py_str(help_text)));
627 }
628
629 let optional = !field.required;
630 if optional {
631 args.push("blank=True".to_string());
632 }
633 let nullable = field.nullable || (optional && !field.field_type.is_textual());
636 if nullable && !field.field_type.is_json() && !field.field_type.is_many_to_many() {
637 args.push("null=True".to_string());
638 }
639
640 let mut leading = Vec::new();
641 let mut trailing = Vec::new();
642 let django_type = match &field.field_type {
643 DjangoFieldType::Char => {
644 leading.push("max_length=255".to_string());
645 "CharField"
646 }
647 DjangoFieldType::Text => "TextField",
648 DjangoFieldType::Integer => "IntegerField",
649 DjangoFieldType::Float => "FloatField",
650 DjangoFieldType::Boolean => "BooleanField",
651 DjangoFieldType::Uuid => "UUIDField",
652 DjangoFieldType::Date => "DateField",
653 DjangoFieldType::DateTime => "DateTimeField",
654 DjangoFieldType::Time => "TimeField",
655 DjangoFieldType::Json { list } => {
656 if optional {
657 trailing.push(format!("default={}", if *list { "list" } else { "dict" }));
658 }
659 "JSONField"
660 }
661 DjangoFieldType::Slug => "SlugField",
662 DjangoFieldType::IpAddress => "GenericIPAddressField",
663 DjangoFieldType::ForeignKey { target } => {
664 leading.push(py_str(target));
665 leading.push("on_delete=models.PROTECT".to_string());
666 leading.push(related_name_arg(model, field));
667 "ForeignKey"
668 }
669 DjangoFieldType::ManyToMany { target } => {
670 leading.push(py_str(target));
671 leading.push(related_name_arg(model, field));
672 "ManyToManyField"
673 }
674 };
675
676 leading.extend(args);
677 leading.extend(trailing);
678 format!(
679 "{} = models.{}({})",
680 field.name,
681 django_type,
682 leading.join(", ")
683 )
684}
685
686fn related_name_arg(model: &ModelSpec, field: &FieldSpec) -> String {
690 format!(
691 "related_name={}",
692 py_str(&format!(
693 "{}_{}",
694 model.class_name.to_lowercase(),
695 field.name
696 ))
697 )
698}
699
700fn render_model_block(model: &ModelSpec) -> String {
701 let mut fields = Vec::with_capacity(model.fields.len() + 3);
702 fields.push(
703 "uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)".to_string(),
704 );
705 fields.push("key = models.TextField()".to_string());
706 fields.push("attrs = models.JSONField(default=dict, blank=True)".to_string());
707 for field in &model.fields {
708 fields.push(render_field(model, field));
709 }
710
711 let mut lines = vec![
712 format!("class {}(models.Model):", model.class_name),
713 format!(" {}", fields.join("\n ")),
714 String::new(),
715 " class Meta:".to_string(),
716 " ordering = [\"key\"]".to_string(),
719 format!(" verbose_name = {}", py_str(&model.type_name)),
720 format!(
721 " verbose_name_plural = {}",
722 py_str(&pluralize(&model.type_name))
723 ),
724 ];
725
726 if !model.key_fields.is_empty() {
727 let unique_fields = model
728 .key_fields
729 .iter()
730 .map(|field| py_str(field))
731 .collect::<Vec<_>>()
732 .join(", ");
733 lines.push(format!(
734 " constraints = [models.UniqueConstraint(fields=[{unique_fields}], name=\"{}_key\")]",
735 model.class_name.to_lowercase()
736 ));
737 }
738
739 lines.push(String::new());
740 lines.push(" def __str__(self):".to_string());
741 if model.key_fields.is_empty() {
746 lines.push(" return self.key".to_string());
747 } else {
748 let parts = model
749 .key_fields
750 .iter()
751 .map(|field| format!("{{self.{field}}}"))
752 .collect::<Vec<_>>()
753 .join(" / ");
754 lines.push(format!(" return f\"{parts}\""));
755 }
756
757 lines.join("\n")
758}
759
760fn render_admin_block(model: &ModelSpec) -> String {
761 let list_display = admin_list_display(model);
762 let list_filter = admin_list_filter(model);
763 let related: Vec<&str> = model
764 .relation_fields()
765 .map(|field| field.name.as_str())
766 .collect();
767 let mut lines = vec![
768 format!(
769 "@admin.register({})\nclass {}Admin(admin.ModelAdmin):",
770 model.class_name, model.class_name
771 ),
772 format!(" list_display = [{}]", join_quoted(&list_display)),
773 format!(
774 " search_fields = [{}]",
775 join_quoted(&text_search_fields(model))
776 ),
777 ];
778 if !list_filter.is_empty() {
779 lines.push(format!(" list_filter = [{}]", join_quoted(&list_filter)));
780 }
781 if !related.is_empty() {
782 lines.push(format!(
784 " list_select_related = [{}]",
785 join_quoted(&related)
786 ));
787 }
788 lines.join("\n")
789}
790
791fn render_serializer_block(model: &ModelSpec) -> String {
792 let fields = serializer_fields(model);
793 format!(
794 "class {}Serializer(serializers.ModelSerializer):\n class Meta:\n model = {}\n fields = [{}]",
795 model.class_name,
796 model.class_name,
797 join_quoted(&fields)
798 )
799}
800
801fn render_view_block(model: &ModelSpec, options: &DjangoEmitOptions) -> String {
802 let prefetch: Vec<&str> = model
803 .many_to_many_fields()
804 .map(|field| field.name.as_str())
805 .collect();
806 let queryset = if prefetch.is_empty() {
807 format!("{}.objects.all()", model.class_name)
808 } else {
809 format!(
810 "{}.objects.all().prefetch_related({})",
811 model.class_name,
812 join_quoted(&prefetch)
813 )
814 };
815
816 let mut lines = vec![
817 format!("class {}ViewSet(viewsets.ModelViewSet):", model.class_name),
818 format!(" queryset = {queryset}"),
819 format!(" serializer_class = {}Serializer", model.class_name),
820 ];
821 if options.filter_backend {
822 lines.push(format!(
823 " filterset_fields = [{}]",
824 join_quoted(&filterset_fields(model))
825 ));
826 }
827 lines.push(format!(
828 " search_fields = [{}]",
829 join_quoted(&text_search_fields(model))
830 ));
831 lines.push(format!(
832 " ordering_fields = [{}]",
833 join_quoted(&ordering_fields(model))
834 ));
835 lines.push(" ordering = [\"key\"]".to_string());
836 lines.join("\n")
837}
838
839fn endpoint_for(model: &ModelSpec) -> String {
840 pluralize(model.class_name.to_lowercase().as_str())
841}
842
843fn render_routes_block(models: &[ModelSpec]) -> String {
844 models
845 .iter()
846 .map(|model| {
847 format!(
848 "router.register(\"{}\", {}ViewSet)",
849 endpoint_for(model),
850 model.class_name
851 )
852 })
853 .collect::<Vec<String>>()
854 .join("\n")
855}
856
857fn py_str(value: &str) -> String {
860 let mut out = String::with_capacity(value.len() + 2);
861 out.push('"');
862 for ch in value.chars() {
863 match ch {
864 '\\' => out.push_str("\\\\"),
865 '"' => out.push_str("\\\""),
866 '\n' => out.push_str("\\n"),
867 '\r' => out.push_str("\\r"),
868 '\t' => out.push_str("\\t"),
869 ch if (ch as u32) < 0x20 => out.push_str(&format!("\\x{:02x}", ch as u32)),
870 ch => out.push(ch),
871 }
872 }
873 out.push('"');
874 out
875}
876
877fn join_quoted(fields: &[&str]) -> String {
878 fields
879 .iter()
880 .map(|field| py_str(field))
881 .collect::<Vec<String>>()
882 .join(", ")
883}
884
885fn import_line<T: AsRef<str>>(prefix: &str, names: &[T]) -> String {
886 if names.is_empty() {
887 String::new()
888 } else {
889 format!(
890 "{prefix}{}",
891 names
892 .iter()
893 .map(|name| name.as_ref())
894 .collect::<Vec<&str>>()
895 .join(", ")
896 )
897 }
898}
899
900fn admin_list_display(model: &ModelSpec) -> Vec<&str> {
901 let mut fields = vec!["__str__"];
904 for field in &model.fields {
905 if field.field_type.is_many_to_many()
908 || field.field_type.is_json()
909 || matches!(field.field_type, DjangoFieldType::Text)
910 {
911 continue;
912 }
913 fields.push(field.name.as_str());
914 }
915 fields.push("uid");
916 fields
917}
918
919fn text_search_fields(model: &ModelSpec) -> Vec<&str> {
922 let mut fields = vec!["key"];
923 for field in &model.fields {
924 if field.field_type.is_textual() {
925 fields.push(field.name.as_str());
926 }
927 }
928 fields
929}
930
931fn admin_list_filter(model: &ModelSpec) -> Vec<&str> {
932 model
933 .fields
934 .iter()
935 .filter(|field| {
936 matches!(field.field_type, DjangoFieldType::Boolean) || field.choices.is_some()
937 })
938 .map(|field| field.name.as_str())
939 .collect()
940}
941
942fn filterset_fields(model: &ModelSpec) -> Vec<&str> {
945 let mut fields = vec!["key", "uid"];
946 for field in &model.fields {
947 if field.field_type.is_json() || matches!(field.field_type, DjangoFieldType::Text) {
948 continue;
949 }
950 fields.push(field.name.as_str());
951 }
952 fields
953}
954
955fn ordering_fields(model: &ModelSpec) -> Vec<&str> {
956 let mut fields = vec!["key", "uid"];
957 for field in &model.fields {
958 if field.field_type.is_json() || field.field_type.is_many_to_many() {
959 continue;
960 }
961 fields.push(field.name.as_str());
962 }
963 fields
964}
965
966fn serializer_fields(model: &ModelSpec) -> Vec<&str> {
967 let mut fields = vec!["uid", "key", "attrs"];
968 for field in &model.fields {
969 fields.push(field.name.as_str());
970 }
971 fields
972}
973
974fn fixture_value(spec: &FieldSpec, value: &Value) -> Value {
982 match (&spec.field_type, value) {
983 (DjangoFieldType::DateTime, Value::String(raw)) => {
984 let mut out = raw.clone();
986 if out.as_bytes().get(10) == Some(&b't') {
987 out.replace_range(10..11, "T");
988 }
989 Value::String(out)
990 }
991 _ => value.clone(),
992 }
993}
994
995fn fixture_entries(app_name: &str, models: &[ModelSpec], objects: &[Object]) -> Result<Vec<Value>> {
998 let by_type: BTreeMap<&str, &ModelSpec> = models
999 .iter()
1000 .map(|model| (model.type_name.as_str(), model))
1001 .collect();
1002
1003 let mut entries: Vec<(String, String, Value)> = Vec::with_capacity(objects.len());
1004 for object in objects {
1005 let type_name = object.type_name.as_str();
1006 let model = by_type.get(type_name).ok_or_else(|| {
1007 anyhow!(
1008 "object {} has type '{type_name}' which is not in the schema",
1009 object.uid
1010 )
1011 })?;
1012
1013 let mut fields = Map::new();
1014 for spec in &model.fields {
1015 let value = object
1016 .attrs
1017 .get(&spec.name)
1018 .or_else(|| object.key.get(&spec.name));
1019 match value {
1020 Some(Value::Null) | None => {}
1021 Some(value) => {
1022 fields.insert(spec.name.clone(), fixture_value(spec, value));
1023 }
1024 }
1025 }
1026 let leftovers: Map<String, Value> = object
1029 .attrs
1030 .iter()
1031 .filter(|(name, _)| !model.fields.iter().any(|spec| &spec.name == *name))
1032 .map(|(name, value)| (name.clone(), value.clone()))
1033 .collect();
1034 fields.insert("key".to_string(), Value::String(key_string(&object.key)));
1035 fields.insert("attrs".to_string(), Value::Object(leftovers));
1036
1037 let model_label = format!("{app_name}.{}", model.class_name.to_lowercase());
1038 let pk = object.uid.to_string();
1039 entries.push((
1040 model_label.clone(),
1041 pk.clone(),
1042 json!({"model": model_label, "pk": pk, "fields": Value::Object(fields)}),
1043 ));
1044 }
1045
1046 entries.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1)));
1047 Ok(entries.into_iter().map(|(_, _, entry)| entry).collect())
1048}
1049
1050fn class_name_for_type(type_name: &str) -> String {
1051 type_name
1052 .split('.')
1053 .map(|segment| {
1054 segment
1055 .split('_')
1056 .filter(|s| !s.is_empty())
1057 .map(|part| {
1058 let mut chars = part.chars();
1059 match chars.next() {
1060 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1061 None => String::new(),
1062 }
1063 })
1064 .collect::<String>()
1065 })
1066 .collect::<String>()
1067}
1068
1069fn field_spec_from_schema(
1070 name: &str,
1071 schema: &alembic_core::FieldSchema,
1072 is_key: bool,
1073) -> FieldSpec {
1074 let mut validators = Vec::new();
1075 let mut choices = None;
1076 let mut member_check = None;
1077
1078 if let Some(format) = &schema.format {
1079 validators.push(format_validator(format));
1080 }
1081 if let Some(pattern) = &schema.pattern {
1082 validators.push(format!("RegexValidator({})", py_str(pattern)));
1083 }
1084 let declared = schema.format.is_some() || schema.pattern.is_some();
1088
1089 let field_type = match &schema.r#type {
1090 FieldType::String => DjangoFieldType::Char,
1091 FieldType::Text => DjangoFieldType::Text,
1092 FieldType::Int => DjangoFieldType::Integer,
1093 FieldType::Float => DjangoFieldType::Float,
1094 FieldType::Bool => DjangoFieldType::Boolean,
1095 FieldType::Uuid => DjangoFieldType::Uuid,
1096 FieldType::Date => DjangoFieldType::Date,
1097 FieldType::Datetime => DjangoFieldType::DateTime,
1098 FieldType::Time => DjangoFieldType::Time,
1099 FieldType::Json => DjangoFieldType::Json { list: false },
1100 FieldType::IpAddress => DjangoFieldType::IpAddress,
1101 FieldType::Cidr | FieldType::Prefix | FieldType::Mac => {
1102 if !declared {
1103 validators.extend(
1104 alembic_core::format_for_field_type(&schema.r#type)
1105 .as_ref()
1106 .map(format_validator),
1107 );
1108 }
1109 DjangoFieldType::Char
1110 }
1111 FieldType::Slug => DjangoFieldType::Slug,
1112 FieldType::Enum { values } => {
1113 choices = Some(values.clone());
1114 DjangoFieldType::Char
1115 }
1116 FieldType::List { item } => {
1117 member_check = member_check_for(item);
1118 DjangoFieldType::Json { list: true }
1119 }
1120 FieldType::Map { .. } => DjangoFieldType::Json { list: false },
1121 FieldType::Ref { target } => DjangoFieldType::ForeignKey {
1122 target: class_name_for_type(target),
1123 },
1124 FieldType::ListRef { target } => DjangoFieldType::ManyToMany {
1125 target: class_name_for_type(target),
1126 },
1127 };
1128
1129 let required = schema.required || is_key;
1130 let nullable = schema.nullable && !is_key;
1131
1132 FieldSpec {
1133 name: name.to_string(),
1134 field_type,
1135 required,
1136 nullable,
1137 choices,
1138 validators,
1139 member_check,
1140 help_text: schema.description.clone(),
1141 }
1142}
1143
1144#[derive(Debug, Clone)]
1148enum MemberCheck {
1149 Choices(Vec<String>),
1150 Regex(&'static str),
1151 Kind(&'static str),
1152}
1153
1154fn member_check_for(item: &FieldType) -> Option<MemberCheck> {
1155 match item {
1156 FieldType::Enum { values } => Some(MemberCheck::Choices(values.clone())),
1157 FieldType::Uuid
1158 | FieldType::Cidr
1159 | FieldType::Prefix
1160 | FieldType::Mac
1161 | FieldType::Slug => alembic_core::format_for_field_type(item)
1162 .map(|format| MemberCheck::Regex(alembic_core::format_regex(&format))),
1163 FieldType::String | FieldType::Text | FieldType::IpAddress => {
1166 Some(MemberCheck::Kind("str"))
1167 }
1168 FieldType::Int => Some(MemberCheck::Kind("int")),
1169 FieldType::Float => Some(MemberCheck::Kind("float")),
1170 FieldType::Bool => Some(MemberCheck::Kind("bool")),
1171 FieldType::List { .. } | FieldType::ListRef { .. } => Some(MemberCheck::Kind("list")),
1175 FieldType::Map { .. } => Some(MemberCheck::Kind("map")),
1176 FieldType::Ref { .. } => Some(MemberCheck::Regex(alembic_core::format_regex(
1179 &FieldFormat::Uuid,
1180 ))),
1181 FieldType::Date | FieldType::Datetime | FieldType::Time => None,
1186 FieldType::Json => None,
1188 }
1189}
1190
1191fn render_member_check(check: Option<&MemberCheck>) -> String {
1192 match check {
1193 None => "_ListMembers()".to_string(),
1197 Some(MemberCheck::Choices(values)) => format!(
1198 "_ListMembers(choices=[{}])",
1199 values
1200 .iter()
1201 .map(|value| py_str(value))
1202 .collect::<Vec<_>>()
1203 .join(", ")
1204 ),
1205 Some(MemberCheck::Regex(pattern)) => format!("_ListMembers(regex={})", py_str(pattern)),
1206 Some(MemberCheck::Kind(kind)) => format!("_ListMembers(kind={})", py_str(kind)),
1207 }
1208}
1209
1210fn format_validator(format: &FieldFormat) -> String {
1211 format!(
1212 "RegexValidator({})",
1213 py_str(alembic_core::format_regex(format))
1214 )
1215}
1216
1217fn write_if_missing(path: impl AsRef<Path>, contents: &str) -> Result<()> {
1218 let path = path.as_ref();
1219 if path
1220 .try_exists()
1221 .with_context(|| format!("check {}", path.display()))?
1222 {
1223 return Ok(());
1224 }
1225 fs::write(path, contents).with_context(|| format!("write {}", path.display()))?;
1226 Ok(())
1227}
1228
1229fn render_template(template: &str, vars: &[(&str, String)]) -> String {
1230 let mut output = template.to_string();
1231 for (key, value) in vars {
1232 let token = format!("{{{{{key}}}}}");
1233 output = output.replace(&token, value);
1234 }
1235 output
1236}
1237
1238fn write_user_file(path: impl AsRef<Path>, contents: &str, defaults: &[&str]) -> Result<()> {
1239 let path = path.as_ref();
1240 if path
1241 .try_exists()
1242 .with_context(|| format!("check {}", path.display()))?
1243 {
1244 let existing =
1245 fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1246 let normalized = existing.trim().replace("\r\n", "\n");
1247 let is_default = defaults
1248 .iter()
1249 .any(|candidate| candidate.trim().replace("\r\n", "\n") == normalized);
1250 if !is_default {
1251 return Ok(());
1252 }
1253 }
1254 fs::write(path, contents).with_context(|| format!("write {}", path.display()))?;
1255 Ok(())
1256}
1257
1258fn user_models_stub() -> &'static str {
1259 "from .generated_models import * # noqa: F401,F403\nfrom .extensions import * # noqa: F401,F403\n"
1260}
1261
1262fn user_admin_stub() -> &'static str {
1263 "from .generated_admin import * # noqa: F401,F403\nfrom .extensions import * # noqa: F401,F403\n"
1264}
1265
1266fn user_serializers_stub() -> &'static str {
1267 "from .generated_serializers import * # noqa: F401,F403\nfrom .extensions import * # noqa: F401,F403\n"
1268}
1269
1270fn user_views_stub() -> &'static str {
1271 "from .generated_views import * # noqa: F401,F403\nfrom .extensions import * # noqa: F401,F403\n"
1272}
1273
1274fn user_urls_stub() -> &'static str {
1275 "from .generated_urls import * # noqa: F401,F403\n"
1276}
1277
1278fn user_extensions_stub() -> &'static str {
1279 "# User extension hooks live here.\n"
1280}
1281
1282fn default_models_stub() -> &'static str {
1283 "from django.db import models\n\n# Create your models here.\n"
1284}
1285
1286fn default_admin_stub() -> &'static str {
1287 "from django.contrib import admin\n\n# Register your models here.\n"
1288}
1289
1290fn default_views_stub() -> &'static str {
1291 "from django.shortcuts import render\n\n# Create your views here.\n"
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296 use super::*;
1297 use alembic_core::{FieldSchema, JsonMap, TypeName};
1298 use serde_json::json;
1299 use tempfile::tempdir;
1300 use uuid::Uuid;
1301
1302 fn attrs_map(pairs: Vec<(&str, Value)>) -> JsonMap {
1303 JsonMap::from(
1304 pairs
1305 .into_iter()
1306 .map(|(key, value)| (key.to_string(), value))
1307 .collect::<BTreeMap<_, _>>(),
1308 )
1309 }
1310
1311 fn key_str(raw: &str) -> alembic_core::Key {
1312 let mut map = BTreeMap::new();
1313 for segment in raw.split('/') {
1314 let (field, value) = segment
1315 .split_once('=')
1316 .unwrap_or_else(|| panic!("invalid key segment: {segment}"));
1317 map.insert(field.to_string(), Value::String(value.to_string()));
1318 }
1319 alembic_core::Key::from(map)
1320 }
1321
1322 fn obj(uid: u128, type_name: &str, key: &str, attrs: JsonMap) -> Object {
1323 Object::new(
1324 Uuid::from_u128(uid),
1325 TypeName::new(type_name),
1326 key_str(key),
1327 attrs,
1328 )
1329 .unwrap()
1330 }
1331
1332 fn field(r#type: FieldType) -> FieldSchema {
1333 FieldSchema {
1334 r#type,
1335 required: true,
1336 nullable: false,
1337 description: None,
1338 format: None,
1339 pattern: None,
1340 }
1341 }
1342
1343 fn optional(r#type: FieldType) -> FieldSchema {
1344 FieldSchema {
1345 required: false,
1346 ..field(r#type)
1347 }
1348 }
1349
1350 fn type_schema(key: Vec<(&str, FieldSchema)>, fields: Vec<(&str, FieldSchema)>) -> TypeSchema {
1351 TypeSchema {
1352 key: key
1353 .into_iter()
1354 .map(|(name, schema)| (name.to_string(), schema))
1355 .collect(),
1356 fields: fields
1357 .into_iter()
1358 .map(|(name, schema)| (name.to_string(), schema))
1359 .collect(),
1360 }
1361 }
1362
1363 fn schema_of(types: Vec<(&str, TypeSchema)>) -> Schema {
1364 Schema {
1365 types: types
1366 .into_iter()
1367 .map(|(name, schema)| (name.to_string(), schema))
1368 .collect(),
1369 }
1370 }
1371
1372 fn test_schema() -> Schema {
1373 schema_of(vec![
1374 (
1375 "dcim.site",
1376 type_schema(
1377 vec![("slug", field(FieldType::Slug))],
1378 vec![
1379 ("name", field(FieldType::String)),
1380 ("slug", field(FieldType::Slug)),
1381 ],
1382 ),
1383 ),
1384 (
1385 "dcim.device",
1386 type_schema(
1387 vec![("name", field(FieldType::Slug))],
1388 vec![
1389 ("name", field(FieldType::String)),
1390 (
1391 "site",
1392 field(FieldType::Ref {
1393 target: "dcim.site".to_string(),
1394 }),
1395 ),
1396 ("role", field(FieldType::String)),
1397 ("device_type", field(FieldType::String)),
1398 ],
1399 ),
1400 ),
1401 (
1402 "dcim.interface",
1403 type_schema(
1404 vec![("name", field(FieldType::Slug))],
1405 vec![
1406 ("name", field(FieldType::String)),
1407 (
1408 "device",
1409 field(FieldType::Ref {
1410 target: "dcim.device".to_string(),
1411 }),
1412 ),
1413 ],
1414 ),
1415 ),
1416 ])
1417 }
1418
1419 fn sample_inventory() -> Inventory {
1420 let objects = vec![
1421 obj(
1422 1,
1423 "dcim.device",
1424 "name=leaf01",
1425 attrs_map(vec![
1426 ("name", json!("leaf01")),
1427 ("site", json!(Uuid::from_u128(2).to_string())),
1428 ("role", json!("leaf")),
1429 ("device_type", json!("leaf-switch")),
1430 ("unmodelled", json!("kept in attrs")),
1431 ]),
1432 ),
1433 obj(
1434 2,
1435 "dcim.site",
1436 "slug=fra1",
1437 attrs_map(vec![("name", json!("FRA1")), ("slug", json!("fra1"))]),
1438 ),
1439 obj(
1440 3,
1441 "dcim.interface",
1442 "name=eth0",
1443 attrs_map(vec![
1444 ("name", json!("eth0")),
1445 ("device", json!(Uuid::from_u128(1).to_string())),
1446 ]),
1447 ),
1448 ];
1449 Inventory {
1450 scope: None,
1451 schema: test_schema(),
1452 objects,
1453 }
1454 }
1455
1456 fn emit_to_temp(inventory: &Inventory) -> tempfile::TempDir {
1457 let dir = tempdir().unwrap();
1458 emit_django_app(dir.path(), inventory, DjangoEmitOptions::default()).unwrap();
1459 dir
1460 }
1461
1462 fn generated(dir: &tempfile::TempDir, name: &str) -> String {
1463 fs::read_to_string(dir.path().join(name)).unwrap()
1464 }
1465
1466 #[test]
1467 fn emit_django_app_writes_files_and_stubs() {
1468 let dir = emit_to_temp(&sample_inventory());
1469
1470 assert!(dir.path().join(GENERATED_MODELS).exists());
1471 assert!(dir.path().join(GENERATED_ADMIN).exists());
1472 assert!(dir.path().join(GENERATED_SERIALIZERS).exists());
1473 assert!(dir.path().join(GENERATED_VIEWS).exists());
1474 assert!(dir.path().join(GENERATED_URLS).exists());
1475 assert!(dir.path().join(USER_MODELS).exists());
1476 assert!(dir.path().join(USER_ADMIN).exists());
1477 assert!(dir.path().join(USER_SERIALIZERS).exists());
1478 assert!(dir.path().join(USER_VIEWS).exists());
1479 assert!(dir.path().join(USER_URLS).exists());
1480 assert!(dir.path().join(USER_EXTENSIONS).exists());
1481 assert!(dir.path().join(FIXTURES_DIR).join(FIXTURE_FILE).exists());
1482
1483 let models = generated(&dir, GENERATED_MODELS);
1484 assert!(models.contains("class DcimSite"));
1485 assert!(models.contains(
1486 "site = models.ForeignKey(\"DcimSite\", on_delete=models.PROTECT, related_name=\"dcimdevice_site\")"
1487 ));
1488 assert!(models.contains(
1489 "device = models.ForeignKey(\"DcimDevice\", on_delete=models.PROTECT, related_name=\"dciminterface_device\")"
1490 ));
1491 assert!(models.contains("attrs = models.JSONField"));
1492 }
1493
1494 #[test]
1495 fn uid_defaults_so_the_api_can_create_objects() {
1496 let models = generated(&emit_to_temp(&sample_inventory()), GENERATED_MODELS);
1499 assert!(models.contains("import uuid"));
1500 assert!(models.contains(
1501 "uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)"
1502 ));
1503 }
1504
1505 #[test]
1506 fn optional_non_text_fields_are_nullable() {
1507 let inventory = Inventory {
1510 scope: None,
1511 schema: schema_of(vec![(
1512 "dcim.site",
1513 type_schema(
1514 vec![("slug", field(FieldType::Slug))],
1515 vec![
1516 ("active", optional(FieldType::Bool)),
1517 ("created", optional(FieldType::Datetime)),
1518 ("mgmt_ip", optional(FieldType::IpAddress)),
1519 ("name", optional(FieldType::String)),
1520 ("meta", optional(FieldType::Json)),
1521 (
1522 "tags",
1523 optional(FieldType::List {
1524 item: Box::new(FieldType::String),
1525 }),
1526 ),
1527 ],
1528 ),
1529 )]),
1530 objects: vec![],
1531 };
1532 let models = generated(&emit_to_temp(&inventory), GENERATED_MODELS);
1533
1534 assert!(models.contains("active = models.BooleanField(blank=True, null=True)"));
1535 assert!(models.contains("created = models.DateTimeField(blank=True, null=True)"));
1536 assert!(models.contains("mgmt_ip = models.GenericIPAddressField(blank=True, null=True)"));
1537 assert!(models.contains("name = models.CharField(max_length=255, blank=True)"));
1539 assert!(models.contains("meta = models.JSONField(blank=True, default=dict)"));
1541 assert!(models.contains(
1542 "tags = models.JSONField(validators=[_ListMembers(kind=\"str\")], blank=True, default=list)"
1543 ));
1544 }
1545
1546 #[test]
1547 fn many_to_many_is_never_nullable() {
1548 let inventory = Inventory {
1550 scope: None,
1551 schema: schema_of(vec![
1552 (
1553 "dcim.tag",
1554 type_schema(vec![("name", field(FieldType::Slug))], vec![]),
1555 ),
1556 (
1557 "dcim.device",
1558 type_schema(
1559 vec![("name", field(FieldType::Slug))],
1560 vec![(
1561 "tags",
1562 optional(FieldType::ListRef {
1563 target: "dcim.tag".to_string(),
1564 }),
1565 )],
1566 ),
1567 ),
1568 ]),
1569 objects: vec![],
1570 };
1571 let models = generated(&emit_to_temp(&inventory), GENERATED_MODELS);
1572 assert!(models.contains(
1573 "tags = models.ManyToManyField(\"DcimTag\", related_name=\"dcimdevice_tags\", blank=True)"
1574 ));
1575 }
1576
1577 fn list_field_models(item: FieldType) -> String {
1578 let inventory = Inventory {
1579 scope: None,
1580 schema: schema_of(vec![(
1581 "dcim.interface",
1582 type_schema(
1583 vec![("name", field(FieldType::Slug))],
1584 vec![(
1585 "members",
1586 field(FieldType::List {
1587 item: Box::new(item),
1588 }),
1589 )],
1590 ),
1591 )]),
1592 objects: vec![],
1593 };
1594 generated(&emit_to_temp(&inventory), GENERATED_MODELS)
1595 }
1596
1597 #[test]
1598 fn list_of_enum_carries_its_declared_members() {
1599 let models = list_field_models(FieldType::Enum {
1600 values: vec!["access".to_string(), "trunk".to_string()],
1601 });
1602 assert!(
1603 models.contains(
1604 "members = models.JSONField(validators=[_ListMembers(choices=[\"access\", \"trunk\"])])"
1605 ),
1606 "{models}"
1607 );
1608 assert!(models.contains("class _ListMembers:"), "{models}");
1609 assert!(
1610 models.contains("from django.utils.deconstruct import deconstructible"),
1611 "{models}"
1612 );
1613 assert!(!models.contains("import RegexValidator"), "{models}");
1616 assert!(!models.contains("import re"), "{models}");
1617 }
1618
1619 #[test]
1620 fn list_of_format_typed_items_carries_the_format_regex() {
1621 let models = list_field_models(FieldType::Mac);
1622 assert!(
1623 models.contains(&format!(
1624 "members = models.JSONField(validators=[_ListMembers(regex={})])",
1625 py_str(alembic_core::format_regex(&FieldFormat::Mac))
1626 )),
1627 "{models}"
1628 );
1629 assert!(models.contains("import re"), "{models}");
1630 }
1631
1632 #[test]
1633 fn list_of_a_primitive_carries_the_type_core_checks_for() {
1634 for (item, kind) in [
1635 (FieldType::String, "str"),
1636 (FieldType::Text, "str"),
1637 (FieldType::IpAddress, "str"),
1638 (FieldType::Int, "int"),
1639 (FieldType::Float, "float"),
1640 (FieldType::Bool, "bool"),
1641 ] {
1642 let models = list_field_models(item);
1643 assert!(
1644 models.contains(&format!(
1645 "members = models.JSONField(validators=[_ListMembers(kind=\"{kind}\")])"
1646 )),
1647 "{models}"
1648 );
1649 }
1650 }
1651
1652 #[test]
1653 fn list_of_a_nested_collection_carries_its_outer_shape() {
1654 for (item, kind) in [
1657 (
1658 FieldType::List {
1659 item: Box::new(FieldType::Int),
1660 },
1661 "list",
1662 ),
1663 (
1664 FieldType::ListRef {
1665 target: "dcim.interface".to_string(),
1666 },
1667 "list",
1668 ),
1669 (
1670 FieldType::Map {
1671 value: Box::new(FieldType::Int),
1672 },
1673 "map",
1674 ),
1675 ] {
1676 let models = list_field_models(item);
1677 assert!(
1678 models.contains(&format!(
1679 "members = models.JSONField(validators=[_ListMembers(kind=\"{kind}\")])"
1680 )),
1681 "{models}"
1682 );
1683 }
1684 }
1685
1686 #[test]
1687 fn list_of_refs_carries_the_uuid_regex() {
1688 let models = list_field_models(FieldType::Ref {
1691 target: "dcim.interface".to_string(),
1692 });
1693 assert!(
1694 models.contains(&format!(
1695 "members = models.JSONField(validators=[_ListMembers(regex={})])",
1696 py_str(alembic_core::format_regex(&FieldFormat::Uuid))
1697 )),
1698 "{models}"
1699 );
1700 }
1701
1702 #[test]
1703 fn list_of_an_uncheckable_element_still_carries_the_outer_shape() {
1704 for item in [
1709 FieldType::Date,
1710 FieldType::Datetime,
1711 FieldType::Time,
1712 FieldType::Json,
1713 ] {
1714 let models = list_field_models(item);
1715 assert!(
1716 models.contains("members = models.JSONField(validators=[_ListMembers()])"),
1717 "{models}"
1718 );
1719 assert!(!models.contains("import re"), "{models}");
1720 }
1721 }
1722
1723 #[test]
1724 fn map_values_carry_no_member_check() {
1725 let inventory = Inventory {
1727 scope: None,
1728 schema: schema_of(vec![(
1729 "dcim.interface",
1730 type_schema(
1731 vec![("name", field(FieldType::Slug))],
1732 vec![(
1733 "labels",
1734 field(FieldType::Map {
1735 value: Box::new(FieldType::Enum {
1736 values: vec!["access".to_string()],
1737 }),
1738 }),
1739 )],
1740 ),
1741 )]),
1742 objects: vec![],
1743 };
1744 let models = generated(&emit_to_temp(&inventory), GENERATED_MODELS);
1745 assert!(models.contains("labels = models.JSONField()"), "{models}");
1746 assert!(!models.contains("_ListMembers"), "{models}");
1747 }
1748
1749 fn core_accepts_member(item: &FieldType, member: &Value) -> bool {
1751 core_accepts(
1752 field(FieldType::List {
1753 item: Box::new(item.clone()),
1754 }),
1755 json!([member]),
1756 )
1757 }
1758
1759 fn core_accepts(schema: FieldSchema, value: Value) -> bool {
1760 let inventory = Inventory {
1761 scope: None,
1762 schema: schema_of(vec![(
1763 "dcim.interface",
1764 type_schema(
1765 vec![("name", field(FieldType::Slug))],
1766 vec![("members", schema)],
1767 ),
1768 )]),
1769 objects: vec![obj(
1770 1,
1771 "dcim.interface",
1772 "name=eth0",
1773 attrs_map(vec![("members", value)]),
1774 )],
1775 };
1776 alembic_core::validate_inventory(&inventory)
1777 .errors
1778 .is_empty()
1779 }
1780
1781 fn check_accepts_member(check: &Option<MemberCheck>, member: &Value) -> bool {
1787 match check {
1788 None => true,
1789 Some(MemberCheck::Choices(values)) => member
1790 .as_str()
1791 .is_some_and(|raw| values.iter().any(|value| value == raw)),
1792 Some(MemberCheck::Regex(pattern)) => {
1793 let mut schema = field(FieldType::String);
1794 schema.pattern = Some((*pattern).to_string());
1795 core_accepts(schema, member.clone())
1796 }
1797 Some(MemberCheck::Kind(kind)) => match *kind {
1798 "str" => member.is_string(),
1799 "int" => member.is_i64() || member.is_u64(),
1800 "float" => member.is_number(),
1801 "bool" => member.is_boolean(),
1802 "list" => member.is_array(),
1803 "map" => member.is_object(),
1804 other => panic!("no member kind {other}"),
1805 },
1806 }
1807 }
1808
1809 #[test]
1810 fn member_check_never_rejects_what_core_accepts() {
1811 let items = vec![
1812 FieldType::Enum {
1813 values: vec!["access".to_string(), "trunk".to_string()],
1814 },
1815 FieldType::Mac,
1816 FieldType::Cidr,
1817 FieldType::Prefix,
1818 FieldType::Uuid,
1819 FieldType::Slug,
1820 FieldType::IpAddress,
1821 FieldType::String,
1822 FieldType::Text,
1823 FieldType::Int,
1824 FieldType::Float,
1825 FieldType::Bool,
1826 FieldType::Date,
1827 FieldType::Datetime,
1828 FieldType::Time,
1829 FieldType::Json,
1830 FieldType::List {
1831 item: Box::new(FieldType::Int),
1832 },
1833 FieldType::Map {
1834 value: Box::new(FieldType::Int),
1835 },
1836 FieldType::Ref {
1837 target: "dcim.interface".to_string(),
1838 },
1839 FieldType::ListRef {
1840 target: "dcim.interface".to_string(),
1841 },
1842 ];
1843 let members = vec![
1844 json!("access"),
1845 json!("trunk"),
1846 json!("ACCESS"),
1847 json!("bogus"),
1848 json!(""),
1849 json!("aa:bb:cc:dd:ee:ff"),
1850 json!("AA-BB-CC-DD-EE-FF"),
1851 json!("aabbccddeeff"),
1852 json!("10.0.0.0/24"),
1853 json!("2001:db8::/32"),
1854 json!("::ffff:192.168.0.1"),
1855 json!("192.168.0.1"),
1856 json!("f47ac10b-58cc-4372-a567-0e02b2c3d479"),
1857 json!("f47ac10b58cc4372a5670e02b2c3d479"),
1858 json!("{f47ac10b-58cc-4372-a567-0e02b2c3d479}"),
1859 json!("urn:uuid:f47ac10b-58cc-4372-a567-0e02b2c3d479"),
1860 json!("fra1"),
1861 json!("fra-1_a"),
1862 json!("Fra1"),
1863 json!("with space"),
1864 json!("with\nnewline"),
1865 json!("ünïcödé"),
1866 json!("2024-01-01"),
1867 json!("2024-01-01T00:00:00Z"),
1868 json!("00:00:00"),
1869 json!(Uuid::from_u128(1).to_string()),
1872 json!(7),
1873 json!(1.5),
1874 json!(true),
1875 json!(null),
1876 json!([]),
1877 json!(["nested"]),
1878 json!({}),
1879 json!({"nested": "object"}),
1880 ];
1881
1882 for item in &items {
1883 let check = member_check_for(item);
1884 let mut accepted = 0;
1885 for member in &members {
1886 if core_accepts_member(item, member) {
1887 accepted += 1;
1888 assert!(
1889 check_accepts_member(&check, member),
1890 "core accepts {member} as a {item:?} member, the generated check rejects it"
1891 );
1892 }
1893 }
1894 assert!(accepted > 0, "no member in the corpus is a valid {item:?}");
1897 }
1898 }
1899
1900 #[test]
1901 fn member_check_rejects_an_undeclared_member() {
1902 let enum_check = member_check_for(&FieldType::Enum {
1905 values: vec!["access".to_string(), "trunk".to_string()],
1906 });
1907 assert!(!check_accepts_member(&enum_check, &json!("bogus")));
1908 assert!(check_accepts_member(&enum_check, &json!("access")));
1909
1910 let mac_check = member_check_for(&FieldType::Mac);
1911 assert!(!check_accepts_member(&mac_check, &json!("not-a-mac")));
1912 assert!(check_accepts_member(
1913 &mac_check,
1914 &json!("aa:bb:cc:dd:ee:ff")
1915 ));
1916
1917 let string_check = member_check_for(&FieldType::String);
1918 assert!(!check_accepts_member(&string_check, &json!(7)));
1919 assert!(check_accepts_member(&string_check, &json!("anything")));
1920
1921 let int_check = member_check_for(&FieldType::Int);
1924 assert!(!check_accepts_member(&int_check, &json!(true)));
1925 assert!(check_accepts_member(&int_check, &json!(7)));
1926
1927 let list_check = member_check_for(&FieldType::List {
1930 item: Box::new(FieldType::Int),
1931 });
1932 assert!(!check_accepts_member(&list_check, &json!("notalist")));
1933 assert!(check_accepts_member(&list_check, &json!(["x"])));
1934
1935 let map_check = member_check_for(&FieldType::Map {
1936 value: Box::new(FieldType::Int),
1937 });
1938 assert!(!check_accepts_member(&map_check, &json!([])));
1939 assert!(check_accepts_member(&map_check, &json!({})));
1940
1941 let ref_check = member_check_for(&FieldType::Ref {
1942 target: "dcim.interface".to_string(),
1943 });
1944 assert!(!check_accepts_member(&ref_check, &json!("not-a-uuid")));
1945 assert!(check_accepts_member(
1946 &ref_check,
1947 &json!(Uuid::from_u128(1).to_string())
1948 ));
1949 }
1950
1951 #[test]
1952 fn string_values_are_escaped_into_python() {
1953 let mut status = field(FieldType::Enum {
1956 values: vec!["active".to_string(), "retired \"old\"".to_string()],
1957 });
1958 status.pattern = Some("^\\d+\"$".to_string());
1959 status.description = Some("a \"quoted\" description".to_string());
1960 let inventory = Inventory {
1961 scope: None,
1962 schema: schema_of(vec![(
1963 "dcim.site",
1964 type_schema(
1965 vec![("slug", field(FieldType::Slug))],
1966 vec![("status", status)],
1967 ),
1968 )]),
1969 objects: vec![],
1970 };
1971 let models = generated(&emit_to_temp(&inventory), GENERATED_MODELS);
1972
1973 assert!(
1974 models.contains(r#"("retired \"old\"", "retired \"old\"")"#),
1975 "{models}"
1976 );
1977 assert!(models.contains(r#"RegexValidator("^\\d+\"$")"#), "{models}");
1978 assert!(
1979 models.contains(r#"help_text="a \"quoted\" description""#),
1980 "{models}"
1981 );
1982 }
1983
1984 fn models_for_field(schema: FieldSchema) -> String {
1985 let inventory = Inventory {
1986 scope: None,
1987 schema: schema_of(vec![(
1988 "ipam.address",
1989 type_schema(
1990 vec![("slug", field(FieldType::Slug))],
1991 vec![("value", schema)],
1992 ),
1993 )]),
1994 objects: vec![],
1995 };
1996 generated(&emit_to_temp(&inventory), GENERATED_MODELS)
1997 }
1998
1999 const CIDR_REGEX: &str = r#"RegexValidator("^[0-9a-fA-F:\\./]+$")"#;
2000
2001 #[test]
2002 fn a_format_typed_field_gets_the_derived_validator() {
2003 let models = models_for_field(field(FieldType::Cidr));
2004
2005 assert!(
2006 models.contains(&format!("validators=[{CIDR_REGEX}]")),
2007 "{models}"
2008 );
2009 }
2010
2011 #[test]
2012 fn a_declared_format_replaces_the_derived_one() {
2013 let mut value = field(FieldType::Cidr);
2014 value.format = Some(FieldFormat::Cidr);
2015 let models = models_for_field(value);
2016
2017 assert_eq!(models.matches(CIDR_REGEX).count(), 1, "{models}");
2018 }
2019
2020 #[test]
2021 fn a_declared_pattern_replaces_the_derived_one() {
2022 let mut value = field(FieldType::Mac);
2023 value.pattern = Some("^00:".to_string());
2024 let models = models_for_field(value);
2025
2026 assert!(
2027 models.contains(r#"validators=[RegexValidator("^00:")]"#),
2028 "{models}"
2029 );
2030 }
2031
2032 #[test]
2033 fn a_declared_format_and_pattern_both_apply() {
2034 let mut value = field(FieldType::Mac);
2035 value.format = Some(FieldFormat::Mac);
2036 value.pattern = Some("^00:".to_string());
2037 let models = models_for_field(value);
2038
2039 assert!(
2040 models.contains(
2041 r#"validators=[RegexValidator("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"), RegexValidator("^00:")]"#
2042 ),
2043 "{models}"
2044 );
2045 }
2046
2047 #[test]
2048 fn field_descriptions_become_help_text() {
2049 let mut name = field(FieldType::String);
2050 name.description = Some("human readable name".to_string());
2051 let inventory = Inventory {
2052 scope: None,
2053 schema: schema_of(vec![(
2054 "dcim.site",
2055 type_schema(vec![("slug", field(FieldType::Slug))], vec![("name", name)]),
2056 )]),
2057 objects: vec![],
2058 };
2059 let models = generated(&emit_to_temp(&inventory), GENERATED_MODELS);
2060 assert!(
2061 models.contains("help_text=\"human readable name\""),
2062 "{models}"
2063 );
2064 }
2065
2066 #[test]
2067 fn unusable_field_names_are_rejected() {
2068 for name in ["class", "key", "attrs", "uid", "pk", "trailing_", "do__ble"] {
2069 let inventory = Inventory {
2070 scope: None,
2071 schema: schema_of(vec![(
2072 "dcim.site",
2073 type_schema(
2074 vec![("slug", field(FieldType::Slug))],
2075 vec![(name, field(FieldType::String))],
2076 ),
2077 )]),
2078 objects: vec![],
2079 };
2080 let dir = tempdir().unwrap();
2081 let result = emit_django_app(dir.path(), &inventory, DjangoEmitOptions::default());
2082 assert!(
2083 result.is_err(),
2084 "expected '{name}' to be rejected instead of emitting broken python"
2085 );
2086 }
2087 }
2088
2089 #[test]
2090 fn colliding_model_names_are_rejected() {
2091 let inventory = Inventory {
2093 scope: None,
2094 schema: schema_of(vec![
2095 (
2096 "dcim.site",
2097 type_schema(vec![("slug", field(FieldType::Slug))], vec![]),
2098 ),
2099 (
2100 "dcim_site",
2101 type_schema(vec![("slug", field(FieldType::Slug))], vec![]),
2102 ),
2103 ]),
2104 objects: vec![],
2105 };
2106 let dir = tempdir().unwrap();
2107 let err = emit_django_app(dir.path(), &inventory, DjangoEmitOptions::default())
2108 .expect_err("colliding model names must fail");
2109 assert!(err.to_string().contains("DcimSite"), "{err}");
2110 }
2111
2112 #[test]
2113 fn dangling_relation_targets_are_rejected() {
2114 let inventory = Inventory {
2115 scope: None,
2116 schema: schema_of(vec![(
2117 "dcim.device",
2118 type_schema(
2119 vec![("name", field(FieldType::Slug))],
2120 vec![(
2121 "site",
2122 field(FieldType::Ref {
2123 target: "dcim.site".to_string(),
2124 }),
2125 )],
2126 ),
2127 )]),
2128 objects: vec![],
2129 };
2130 let dir = tempdir().unwrap();
2131 let err = emit_django_app(dir.path(), &inventory, DjangoEmitOptions::default())
2132 .expect_err("a relation to a type outside the model must fail");
2133 assert!(err.to_string().contains("dcim.site"), "{err}");
2134 }
2135
2136 #[test]
2137 fn a_datetime_is_emitted_with_an_uppercase_separator() {
2138 let inventory = Inventory {
2143 scope: None,
2144 schema: schema_of(vec![(
2145 "ops.window",
2146 type_schema(
2147 vec![("slug", field(FieldType::Slug))],
2148 vec![("starts_at", optional(FieldType::Datetime))],
2149 ),
2150 )]),
2151 objects: vec![obj(
2152 1,
2153 "ops.window",
2154 "slug=maintenance",
2155 attrs_map(vec![
2156 ("slug", json!("maintenance")),
2157 ("starts_at", json!("2026-08-01t22:00:00Z")),
2158 ]),
2159 )],
2160 };
2161
2162 let dir = emit_to_temp(&inventory);
2163 let fixture: Value = serde_json::from_str(
2164 &fs::read_to_string(dir.path().join(FIXTURES_DIR).join(FIXTURE_FILE)).unwrap(),
2165 )
2166 .unwrap();
2167 let entry = &fixture.as_array().expect("a fixture list")[0];
2168 assert_eq!(entry["fields"]["starts_at"], json!("2026-08-01T22:00:00Z"));
2169 }
2170
2171 #[test]
2172 fn objects_are_emitted_as_a_fixture() {
2173 let dir = emit_to_temp(&sample_inventory());
2174 let fixture: Value = serde_json::from_str(
2175 &fs::read_to_string(dir.path().join(FIXTURES_DIR).join(FIXTURE_FILE)).unwrap(),
2176 )
2177 .unwrap();
2178 let entries = fixture.as_array().expect("a fixture list");
2179 assert_eq!(entries.len(), 3);
2180
2181 let device = entries
2182 .iter()
2183 .find(|entry| entry["pk"] == json!(Uuid::from_u128(1).to_string()))
2184 .expect("the device is in the fixture");
2185 let app_name = dir.path().file_name().unwrap().to_str().unwrap();
2186 assert_eq!(device["model"], json!(format!("{app_name}.dcimdevice")));
2187 assert_eq!(device["fields"]["name"], json!("leaf01"));
2188 assert_eq!(
2190 device["fields"]["site"],
2191 json!(Uuid::from_u128(2).to_string())
2192 );
2193 assert_eq!(device["fields"]["key"], json!("{\"name\":\"leaf01\"}"));
2194 assert_eq!(
2196 device["fields"]["attrs"],
2197 json!({"unmodelled": "kept in attrs"})
2198 );
2199 }
2200
2201 #[test]
2202 fn admin_list_display_excludes_many_to_many_fields() {
2203 let inventory = Inventory {
2206 scope: None,
2207 schema: schema_of(vec![
2208 (
2209 "dcim.tag",
2210 type_schema(vec![("name", field(FieldType::Slug))], vec![]),
2211 ),
2212 (
2213 "dcim.device",
2214 type_schema(
2215 vec![("name", field(FieldType::Slug))],
2216 vec![
2217 ("name", field(FieldType::String)),
2218 (
2219 "tags",
2220 optional(FieldType::ListRef {
2221 target: "dcim.tag".to_string(),
2222 }),
2223 ),
2224 ],
2225 ),
2226 ),
2227 ]),
2228 objects: vec![],
2229 };
2230 let dir = emit_to_temp(&inventory);
2231
2232 assert!(generated(&dir, GENERATED_MODELS).contains("tags = models.ManyToManyField("));
2234
2235 let admin = generated(&dir, GENERATED_ADMIN);
2237 assert!(admin.contains("list_display"));
2238 assert!(!admin.contains("\"tags\""));
2239 }
2240
2241 #[test]
2242 fn emit_django_app_does_not_overwrite_user_files() {
2243 let dir = tempdir().unwrap();
2244 let models_path = dir.path().join(USER_MODELS);
2245 let admin_path = dir.path().join(USER_ADMIN);
2246 fs::write(&models_path, "# user models\n").unwrap();
2247 fs::write(&admin_path, "# user admin\n").unwrap();
2248
2249 emit_django_app(
2250 dir.path(),
2251 &sample_inventory(),
2252 DjangoEmitOptions::default(),
2253 )
2254 .unwrap();
2255
2256 assert_eq!(fs::read_to_string(models_path).unwrap(), "# user models\n");
2257 assert_eq!(fs::read_to_string(admin_path).unwrap(), "# user admin\n");
2258 }
2259
2260 #[test]
2261 fn emit_django_app_overwrites_default_skeleton() {
2262 let dir = tempdir().unwrap();
2263 let models_path = dir.path().join(USER_MODELS);
2264 let admin_path = dir.path().join(USER_ADMIN);
2265 let views_path = dir.path().join(USER_VIEWS);
2266 fs::write(&models_path, default_models_stub()).unwrap();
2267 fs::write(&admin_path, default_admin_stub()).unwrap();
2268 fs::write(&views_path, default_views_stub()).unwrap();
2269
2270 emit_django_app(
2271 dir.path(),
2272 &sample_inventory(),
2273 DjangoEmitOptions::default(),
2274 )
2275 .unwrap();
2276
2277 let models = fs::read_to_string(models_path).unwrap();
2278 let admin = fs::read_to_string(admin_path).unwrap();
2279 let views = fs::read_to_string(views_path).unwrap();
2280 assert!(models.contains("generated_models"));
2281 assert!(admin.contains("generated_admin"));
2282 assert!(views.contains("generated_views"));
2283 }
2284
2285 #[test]
2286 fn generated_admin_includes_defaults() {
2287 let admin = generated(&emit_to_temp(&sample_inventory()), GENERATED_ADMIN);
2288
2289 assert!(admin.contains("class DcimDeviceAdmin"));
2290 assert!(admin.contains(
2291 "list_display = [\"__str__\", \"name\", \"device_type\", \"role\", \"site\", \"uid\"]"
2292 ));
2293 assert!(admin.contains("search_fields = [\"key\", \"name\", \"device_type\", \"role\"]"));
2295 assert!(admin.contains("list_select_related = [\"site\"]"));
2296 assert!(admin.contains("class DcimInterfaceAdmin"));
2297 }
2298
2299 #[test]
2300 fn str_renders_the_key_values_not_the_json_key() {
2301 let models = generated(&emit_to_temp(&sample_inventory()), GENERATED_MODELS);
2304 assert!(
2305 models.contains(" return f\"{self.name}\""),
2306 "{models}"
2307 );
2308 let inventory = Inventory {
2310 scope: None,
2311 schema: schema_of(vec![
2312 (
2313 "dcim.device",
2314 type_schema(vec![("name", field(FieldType::Slug))], vec![]),
2315 ),
2316 (
2317 "dcim.interface",
2318 type_schema(
2319 vec![
2320 (
2321 "device",
2322 field(FieldType::Ref {
2323 target: "dcim.device".to_string(),
2324 }),
2325 ),
2326 ("name", field(FieldType::Slug)),
2327 ],
2328 vec![],
2329 ),
2330 ),
2331 ]),
2332 objects: vec![],
2333 };
2334 let models = generated(&emit_to_temp(&inventory), GENERATED_MODELS);
2335 assert!(
2336 models.contains(" return f\"{self.device} / {self.name}\""),
2337 "{models}"
2338 );
2339 }
2340
2341 #[test]
2342 fn generated_api_files_include_models() {
2343 let dir = emit_to_temp(&sample_inventory());
2344 let serializers = generated(&dir, GENERATED_SERIALIZERS);
2345 let views = generated(&dir, GENERATED_VIEWS);
2346 let urls = generated(&dir, GENERATED_URLS);
2347
2348 assert!(serializers.contains("class DcimDeviceSerializer"));
2349 assert!(views.contains("class DcimDeviceViewSet"));
2350 assert!(views.contains("ordering = [\"key\"]"));
2351 assert!(urls.contains("router.register(\"dcimdevices\""));
2352 assert!(!urls.contains("schema"), "{urls}");
2354 }
2355
2356 #[test]
2357 fn filterset_fields_are_only_emitted_with_a_filter_backend() {
2358 let dir = tempdir().unwrap();
2361 emit_django_app(
2362 dir.path(),
2363 &sample_inventory(),
2364 DjangoEmitOptions {
2365 emit_admin: true,
2366 filter_backend: false,
2367 ..DjangoEmitOptions::default()
2368 },
2369 )
2370 .unwrap();
2371 assert!(!generated(&dir, GENERATED_VIEWS).contains("filterset_fields"));
2372
2373 let dir = tempdir().unwrap();
2374 emit_django_app(
2375 dir.path(),
2376 &sample_inventory(),
2377 DjangoEmitOptions {
2378 emit_admin: true,
2379 filter_backend: true,
2380 ..DjangoEmitOptions::default()
2381 },
2382 )
2383 .unwrap();
2384 let views = generated(&dir, GENERATED_VIEWS);
2385 assert!(
2386 views.contains("filterset_fields = [\"key\", \"uid\", \"name\""),
2387 "{views}"
2388 );
2389 }
2390
2391 #[test]
2392 fn schema_route_is_wired_when_drf_can_serve_it() {
2393 let dir = tempdir().unwrap();
2394 emit_django_app(
2395 dir.path(),
2396 &sample_inventory(),
2397 DjangoEmitOptions {
2398 schema_view: true,
2399 ..DjangoEmitOptions::default()
2400 },
2401 )
2402 .unwrap();
2403 let urls = generated(&dir, GENERATED_URLS);
2404 assert!(urls.contains("from drf_spectacular.views import"), "{urls}");
2405 assert!(urls.contains("SpectacularAPIView.as_view()"), "{urls}");
2406 assert!(urls.contains("SpectacularSwaggerView.as_view("), "{urls}");
2407 }
2408
2409 #[test]
2410 fn generated_models_are_deterministic_by_kind() {
2411 let models = generated(&emit_to_temp(&sample_inventory()), GENERATED_MODELS);
2412 let device_pos = models.find("class DcimDevice").unwrap();
2413 let interface_pos = models.find("class DcimInterface").unwrap();
2414 let site_pos = models.find("class DcimSite").unwrap();
2415 assert!(device_pos < interface_pos);
2416 assert!(interface_pos < site_pos);
2417 }
2418
2419 fn plain_field(r#type: FieldType) -> FieldSchema {
2420 FieldSchema {
2421 r#type,
2422 required: false,
2423 nullable: false,
2424 description: None,
2425 format: None,
2426 pattern: None,
2427 }
2428 }
2429
2430 fn inventory_with_field(field_name: &str) -> Inventory {
2431 let mut types = BTreeMap::new();
2432 types.insert(
2433 "dcim.site".to_string(),
2434 TypeSchema {
2435 key: BTreeMap::from([("name".to_string(), plain_field(FieldType::Slug))]),
2436 fields: BTreeMap::from([(field_name.to_string(), plain_field(FieldType::String))]),
2437 },
2438 );
2439 Inventory {
2440 scope: None,
2441 schema: Schema { types },
2442 objects: vec![],
2443 }
2444 }
2445
2446 fn emit_error(inventory: &Inventory) -> String {
2447 let dir = tempdir().unwrap();
2448 let result = emit_django_app(dir.path(), inventory, DjangoEmitOptions::default());
2449 result
2450 .expect_err("expected emit to reject the schema")
2451 .to_string()
2452 }
2453
2454 #[test]
2455 fn rejects_field_named_uid() {
2456 let err = emit_error(&inventory_with_field("uid"));
2460 assert!(
2461 err.contains("uid"),
2462 "error should name the field, got: {err}"
2463 );
2464 assert!(
2465 err.contains("reserved"),
2466 "error should say reserved, got: {err}"
2467 );
2468 }
2469
2470 #[test]
2471 fn rejects_field_named_key() {
2472 let err = emit_error(&inventory_with_field("key"));
2473 assert!(
2474 err.contains("'key'"),
2475 "error should name the field, got: {err}"
2476 );
2477 assert!(
2478 err.contains("reserved"),
2479 "error should say reserved, got: {err}"
2480 );
2481 }
2482
2483 #[test]
2484 fn rejects_field_named_attrs() {
2485 let err = emit_error(&inventory_with_field("attrs"));
2486 assert!(
2487 err.contains("'attrs'"),
2488 "error should name the field, got: {err}"
2489 );
2490 assert!(
2491 err.contains("reserved"),
2492 "error should say reserved, got: {err}"
2493 );
2494 }
2495
2496 #[test]
2497 fn rejects_python_keyword_field_name() {
2498 let err = emit_error(&inventory_with_field("from"));
2499 assert!(
2500 err.contains("'from'"),
2501 "error should name the field, got: {err}"
2502 );
2503 }
2504
2505 #[test]
2506 fn rejects_leading_digit_field_name() {
2507 let err = emit_error(&inventory_with_field("10g_port"));
2508 assert!(
2509 err.contains("'10g_port'"),
2510 "error should name the field, got: {err}"
2511 );
2512 }
2513
2514 #[test]
2515 fn rejects_invalid_key_field_name() {
2516 let mut types = BTreeMap::new();
2519 types.insert(
2520 "dcim.site".to_string(),
2521 TypeSchema {
2522 key: BTreeMap::from([("class".to_string(), plain_field(FieldType::Slug))]),
2523 fields: BTreeMap::new(),
2524 },
2525 );
2526 let inventory = Inventory {
2527 scope: None,
2528 schema: Schema { types },
2529 objects: vec![],
2530 };
2531 let err = emit_error(&inventory);
2532 assert!(
2533 err.contains("'class'"),
2534 "error should name the key field, got: {err}"
2535 );
2536 }
2537
2538 #[test]
2539 fn rejects_type_name_that_renders_to_invalid_class_name() {
2540 let mut types = BTreeMap::new();
2543 types.insert(
2544 "10g.port".to_string(),
2545 TypeSchema {
2546 key: BTreeMap::from([("name".to_string(), plain_field(FieldType::Slug))]),
2547 fields: BTreeMap::new(),
2548 },
2549 );
2550 let inventory = Inventory {
2551 scope: None,
2552 schema: Schema { types },
2553 objects: vec![],
2554 };
2555 let err = emit_error(&inventory);
2556 assert!(
2557 err.contains("10g.port"),
2558 "error should name the type, got: {err}"
2559 );
2560 assert!(
2561 err.contains("10gPort"),
2562 "error should show the class name, got: {err}"
2563 );
2564 }
2565
2566 #[test]
2567 fn reports_all_offending_names_at_once() {
2568 let mut types = BTreeMap::new();
2569 types.insert(
2570 "dcim.site".to_string(),
2571 TypeSchema {
2572 key: BTreeMap::from([("name".to_string(), plain_field(FieldType::Slug))]),
2573 fields: BTreeMap::from([
2574 ("uid".to_string(), plain_field(FieldType::String)),
2575 ("from".to_string(), plain_field(FieldType::String)),
2576 ("2fa".to_string(), plain_field(FieldType::String)),
2577 ]),
2578 },
2579 );
2580 let inventory = Inventory {
2581 scope: None,
2582 schema: Schema { types },
2583 objects: vec![],
2584 };
2585 let err = emit_error(&inventory);
2586 for name in ["'uid'", "'from'", "'2fa'"] {
2587 assert!(err.contains(name), "error should list {name}, got: {err}");
2588 }
2589 }
2590
2591 #[test]
2592 fn accepts_soft_keyword_field_names() {
2593 let dir = tempdir().unwrap();
2595 let mut types = BTreeMap::new();
2596 types.insert(
2597 "dcim.site".to_string(),
2598 TypeSchema {
2599 key: BTreeMap::from([("name".to_string(), plain_field(FieldType::Slug))]),
2600 fields: BTreeMap::from([
2601 ("match".to_string(), plain_field(FieldType::String)),
2602 ("type".to_string(), plain_field(FieldType::String)),
2603 ]),
2604 },
2605 );
2606 let inventory = Inventory {
2607 scope: None,
2608 schema: Schema { types },
2609 objects: vec![],
2610 };
2611 emit_django_app(dir.path(), &inventory, DjangoEmitOptions::default()).unwrap();
2612 }
2613
2614 #[test]
2615 fn render_routes_block_pluralizes_endpoints() {
2616 let models = vec![
2619 ModelSpec {
2620 type_name: "ipam.prefix".to_string(),
2621 class_name: "Prefix".to_string(),
2622 fields: Vec::new(),
2623 key_fields: Vec::new(),
2624 },
2625 ModelSpec {
2626 type_name: "ipam.gateway".to_string(),
2627 class_name: "Gateway".to_string(),
2628 fields: Vec::new(),
2629 key_fields: Vec::new(),
2630 },
2631 ];
2632 let routes = render_routes_block(&models);
2633 assert!(routes.contains("router.register(\"prefixes\", PrefixViewSet)"));
2634 assert!(routes.contains("router.register(\"gateways\", GatewayViewSet)"));
2635 }
2636
2637 #[test]
2642 fn write_if_missing_reports_a_stat_it_could_not_make() {
2643 let dir = tempdir().unwrap();
2644 let blocker = dir.path().join("blocker");
2645 fs::write(&blocker, "regular file").unwrap();
2646
2647 let err = write_if_missing(blocker.join("extensions.py"), "new").unwrap_err();
2648
2649 assert!(
2650 format!("{err:#}").contains("extensions.py"),
2651 "the error must name the path: {err:#}"
2652 );
2653 }
2654
2655 #[test]
2656 fn write_user_file_reports_a_stat_it_could_not_make() {
2657 let dir = tempdir().unwrap();
2658 let blocker = dir.path().join("blocker");
2659 fs::write(&blocker, "regular file").unwrap();
2660
2661 let err = write_user_file(blocker.join("models.py"), "new", &[]).unwrap_err();
2662
2663 assert!(
2664 format!("{err:#}").contains("models.py"),
2665 "the error must name the path: {err:#}"
2666 );
2667 }
2668
2669 #[cfg(unix)]
2672 #[test]
2673 fn write_helpers_name_the_path_when_the_stat_succeeded() {
2674 let dir = tempdir().unwrap();
2675
2676 let dangling = dir.path().join("extensions.py");
2677 std::os::unix::fs::symlink("nowhere/target.py", &dangling).unwrap();
2678 let err = write_if_missing(&dangling, "new").unwrap_err();
2679 assert!(
2680 format!("{err:#}").contains("extensions.py"),
2681 "the write must name the path: {err:#}"
2682 );
2683
2684 let dangling = dir.path().join("models.py");
2685 std::os::unix::fs::symlink("nowhere/target.py", &dangling).unwrap();
2686 let err = write_user_file(&dangling, "new", &[]).unwrap_err();
2687 assert!(
2688 format!("{err:#}").contains("models.py"),
2689 "the write must name the path: {err:#}"
2690 );
2691
2692 let as_dir = dir.path().join("settings.py");
2693 fs::create_dir(&as_dir).unwrap();
2694 let err = write_user_file(&as_dir, "new", &[]).unwrap_err();
2695 assert!(
2696 format!("{err:#}").contains("settings.py"),
2697 "the read must name the path: {err:#}"
2698 );
2699 }
2700
2701 #[test]
2704 fn write_helpers_still_treat_enoent_as_absent() {
2705 let dir = tempdir().unwrap();
2706
2707 let fresh = dir.path().join("fresh.py");
2708 write_if_missing(&fresh, "generated").unwrap();
2709 assert_eq!(fs::read_to_string(&fresh).unwrap(), "generated");
2710 write_if_missing(&fresh, "second").unwrap();
2711 assert_eq!(fs::read_to_string(&fresh).unwrap(), "generated");
2712
2713 let user = dir.path().join("user.py");
2714 write_user_file(&user, "stub", &[]).unwrap();
2715 fs::write(&user, "hand written").unwrap();
2716 write_user_file(&user, "stub", &["stub"]).unwrap();
2717 assert_eq!(fs::read_to_string(&user).unwrap(), "hand written");
2718 }
2719}