using System.Text.RegularExpressions; using Humanizer; using MycroForge.Core; namespace MycroForge.CLI.CodeGen; public class RequestClassGenerator { public record Import(string Name, List Types) { public bool Match(string type) => Types.Any(t => type == t || type.StartsWith(t)); public string FindType(string type) => Types.First(t => type == t || type.StartsWith(t)); }; public record Field(string Name, string Type); public enum Type { Create, Update } private static readonly string[] Template = [ "from pydantic import BaseModel", "%imports%", "", "class %request_type%%entity_class_name%Request(BaseModel):", "%fields%", ]; private static readonly Regex ImportInfoRegex = new(@"from\s+(.+)\s+import\s+(.+)"); private static readonly Regex FieldInfoRegex = new(@"([_a-zA-Z-0-9]+)\s*:\s*Mapped\s*\[\s*(.+)\s*\]\s*=\s*.+"); private readonly ProjectContext _context; public RequestClassGenerator(ProjectContext context) { _context = context; } public async Task Generate(string path, string entity, Type type) { var entitySnakeCaseName = entity.Underscore().ToLower(); var entityClassName = entity.Pascalize(); var entitiesFolderPath = $"{Features.Db.FeatureName}/entities/{path}"; var entityFilePath = $"{entitiesFolderPath}/{entitySnakeCaseName}.py"; var entitySource = await _context.ReadFile(entityFilePath); var fieldInfo = ReadFields(entitySource); var fields = string.Join('\n', fieldInfo.Select(x => ToFieldString(x, type))); var requestsFolderPath = $"{Features.Api.FeatureName}/requests/{path}"; var updateRequestFilePath = $"{requestsFolderPath}/{type.ToString().ToLower()}_{entitySnakeCaseName}_request.py"; var service = string.Join("\n", Template) .Replace("%imports%", GetImportString(entitySource, fieldInfo, type)) .Replace("%request_type%", type.ToString().Pascalize()) .Replace("%entity_class_name%", entityClassName) .Replace("%fields%", fields) ; await _context.CreateFile(updateRequestFilePath, service); } private string ToFieldString(Field field, Type type) { var @string = $"\t{field.Name}: "; if (type == Type.Create) { @string += $"{field.Type} = None"; } else if (type == Type.Update) { @string += $"Optional[{field.Type}] = None"; } else throw new Exception($"Request type {type} is not supported."); return @string; } private string GetImportString(string entitySource, List fields, Type type) { var imports = GetImports(entitySource); var importStringBuffer = type == Type.Create ? new Dictionary>() : new Dictionary> { ["typing"] = ["Optional"] }; foreach (var field in fields) { /* The following snippet will allow importing nested types if necessary. var str = "List[Dict[str, Any]]"; str = str.Replace("[", ",") .Replace("]", "") .Replace(" ", ""); Console.WriteLine(str); // = "List,Dict,str,Any" */ var dissectedTypes = field.Type.Replace("[", ",") .Replace("]", "") .Replace(" ", "") .Split(); foreach (var dissectedType in dissectedTypes) { if (imports.FirstOrDefault(i => i.Match(dissectedType)) is Import import) { if (!importStringBuffer.ContainsKey(import.Name)) { importStringBuffer.Add(import.Name, []); } importStringBuffer[import.Name].Add(import.FindType(field.Type)); } } } return string.Join("\n", importStringBuffer.Select( pair => $"from {pair.Key} import {string.Join(", ", pair.Value)}\n") ); } private List ReadFields(string entitySource) { var fields = new List(); var matches = FieldInfoRegex.Matches(entitySource); foreach (Match match in matches) { // Index 0 contains the full Regex match var fullMatch = match.Groups[0].Value; // Ignore primary_key fields if (fullMatch.IndexOf("=", StringComparison.Ordinal) < fullMatch.IndexOf("primary_key", StringComparison.Ordinal)) continue; // Ignore relationship fields, these need to be done manually if (fullMatch.IndexOf("=", StringComparison.Ordinal) < fullMatch.IndexOf("relationship(", StringComparison.Ordinal)) continue; var name = Clean(match.Groups[1].Value); var type = Clean(match.Groups[2].Value); fields.Add(new Field(name, type)); } return fields; } private List GetImports(string entitySource) { var imports = new List(); var matches = ImportInfoRegex.Matches(entitySource); foreach (Match match in matches) { // Index 0 contains the whole Regex match, so we ignore this, since we're only interested in the captured groups. var name = Clean(match.Groups[1].Value); var types = Clean(match.Groups[2].Value) .Split(',') .Select(s => s.Trim()) .ToArray(); imports.Add(new Import(name, [..types])); } if (imports.FirstOrDefault(i => i.Name == "typing") is Import typingImport) { typingImport.Types.AddRange(["Any", "Dict", "List", "Optional"]); } else { imports.Add(new("typing", ["Any", "Dict", "List", "Optional"])); } return imports; } private static string Clean(string value) => value.Replace(" ", string.Empty).Trim(); }