Improved db feature and adding documentation

This commit is contained in:
mdnapo 2024-06-09 20:35:54 +02:00
parent 67b7dba341
commit 220a818970
12 changed files with 318 additions and 188 deletions

View File

@ -9,14 +9,13 @@ public partial class MycroForge
{ {
public partial class Generate : Command, ISubCommandOf<Db> public partial class Generate : Command, ISubCommandOf<Db>
{ {
public Generate(IEnumerable<ISubCommandOf<Generate>> subCommands) : public Generate(IEnumerable<ISubCommandOf<Generate>> commands) :
base("generate", "Generate a database item") base("generate", "Generate a database item")
{ {
AddAlias("g"); AddAlias("g");
foreach (var subCommandOf in subCommands.Cast<Command>()) foreach (var command in commands.Cast<Command>())
AddCommand(subCommandOf); AddCommand(command);
} }
} }
} }
} }

View File

@ -0,0 +1,29 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Interfaces;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
public partial class Db
{
public partial class Run : Command, ISubCommandOf<Db>
{
private readonly ProjectContext _context;
public Run(ProjectContext context) :
base("run", $"Runs {Features.Db.FeatureName}.docker-compose.yml")
{
_context = context;
this.SetHandler(ExecuteAsync);
}
private async Task ExecuteAsync()
{
var config = await _context.LoadConfig();
var env = $"DB_PORT={config.Db.DbPort} PMA_PORT={config.Db.PmaPort}";
await _context.Bash($"{env} docker compose -f {Features.Db.FeatureName}.docker-compose.yml up -d");
}
}
}
}

View File

@ -0,0 +1,29 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Interfaces;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
public partial class Db
{
public partial class Stop : Command, ISubCommandOf<Db>
{
private readonly ProjectContext _context;
public Stop(ProjectContext context) :
base("stop", $"Stops {Features.Db.FeatureName}.docker-compose.yml")
{
_context = context;
this.SetHandler(ExecuteAsync);
}
private async Task ExecuteAsync()
{
var config = await _context.LoadConfig();
var env = $"DB_PORT={config.Db.DbPort} PMA_PORT={config.Db.PmaPort}";
await _context.Bash($"{env} docker compose -f {Features.Db.FeatureName}.docker-compose.yml down");
}
}
}
}

View File

@ -35,6 +35,8 @@ public static class ServiceCollectionExtensions
// Register "m4g db" // Register "m4g db"
services.AddScoped<ISubCommandOf<Commands.MycroForge>, Commands.MycroForge.Db>(); services.AddScoped<ISubCommandOf<Commands.MycroForge>, Commands.MycroForge.Db>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Run>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Stop>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Migrate>(); services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Migrate>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Rollback>(); services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Rollback>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Generate>(); services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Generate>();

View File

@ -37,6 +37,8 @@ public sealed class Api : IFeature
public async Task ExecuteAsync(ProjectContext context) public async Task ExecuteAsync(ProjectContext context)
{ {
var config = await context.LoadConfig(); var config = await context.LoadConfig();
config.Api = new() { Port = 8000 };
await context.SaveConfig(config);
await context.Bash( await context.Bash(
"source .venv/bin/activate", "source .venv/bin/activate",
@ -49,12 +51,5 @@ public sealed class Api : IFeature
var main = await context.ReadFile("main.py"); var main = await context.ReadFile("main.py");
main = string.Join('\n', MainTemplate) + main; main = string.Join('\n', MainTemplate) + main;
await context.WriteFile("main.py", main); await context.WriteFile("main.py", main);
config.Api = new()
{
Port = 8000
};
await context.SaveConfig(config);
} }
} }

View File

