diff --git a/MycroForge.CLI/CodeGen/CrudRouterGenerator.cs b/MycroForge.CLI/CodeGen/CrudRouterGenerator.cs new file mode 100644 index 0000000..f77f7b7 --- /dev/null +++ b/MycroForge.CLI/CodeGen/CrudRouterGenerator.cs @@ -0,0 +1,145 @@ +using Humanizer; + +namespace MycroForge.CLI.CodeGen; + +public class CrudRouterGenerator +{ + private static readonly string[] Template = + [ + "from typing import Annotated", + "from fastapi import APIRouter, Depends", + "from fastapi.responses import JSONResponse", + "from fastapi.encoders import jsonable_encoder", + "from %service_import_path% import %service_class_name%", + "from %create_entity_request_import_path% import %create_entity_request_class_name%", + "from %update_entity_request_import_path% import %update_entity_request_class_name%", + "", + "router = APIRouter()", + "", + "@router.get(\"/\")", + "async def list(", + "\tservice: Annotated[%service_class_name%, Depends(%service_class_name%)]", + "):", + "\ttry:", + "\t\tresult = await service.list()", + "\t\treturn JSONResponse(status_code=200, content=jsonable_encoder(result))", + "\texcept Exception as ex:", + "\t\tprint(str(ex))", + "\t\treturn JSONResponse(status_code=500, content=str(ex))", + "", + "@router.get(\"/{id}\")", + "async def get_by_id(", + "\tid: int,", + "\tservice: Annotated[%service_class_name%, Depends(%service_class_name%)]", + "):", + "\ttry:", + "\t\tresult = await service.get_by_id(id)", + "\t\treturn JSONResponse(status_code=200 if result is not None else 404, content=jsonable_encoder(result))", + "\texcept Exception as ex:", + "\t\tprint(str(ex))", + "\t\treturn JSONResponse(status_code=500, content=str(ex))", + "", + "@router.post(\"/\")", + "async def create(", + "\trequest: Create%entity_class_name%Request,", + "\tservice: Annotated[%service_class_name%, Depends(%service_class_name%)]", + "):", + "\ttry:", + "\t\tawait service.create(request.model_dump())", + "\t\treturn JSONResponse(status_code=201, content=None)", + "\texcept Exception as ex:", + "\t\tprint(str(ex))", + "\t\treturn JSONResponse(status_code=500, content=str(ex))", + "", + "@router.patch(\"/{id}\")", + "async def update(", + "\trequest: Update%entity_class_name%Request,", + "\tservice: Annotated[%service_class_name%, Depends(%service_class_name%)]", + "):", + "\ttry:", + "\t\tupdated = await service.update(request.model_dump(exclude_unset=True))", + "\t\treturn JSONResponse(status_code=204 if updated else 404, content=None)", + "\texcept Exception as ex:", + "\t\tprint(str(ex))", + "\t\treturn JSONResponse(status_code=500, content=str(ex))", + "", + "@router.delete(\"/{id}\")", + "async def delete(", + "\tid: int,", + "\tservice: Annotated[%service_class_name%, Depends(%service_class_name%)]", + "):", + "\ttry:", + "\t\tdeleted = await service.delete(id)", + "\t\treturn JSONResponse(status_code=204 if deleted else 404, content=None)", + "\texcept Exception as ex:", + "\t\tprint(str(ex))", + "\t\treturn JSONResponse(status_code=500, content=str(ex))", + ]; + + private readonly ProjectContext _context; + + public CrudRouterGenerator(ProjectContext context) + { + _context = context; + } + + public async Task Generate(string path, string entity) + { + var entitySnakeCaseName = entity.Underscore().ToLower(); + var entityClassName = entity.Pascalize(); + var serviceClassName = $"{entityClassName}Service"; + var entityRoutePrefix = entity.Kebaberize().Pluralize().ToLower(); + + var servicesFolderPath = $"{Features.Api.FeatureName}/services/{path}"; + var serviceFilePath = $"{servicesFolderPath}/{entitySnakeCaseName}_service.py"; + var serviceImportPath = serviceFilePath + .Replace('/', '.') + .Replace('\\', '.') + .Replace(".py", string.Empty) + .Trim(); + + var routersFolderPath = $"{Features.Api.FeatureName}/routers/{path}"; + var routerFilePath = $"{routersFolderPath}/{entitySnakeCaseName}.py"; + var routerImportPath = routersFolderPath + .Replace('/', '.') + .Replace('\\', '.') + .Trim(); + + var requestsFolderPath = $"{Features.Api.FeatureName}/requests/{path}"; + + var createRequestImportPath = $"{requestsFolderPath}/Create{entityClassName}Request" + .Replace('/', '.') + .Replace('\\', '.') + .Underscore() + .ToLower(); + var createRequestClassName = $"Create{entityClassName}Request"; + + var updateRequestImportPath = $"{requestsFolderPath}/Update{entityClassName}Request" + .Replace('/', '.') + .Replace('\\', '.') + .Underscore() + .ToLower(); + var updateRequestClassName = $"Update{entityClassName}Request"; + + var router = string.Join("\n", Template) + .Replace("%service_import_path%", serviceImportPath) + .Replace("%entity_class_name%", entityClassName) + .Replace("%service_class_name%", serviceClassName) + .Replace("%create_entity_request_import_path%", createRequestImportPath) + .Replace("%create_entity_request_class_name%", createRequestClassName) + .Replace("%update_entity_request_import_path%", updateRequestImportPath) + .Replace("%update_entity_request_class_name%", updateRequestClassName); + + await _context.CreateFile(routerFilePath, router); + + var main = await _context.ReadFile("main.py"); + + main += string.Join('\n', [ + "", + $"from {routerImportPath} import {entitySnakeCaseName}", + $"app.include_router(prefix=\"/{entityRoutePrefix}\", router={entitySnakeCaseName}.router)" + ]); + + await _context.WriteFile("main.py", main); + } +} \ No newline at end of file diff --git a/MycroForge.CLI/CodeGen/CrudServiceGenerator.cs b/MycroForge.CLI/CodeGen/CrudServiceGenerator.cs new file mode 100644 index 0000000..0449266 --- /dev/null +++ b/MycroForge.CLI/CodeGen/CrudServiceGenerator.cs @@ -0,0 +1,89 @@ +using Humanizer; + +namespace MycroForge.CLI.CodeGen; + +public class CrudServiceGenerator +{ + private static readonly string[] Template = + [ + "from typing import Any, Dict, List, Optional", + "from sqlalchemy import select", + $"from {Features.Db.FeatureName}.engine.async_session import async_session", + "from %entity_import_path% import %entity_class_name%", + "", + "class %entity_class_name%Service:", + "\tasync def list(self) -> List[%entity_class_name%]:", + "\t\tasync with async_session() as session:", + "\t\t\tstmt = select(%entity_class_name%)", + "\t\t\tresults = (await session.scalars(stmt)).all()", + "\t\t\treturn results", + "", + "\tasync def get_by_id(self, id: int) -> Optional[%entity_class_name%]:", + "\t\tasync with async_session() as session:", + "\t\t\tstmt = select(%entity_class_name%).where(%entity_class_name%.id == id)", + "\t\t\tresult = (await session.scalars(stmt)).first()", + "\t\t\treturn result", + "", + "\tasync def create(self, data: Dict[str, Any]) -> None:", + "\t\tasync with async_session() as session:", + "\t\t\tentity = %entity_class_name%(**data)", + "\t\t\tsession.add(entity)", + "\t\t\tawait session.commit()", + "", + "\tasync def update(self, id: int, data: Dict[str, Any]) -> bool:", + "\t\tasync with async_session() as session:", + "\t\t\tstmt = select(%entity_class_name%).where(%entity_class_name%.id == id)", + "\t\t\tentity = (await session.scalars(stmt)).first()", + "", + "\t\t\tif entity is None:", + "\t\t\t\treturn False", + "\t\t\telse:", + "\t\t\t\tfor key, value in data.items():", + "\t\t\t\t\tsetattr(entity, key, value)", + "\t\t\t\tawait session.commit()", + "\t\t\t\treturn True", + "", + "\tasync def delete(self, id: int) -> bool:", + "\t\tasync with async_session() as session:", + "\t\t\tstmt = select(%entity_class_name%).where(%entity_class_name%.id == id)", + "\t\t\tentity = (await session.scalars(stmt)).first()", + "", + "\t\t\tif entity is None:", + "\t\t\t\treturn False", + "\t\t\telse:", + "\t\t\t\tawait session.delete(entity)", + "\t\t\t\tawait session.commit()", + "\t\t\t\treturn True", + ]; + + private readonly ProjectContext _context; + + public CrudServiceGenerator(ProjectContext context) + { + _context = context; + } + + public async Task Generate(string path, string entity) + { + var entitySnakeCaseName = entity.Underscore().ToLower(); + var entityClassName = entity.Pascalize(); + + var entitiesFolderPath = $"{Features.Db.FeatureName}/entities/{path}"; + var entityFilePath = $"{entitiesFolderPath}/{entitySnakeCaseName}.py"; + var entityImportPath = entityFilePath + .Replace('/', '.') + .Replace('\\', '.') + .Replace(".py", string.Empty) + .Trim(); + + var servicesFolderPath = $"{Features.Api.FeatureName}/services/{path}"; + var serviceFilePath = $"{servicesFolderPath}/{entity.Underscore().ToLower()}_service.py"; + + var service = string.Join("\n", Template) + .Replace("%entity_import_path%", entityImportPath) + .Replace("%entity_class_name%", entityClassName) + ; + + await _context.CreateFile(serviceFilePath, service); + } +} \ No newline at end of file diff --git a/MycroForge.CLI/CodeGen/DbEnvInitializer.cs b/MycroForge.CLI/CodeGen/DbEnvInitializer.cs index e6eb3fb..b9c15da 100644 --- a/MycroForge.CLI/CodeGen/DbEnvInitializer.cs +++ b/MycroForge.CLI/CodeGen/DbEnvInitializer.cs @@ -32,8 +32,8 @@ public class DbEnvInitializer : PythonSourceModifier Rewrite(_alembicImport, [ GetOriginalText(_alembicImport), - "from db.settings import DbSettings", - "from db.entities.entity_base import EntityBase" + $"from {Features.Db.FeatureName}.settings import DbSettings", + $"from {Features.Db.FeatureName}.entities.entity_base import EntityBase" ]); Rewrite(_targetMetaDataAssignment, "target_metadata = EntityBase.metadata"); diff --git a/MycroForge.CLI/CodeGen/DbEnvUpdater.cs b/MycroForge.CLI/CodeGen/DbEnvUpdater.cs index 07f1703..8c7875e 100644 --- a/MycroForge.CLI/CodeGen/DbEnvUpdater.cs +++ b/MycroForge.CLI/CodeGen/DbEnvUpdater.cs @@ -26,7 +26,7 @@ public class DbEnvUpdater : PythonSourceModifier Rewrite(_lastImport, [ lastImportText, - $"from db.entities.{_importPath} import {_className}" + $"from {Features.Db.FeatureName}.entities.{_importPath} import {_className}" ]); return Rewriter.GetText(); @@ -36,7 +36,7 @@ public class DbEnvUpdater : PythonSourceModifier { var text = GetOriginalText(context); - if (text.StartsWith("from db.entities")) + if (text.StartsWith($"from {Features.Db.FeatureName}.entities")) _lastImport = context; return base.VisitImport_from(context); diff --git a/MycroForge.CLI/CodeGen/EntityLinker.cs b/MycroForge.CLI/CodeGen/EntityLinker.cs index ebf42bd..2537972 100644 --- a/MycroForge.CLI/CodeGen/EntityLinker.cs +++ b/MycroForge.CLI/CodeGen/EntityLinker.cs @@ -9,7 +9,7 @@ public partial class EntityLinker private static readonly string[] AssociationTable = [ "from sqlalchemy import Column, ForeignKey, Table", - "from db.entities.entity_base import EntityBase", + $"from {Features.Db.FeatureName}.entities.entity_base import EntityBase", "", "%left_entity%_%right_entity%_mapping = Table(", "\t\"%left_entity%_%right_entity%_mapping\",", diff --git a/MycroForge.CLI/CodeGen/PythonSourceModifier.cs b/MycroForge.CLI/CodeGen/PythonSourceModifier.cs index f61a995..6fff5ab 100644 --- a/MycroForge.CLI/CodeGen/PythonSourceModifier.cs +++ b/MycroForge.CLI/CodeGen/PythonSourceModifier.cs @@ -1,32 +1,15 @@ using Antlr4.Runtime; -using MycroForge.Parsing; namespace MycroForge.CLI.CodeGen; -public abstract class PythonSourceModifier : PythonParserBaseVisitor +public abstract class PythonSourceModifier : PythonSourceVisitor { - protected CommonTokenStream Stream { get; } - protected PythonParser Parser { get; } - protected TokenStreamRewriter Rewriter { get; } - - protected PythonSourceModifier(string source) + protected PythonSourceModifier(string source) : base(source) { - var input = new AntlrInputStream(source); - var lexer = new PythonLexer(input); - Stream = new CommonTokenStream(lexer); - Parser = new PythonParser(Stream); - Rewriter = new TokenStreamRewriter(Stream); } public abstract string Rewrite(); - protected string GetOriginalText(ParserRuleContext context) - { - // The parser does not necessarily return the original source, - // so we return the text from Rewriter.TokenStream, since this is unmodified. - return Rewriter.TokenStream.GetText(context); - } - protected void Rewrite(ParserRuleContext context, params string[] text) { Rewriter.Replace(from: context.start, to: context.Stop, text: string.Join('\n', text)); diff --git a/MycroForge.CLI/CodeGen/PythonSourceVisitor.cs b/MycroForge.CLI/CodeGen/PythonSourceVisitor.cs new file mode 100644 index 0000000..820cf47 --- /dev/null +++ b/MycroForge.CLI/CodeGen/PythonSourceVisitor.cs @@ -0,0 +1,27 @@ +using Antlr4.Runtime; +using MycroForge.Parsing; + +namespace MycroForge.CLI.CodeGen; + +public abstract class PythonSourceVisitor : PythonParserBaseVisitor +{ + protected CommonTokenStream Stream { get; } + protected PythonParser Parser { get; } + protected TokenStreamRewriter Rewriter { get; } + + protected PythonSourceVisitor(string source) + { + var input = new AntlrInputStream(source); + var lexer = new PythonLexer(input); + Stream = new CommonTokenStream(lexer); + Parser = new PythonParser(Stream); + Rewriter = new TokenStreamRewriter(Stream); + } + + protected string GetOriginalText(ParserRuleContext context) + { + // The parser does not necessarily return the original source, + // so we return the text from Rewriter.TokenStream, since this is unmodified. + return Rewriter.TokenStream.GetText(context); + } +} \ No newline at end of file diff --git a/MycroForge.CLI/CodeGen/RequestClassGenerator.cs b/MycroForge.CLI/CodeGen/RequestClassGenerator.cs new file mode 100644 index 0000000..a3a0626 --- /dev/null +++ b/MycroForge.CLI/CodeGen/RequestClassGenerator.cs @@ -0,0 +1,155 @@ +using System.Text.RegularExpressions; +using Humanizer; + +namespace MycroForge.CLI.CodeGen; + +public class RequestClassGenerator +{ + public record Import(string Name, List Types) + { + // The Match method accounts for generic types like List[str] or Dict[str, Any] + 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*\]"); + + 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) + { + if (imports.FirstOrDefault(i => i.Match(field.Type)) 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 whole Regex match, so we ignore this, since we're only interested in the captured groups. + 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(); +} \ No newline at end of file diff --git a/MycroForge.CLI/Commands/MycroForge.Api.Generate.Crud.cs b/MycroForge.CLI/Commands/MycroForge.Api.Generate.Crud.cs new file mode 100644 index 0000000..2600932 --- /dev/null +++ b/MycroForge.CLI/Commands/MycroForge.Api.Generate.Crud.cs @@ -0,0 +1,45 @@ +using System.CommandLine; +using MycroForge.CLI.CodeGen; +using MycroForge.CLI.Commands.Interfaces; + +namespace MycroForge.CLI.Commands; + +public partial class MycroForge +{ + public partial class Api + { + public partial class Generate + { + public class Crud : Command, ISubCommandOf + { + private static readonly Argument EntityArgument = + new(name: "entity", description: "The entity to target"); + + private readonly ProjectContext _context; + + public Crud(ProjectContext context) + : base("crud", "Generated CRUD functionality for an entity") + { + _context = context; + AddArgument(EntityArgument); + this.SetHandler(ExecuteAsync, EntityArgument); + } + + private async Task ExecuteAsync(string entity) + { + var path = string.Empty; + if (entity.Split(':').Select(s => s.Trim()).ToArray() is { Length: 2 } fullName) + { + path = fullName[0]; + entity = fullName[1]; + } + + await new CrudServiceGenerator(_context).Generate(path, entity); + await new RequestClassGenerator(_context).Generate(path, entity, RequestClassGenerator.Type.Create); + await new RequestClassGenerator(_context).Generate(path, entity, RequestClassGenerator.Type.Update); + await new CrudRouterGenerator(_context).Generate(path, entity); + } + } + } + } +} \ No newline at end of file diff --git a/MycroForge.CLI/Commands/MycroForge.Api.Generate.Router.cs b/MycroForge.CLI/Commands/MycroForge.Api.Generate.Router.cs index 6269fd5..4beb265 100644 --- a/MycroForge.CLI/Commands/MycroForge.Api.Generate.Router.cs +++ b/MycroForge.CLI/Commands/MycroForge.Api.Generate.Router.cs @@ -1,6 +1,7 @@ using System.CommandLine; using Humanizer; using MycroForge.CLI.Commands.Interfaces; +using MycroForge.CLI.Extensions; namespace MycroForge.CLI.Commands; @@ -40,16 +41,30 @@ public partial class MycroForge private async Task ExecuteAsync(string name) { - _context.AssertDirectoryExists($"{Features.Api.FeatureName}/routers"); + var folderPath = $"{Features.Api.FeatureName}/routers"; - var moduleName = name.Underscore(); - await _context.CreateFile($"{Features.Api.FeatureName}/routers/{moduleName}.py", Template); + _context.AssertDirectoryExists(folderPath); + + if (name.FullyQualifiedName() is { Length: 2 } fullName) + { + folderPath = Path.Join(folderPath, fullName[0]); + name = fullName[1]; + } + + var moduleImportPath = folderPath.Replace('\\', '.').Replace('/', '.'); + var moduleName = name.Underscore().ToLower(); + var fileName = $"{moduleName}.py"; + var filePath = Path.Join(folderPath, fileName); + + await _context.CreateFile(filePath, Template); var main = await _context.ReadFile("main.py"); + main += string.Join('\n', - $"\n\nfrom {Features.Api.FeatureName}.routers import {moduleName}", + $"\n\nfrom {moduleImportPath} import {moduleName}", $"app.include_router(prefix=\"/{name.Kebaberize()}\", router={moduleName}.router)" ); + await _context.WriteFile("main.py", main); } } diff --git a/MycroForge.CLI/Commands/MycroForge.Db.Generate.Entity.cs b/MycroForge.CLI/Commands/MycroForge.Db.Generate.Entity.cs index ee8452c..9990f24 100644 --- a/MycroForge.CLI/Commands/MycroForge.Db.Generate.Entity.cs +++ b/MycroForge.CLI/Commands/MycroForge.Db.Generate.Entity.cs @@ -2,6 +2,7 @@ using System.CommandLine; using Humanizer; using MycroForge.CLI.CodeGen; using MycroForge.CLI.Commands.Interfaces; +using MycroForge.CLI.Extensions; namespace MycroForge.CLI.Commands; @@ -19,7 +20,7 @@ public partial class MycroForge [ "from sqlalchemy import %type_imports%", "from sqlalchemy.orm import Mapped, mapped_column", - "from db.entities.entity_base import EntityBase", + $"from {Features.Db.FeatureName}.entities.entity_base import EntityBase", "", "class %class_name%(EntityBase):", "\t__tablename__ = \"%table_name%\"", @@ -67,12 +68,13 @@ public partial class MycroForge private async Task ExecuteAsync(string name, IEnumerable columns) { - _context.AssertDirectoryExists(Features.Db.FeatureName); + var folderPath = $"{Features.Db.FeatureName}/entities"; - var path = string.Empty; - if (name.Split(':').Select(s => s.Trim()).ToArray() is { Length: 2 } fullName) + _context.AssertDirectoryExists(Features.Db.FeatureName); + + if (name.FullyQualifiedName() is { Length: 2 } fullName) { - path = fullName[0]; + folderPath = Path.Join(folderPath, fullName[0]); name = fullName[1]; } @@ -87,12 +89,12 @@ public partial class MycroForge code = code.Replace("%table_name%", name.Underscore().ToLower().Pluralize()); code = code.Replace("%column_definitions%", columnDefinitions); - var folderPath = Path.Join($"{Features.Db.FeatureName}/entities", path); - var fileName = $"{name.ToLower()}.py"; + // var folderPath = Path.Join(, path); + var fileName = $"{name.Underscore().ToLower()}.py"; var filePath = Path.Join(folderPath, fileName); await _context.CreateFile(filePath, code); - var importPathParts = new[] { path, fileName.Replace(".py", "") } + var importPathParts = new[] { folderPath, fileName.Replace(".py", "") } .Where(s => !string.IsNullOrEmpty(s)); var importPath = string.Join('.', importPathParts) diff --git a/MycroForge.CLI/Commands/MycroForge.Db.Link.Many.cs b/MycroForge.CLI/Commands/MycroForge.Db.Link.Many.cs index 20f86ac..8945a33 100644 --- a/MycroForge.CLI/Commands/MycroForge.Db.Link.Many.cs +++ b/MycroForge.CLI/Commands/MycroForge.Db.Link.Many.cs @@ -32,7 +32,6 @@ public partial class MycroForge this.SetHandler(ExecuteAsync, LeftArgument, ToOneOption, ToManyOption); } - private async Task ExecuteAsync(string left, string? toOneOption, string? toManyOption) { if (toOneOption is not null && toManyOption is not null) diff --git a/MycroForge.CLI/Commands/MycroForge.Generate.Service.cs b/MycroForge.CLI/Commands/MycroForge.Generate.Service.cs index f61634f..80b2764 100644 --- a/MycroForge.CLI/Commands/MycroForge.Generate.Service.cs +++ b/MycroForge.CLI/Commands/MycroForge.Generate.Service.cs @@ -1,6 +1,7 @@ using System.CommandLine; using Humanizer; using MycroForge.CLI.Commands.Interfaces; +using MycroForge.CLI.Extensions; namespace MycroForge.CLI.Commands; @@ -13,36 +14,33 @@ public partial class MycroForge private static readonly Argument NameArgument = new(name: "name", description: "The name of the service"); - private static readonly Option PathOption = - new(name: "--path", description: "The folder path of the service") { IsRequired = true }; - private static readonly Option WithSessionOption = new(name: "--with-session", description: "Create a service that uses database sessions"); - private static readonly string[] DefaultTemplate = [ "class %class_name%:", "", - "\tdef do_stuff(self, stuff: str) -> str:", - "\t\treturn f\"Hey, I'm doing stuff!\"" + "\tdef hello(self, name: str) -> str:", + "\t\treturn f\"Hello, {str}!\"" ]; private static readonly string[] WithSessionTemplate = [ - "from db.engine.async_session import async_session", + "from typing import List", "from sqlalchemy import select", - "# from db.entities.some_entity import SomeEntity", + $"from {Features.Db.FeatureName}.engine.async_session import async_session", + $"# from {Features.Db.FeatureName}.entities.entity import Entity", "", - "class %class_name%Service:", + "class %class_name%:", "", - "\tasync def do_stuff(self, stuff: str) -> str:", + "\tasync def list(self, value: str) -> List[Entity]:", "\t\tasync with async_session() as session:", - "\t\t\t# stmt = select(User).where(SomeEntity.value == \"some_value\")", + "\t\t\t# stmt = select(User).where(Entity.value == value)", "\t\t\t# results = (await session.scalars(stmt)).all()", - "\t\t\t# print(len(results))", + "\t\t\t# return results", "\t\t\tpass", - "\t\treturn f\"Hey, I'm doing stuff!\"" + "\t\treturn []" ]; private readonly ProjectContext _context; @@ -52,26 +50,25 @@ public partial class MycroForge _context = context; AddAlias("s"); AddArgument(NameArgument); - AddOption(PathOption); AddOption(WithSessionOption); - this.SetHandler(ExecuteAsync, NameArgument, PathOption, WithSessionOption); + this.SetHandler(ExecuteAsync, NameArgument, WithSessionOption); } - private async Task ExecuteAsync(string name, string? path, bool withSession) + private async Task ExecuteAsync(string name, bool withSession) { - var folderPath = "services"; - - if (!string.IsNullOrEmpty(path) && !path.Equals(".")) + var folderPath = string.Empty; + + if (name.FullyQualifiedName() is { Length: 2} fullName) { - folderPath = Path.Join(_context.RootDirectory, path); - Directory.CreateDirectory(folderPath); + folderPath = Path.Join(folderPath, fullName[0]); + name = fullName[1]; } + var filePath = Path.Join(folderPath, $"{name.Underscore().ToLower()}.py"); var className = Path.GetFileName(name).Pascalize(); var code = string.Join('\n', withSession ? WithSessionTemplate : DefaultTemplate) .Replace("%class_name%", className); - var filePath = Path.Join(folderPath, $"{name.Underscore().ToLower()}_service.py"); await _context.CreateFile(filePath, code); } } diff --git a/MycroForge.CLI/Commands/MycroForge.Script.Create.cs b/MycroForge.CLI/Commands/MycroForge.Script.Create.cs index 10635d4..749d276 100644 --- a/MycroForge.CLI/Commands/MycroForge.Script.Create.cs +++ b/MycroForge.CLI/Commands/MycroForge.Script.Create.cs @@ -1,5 +1,6 @@ using System.CommandLine; using System.Diagnostics; +using Microsoft.Scripting.Utils; using MycroForge.CLI.Commands.Interfaces; namespace MycroForge.CLI.Commands; diff --git a/MycroForge.CLI/Commands/MycroForge.Script.List.cs b/MycroForge.CLI/Commands/MycroForge.Script.List.cs new file mode 100644 index 0000000..44f75f1 --- /dev/null +++ b/MycroForge.CLI/Commands/MycroForge.Script.List.cs @@ -0,0 +1,32 @@ +using System.CommandLine; +using MycroForge.CLI.Commands.Interfaces; + +namespace MycroForge.CLI.Commands; + +public partial class MycroForge +{ + public partial class Script + { + public class List : Command, ISubCommandOf