# SPDX-License-Identifier: GPL-3.0-or-later
"""
Main module for checking Django models against TypeScript types.
"""
from ts_backend_check.parsers.django_parser import (
DjangoModelVisitor,
extract_model_fields,
)
from ts_backend_check.parsers.typescript_parser import TypeScriptParser
from ts_backend_check.utils import is_ordered_subset, snake_to_camel
[docs]
class TypeChecker:
"""
Main class for checking Django models against TypeScript types.
Parameters
----------
models_file : str
The file path for the models file to check.
concatenated_types_file : str
A concatenated file text joined from all paths in ts_interface_file_paths.
check_blank : bool, default=False
Whether to also check that fields marked 'blank=True' within Django models are optional (?) in the TypeScript interfaces.
model_name_conversions : dict[str: list[str]], default={}
A dictionary containing conversions of model names to their corresponding TypeScript interfaces.
backend_models_to_ignore : list[str], default=None | []
A list containing all Django models to be ignored by tsbc.
"""
def __init__(
self,
models_file: str,
concatenated_types_file: str,
check_blank: bool = False,
model_name_conversions: dict[str, list[str]] = {},
backend_models_to_ignore: list[str] = [],
) -> None:
self.models_file = models_file
self.concatenated_types_file = concatenated_types_file
self.check_blank = check_blank
self.model_name_conversions = model_name_conversions
self.django_model_visitor = DjangoModelVisitor
(
self.models_all_fields_and_blank_fields_ordered,
self.models_all_fields,
self.models_all_blank_fields,
) = extract_model_fields(
models_file=models_file,
models_to_ignore=backend_models_to_ignore,
)
self.ts_parser = TypeScriptParser(concatenated_types_file)
self.ts_interfaces = self.ts_parser.parse_interfaces()
self.backend_only = self.ts_parser.get_ignored_fields()
[docs]
def check(self) -> list[str]:
"""
Check models against TypeScript types.
Returns
-------
list
A list of fields missing from the TypeScript file.
"""
error_fields: list[str] = []
for model_name in list(self.models_all_fields_and_blank_fields_ordered.keys()):
fields_and_blank_fields_ordered = (
self.models_all_fields_and_blank_fields_ordered[model_name]
)
# fields = (
# self.models_all_fields[model_name]
# if model_name in self.models_all_fields
# else []
# )
blank_fields: list[str] = self._blank_fields(
models_all_blank_fields=self.models_all_blank_fields,
model_name=model_name,
)
missing_fields_exist = False
interfaces, _ = self._find_matching_interfaces(model_name=model_name)
if not interfaces:
error_fields.append(
self._format_missing_interface_message(model_name=model_name)
)
continue
for field in fields_and_blank_fields_ordered:
if not self._field_is_accounted_for(field=field, interfaces=interfaces):
error_fields.append(
self._format_missing_field_message(
field=field, model_name=model_name, interfaces=interfaces
)
)
missing_fields_exist = True
if self.check_blank and blank_fields:
error_fields.extend(
self._format_optional_properties_message(
field=bf,
model_name=model_name,
models_file=self.models_file,
)
for bf in blank_fields
if not self._property_is_optional_when_field_is_blank(
model_name=model_name,
field=bf,
)
)
if not missing_fields_exist and not self._ts_interface_properties_ordered(
model_name=model_name, fields=fields_and_blank_fields_ordered
):
error_fields.append(
self._format_unordered_interface_properties_message(
models_file=self.models_file
)
)
return error_fields
[docs]
def _blank_fields(
self, models_all_blank_fields: dict[str, list[str]], model_name: str
) -> list[str]:
"""
Get a list of all blank fields for each model.
Parameters
----------
models_all_blank_fields : dict[str,list[str]]
A dictionary containing all models as keys and their blank fields as values.
model_name : str
The name of the model to check the frontend TypeScript file for.
Returns
-------
list[str]
A list of all blank fields for the model.
"""
blank_fields: list[str] = models_all_blank_fields.get(model_name, [])
return blank_fields
[docs]
def _find_matching_interfaces(
self, model_name: str
) -> tuple[dict[str, list[str]], dict[str, list[str]]]:
"""
Find matching TypeScript interfaces for a model.
Parameters
----------
model_name : str
The name of the model to check the frontend TypeScript file for.
Returns
-------
tuple[dict[str, list[str]], dict[str, list[str]]]
Interfaces that match a model name.
"""
if self.model_name_conversions and model_name in self.model_name_conversions:
potential_names = self.model_name_conversions[model_name]
else:
potential_names = [model_name]
interfaces = {
name: interface.properties
for name, interface in self.ts_interfaces.items()
if any(potential == name for potential in potential_names)
}
interfaces_with_optional_properties = {
name: interface.optional_properties
for name, interface in self.ts_interfaces.items()
if any(potential == name for potential in potential_names)
}
return interfaces, interfaces_with_optional_properties
[docs]
def _field_is_accounted_for(
self, field: str, interfaces: dict[str, list[str]]
) -> bool:
"""
Check if a field is accounted for in TypeScript.
Parameters
----------
field : str
The field that should be used in the frontend TypeScript file.
interfaces : dict[str, list[str]]
The interfaces from the frontend TypeScript file.
Returns
-------
Bool
Whether the field is accounted for in the frontend TypeScript file.
"""
camel_field = snake_to_camel(input_str=field)
return (
camel_field in self.backend_only
or field in self.backend_only
or any(camel_field in fields for fields in interfaces.values())
)
[docs]
def _property_is_optional_when_field_is_blank(
self, model_name: str, field: str
) -> bool:
"""
Check that if the field is 'blank=True' that the corresponding interface property is optional (?).
Parameters
----------
model_name : str
The name of the model to check the frontend TypeScript file for.
field : str
The field that should match the optional state of the property in the TypeScript file.
Returns
-------
Bool
Whether the blank status of the model field matches the optional status of the interface property.
"""
camel_field = snake_to_camel(input_str=field)
_, interfaces_with_optional_properties = self._find_matching_interfaces(
model_name
)
return any(
camel_field in properties
for properties in interfaces_with_optional_properties.values()
)
[docs]
def _ts_interface_properties_ordered(
self, model_name: str, fields: list[str]
) -> bool:
"""
Check if the order of the TypeScript interface properties exactly matches that of the backend model fields.
Parameters
----------
model_name : str
The name of the model to check the frontend TypeScript file for.
fields : list[str]
The fields of the backend model.
Returns
-------
bool
Whether the order of the properties of the TypeScript interface file match that of the backend model fields.
"""
camel_fields = [snake_to_camel(input_str=f) for f in fields]
interfaces, _ = self._find_matching_interfaces(model_name)
return all(
is_ordered_subset(
reference_list=camel_fields, candidate_sub_list=interfaces[i]
)
for i in interfaces
)