@ -10,10 +10,8 @@ public sealed class Db : IFeature
[ [
"class DbSettings:", "class DbSettings:",
"\tdef get_connectionstring() -> str:", "\tdef get_connectionstring() -> str:",
"\t\t# Example", "\t\tconnectionstring = \"mysql+asyncmy://root:password@localhost:%db_port%/%app_name%\"",
"\t\t# connectionstring = \"mysql+asyncmy://root:root@localhost:3306/your_database\"", "\t\treturn connectionstring",
"\t\t# return connectionstring",
"\t\traise Exception(\"DbSettings.get_connectionstring was not implemented.\")",
]; ];
private static readonly string[] AsyncSession = private static readonly string[] AsyncSession =
@ -35,20 +33,44 @@ public sealed class Db : IFeature
"\tpass" "\tpass"
]; ];
private static readonly string[] User = private static readonly string[] DockerCompose =
[ [
"from sqlalchemy import String", "version: '3.8'",
"from sqlalchemy.orm import Mapped, mapped_column", "# Access the database UI at http://localhost:${DB_PORT}.",
$"from {FeatureName}.entities.entity_base import EntityBase", "# Login: username = root & password = password",
"", "",
"class User(EntityBase):", "services:",
"\t__tablename__ = \"users\"", " %app_name%_mariadb:",
"\tid: Mapped[int] = mapped_column(primary_key=True)", " image: 'mariadb:10.4'",
"\tfirstname: Mapped[str] = mapped_column(String(255))", " container_name: '%app_name%_mariadb'",
"\tlastname: Mapped[str] = mapped_column(String(255))", " networks:",
" - default",
" ports:",
" - '${DB_PORT}:3306'",
" environment:",
" MYSQL_ROOT_PASSWORD: 'password'",
" MYSQL_USER: 'root'",
" MYSQL_PASSWORD: 'password'",
" MYSQL_DATABASE: '%app_name%'",
" volumes:",
" - '%app_name%_mariadb:/var/lib/mysql'",
"", "",
"\tdef __repr__(self) -> str:", " %app_name%_phpmyadmin:",
"\t\treturn f\"User(id={self.id!r}, firstname={self.firstname!r}, lastname={self.lastname!r})\"" " image: phpmyadmin/phpmyadmin",
" container_name: %app_name%_phpmyadmin",
" ports:",
" - '${PMA_PORT}:80'",
" networks:",
" - default",
" environment:",
" PMA_HOST: %app_name%_mariadb",
" PMA_PORT: 3306",
" PMA_ARBITRARY: 1",
" links:",
" - %app_name%_mariadb",
"",
"volumes:",
" %app_name%_mariadb:\n",
]; ];
#endregion #endregion
@ -60,6 +82,10 @@ public sealed class Db : IFeature
public async Task ExecuteAsync(ProjectContext context) public async Task ExecuteAsync(ProjectContext context)
{ {
var config = await context.LoadConfig(); var config = await context.LoadConfig();
config.Db = new() { DbPort = 5050, PmaPort = 5051 };
await context.SaveConfig(config);
var appName = context.AppName;
await context.Bash( await context.Bash(
"source .venv/bin/activate", "source .venv/bin/activate",
@ -70,17 +96,21 @@ public sealed class Db : IFeature
var env = await context.ReadFile($"{FeatureName}/env.py"); var env = await context.ReadFile($"{FeatureName}/env.py");
env = new DbEnvInitializer(env).Rewrite(); env = new DbEnvInitializer(env).Rewrite();
// env = new DbEnvUpdater(env, "user", "User").Rewrite();
await context.WriteFile($"{FeatureName}/env.py", env); await context.WriteFile($"{FeatureName}/env.py", env);
await context.CreateFile($"{FeatureName}/settings.py", Settings); var settings = string.Join('\n', Settings)
.Replace("%app_name%", appName)
.Replace("%db_port%", config.Db.DbPort.ToString())
;
await context.CreateFile($"{FeatureName}/settings.py", settings);
await context.CreateFile($"{FeatureName}/engine/async_session.py", AsyncSession); await context.CreateFile($"{FeatureName}/engine/async_session.py", AsyncSession);
await context.CreateFile($"{FeatureName}/entities/entity_base.py", EntityBase); await context.CreateFile($"{FeatureName}/entities/entity_base.py", EntityBase);
// await context.CreateFile($"{FeatureName}/entities/user.py", User); var dockerCompose = string.Join('\n', DockerCompose).Replace("%app_name%", appName);
await context.SaveConfig(config); await context.CreateFile($"{FeatureName}.docker-compose.yml", dockerCompose);
} }
} }

View File

@ -0,0 +1,10 @@
namespace MycroForge.CLI;
public partial class ProjectConfig
{
public class DbConfig
{
public int DbPort { get; set; }
public int PmaPort { get; set; }
}
}

View File

@ -3,4 +3,5 @@
public partial class ProjectConfig public partial class ProjectConfig
{ {
public ApiConfig Api { get; set; } = default!; public ApiConfig Api { get; set; } = default!;
public DbConfig Db { get; set; } = default!;
} }

View File

@ -1,5 +1,6 @@
using System.Diagnostics; using System.Diagnostics;
using System.Text.Json; using System.Text.Json;
using Humanizer;
using MycroForge.CLI.Extensions; using MycroForge.CLI.Extensions;
namespace MycroForge.CLI; namespace MycroForge.CLI;
@ -7,6 +8,7 @@ namespace MycroForge.CLI;
public class ProjectContext public class ProjectContext
{ {
public string RootDirectory { get; private set; } = Environment.CurrentDirectory; public string RootDirectory { get; private set; } = Environment.CurrentDirectory;
public string AppName => Path.GetFileNameWithoutExtension(RootDirectory).Underscore().ToLower();
private string ConfigPath => Path.Combine(RootDirectory, "m4g.json"); private string ConfigPath => Path.Combine(RootDirectory, "m4g.json");

View File

@ -1,10 +1,10 @@
### Dependencies ### Dependencies
bash (/usr/bin/bash) - Docker
- bash (/bin/bash)
Python 3.10.2 (/usr/bin/python3) - Python 3.10.2 (/usr/bin/python3)
- python3-pip - python3-pip
- python3-venv - python3-venv
#### Note #### Note
The MycroForge CLI assumes a linux compatible environment, so on Windows you'll have to use WSL. The MycroForge CLI assumes a linux compatible environment, so on Windows you'll have to use WSL.

59
docs/README.md Normal file
View File

@ -0,0 +1,59 @@
# General introduction
## What is MycroForge?
MycroForge is an opinionated CLI tool that is meant to facilitate the development of FastAPI & SQLAlchemy based backends.
It provides a command line interface that allows users to generate a skeleton project and other common items like
database entities, migrations, routers and even basic CRUD functionality. The main purpose of this tool is to generate
boilerplate code and to provide a unified interface for performing recurrent development activities.
## Generating a project
To generate a project you can run the following command.
`m4g init <name>`
```
Description:
Initialize a new project
Usage:
m4g init <name> [options]
Arguments:
<name> The name of your project
Options:
--without <api|db|git> Features to exclude
-?, -h, --help Show help and usage information
```
Running this command will generate
```
📦<project_name>
┣ 📂.git
┣ 📂.venv
┣ 📂api
┃ ┗ 📂routers
┃ ┃ ┗ 📜hello.py
┣ 📂db
┃ ┣ 📂engine
┃ ┃ ┗ 📜async_session.py
┃ ┣ 📂entities
┃ ┃ ┗ 📜entity_base.py
┃ ┣ 📂versions
┃ ┣ 📜README
┃ ┣ 📜env.py
┃ ┣ 📜script.py.mako
┃ ┗ 📜settings.py
┣ 📜.gitignore
┣ 📜alembic.ini
┣ 📜db.docker-compose.yml
┣ 📜m4g.json
┣ 📜main.py
┗ 📜requirements.txt
```

View File

@ -1,6 +1,6 @@
# Quick tutorial # Quick tutorial
Summary: We're gonna build a todo app to demonstrate how the `m4g` command works.<br/> Summary: We're gonna build a todo app to demonstrate how the `m4g` command line tool works.<br/>
TODO: Supplement this section. TODO: Supplement this section.
## Initialize the project ## Initialize the project
@ -12,71 +12,9 @@ this command. If you prefer using another editor, then manually open the generat
## Setup the database ## Setup the database
Summary: This section explains how to interact with the database. ### Start the database
TODO: Supplement this section.
### Create a docker compose file for the database `m4g db run`
Create a file called `docker-compose.yml` in the root of your project with the following content.
```yaml
version: '3.8'
services:
# MariaDB for the database
todo_db:
image: 'mariadb:10.4'
container_name: 'todo_db'
restart: unless-stopped
networks:
- default
ports:
- '3306:3306'
environment:
MYSQL_ROOT_PASSWORD: 'root'
MYSQL_USER: 'root'
MYSQL_PASSWORD: 'root'
MYSQL_DATABASE: 'todo'
volumes:
- 'todo_db:/var/lib/mysql'
# PhpMyAdmin for the database UI
phpmyadmin:
image: phpmyadmin/phpmyadmin
container_name: phpmyadmin
restart: unless-stopped
ports:
- 8888:80
networks:
- default
environment:
PMA_HOST: todo_db
PMA_PORT: 3306
PMA_ARBITRARY: 1
links:
- todo_db
volumes:
todo_db:
```
Run `docker compose up -d` to start the database containers.<br/>
You should now be able to log into the database server at http://localhost:8888, with the following credentials.<br/>
**username**: root, **password**: root
### Update the database connectionstring
Replace the contents of `db/settings.py` with the following.
```python
class DbSettings:
def get_connectionstring() -> str:
connectionstring = "mysql+asyncmy://root:root@localhost:3306/todo"
return connectionstring
```
The connectionstring has been hardcoded for demo purposes.
In a production app, you would use some kind of secret manager to retrieve it.
### Create the entities ### Create the entities
@ -96,7 +34,9 @@ In a production app, you would use some kind of secret manager to retrieve it.
`m4g db migrate` `m4g db migrate`
If you inspect the database in [PhpMyAdmin](http://localhost:8888), you should now see a populated schema. If you inspect the database in [PhpMyAdmin](http://localhost:5051), you should now see a populated schema.
To stop the database, you can run `m4g db stop`
## Setup the API ## Setup the API
@ -106,126 +46,160 @@ If you inspect the database in [PhpMyAdmin](http://localhost:8888), you should n
`m4g api generate crud Todo` `m4g api generate crud Todo`
### Modify the generated Todo request classes
Modify `CreateTodoRequest` in `api/requests/create_todo_request.py`, you might need to import `List` from `typing`
```python
# Before
class CreateTodoRequest(BaseModel):
description: str = None
is_done: bool = None
# After
class CreateTodoRequest(BaseModel):
description: str = None
is_done: bool = None
tag_ids: Optional[List[int]] = []
```
Modify `UpdateTodoRequest` in `api/requests/update_todo_request.py`, you might need to import `List` from `typing`
```python
# Before
class UpdateTodoRequest(BaseModel):
description: Optional[str] = None
is_done: Optional[bool] = None
tag_ids: Optional[List[int]] = []
# After
class UpdateTodoRequest(BaseModel):
description: Optional[str] = None
is_done: Optional[bool] = None
tag_ids: Optional[List[int]] = []
```
### Modify generated TodoService ### Modify generated TodoService
Add the following import in `api/services/todo_service.py`. Add the following imports in `api/services/todo_service.py`.
`from sqlalchemy.orm import selectinload` ```python
from sqlalchemy.orm import selectinload
from db.entities.tag import Tag
```
Modify `TodoService.list` Modify `TodoService.list`
```python ```python
# Before # Before
async with async_session() as session: async with async_session() as session:
stmt = select(Todo) stmt = select(Todo)
results = (await session.scalars(stmt)).all() results = (await session.scalars(stmt)).all()
return results return results
# After # After
async with async_session() as session: async with async_session() as session:
stmt = select(Todo).options(selectinload(Todo.tags)) stmt = select(Todo).options(selectinload(Todo.tags))
results = (await session.scalars(stmt)).all() results = (await session.scalars(stmt)).all()
return results return results
``` ```
Modify `TodoService.get_by_id` Modify `TodoService.get_by_id`
```python ```python
# Before # Before
async def get_by_id(self, id: int) -> Optional[Todo]: async def get_by_id(self, id: int) -> Optional[Todo]:
async with async_session() as session: async with async_session() as session:
stmt = select(Todo).where(Todo.id == id) stmt = select(Todo).where(Todo.id == id)
result = (await session.scalars(stmt)).first() result = (await session.scalars(stmt)).first()
return result return result
# After # After
async def get_by_id(self, id: int) -> Optional[Todo]: async def get_by_id(self, id: int) -> Optional[Todo]:
async with async_session() as session: async with async_session() as session:
stmt = select(Todo).where(Todo.id == id).options(selectinload(Todo.tags)) stmt = select(Todo).where(Todo.id == id).options(selectinload(Todo.tags))
result = (await session.scalars(stmt)).first() result = (await session.scalars(stmt)).first()
return result return result
``` ```
Modify `TodoService.create` Modify `TodoService.create`
```python ```python
# Before # Before
async def create(self, data: Dict[str, Any]) -> None: async def create(self, data: Dict[str, Any]) -> None:
async with async_session() as session: async with async_session() as session:
entity = Todo(**data) entity = Todo(**data)
session.add(entity) session.add(entity)
await session.commit() await session.commit()
# After # After
async def create(self, data: Dict[str, Any]) -> None: async def create(self, data: Dict[str, Any]) -> None:
tag_ids = [] tag_ids = []
if "tag_ids" in data.keys(): if "tag_ids" in data.keys():
tag_ids = data["tag_ids"] tag_ids = data["tag_ids"]
del data["tag_ids"] del data["tag_ids"]
async with async_session() as session: async with async_session() as session:
entity = Todo(**data) entity = Todo(**data)
if len(tag_ids) > 0: if len(tag_ids) > 0:
stmt = select(Tag).where(Tag.id.in_(tag_ids)) stmt = select(Tag).where(Tag.id.in_(tag_ids))
result = (await session.scalars(stmt)).all() result = (await session.scalars(stmt)).all()
entity.tags = list(result) entity.tags = list(result)
session.add(entity) session.add(entity)
await session.commit() await session.commit()
``` ```
Modify `TodoService.update` Modify `TodoService.update`
```python ```python
# Before # Before
async def update(self, id: int, data: Dict[str, Any]) -> bool: async def update(self, id: int, data: Dict[str, Any]) -> bool:
tag_ids = [] tag_ids = []
if "tag_ids" in data.keys(): if "tag_ids" in data.keys():
tag_ids = data["tag_ids"] tag_ids = data["tag_ids"]
del data["tag_ids"] del data["tag_ids"]
async with async_session() as session: async with async_session() as session:
stmt = select(Todo).where(Todo.id == id) stmt = select(Todo).where(Todo.id == id)
entity = (await session.scalars(stmt)).first() entity = (await session.scalars(stmt)).first()
if entity is None: if entity is None:
return False return False
else: else:
for key, value in data.items(): for key, value in data.items():
setattr(entity, key, value) setattr(entity, key, value)
await session.commit() await session.commit()
return True return True
# After # After
async def update(self, id: int, data: Dict[str, Any]) -> bool: async def update(self, id: int, data: Dict[str, Any]) -> bool:
tag_ids = [] tag_ids = []
if "tag_ids" in data.keys(): if "tag_ids" in data.keys():
tag_ids = data["tag_ids"] tag_ids = data["tag_ids"]
del data["tag_ids"] del data["tag_ids"]
async with async_session() as session: async with async_session() as session:
stmt = select(Todo).where(Todo.id == id).options(selectinload(Todo.tags)) stmt = select(Todo).where(Todo.id == id).options(selectinload(Todo.tags))
entity = (await session.scalars(stmt)).first() entity = (await session.scalars(stmt)).first()
if entity is None: if entity is None:
return False return False
else:
for key, value in data.items():
setattr(entity, key, value)
if len(tag_ids) > 0:
stmt = select(Tag).where(Tag.id.in_(tag_ids))
result = (await session.scalars(stmt)).all()
entity.tags = list(result)
else: else:
entity.tags = [] for key, value in data.items():
setattr(entity, key, value)
await session.commit()
return True if len(tag_ids) > 0:
stmt = select(Tag).where(Tag.id.in_(tag_ids))
result = (await session.scalars(stmt)).all()
entity.tags = list(result)
else:
entity.tags = []
await session.commit()
return True
``` ```