diff --git a/.gitignore b/.gitignore index 8c63e06..a782b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ bld/ # Visual Studio 2015/2017 cache/options directory .vs/ +.vscode/ + # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ @@ -488,4 +490,9 @@ $RECYCLE.BIN/ # Project and App Specific .env.local - +app.yaml +app-generated.yaml +app-test.yaml +research/ +tmp/ +research.sh diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7d0170f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,64 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +- `Components/`, `Pages/`, `Shared/`: Blazor UI components and layouts. +- `Controllers/`: Web API endpoints (generic and entity controllers). +- `Services/`: Business logic and DI services. +- `Data/`: `AppDbContext`, tenancy helpers, and EF configuration. +- `DdlParser/`: SQL DDL → `app.yaml` converter used in the pipeline. +- `DotNetWebApp.Models/`: Separate models assembly containing all data models, configuration classes, and YAML model classes. +- `DotNetWebApp.Models/Generated/`: Auto-generated entity types from `ModelGenerator`. +- `DotNetWebApp.Models/AppDictionary/`: YAML model classes for app.yaml structure. +- `ModelGenerator/`: Reads `app.yaml` and produces generated models. +- `Migrations/`: Generated EF Core migration files (current baseline checked in; pipeline regenerates). +- `wwwroot/`: Static assets (CSS, images, JS). + +## Build, Test, and Development Commands + +- `make check`: Runs `shellcheck` on `setup.sh`, `dotnet-build.sh`, and `Makefile`, then restores and builds. +- `make restore`: Restores app, generator, parser, and test projects. +- `make build`: Builds `DotNetWebApp.Models`, `DotNetWebApp`, `ModelGenerator`, and `DdlParser` (default `BUILD_CONFIGURATION=Debug`). +- `make build-all`: Builds the full solution, including tests; automatically runs `cleanup-nested-dirs` to prevent inotify exhaustion. +- `make build-release`: Release builds for main projects only. +- `make run-ddl-pipeline`: DDL → YAML → models → migration pipeline, then build. +- `make migrate`: Applies the current EF Core migration (SQL Server must be running). +- `make dev`: Runs with hot reload (`dotnet watch`). +- `make run`: Runs once without hot reload. +- `make test`: Builds and runs `dotnet test` for `tests/DotNetWebApp.Tests` and `tests/ModelGenerator.Tests` (uses `BUILD_CONFIGURATION`); automatically runs `cleanup-nested-dirs`. +- `make seed`: Runs the app in seed mode to apply `seed.sql` via EF (`-- --seed`). +- `make cleanup-nested-dirs`: Removes nested project directories created by MSBuild to prevent inotify watch exhaustion on Linux. +- Docker DB helpers: `make db-start`, `make db-stop`, `make db-logs`, `make db-drop`. +- Local SQL Server helpers: `make ms-status`, `make ms-start`, `make ms-logs`, `make ms-drop`. + +## Project Goal & Session Notes + +- **Primary Goal:** Abstract the application's data model, configuration, and branding into a single `app.yaml` file for dynamic customization. +- **Current State:** DDL → YAML → models → migration pipeline drives generated models and schema; run `make run-ddl-pipeline` before `make migrate`/`make seed` when the DDL changes. Seed data lives in `seed.sql` and is applied via `make seed`. +- Review `SESSION_SUMMARY.md` before starting work and update it when you make meaningful progress or decisions. + +## Coding Style & Naming Conventions + +- C#: 4-space indentation, PascalCase for types/props, camelCase for locals/params, `Async` suffix for async methods. +- Razor components: PascalCase filenames (e.g., `GenericEntityPage.razor`). +- Generated files in `DotNetWebApp.Models/Generated/` should not be edited directly; update `ModelGenerator/EntityTemplate.scriban` and regenerate instead. +- Model classes in `DotNetWebApp.Models/` are shared across the application; ensure changes don't break existing consumers. +- Keep Radzen UI wiring intact in `Shared/` and `_Layout.cshtml`. + +## Testing Guidelines + +- Tests live in `tests/` using a `ProjectName.Tests` project and `*Tests` class naming. +- Run tests via `make test` and include failing/passing notes in PRs. + +## Commit & Pull Request Guidelines + +- Commit messages are short and imperative (e.g., “Add docker database commands”, “Fix nav bar button”); keep them concise. +- PRs should include: a brief summary, commands run (`make check`, `make build`, etc.), screenshots for UI changes, and DDL pipeline notes if schema changed. + +## Configuration & Safety Notes + +- Secrets belong in user secrets or environment variables; see `SECRETS.md`. +- `app.yaml` drives model generation; branding/navigation labels still come from `appsettings.json` via `AppCustomizationOptions`. +- `dotnet-build.sh` sets `DOTNET_ROOT` for global tools; do not modify or reinstall the system .NET runtime. +- Tenant schema switching uses the `X-Customer-Schema` header (defaults to `dbo`). +- Models are in separate `DotNetWebApp.Models` project; YamlDotNet dependency lives there. diff --git a/ARCHITECTURE_SUMMARY.md b/ARCHITECTURE_SUMMARY.md new file mode 100644 index 0000000..d09e6ae --- /dev/null +++ b/ARCHITECTURE_SUMMARY.md @@ -0,0 +1,417 @@ +# DotNetWebApp Architecture Summary + +**Last Updated:** 2026-01-26 +**Status:** Architecture finalized, ready for Phase 1 implementation + +--- + +## Quick Navigation + +| Document | Purpose | When to Read | +|----------|---------|--------------| +| **REFACTOR.md** | Complete refactoring plan with all 5 phases | Before starting any refactoring work | +| **PHASE2_VIEW_PIPELINE.md** | Detailed step-by-step implementation guide for View Pipeline | When implementing Phase 2 | +| **HYBRID_ARCHITECTURE.md** | Simplified EF+Dapper architecture reference | When understanding data access patterns | +| **CLAUDE.md** | Project context for Claude Code sessions | Every new Claude session | +| **SESSION_SUMMARY.md** | Development log and decisions | When catching up on recent work | + +--- + +## Architecture Overview + +### Core Philosophy: SQL-First Everything + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ENTITIES (200+ tables) │ +│ SQL DDL → app.yaml → Generated/*.cs → EF Core CRUD │ +│ │ +│ Pipeline: make run-ddl-pipeline │ +│ Data Access: IEntityOperationService (reflection-based) │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ VIEWS (complex queries) │ +│ SQL SELECT → views.yaml → ViewModels/*.cs → Dapper reads │ +│ │ +│ Pipeline: make run-view-pipeline │ +│ Data Access: IViewService (type-safe queries) │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ BUSINESS LOGIC │ +│ Blazor Server → C# event handlers (no JavaScript/AJAX) │ +│ │ +│ Writes: IEntityOperationService (EF Core) │ +│ Reads: IViewService (Dapper for complex JOINs) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Key Architectural Decisions + +### ✅ What We ARE Doing + +1. **Hybrid Data Access:** + - EF Core for all writes (200+ generated entities) + - Dapper for complex reads (SQL-first views) + - Shared connection for automatic tenant schema inheritance + +2. **SQL-First View Pipeline:** + - Legacy SQL queries as source of truth + - YAML registry (`views.yaml`) + - Generated C# view models + - Type-safe service layer (`IViewService`) + +3. **Multi-Tenancy:** + - Finbuckle.MultiTenant for robust tenant isolation + - Header-based strategy (`X-Customer-Schema`) + - Automatic schema propagation to Dapper (via shared EF connection) + +4. **Single-Project Organization:** + - Namespace-based separation (not 4 separate projects) + - Pragmatic for small team + - Can refactor to projects later if team grows + +5. **Dynamic Patterns:** + - Reflection-based entity operations (scalable to 200+ entities) + - Dynamic API endpoints (`/api/entities/{entityName}`) + - Runtime YAML-driven UI components + +### ❌ What We Are NOT Doing + +1. **Full Clean Architecture:** + - No Domain/Application/Infrastructure/WebUI projects + - Namespaces provide sufficient organization + +2. **Repository Pattern:** + - `IEntityOperationService` + `IViewService` are sufficient abstractions + - Avoids redundant layers + +3. **CQRS/Mediator:** + - Adds complexity without benefit at this scale + - Direct service calls are clearer + +4. **OData:** + - Our reflection-based approach is simpler + - More flexible for dynamic requirements + +5. **Client-Side Complexity:** + - No bloated JavaScript/AJAX + - Server-side C# event handlers via Blazor SignalR + +--- + +## Implementation Phases + +### ✅ Completed (Before 2026-01-26) + +- DDL-first entity generation pipeline +- Dynamic EF Core entity discovery +- Generic CRUD API (`EntitiesController`) +- Blazor Server SPA with Radzen components +- Multi-tenant schema switching (custom implementation) + +### 🔄 Phase 1: Extract Reflection Logic (1-2 weeks) + +**Goal:** Centralize EF Core operations for 200+ entities + +**Deliverables:** +- `IEntityOperationService` interface +- `EntityOperationService` implementation +- Updated `EntitiesController` (reduced from 369 to ~150 lines) +- Unit tests + +**Why First:** Foundation for all subsequent work + +### 🔄 Phase 2: SQL-First View Pipeline (1-2 weeks) + +**Goal:** Enable legacy SQL as source of truth for complex UI features + +**Deliverables:** +- `views.yaml` schema definition +- SQL view files in `sql/views/` +- `ViewModelGenerator` (extends ModelGenerator) +- `ViewRegistry`, `ViewService`, `DapperQueryService` +- `make run-view-pipeline` Makefile target +- Example Blazor component + +**Why Critical:** Scales to 200+ entities without hand-writing services + +**Detailed Plan:** See `PHASE2_VIEW_PIPELINE.md` + +### 🔄 Phase 3: Validation Pipeline (1 day) + +**Goal:** Prevent invalid data entry + +**Deliverables:** +- Validation middleware in controllers +- Integration tests + +### 🔄 Phase 4: Finbuckle Multi-Tenancy (2-3 days) + +**Goal:** Robust tenant isolation for multiple schemas + +**Deliverables:** +- `TenantInfo` class +- Finbuckle DI registration +- Updated `AppDbContext` +- Multi-tenant integration tests (EF + Dapper) + +### 🔄 Phase 5: Configuration & Immutability (1 day) + +**Goal:** Code quality improvements + +**Deliverables:** +- YAML models with `init` accessors +- Configuration consolidation + +**Total Timeline:** 3-4 weeks + +--- + +## Data Access Patterns + +### Pattern 1: Simple CRUD (Use EF Core) + +```csharp +@inject IEntityOperationService EntityService + +private async Task OnSaveAsync() +{ + var productType = typeof(Product); + var product = new Product { Name = "Widget", Price = 9.99m }; + await EntityService.CreateAsync(productType, product); +} +``` + +**When:** +- Single entity operations +- Simple queries +- All writes (inserts, updates, deletes) + +### Pattern 2: Complex Views (Use Dapper) + +```csharp +@inject IViewService ViewService + +protected override async Task OnInitializedAsync() +{ + products = await ViewService.ExecuteViewAsync( + "ProductSalesView", + new { TopN = 50 }); +} +``` + +**When:** +- Multi-table JOINs (3+ tables) +- Aggregations (SUM, AVG, GROUP BY) +- Reports and dashboards +- Read-only queries + +--- + +## Project Structure + +``` +DotNetWebApp/ +├── sql/ +│ ├── schema.sql # DDL (entities) +│ └── views/ # SQL views (NEW) +├── app.yaml # Entity definitions +├── views.yaml # View definitions (NEW) +├── DotNetWebApp.Models/ +│ ├── Generated/ # EF entities +│ ├── ViewModels/ # Dapper DTOs (NEW) +│ └── AppDictionary/ # YAML models +├── Services/ +│ ├── IEntityOperationService.cs # EF CRUD (NEW) +│ ├── EntityOperationService.cs # (NEW) +│ └── Views/ # Dapper services (NEW) +│ ├── IViewRegistry.cs +│ ├── ViewRegistry.cs +│ ├── IViewService.cs +│ └── ViewService.cs +├── Data/ +│ ├── AppDbContext.cs # EF Core +│ └── Dapper/ # (NEW) +│ ├── IDapperQueryService.cs +│ └── DapperQueryService.cs +├── Controllers/ +│ └── EntitiesController.cs # Dynamic CRUD API +├── Components/ +│ ├── Pages/ # Blazor pages +│ └── Shared/ # Reusable components +├── ModelGenerator/ +│ ├── EntityGenerator.cs # Existing +│ └── ViewModelGenerator.cs # (NEW) +├── Makefile +├── REFACTOR.md # Complete refactoring plan +├── PHASE2_VIEW_PIPELINE.md # Phase 2 detailed guide +├── HYBRID_ARCHITECTURE.md # Architecture reference +├── ARCHITECTURE_SUMMARY.md # This file +└── CLAUDE.md # Claude Code context +``` + +--- + +## Multi-Tenancy Strategy + +### Header-Based Tenant Resolution + +```http +GET /api/entities/Product +X-Customer-Schema: customer1 + +# Resolves to: SELECT * FROM customer1.Products +``` + +### Finbuckle Configuration + +```csharp +builder.Services.AddMultiTenant() + .WithHeaderStrategy("X-Customer-Schema") + .WithInMemoryStore(/* tenants */); +``` + +### Automatic Schema Inheritance (EF → Dapper) + +```csharp +// Dapper shares EF's connection +builder.Services.AddScoped(sp => +{ + var dbContext = sp.GetRequiredService(); + return new DapperQueryService(dbContext); // ✅ Same connection +}); +``` + +**Result:** No manual schema injection needed! + +--- + +## Testing Strategy + +### Unit Tests +- `EntityOperationService` (EF Core operations) +- `ViewRegistry` (YAML loading) +- `ViewService` (view execution) +- Validation pipeline + +### Integration Tests +- Multi-tenant scenarios (EF + Dapper with different schemas) +- End-to-end API tests +- View pipeline (SQL → generated model → Blazor render) + +### Performance Tests +- Benchmark Dapper vs EF for complex JOINs +- Query profiling with Application Insights + +--- + +## Common Workflows + +### Adding a New Entity + +1. Update `schema.sql` with DDL +2. Run `make run-ddl-pipeline` +3. Run `dotnet ef migrations add AddNewEntity` +4. Run `make migrate` +5. Entity automatically available via `/api/entities/NewEntity` + +### Adding a New SQL View + +1. Create `sql/views/MyView.sql` +2. Add entry to `views.yaml` +3. Run `make run-view-pipeline` +4. Use generated `MyView.cs` in Blazor components: + ```csharp + @inject IViewService ViewService + var data = await ViewService.ExecuteViewAsync("MyView"); + ``` + +### Adding Business Logic + +1. Create server-side event handler in Blazor component: + ```csharp + private async Task OnProcessAsync(int id) + { + // Business logic in C# (not JavaScript) + await EntityService.UpdateAsync(/* ... */); + } + ``` +2. Bind to Radzen component: + ```razor + + ``` + +--- + +## Performance Optimization Guidelines + +1. **Use compiled queries for hot paths** (EF Core) +2. **Add caching to metadata services** (EntityMetadataService, ViewRegistry) +3. **Convert slow EF queries to Dapper** (after profiling) +4. **Enable query splitting for collections** (EF Core) +5. **Use Dapper for read-heavy endpoints** (dashboards, reports) + +--- + +## FAQ for Future Claude Sessions + +### Q: Should I use EF Core or Dapper for this feature? + +**A:** See "Data Access Patterns" section above. General rule: +- **Writes:** Always EF Core (via `IEntityOperationService`) +- **Simple reads:** EF Core +- **Complex reads (3+ table JOINs):** Dapper (via `IViewService`) + +### Q: How do I add a new entity? + +**A:** Update `schema.sql`, run `make run-ddl-pipeline`, run migrations. See "Common Workflows" above. + +### Q: How do I add a new SQL view? + +**A:** Create SQL file, update `views.yaml`, run `make run-view-pipeline`. See "Common Workflows" above. + +### Q: Do I need to implement a repository for each entity? + +**A:** No! `IEntityOperationService` handles all 200+ entities dynamically via reflection. + +### Q: How does multi-tenancy work with Dapper? + +**A:** Dapper shares EF Core's connection, so tenant schema is automatic. No manual injection needed. + +### Q: Should I use Clean Architecture with separate projects? + +**A:** No. We use namespace-based organization in a single project. See "Key Architectural Decisions" above. + +--- + +## Success Criteria + +After completing all phases: + +- ✅ EntitiesController reduced from 369 to ~150 lines +- ✅ SQL-first view pipeline operational +- ✅ Legacy SQL queries as source of truth +- ✅ Dapper for complex reads, EF for writes +- ✅ Finbuckle multi-tenancy with automatic Dapper schema inheritance +- ✅ All tests passing +- ✅ Blazor components use C# event handlers (no JavaScript/AJAX) +- ✅ Scalable to 200+ entities + +--- + +## Next Steps + +1. **Begin Phase 1:** Extract `IEntityOperationService` (see REFACTOR.md) +2. **After Phase 1:** Implement Phase 2 View Pipeline (see PHASE2_VIEW_PIPELINE.md) +3. **After Phase 2:** Continue with Phases 3-5 (see REFACTOR.md) + +--- + +**For detailed implementation guidance, refer to:** +- **REFACTOR.md** - All phases with code examples +- **PHASE2_VIEW_PIPELINE.md** - Step-by-step Phase 2 guide +- **HYBRID_ARCHITECTURE.md** - Architecture patterns and reference diff --git a/ARCHIVED_EF_Dapper_Hybrid__Architecture.md b/ARCHIVED_EF_Dapper_Hybrid__Architecture.md new file mode 100644 index 0000000..ff95666 --- /dev/null +++ b/ARCHIVED_EF_Dapper_Hybrid__Architecture.md @@ -0,0 +1,808 @@ +# This file follows **Microsoft Clean Architecture** standards: + +### 1. **EF Core** is isolated in the Infrastructure layer (for migrations and state). +### 2. **Dapper** is utilized in the Application layer (for fast reads and complex SQL writes). +### 3. **Blazor/Radzen** stays in the Web layer, consuming the Dapper DTOs. + +## **Implementation Blueprint: HybridArchitecture.md** + +### TASK: Implement Hybrid .NET 8/9 Clean Architecture (EF Core \+ Dapper) + +## **1. PRE-REQUISITES** +- @REFACTOR.md MUST be implemented and completed first! + +## **2. PROJECT INITIALIZATION (CLI)** +Execute these commands to build the four-tier architecture (HybridSystem is a "placeholder" for your project): + +```bash +dotnet new sln -n HybridSystem +dotnet new classlib -n HybridSystem.Domain +dotnet new classlib -n HybridSystem.Application +dotnet new classlib -n HybridSystem.Infrastructure +dotnet new blazor -n HybridSystem.WebUI --interactivity Server +dotnet sln add (ls **/*.csproj) +``` + +## **3. DEPENDENCY GRAPH** + +Configure references to ensure the Domain remains pure: +* **Application** -> Domain +* **Infrastructure** -> Application, Domain +* **WebUI** -> Infrastructure, Application + +## **4. MODULE ARCHITECTURE & STANDARDS** + +### **A. DOMAIN LAYER (POCOs)** + +* **Path**: HybridSystem.Domain/Entities/ +* **Rule**: Pure C\# classes only. No EF or Dapper references. +* **Goal**: Database-agnostic business models. + +### **B. INFRASTRUCTURE LAYER (EF/LINQ)** + +* **Path**: HybridSystem.Infrastructure/Persistence/ +* **Technology**: EF Core (Microsoft.EntityFrameworkCore.SqlServer) +* **Purpose**: Database schema management, Migrations, and Identity. +* **Best Practice**: Use this layer for "Writes" where change-tracking is needed (e.g., Simple CRUD). + +### **C. APPLICATION LAYER (DAPPER/SQL)** + +* **Path**: HybridSystem.Application/Data/ +* **Technology**: Dapper +* **Purpose**: High-performance Read models (DTOs) and Complex "Task-Writes." +* **Standard**: All DTOs for Radzen components live here. Hand-written SQL only. + +## **5. CODE IMPLEMENTATION: SHARED CONNECTION** + +Create a service in HybridSystem.Infrastructure that registers a shared IDbConnection so EF and Dapper share the same underlying pipeline: + +### 5.1 DbContext with Dynamic Entity Registration + +**File:** `Data/EF/AppDbContext.cs` + +```csharp +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using DotNetWebApp.Data.Tenancy; + +namespace DotNetWebApp.Data.EF +{ + public class AppDbContext : DbContext + { + public AppDbContext( + DbContextOptions options, + ITenantSchemaAccessor tenantSchemaAccessor) : base(options) + { + Schema = tenantSchemaAccessor.Schema; + } + + public string Schema { get; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Set default schema for multi-tenancy + if (!string.IsNullOrWhiteSpace(Schema)) + { + modelBuilder.HasDefaultSchema(Schema); + } + + // Dynamically register ALL entities from Generated namespace via reflection + var entityTypes = Assembly.GetExecutingAssembly().GetTypes() + .Where(t => t.IsClass && t.Namespace == "DotNetWebApp.Models.Generated"); + + foreach (var type in entityTypes) + { + modelBuilder.Entity(type) + .ToTable(ToPlural(type.Name)); // Product → Products + } + } + + // Pluralization logic: Category → Categories, Product → Products + private static string ToPlural(string name) + { + if (name.EndsWith("y", StringComparison.OrdinalIgnoreCase) && name.Length > 1) + { + var beforeY = name[name.Length - 2]; + if (!"aeiou".Contains(char.ToLowerInvariant(beforeY))) + { + return name[..^1] + "ies"; + } + } + return name.EndsWith("s", StringComparison.OrdinalIgnoreCase) ? name : $"{name}s"; + } + } +} +``` + +### 5.2 Shared Connection Setup in Program.cs + +**File:** `Program.cs` + +```csharp +using System.Data; +using System.Data.SqlClient; + +// EF Core with connection pooling +builder.Services.AddDbContext(options => + options.UseSqlServer( + builder.Configuration.GetConnectionString("DefaultConnection"), + sqlServerOptions => sqlServerOptions + .CommandTimeout(30) + .EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelaySeconds: 5))); + +builder.Services.AddScoped(sp => sp.GetRequiredService()); + +// Dapper: Share the same connection pool +builder.Services.AddScoped(sp => +{ + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("Connection string not found"); + return new SqlConnection(connectionString); +}); + +// Data access abstraction +builder.Services.AddScoped(); +``` + +### 5.3 Dapper Repository Interface + +**File:** `Data/Dapper/IDapperRepository.cs` + +```csharp +using System.Data; + +namespace DotNetWebApp.Data.Dapper +{ + public interface IDapperRepository + { + Task> QueryAsync(string sql, object? param = null); + Task QuerySingleAsync(string sql, object? param = null); + Task ExecuteAsync(string sql, object? param = null); + Task QueryMultipleAsync(string sql, object? param = null); + } + + public interface IMultipleQuery : IDisposable + { + IEnumerable Read(); + } +} +``` + +### 5.4 Dapper Repository Implementation + +**File:** `Data/Dapper/DapperRepository.cs` + +```csharp +using System.Data; +using Dapper; +using Microsoft.Extensions.Logging; + +namespace DotNetWebApp.Data.Dapper +{ + public class DapperRepository : IDapperRepository + { + private readonly IDbConnection _connection; + private readonly ILogger _logger; + + public DapperRepository(IDbConnection connection, ILogger logger) + { + _connection = connection; + _logger = logger; + } + + public async Task> QueryAsync(string sql, object? param = null) + { + try + { + _logger.LogDebug("Executing query: {Sql}", sql); + return await _connection.QueryAsync(sql, param); + } + catch (Exception ex) + { + _logger.LogError(ex, "Query failed: {Sql}", sql); + throw new InvalidOperationException($"Query execution failed: {ex.Message}", ex); + } + } + + public async Task QuerySingleAsync(string sql, object? param = null) + { + try + { + _logger.LogDebug("Executing single query: {Sql}", sql); + return await _connection.QuerySingleOrDefaultAsync(sql, param); + } + catch (Exception ex) + { + _logger.LogError(ex, "Query failed: {Sql}", sql); + throw new InvalidOperationException($"Query execution failed: {ex.Message}", ex); + } + } + + public async Task ExecuteAsync(string sql, object? param = null) + { + try + { + _logger.LogDebug("Executing command: {Sql}", sql); + return await _connection.ExecuteAsync(sql, param); + } + catch (Exception ex) + { + _logger.LogError(ex, "Command failed: {Sql}", sql); + throw new InvalidOperationException($"Command execution failed: {ex.Message}", ex); + } + } + + public async Task QueryMultipleAsync(string sql, object? param = null) + { + try + { + _logger.LogDebug("Executing multiple query: {Sql}", sql); + var grid = await _connection.QueryMultipleAsync(sql, param); + return new DapperMultipleQuery(grid); + } + catch (Exception ex) + { + _logger.LogError(ex, "Multiple query failed: {Sql}", sql); + throw new InvalidOperationException($"Multiple query failed: {ex.Message}", ex); + } + } + } + + public class DapperMultipleQuery : IMultipleQuery + { + private readonly SqlMapper.GridReader _grid; + + public DapperMultipleQuery(SqlMapper.GridReader grid) + { + _grid = grid; + } + + public IEnumerable Read() + { + return _grid.Read(); + } + + public void Dispose() + { + _grid?.Dispose(); + } + } +} +``` + +## **6. APPLICATION LAYER: DAPPER SERVICES** + +### 6.1 Dapper Service for Complex Reads + +When you need high-performance reads with JOINs and complex queries, create a Dapper service in the Application layer: + +**File:** `Data/Dapper/ProductDapperService.cs` + +```csharp +namespace DotNetWebApp.Data.Dapper +{ + public interface IProductDapperService + { + Task> GetProductsWithCategoriesAsync(); + Task GetSalesReportAsync(int productId); + } + + public class ProductDapperService : IProductDapperService + { + private readonly IDapperRepository _repository; + private readonly ILogger _logger; + + public ProductDapperService(IDapperRepository repository, ILogger logger) + { + _repository = repository; + _logger = logger; + } + + // Simple join query + public async Task> GetProductsWithCategoriesAsync() + { + const string sql = @" + SELECT + p.Id, + p.Name, + p.Price, + c.Name AS CategoryName + FROM Products p + LEFT JOIN Categories c ON p.CategoryId = c.Id + ORDER BY c.Name, p.Name"; + + return await _repository.QueryAsync(sql); + } + + // Complex report with CTE and aggregation + public async Task GetSalesReportAsync(int productId) + { + const string sql = @" + -- Summary stats + SELECT + p.Id, + p.Name, + COUNT(*) AS TotalOrders, + ISNULL(SUM(Quantity), 0) AS TotalQuantitySold, + ISNULL(SUM(Quantity * p.Price), 0) AS TotalRevenue + FROM Products p + LEFT JOIN OrderDetails od ON p.Id = od.ProductId + WHERE p.Id = @ProductId + GROUP BY p.Id, p.Name; + + -- Recent orders + SELECT TOP 10 + od.Id, + o.OrderDate, + od.Quantity, + CAST(od.Quantity * p.Price AS DECIMAL(18,2)) AS LineTotal + FROM OrderDetails od + JOIN Orders o ON od.OrderId = o.Id + JOIN Products p ON od.ProductId = p.Id + WHERE p.Id = @ProductId + ORDER BY o.OrderDate DESC"; + + using var multi = await _repository.QueryMultipleAsync(sql, new { ProductId = productId }); + + var summary = multi.Read().FirstOrDefault() + ?? throw new InvalidOperationException($"Product {productId} not found"); + + summary.RecentOrders = multi.Read().ToList(); + return summary; + } + } + + public class ProductReadDto + { + public int Id { get; set; } + public string? Name { get; set; } + public decimal? Price { get; set; } + public string? CategoryName { get; set; } + } + + public class ProductSalesReportDto + { + public int Id { get; set; } + public string? Name { get; set; } + public int TotalOrders { get; set; } + public int TotalQuantitySold { get; set; } + public decimal TotalRevenue { get; set; } + public List RecentOrders { get; set; } = new(); + } + + public class OrderLineDto + { + public int Id { get; set; } + public DateTime OrderDate { get; set; } + public int Quantity { get; set; } + public decimal LineTotal { get; set; } + } +} +``` + +### 6.2 Dapper Write Operations + +When a user clicks "Process" in the UI, use Dapper in the Application Layer to execute complex write operations: + +**File:** `Data/Dapper/OrderProcessingService.cs` + +```csharp +namespace DotNetWebApp.Data.Dapper +{ + public interface IOrderProcessingService + { + Task ProcessOrderAsync(int orderId); + Task UpdateBulkInventoryAsync(List<(int productId, int quantityAdjustment)> adjustments); + } + + public class OrderProcessingService : IOrderProcessingService + { + private readonly IDapperRepository _repository; + private readonly ILogger _logger; + + public OrderProcessingService(IDapperRepository repository, ILogger logger) + { + _repository = repository; + _logger = logger; + } + + // Complex write with multiple operations in single batch + public async Task ProcessOrderAsync(int orderId) + { + const string sql = @" + -- Update order status + UPDATE Orders SET Status = 'Processed', ProcessedDate = GETUTC() + WHERE Id = @OrderId; + + -- Log the event + INSERT INTO AuditLog (EntityType, EntityId, Action, Timestamp) + VALUES ('Order', @OrderId, 'Processed', GETUTC()); + + -- Update inventory from order details + UPDATE Products + SET Stock = Stock - od.Quantity + FROM Products p + JOIN OrderDetails od ON p.Id = od.ProductId + WHERE od.OrderId = @OrderId;"; + + try + { + var rowsAffected = await _repository.ExecuteAsync(sql, new { OrderId = orderId }); + _logger.LogInformation("Processed order {OrderId}. Rows affected: {RowsAffected}", + orderId, rowsAffected); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to process order {OrderId}", orderId); + throw; + } + } + + // Batch update operation + public async Task UpdateBulkInventoryAsync(List<(int productId, int quantityAdjustment)> adjustments) + { + if (!adjustments.Any()) + return; + + var sql = @" + UPDATE Products + SET Stock = Stock + @QuantityAdjustment + WHERE Id = @ProductId"; + + foreach (var (productId, adjustment) in adjustments) + { + await _repository.ExecuteAsync(sql, new { ProductId = productId, QuantityAdjustment = adjustment }); + } + + _logger.LogInformation("Updated inventory for {Count} products", adjustments.Count); + } + } +} +``` + +### 6.3 Using Dapper Services in Blazor Components + +**File:** `Components/Sections/OrderProcessingSection.razor` + +```razor +@page "/order-processing" +@inject IOrderProcessingService OrderService +@inject ILogger Logger + +
+

Order Processing

+ + @if (isProcessing) + { +

Processing order...

+ } + else if (!string.IsNullOrWhiteSpace(statusMessage)) + { +
@statusMessage
+ } + + +
+ +@code { + private bool isProcessing = false; + private string? statusMessage; + + private async Task ProcessOrderAsync() + { + isProcessing = true; + statusMessage = null; + + try + { + // This calls Dapper under the hood for high-performance processing + await OrderService.ProcessOrderAsync(123); + statusMessage = "Order processed successfully"; + } + catch (Exception ex) + { + Logger.LogError(ex, "Order processing failed"); + statusMessage = $"Error: {ex.Message}"; + } + finally + { + isProcessing = false; + } + } +} +``` + +## **7. TRANSACTION COORDINATION & ERROR HANDLING** + +### 7.1 Explicit Transactions with EF + Dapper + +When you need to coordinate operations across both ORMs: + +```csharp +public async Task ComplexBusinessOperationAsync(int productId, int quantity) +{ + using var transaction = await _dbContext.Database.BeginTransactionAsync(); + + try + { + // EF Core operation + var product = await _dbContext.Set() + .FirstOrDefaultAsync(p => p.Id == productId); + + if (product == null) + throw new InvalidOperationException($"Product {productId} not found"); + + product.Stock -= quantity; + + // Dapper operation in same transaction + const string logSql = @" + INSERT INTO ProductActivityLog (ProductId, Action, Timestamp) + VALUES (@ProductId, 'StockDecremented', GETUTC())"; + + await _dapperRepository.ExecuteAsync(logSql, new { ProductId = productId }); + + // Save both operations atomically + await _dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + + _logger.LogInformation("Operation completed for product {ProductId}", productId); + } + catch (Exception ex) + { + await transaction.RollbackAsync(); + _logger.LogError(ex, "Operation failed for product {ProductId}", productId); + throw new DataAccessException("Failed to complete operation", ex); + } + finally + { + await transaction.DisposeAsync(); + } +} +``` + +### 7.2 Error Handling Strategy + +**File:** `Exceptions/DataAccessException.cs` + +```csharp +namespace DotNetWebApp.Exceptions +{ + public class EntityNotFoundException : Exception + { + public EntityNotFoundException(string entityName, int id) + : base($"Entity '{entityName}' with ID {id} not found") { } + } + + public class InvalidEntityDataException : Exception + { + public InvalidEntityDataException(string entityName, string details) + : base($"Invalid data for entity '{entityName}': {details}") { } + } + + public class DataAccessException : Exception + { + public DataAccessException(string message, Exception innerException) + : base(message, innerException) { } + } +} +``` + +### 7.3 Dependency Injection Setup + +**File:** `Program.cs` (Complete DI configuration) + +```csharp +// Services registration in order +var builder = WebApplication.CreateBuilder(args); + +// Configuration +builder.Services.Configure( + builder.Configuration.GetSection("AppCustomization")); +builder.Services.Configure( + builder.Configuration.GetSection("TenantSchema")); + +// Web services +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddControllers(); +builder.Services.AddRazorPages(); +builder.Services.AddServerSideBlazor(); +builder.Services.AddRadzenComponents(); + +// HTTP context and client +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(sp => +{ + var navigationManager = sp.GetRequiredService(); + var handler = new HttpClientHandler(); + if (builder.Environment.IsDevelopment()) + { + handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true; + } + return new HttpClient(handler) { BaseAddress = new Uri(navigationManager.BaseUri) }; +}); + +// Infrastructure services (singletons for cached data) +builder.Services.AddSingleton(sp => +{ + var env = sp.GetRequiredService(); + var yamlPath = Path.Combine(env.ContentRootPath, "app.yaml"); + return new AppDictionaryService(yamlPath); +}); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// Database layer +builder.Services.AddDbContext(options => + options.UseSqlServer( + builder.Configuration.GetConnectionString("DefaultConnection"), + sqlServerOptions => sqlServerOptions + .CommandTimeout(30) + .EnableRetryOnFailure(maxRetryCount: 3, maxRetryDelaySeconds: 5))); + +builder.Services.AddScoped(sp => sp.GetRequiredService()); + +// Data access layer +builder.Services.AddScoped(sp => +{ + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") + ?? throw new InvalidOperationException("Connection string not found"); + return new SqlConnection(connectionString); +}); +builder.Services.AddScoped(); + +// Business logic services +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Tenancy +builder.Services.AddScoped(); + +// Build app +var app = builder.Build(); + +// Seed mode +var seedMode = args.Any(arg => + string.Equals(arg, "--seed", StringComparison.OrdinalIgnoreCase)); + +if (seedMode) +{ + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + await scope.ServiceProvider.GetRequiredService().SeedAsync(); + return; +} + +// Middleware +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); +app.UseStaticFiles(); +app.UseRouting(); +app.MapControllers(); +app.MapBlazorHub(); +app.MapFallbackToPage("/_Host"); +app.Run(); +``` + +--- + +## **8. PROJECT STRUCTURE** + +``` +DotNetWebApp/ +├── Controllers/ +│ ├── EntitiesController.cs # Generic REST API (reflection-based) +│ └── ... +├── Components/ +│ ├── Pages/ +│ │ ├── GenericEntityPage.razor # Dynamic entity pages +│ │ ├── SpaApp.razor # SPA root +│ │ └── ... +│ ├── Sections/ +│ │ ├── DashboardSection.razor +│ │ ├── OrderProcessingSection.razor +│ │ └── ... +│ └── Shared/ +│ ├── DynamicDataGrid.razor # Radzen grid with reflection +│ ├── MainLayout.razor +│ └── ... +├── Data/ +│ ├── EF/ +│ │ ├── AppDbContext.cs # EF Core with dynamic entities +│ │ └── AppDbContextFactory.cs # Design-time factory +│ ├── Dapper/ +│ │ ├── IDapperRepository.cs # Dapper abstraction +│ │ ├── DapperRepository.cs +│ │ ├── IProductDapperService.cs +│ │ ├── ProductDapperService.cs +│ │ ├── IOrderProcessingService.cs +│ │ └── OrderProcessingService.cs +│ ├── Tenancy/ +│ │ ├── ITenantSchemaAccessor.cs +│ │ └── HeaderTenantSchemaAccessor.cs +│ └── ... +├── Models/ +│ ├── Generated/ # Auto-generated entities +│ │ ├── Product.cs +│ │ ├── Category.cs +│ │ └── ... +│ ├── AppDictionary/ +│ │ ├── AppDefinition.cs +│ │ └── ... +│ └── DTOs/ +│ ├── ProductReadDto.cs +│ ├── ProductSalesReportDto.cs +│ └── ... +├── Services/ +│ ├── AppDictionaryService.cs # YAML loading (singleton) +│ ├── EntityMetadataService.cs # Entity mapping (singleton) +│ ├── EntityApiService.cs # HTTP CRUD calls (scoped) +│ ├── DashboardService.cs +│ ├── DataSeeder.cs +│ ├── SpaSectionService.cs +│ ├── AsyncUiState.cs +│ └── ... +├── Exceptions/ +│ ├── EntityNotFoundException.cs +│ ├── InvalidEntityDataException.cs +│ └── DataAccessException.cs +├── Migrations/ +│ ├── 20260125174732_InitialCreate.cs +│ └── AppDbContextModelSnapshot.cs +├── wwwroot/ +│ └── css/ +│ └── app.css +├── app.yaml # Generated from schema.sql +├── schema.sql # DDL source +├── seed.sql # Seed data +├── appsettings.json +├── Program.cs +└── DotNetWebApp.csproj +``` + +--- + +## **9. MIGRATION WORKFLOW** + +### Creating & Applying Migrations + +```bash +# After modifying app.yaml or Models/Generated/ +dotnet ef migrations add DescriptiveNameHere +dotnet ef database update + +# View pending migrations +dotnet ef migrations list + +# Revert to previous state +dotnet ef database update PreviousMigrationName +``` + +### Hybrid Guideline + +- **EF Core**: Handle schema migrations (`dotnet ef migrations add`) +- **Dapper**: Execute raw SQL for complex operations, read models, aggregations + +--- + +## **7. SUMMARY OF ARCHITECTURAL INTENT** + +This hybrid approach is designed for teams of **SQL experts**. + +* **EF Core** is used as a "Database Management Tool" (Migrations). +* **Dapper** is used as the "Application Engine" (Fast UI Data). +* **Radzen** components bind to "Flat DTOs" in the Application layer, keeping the WebUI decoupled from the physical database schema. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 9953898..8f22b27 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,27 +7,60 @@ You're an expert .NET/C# engineer with deep knowledge of: - Modern C# patterns and best practices - RESTful API design - Fullstack development with excellent programming skills in Javascript, HTML & CSS -- Database migrations and data modeling +- Database schema modeling (DDL-first) ## Project Overview -This is a .NET 8 Web API project with Entity Framework Core for data access and is an SPA (Single Page Application) using Blazor Server. +This is a .NET 8 Web API + Blazor Server SPA with Entity Framework Core and a SQL DDL-driven data model/branding configuration. + +## 🚨 IMPORTANT: Architecture Documentation (READ FIRST) + +**Before starting any refactoring or architectural work, read these documents in order:** + +1. **ARCHITECTURE_SUMMARY.md** - Quick overview of architecture decisions and current state +2. **REFACTOR.md** - Complete 5-phase refactoring plan (Phases 1-5) +3. **PHASE2_VIEW_PIPELINE.md** - Detailed implementation guide for SQL-first view pipeline +4. **HYBRID_ARCHITECTURE.md** - EF Core + Dapper architecture reference + +**Key Architectural Decisions (2026-01-26):** +- ✅ **Hybrid data access:** EF Core for writes (200+ entities), Dapper for complex reads (SQL-first views) +- ✅ **SQL-first everything:** Both entities (DDL) and views (SELECT queries) start as SQL +- ✅ **Single-project organization:** Namespace-based separation (NOT 4 separate projects) +- ✅ **Multi-tenancy:** Finbuckle.MultiTenant with automatic schema inheritance for Dapper +- ✅ **No Repository Pattern:** `IEntityOperationService` + `IViewService` provide sufficient abstraction +- ✅ **Scale target:** 200+ entities, multiple schemas, small team + +**Current Phase:** Ready to begin Phase 1 (Extract Reflection Logic to IEntityOperationService) + +## Project Goal & Session Notes +- **Primary Goal:** Use SQL DDL as the source of truth, generating `app.yaml` and C# models for dynamic customization. +- Review `SESSION_SUMMARY.md` before starting work and update it when you make meaningful progress or decisions. +- **Build Optimizations:** See `BUILD_OPTIMIZATION_SUMMARY.md` for complete details on build performance improvements (30+ min → 2-5 min) ## Key Commands -- Check/Setup: `make check` (restore and build) -- Build: `make build` +- Check/Setup: `make check` (restore and build main projects - 4-8 min) +- Build: `make build` (fast Debug build, main projects only - 2-5 min) +- Build All: `make build-all` (includes test projects - 10-20 min, higher memory) +- Build Release: `make build-release` (production build - 10-20 min) - Run (dev): `make dev` (with hot reload - use for active development) - Run (prod): `make run` (without hot reload - use for production-like testing) -- Test: `make test` -- Apply Migrations: `make migrate` -- Add Migration: `./dotnet-build.sh ef migrations add ` +- Test: `make test` (build and run tests sequentially - 10-15 min) +- Run DDL Pipeline: `make run-ddl-pipeline` (generate entity models from schema.sql) +- Run View Pipeline: `make run-view-pipeline` (generate view models from views.yaml) **[Phase 2]** +- Run All Pipelines: `make run-all-pipelines` (both entity and view generation) **[Phase 2]** +- Apply Migration: `make migrate` - Docker Build: `make docker-build` -- Clean: `make clean` +- Clean: `make clean` (cleans build outputs + stops build servers + stops dev sessions) +- Stop Dev: `make stop-dev` (kills orphaned `dotnet watch` processes) +- Shutdown Build Servers: `make shutdown-build-servers` (kills MSBuild/Roslyn processes) + +**Important:** Default `make build` excludes test projects to prevent OOM errors. Use `make build-all` if you need tests built. ## Build Commands -- The project uses a Makefile with the following targets: `check`, `build`, `dev`, `run`, `test`, `migrate`, `docker-build`, `clean` +- The project uses a Makefile with the following targets: `check`, `build`, `dev`, `run`, `test`, `migrate`, `docker-build`, `clean`, `stop-dev`, `shutdown-build-servers` - The dotnet-build.sh script is located in the project root and handles global.json SDK version conflicts - Use `make ` for standard operations - Use `./dotnet-build.sh ` directly only for advanced dotnet CLI operations not covered by Makefile targets +- **Process cleanup:** If you notice accumulating dotnet processes, run `make clean` (full cleanup) or individually `make stop-dev` / `make shutdown-build-servers` ## SDK Version Management The project uses `dotnet-build.sh` wrapper script to handle SDK version conflicts between Windows and WSL environments. Different developers may have different .NET SDK versions installed (e.g., from Snap, apt-get, or native installers). The wrapper temporarily bypasses `global.json` version enforcement during local development, allowing flexibility while keeping the version specification in place for CI/CD servers. @@ -35,38 +68,134 @@ The project uses `dotnet-build.sh` wrapper script to handle SDK version conflict **For Windows + WSL developers**: Install any supported .NET 8.x version locally. The wrapper script handles compatibility. CI/CD and production use the exact version specified in `global.json`. ## Project Structure -- Controllers/ - API controllers -- Models/ - Data models and DTOs -- Data/ - DbContext and data access -- Migrations/ - EF Core migrations -- Pages/ - Blazor host pages and layouts (_Host.cshtml, _Layout.cshtml) -- Components/Pages/ - Blazor routable pages (Home.razor, SpaApp.razor) -- Components/Sections/ - SPA section components (Dashboard, Products, Settings) -- Shared/ - Shared Blazor components (MainLayout.razor, NavMenu.razor) -- wwwroot/ - Static files (CSS, favicon, etc.) -- _Imports.razor - Global Blazor using statements +``` +DotNetWebApp/ +├── sql/ +│ ├── schema.sql # 📋 SQL DDL source (entities) +│ └── views/ # 🆕 SQL SELECT queries for complex views (Phase 2) +│ ├── ProductSalesView.sql +│ └── ... +├── Controllers/ # API endpoints (EntitiesController, etc.) +├── Components/ +│ ├── Pages/ # Routable Blazor pages (Home.razor, SpaApp.razor) +│ └── Sections/ # SPA components (Dashboard, Settings, Entity, etc.) +├── Data/ +│ ├── AppDbContext.cs # EF Core DbContext with dynamic entity discovery +│ ├── DataSeeder.cs # Executes seed.sql via EF +│ └── Dapper/ # 🆕 Dapper infrastructure (Phase 2) +│ ├── IDapperQueryService.cs +│ └── DapperQueryService.cs +├── DotNetWebApp.Models/ # 🔄 Separate models assembly (extracted from main project) +│ ├── Generated/ # 🔄 Auto-generated entities from app.yaml (Product.cs, Category.cs, etc.) +│ ├── ViewModels/ # 🆕 Auto-generated view models from views.yaml (Phase 2) +│ ├── AppDictionary/ # YAML model classes (AppDefinition.cs, Entity.cs, Property.cs, etc.) +│ ├── AppCustomizationOptions.cs # App customization settings +│ ├── DashboardSummary.cs # Dashboard data model +│ ├── DataSeederOptions.cs # Data seeder configuration +│ ├── EntityMetadata.cs # Entity metadata record +│ ├── SpaSection.cs # SPA section model +│ └── SpaSectionInfo.cs # SPA section info model +├── Services/ +│ ├── AppDictionaryService.cs # Loads and caches app.yaml +│ ├── IEntityMetadataService.cs # Maps YAML entities to CLR types +│ ├── EntityMetadataService.cs # Implementation +│ ├── IEntityOperationService.cs # 🆕 EF CRUD operations (Phase 1) +│ ├── EntityOperationService.cs # 🆕 Implementation (Phase 1) +│ └── Views/ # 🆕 Dapper view services (Phase 2) +│ ├── IViewRegistry.cs +│ ├── ViewRegistry.cs +│ ├── IViewService.cs +│ └── ViewService.cs +├── Migrations/ # Generated EF Core migrations +├── Pages/ # Blazor host pages (_Host.cshtml, _Layout.cshtml) +├── Shared/ # Shared Blazor components (MainLayout.razor, NavMenu.razor, GenericEntityPage.razor, DynamicDataGrid.razor) +├── DdlParser/ # SQL DDL → YAML converter (separate console project) +│ ├── Program.cs +│ ├── SqlDdlParser.cs +│ ├── CreateTableVisitor.cs +│ ├── TypeMapper.cs +│ └── YamlGenerator.cs +├── ModelGenerator/ # YAML → C# generator (separate console project) +│ ├── EntityGenerator.cs # Entities from app.yaml (existing) +│ └── ViewModelGenerator.cs # 🆕 Views from views.yaml (Phase 2) +├── tests/ +│ ├── DotNetWebApp.Tests/ # Unit/integration tests +│ └── ModelGenerator.Tests/ # Model generator path resolution tests +├── wwwroot/ # Static files (CSS, JS, images) +├── _Imports.razor # Global Blazor using statements +├── app.yaml # 📋 Entity definitions (from SQL DDL) +├── views.yaml # 🆕 View definitions (from SQL SELECT queries) (Phase 2) +├── schema.sql # Sample SQL DDL for testing DDL parser +├── seed.sql # Sample seed data (Categories, Products) +├── Makefile # Build automation +├── dotnet-build.sh # .NET SDK version wrapper +├── REFACTOR.md # 🆕 Complete 5-phase refactoring plan +├── PHASE2_VIEW_PIPELINE.md # 🆕 Detailed Phase 2 implementation guide +├── HYBRID_ARCHITECTURE.md # 🆕 EF+Dapper architecture reference +├── ARCHITECTURE_SUMMARY.md # 🆕 Quick architecture overview +├── DotNetWebApp.sln # Solution file (includes all projects) +└── DotNetWebApp.csproj # Main project file +``` ## Current State -- Basic product API with CRUD operations -- Entity Framework configured with Products model using .NET 8 with wildcard package versions (`8.*`) -- Initial migration checked in and ready to apply with `make migrate` -- Blazor Server SPA configured with basic layout and navigation -- API endpoints accessible via /swagger/index.html -- Main SPA application at /app route with three sections: - - Dashboard: Metrics and activity overview - - Products: AJAX-loaded product management with CRUD operations - - Settings: Application configuration interface -- Client-side navigation with no page reloads between sections -- HttpClient configured for API communication -- Makefile provides convenient targets for all common operations + +### ✅ Completed Features +- **DDL-driven data model:** SQL DDL generates `app.yaml` and entity models +- **Model Generation:** `ModelGenerator` reads `app.yaml` and generates C# entities with nullable value types for optional fields +- **Modular Architecture:** Models extracted to separate `DotNetWebApp.Models` assembly for better separation of concerns +- **Dynamic Data Layer:** `AppDbContext` discovers entities via reflection and pluralizes table names (e.g., `Product` → `Products`) +- **Dynamic Entity API:** `EntitiesController` provides CRUD endpoints at `/api/entities/{entityName}` and `/api/entities/{entityName}/count` +- **Optional SPA example:** Toggle the `/app` routes via `AppCustomization:EnableSpaExample` in `appsettings.json` +- **Generic CRUD UI:** `GenericEntityPage.razor` + `DynamicDataGrid.razor` render dynamic data grids from YAML definitions +- **Dynamic Navigation:** `NavMenu.razor` renders "Data" section with links to all entities via `AppDictionaryService` +- **DDL to YAML Parser:** Complete pipeline (DdlParser → app.yaml → ModelGenerator → DotNetWebApp.Models/Generated) + - Converts SQL Server DDL files to `app.yaml` format + - Handles table definitions, constraints, foreign keys, IDENTITY columns, DEFAULT values + - Pipeline target: `make run-ddl-pipeline` executes the full workflow +- **Entity Metadata Service:** `IEntityMetadataService` maps app.yaml entities to CLR types for API/UI reuse +- **Seed Data System:** `DataSeeder` executes `seed.sql` once schema exists + - Run with: `make seed` + - Guards against duplicate inserts +- **Tenant Schema Support:** Multi-schema via `X-Customer-Schema` header (defaults to `dbo`) +- **Unit Tests:** `DotNetWebApp.Tests` covers DataSeeder with SQLite-backed integration tests; `ModelGenerator.Tests` validates path resolution +- **Shell Script Validation:** `make check` runs `shellcheck` on setup.sh, dotnet-build.sh, and verify.sh +- **Build Passes:** `make check` and `make build` pass; `make test` passes with Release config +- **Build Optimization:** `cleanup-nested-dirs` Makefile target prevents inotify exhaustion on Linux systems +- **Docker Support:** Makefile includes Docker build and SQL Server container commands + +### ⚠️ Current Limitations / WIP +- Generated models folder (`DotNetWebApp.Models/Generated/`) is empty initially; populated by `make run-ddl-pipeline` or manual `ModelGenerator` run +- Branding currently mixed between `appsettings.json` and `app.yaml` (could be fully moved to YAML) +- Composite primary keys not supported in DDL parser (single column PKs only) +- CHECK and UNIQUE constraints ignored by DDL parser +- Computed columns ignored by DDL parser + +### 🔧 Development Status +- All Makefile targets working (`check`, `build`, `dev`, `run`, `test`, `migrate`, `seed`, `docker-build`, `db-start`, `db-stop`, `db-drop`, `stop-dev`, `shutdown-build-servers`) +- `dotnet-build.sh` wrapper manages .NET SDK version conflicts across Windows/WSL/Linux +- `make migrate` requires SQL Server running and valid connection string +- Session tracking via `SESSION_SUMMARY.md` for LLM continuity between sessions + +### ⚠️ Known Process Management Pitfalls +- **MSBuild node reuse:** `dotnet build` spawns MSBuild node processes (`/nodeReuse:true`) that persist after builds. Use `make shutdown-build-servers` to force-kill them. +- **dotnet build-server shutdown limitations:** The `dotnet build-server shutdown` command claims success but orphaned MSBuild/Roslyn processes may not actually terminate. Our `shutdown-build-servers` target force-kills stuck processes after attempting graceful shutdown. +- **dotnet watch signal handling:** `dotnet watch` catches SIGTERM (default `kill` signal) for graceful shutdown but ignores it when orphaned/detached. Must use `kill -9` (SIGKILL) to terminate. Use `make stop-dev` which handles this correctly. +- **Zombie processes:** Killed processes may become zombies (``) until parent reaps them. These are harmless and don't consume resources. +- **Process accumulation:** Running multiple `make` commands (especially `test`, `run-ddl-pipeline`) without cleanup causes dotnet process accumulation. Run `make clean` periodically or `make stop-dev` + `make shutdown-build-servers` as needed. +- **Wrapper script processes:** The `dotnet-build.sh` wrapper may leave bash process entries after termination. These typically become zombies and don't need manual cleanup. ## Architecture Notes -- Hybrid architecture: Web API backend + Blazor Server frontend -- SignalR connection for Blazor Server real-time updates -- Shared data access through Entity Framework -- SPA uses component-based architecture with section-specific components -- CSS animations defined in wwwroot/css/app.css (pulse, spin, slideIn) -- Product model defined inline in SpaApp.razor (should be moved to Models/ folder) +- **Hybrid architecture:** ASP.NET Core Web API backend + Blazor Server SPA frontend +- **SignalR connection:** Real-time updates between client and server +- **Entity Framework Core:** Dynamic model registration via reflection; DbContext discovers entities at startup +- **REST API design:** `EntitiesController` serves dynamic endpoints at `/api/entities/{entityName}` +- **UI architecture:** Generic Blazor pages (`GenericEntityPage.razor`) with reusable data grid components +- **YAML-driven generation:** `ModelGenerator` reads `app.yaml` → generates entities → migration generated for schema application +- **DDL parser pipeline:** SQL Server DDL → `app.yaml` → C# entities → migration generation +- **Data model:** All entities support IDENTITY primary keys, nullable value types for optional fields, foreign key relationships +- **Multi-tenancy:** Schema switching via `X-Customer-Schema` HTTP header +- **CSS:** Global animations (pulse, spin, slideIn) in `wwwroot/css/app.css` +- **Dependency injection:** Services registered in `Program.cs` (DbContext, AppDictionaryService, EntityMetadataService, DataSeeder) ## Secrets Management - Project uses **User Secrets** for local development (see SECRETS.md for details) @@ -74,13 +203,50 @@ The project uses `dotnet-build.sh` wrapper script to handle SDK version conflict - `setup.sh` script automatically configures User Secrets when setting up SQL Server - Manual management: `dotnet user-secrets list`, `dotnet user-secrets set`, etc. +## Key Files and Their Purposes + +| File | Purpose | +|------|---------| +| `app.yaml` | 📋 Generated data model and theme configuration (from SQL DDL) | +| `DotNetWebApp.Models/` | 🔄 Separate models assembly containing all data models and configuration classes | +| `DotNetWebApp.Models/Generated/` | 🔄 Auto-generated C# entities (don't edit manually) | +| `DotNetWebApp.Models/AppDictionary/` | YAML model classes for app.yaml structure | +| `schema.sql` | Sample SQL DDL demonstrating Categories/Products schema; used by `make run-ddl-pipeline` | +| `seed.sql` | Sample seed data INSERT statements for default schema; executed by `make seed` | +| `Data/AppDbContext.cs` | EF Core DbContext that discovers generated entities via reflection | +| `Services/AppDictionaryService.cs` | Loads and caches `app.yaml` for runtime access to entity definitions | +| `Services/IEntityMetadataService.cs` | Maps YAML entity names to CLR types for API/UI | +| `Controllers/EntitiesController.cs` | Dynamic controller providing CRUD endpoints for all entities | +| `Components/Shared/GenericEntityPage.razor` | Reusable page component for rendering any entity's CRUD UI | +| `Components/Shared/DynamicDataGrid.razor` | Dynamic data grid component that renders columns from YAML definitions | +| `DdlParser/` | Console project: SQL DDL → `app.yaml` (standalone, not compiled into main app) | +| `ModelGenerator/` | Console project: YAML → C# entities (run separately when updating models) | +| `Makefile` | Build automation with targets for check, build, dev, test, migrate, seed, docker, cleanup-nested-dirs | +| `dotnet-build.sh` | Wrapper script managing .NET SDK version conflicts across environments | + +## Recent Development History (git log) + +Recent commits show the project has evolved through: +1. **Foundation (earlier commits):** Initial Blazor Server + API setup, Docker integration, self-signed certs +2. **Data Model Generation:** Introduction of YAML-driven approach with ModelGenerator +3. **DDL Parser Pipeline:** SQL DDL → YAML → C# entities workflow +4. **Entity Metadata Service:** System for mapping YAML entities to CLR types +5. **Seed Data Implementation:** Integration of sample data seeding +6. **Unit Tests:** Test suite covering seed logic and integration scenarios +7. **Models Extraction (2026-01-25):** Models moved to separate `DotNetWebApp.Models` project for better separation of concerns (commits: `552127d`, `601f84d`) +8. **Build Optimization (2026-01-25):** Added `cleanup-nested-dirs` Makefile target to prevent inotify exhaustion on Linux +9. **Documentation Expansion (2026-01-25):** SKILLS.md significantly expanded with comprehensive guides; SESSION_SUMMARY.md simplified to documentation index + +Latest work focuses on modular architecture and comprehensive developer documentation. + ## Development Notes - Development occurs on both Windows and WSL (Ubuntu/Debian via apt-get) - global.json specifies .NET 8.0.410 as the target version -- New developer setup: Run `./setup.sh` to install SQL Server and configure secrets, then `make check` and `make migrate` -- For new migrations, use: `./dotnet-build.sh ef migrations add ` -- The dotnet-build.sh wrapper script temporarily hides global.json during execution, allowing local development flexibility while supporting CI/CD servers with strict version requirements -- dotnet-build.sh validates that both `dotnet` and `dotnet-ef` CLIs are installed before execution -- All build errors have been resolved and application compiles successfully -- Makefile uses the wrapper script for consistency across all dotnet operations -- Package versions use wildcards (`8.*`) to support flexibility across different developer environments while maintaining .NET 8 compatibility \ No newline at end of file +- New developer setup: Run `./setup.sh`, then `make check`, `make db-start` (if Docker), `make run-ddl-pipeline`, and `make migrate` +- `dotnet-build.sh` sets `DOTNET_ROOT` for global tools and temporarily hides global.json during execution +- `make check` runs `shellcheck` on all shell scripts (setup.sh, dotnet-build.sh, verify.sh) before restore/build +- `make migrate` requires SQL Server running and a valid connection string; `dotnet-ef` may warn about version mismatches +- `make cleanup-nested-dirs` removes nested project directories created by MSBuild to prevent inotify watch exhaustion on Linux (runs automatically after `make build-all` and `make test`) +- Makefile uses the wrapper script for consistency across all dotnet operations; do not modify the system .NET runtime +- Package versions use wildcards (`8.*`) to support flexibility across different developer environments while maintaining .NET 8 compatibility +- Models are in separate `DotNetWebApp.Models` project; YamlDotNet dependency lives there (removed from main project) diff --git a/Components/Pages/GenericEntityPage.razor b/Components/Pages/GenericEntityPage.razor new file mode 100644 index 0000000..99c9e09 --- /dev/null +++ b/Components/Pages/GenericEntityPage.razor @@ -0,0 +1,67 @@ +@page "/{EntityName}" +@inject IEntityApiService EntityApi +@inject IEntityMetadataService EntityMetadataService + +@if (isLoading) +{ +

Loading...

+} +else if (!string.IsNullOrWhiteSpace(errorMessage)) +{ +

@errorMessage

+} +else if (!string.IsNullOrWhiteSpace(EntityName)) +{ +

@EntityName

+ + @if (entities != null) + { + + } +} + +@code { + [Parameter] + public string? EntityName { get; set; } + + private IReadOnlyList? entities; + + private bool isLoading; + private string? errorMessage; + + protected override async Task OnParametersSetAsync() + { + isLoading = true; + errorMessage = null; + entities = null; + + if (string.IsNullOrWhiteSpace(EntityName)) + { + errorMessage = "Entity not specified."; + isLoading = false; + return; + } + + var metadata = EntityMetadataService.Find(EntityName); + if (metadata == null || metadata.ClrType == null) + { + errorMessage = $"No model type found for '{EntityName}'."; + isLoading = false; + return; + } + + try + { + var result = await EntityApi.GetEntitiesAsync(metadata.Definition.Name); + entities = result.ToList().AsReadOnly(); + } + catch (Exception ex) + { + errorMessage = $"Failed to load {metadata.Definition.Name} data: {ex.Message}"; + } + finally + { + isLoading = false; + } + } +} diff --git a/Components/Pages/Home.razor b/Components/Pages/Home.razor index 743979a..053b2d9 100644 --- a/Components/Pages/Home.razor +++ b/Components/Pages/Home.razor @@ -1,7 +1,8 @@ @page "/" +@inject IAppDictionaryService AppDictionary -.NET SPA +@AppDictionary.AppDefinition.App.Title -

Hello, SPA!

+

Welcome to @AppDictionary.AppDefinition.App.Name!

-Welcome to your new Blazor Server app, built with free Radzen UI components atop a .NET Web API backend. +Welcome to your new Blazor Server app, built with free Radzen UI components atop a .NET Web API backend. diff --git a/Components/Pages/SpaApp.razor b/Components/Pages/SpaApp.razor index bee2eb7..a30c7b5 100644 --- a/Components/Pages/SpaApp.razor +++ b/Components/Pages/SpaApp.razor @@ -1,38 +1,62 @@ @page "/app" @page "/app/{Section?}" -@inject NavigationManager Navigation @inject ISpaSectionService SpaSections -@inject IProductService ProductService +@inject IEntityMetadataService EntityMetadataService +@inject IOptions AppOptions DotNet SPA - - - @if (activeSection == SpaSection.Dashboard) + @if (!IsSpaEnabled) + { + + + + + + + } + else if (activeSection == null) { - + + + + + + } - else if (activeSection == SpaSection.Products) + else if (isEntitySection && activeEntityName != null) { - + + } - else if (activeSection == SpaSection.Settings) + else { - + + + @if (activeSection.Section == SpaSection.Dashboard) + { + + } + else if (activeSection.Section == SpaSection.Settings) + { + + } } - -@code { - private SpaSection activeSection = SpaSection.Dashboard; + +@code { + private SpaSectionInfo? activeSection; + private string? activeEntityName; + private bool isEntitySection => activeSection?.Section == SpaSection.Entity && activeEntityName != null; private AsyncUiState? loadingState; - private IReadOnlyList? products; private bool IsLoading => loadingState?.IsBusy == true; + private bool IsSpaEnabled => AppOptions.Value.EnableSpaExample; [Parameter] public string? Section { get; set; } @@ -44,11 +68,42 @@ protected override async Task OnParametersSetAsync() { - var requestedSection = SpaSections.FromRouteSegment(Section) ?? SpaSections.DefaultSection; - await LoadSection(requestedSection); + if (!IsSpaEnabled) + { + activeSection = null; + activeEntityName = null; + return; + } + + var segment = Section?.Trim(); + if (string.IsNullOrEmpty(segment)) + { + // Default to the first SPA section. + activeSection = SpaSections.DefaultSection; + activeEntityName = null; + return; + } + + // Try to match static section first + var section = SpaSections.FromRouteSegment(segment); + if (section != null) + { + activeSection = section; + activeEntityName = section.Section == SpaSection.Entity ? section.EntityName : null; + + if (section.Section != SpaSection.Entity) + { + await LoadSection(section); + } + return; + } + + // Treat any non-static route segment as an entity name. + activeSection = new SpaSectionInfo(SpaSection.Entity, segment, segment, segment, segment); + activeEntityName = segment; } - private async Task LoadSection(SpaSection section) + private async Task LoadSection(SpaSectionInfo section) { if (activeSection == section && !IsLoading) { @@ -60,20 +115,8 @@ await loadingState!.RunAsync(async () => { SpaSections.NavigateTo(section); - - if (section == SpaSection.Products) - { - await LoadProducts(); - } - else - { - await Task.Delay(500); - } + await Task.Delay(500); }); } - private async Task LoadProducts() - { - products = await ProductService.GetProductsAsync(); - } } diff --git a/Components/Sections/DashboardSection.razor b/Components/Sections/DashboardSection.razor index a61353d..5510d98 100644 --- a/Components/Sections/DashboardSection.razor +++ b/Components/Sections/DashboardSection.razor @@ -1,20 +1,26 @@ - - - - - @if (isLoading) - { - - } - else - { - - } - - - + @foreach (var entityMeta in EntityMetadataService.Entities) + { + var displayName = entityMeta.Definition.Name; + var count = entityCountsByName.GetValueOrDefault(displayName, 0); + + + + + + @if (isLoading) + { + + } + else + { + + } + + + + } @@ -61,16 +67,22 @@ @inject IDashboardService DashboardService +@inject IEntityMetadataService EntityMetadataService @code { private DashboardSummary summary = new(); private bool isLoading = true; + private IReadOnlyDictionary entityCountsByName = new Dictionary(StringComparer.OrdinalIgnoreCase); protected override async Task OnInitializedAsync() { try { summary = await DashboardService.GetSummaryAsync(); + entityCountsByName = summary.EntityCounts.ToDictionary( + entityCount => entityCount.EntityName, + entityCount => entityCount.Count, + StringComparer.OrdinalIgnoreCase); } finally { diff --git a/Components/Sections/EntitySection.razor b/Components/Sections/EntitySection.razor new file mode 100644 index 0000000..80e9e29 --- /dev/null +++ b/Components/Sections/EntitySection.razor @@ -0,0 +1,71 @@ +@inject IEntityApiService EntityApiService +@inject IEntityMetadataService EntityMetadataService + + + + @if (!string.IsNullOrEmpty(errorMessage)) + { + + @errorMessage + + } + else if (isLoading) + { + + + + + } + else if (entities != null && entities.Count > 0) + { + + } + else + { + + } + + + +@code { + [Parameter] + public string EntityName { get; set; } = string.Empty; + + private IReadOnlyList? entities; + private bool isLoading; + private string? errorMessage; + + protected override async Task OnParametersSetAsync() + { + if (string.IsNullOrWhiteSpace(EntityName)) + { + errorMessage = "Entity not specified."; + return; + } + + isLoading = true; + errorMessage = null; + entities = null; + + try + { + var metadata = EntityMetadataService.Find(EntityName); + if (metadata == null) + { + errorMessage = $"Entity '{EntityName}' not found"; + return; + } + + var result = await EntityApiService.GetEntitiesAsync(metadata.Definition.Name); + entities = result.ToList().AsReadOnly(); + } + catch (Exception ex) + { + errorMessage = $"Error loading {EntityName}: {ex.Message}"; + } + finally + { + isLoading = false; + } + } +} diff --git a/Components/Sections/ProductsSection.razor b/Components/Sections/ProductsSection.razor deleted file mode 100644 index 2edf3b5..0000000 --- a/Components/Sections/ProductsSection.razor +++ /dev/null @@ -1,86 +0,0 @@ -@using DotNetWebApp.Components.Pages - - - - - - - - - - - - @if (Products == null || Products.Count == 0) - { - @if (IsLoading) - { - - - - - } - else - { - - - - - } - } - else - { - - - - - - - - - - - Actions - - - - - } - - - -@code { - [Parameter] public IReadOnlyList? Products { get; set; } - [Parameter] public bool IsLoading { get; set; } - [Parameter] public EventCallback OnRefresh { get; set; } - - private async Task AddNewProduct() - { - // In a real app, this would open a modal or navigate to an add form - await Task.Delay(100); // Placeholder - Console.WriteLine("Add new product clicked"); - } - - private async Task EditProduct(int productId) - { - // In a real app, this would open edit modal or form - await Task.Delay(100); // Placeholder - Console.WriteLine($"Edit product {productId} clicked"); - } - - private async Task DeleteProduct(int productId) - { - // In a real app, this would show confirmation and delete - await Task.Delay(100); // Placeholder - Console.WriteLine($"Delete product {productId} clicked"); - } -} diff --git a/Components/Shared/DynamicDataGrid.razor b/Components/Shared/DynamicDataGrid.razor new file mode 100644 index 0000000..69c84fb --- /dev/null +++ b/Components/Shared/DynamicDataGrid.razor @@ -0,0 +1,65 @@ +@using System.Reflection +@using Microsoft.AspNetCore.Components.Rendering +@inject IEntityMetadataService EntityMetadataService + +@if (DataType == null || Entities == null) +{ +

Loading...

+} +else +{ + @Grid +} + +@code { + [Parameter] + public string? EntityName { get; set; } + + [Parameter] + public IReadOnlyList? Entities { get; set; } + + private Type? DataType + { + get + { + if (string.IsNullOrWhiteSpace(EntityName)) + { + return null; + } + + return EntityMetadataService.Find(EntityName)?.ClrType; + } + } + + private RenderFragment Grid => builder => + { + if (DataType == null || Entities == null) + { + return; + } + + var gridType = typeof(RadzenDataGrid<>).MakeGenericType(DataType); + var castMethod = typeof(Enumerable) + .GetMethod(nameof(Enumerable.Cast), BindingFlags.Public | BindingFlags.Static)? + .MakeGenericMethod(DataType); + var typedEntities = castMethod?.Invoke(null, new object[] { Entities }); + + builder.OpenComponent(0, gridType); + builder.AddAttribute(1, "Data", typedEntities); + builder.AddAttribute(2, "AllowFiltering", true); + builder.AddAttribute(3, "AllowPaging", true); + builder.AddAttribute(4, "AllowSorting", true); + builder.AddAttribute(5, "Columns", (RenderFragment)(builder2 => + { + foreach (var property in DataType.GetProperties()) + { + var columnType = typeof(RadzenDataGridColumn<>).MakeGenericType(DataType); + builder2.OpenComponent(0, columnType); + builder2.AddAttribute(1, "Property", property.Name); + builder2.AddAttribute(2, "Title", property.Name); + builder2.CloseComponent(); + } + })); + builder.CloseComponent(); + }; +} diff --git a/Controllers/EntitiesController.cs b/Controllers/EntitiesController.cs new file mode 100644 index 0000000..56e1c06 --- /dev/null +++ b/Controllers/EntitiesController.cs @@ -0,0 +1,369 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using DotNetWebApp.Services; +using DotNetWebApp.Models; +using System.Collections; +using System.Reflection; +using System.Text.Json; + +namespace DotNetWebApp.Controllers +{ + [ApiController] + [Route("api/entities")] + public class EntitiesController : ControllerBase + { + private readonly DbContext _context; + private readonly IEntityMetadataService _metadataService; + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public EntitiesController( + DbContext context, + IEntityMetadataService metadataService) + { + _context = context; + _metadataService = metadataService; + } + + private async Task ExecuteToListAsync(Type entityType, IQueryable query) + { + var methods = typeof(EntityFrameworkQueryableExtensions) + .GetMethods() + .Where(m => m.Name == nameof(EntityFrameworkQueryableExtensions.ToListAsync) + && m.IsGenericMethodDefinition + && m.GetParameters().Length == 2) + .ToArray(); + + if (methods.Length == 0) + { + throw new InvalidOperationException("Failed to find ToListAsync method"); + } + + var toListAsyncMethod = methods[0].MakeGenericMethod(entityType); + + var task = (Task)toListAsyncMethod.Invoke(null, new object[] { query, CancellationToken.None })!; + await task; + + var resultProperty = task.GetType().GetProperty("Result"); + if (resultProperty == null) + { + throw new InvalidOperationException("Failed to extract result from Task"); + } + + return (IList)resultProperty.GetValue(task)!; + } + + private async Task ExecuteCountAsync(Type entityType, IQueryable query) + { + var methods = typeof(EntityFrameworkQueryableExtensions) + .GetMethods() + .Where(m => m.Name == nameof(EntityFrameworkQueryableExtensions.CountAsync) + && m.IsGenericMethodDefinition + && m.GetParameters().Length == 2 + && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IQueryable<>)) + .ToArray(); + + if (methods.Length == 0) + { + throw new InvalidOperationException("Failed to find CountAsync method"); + } + + var countAsyncMethod = methods[0].MakeGenericMethod(entityType); + + var task = (Task)countAsyncMethod.Invoke(null, new object[] { query, CancellationToken.None })!; + return await task; + } + + [HttpGet("{entityName}")] + public async Task GetEntities(string entityName) + { + var metadata = _metadataService.Find(entityName); + if (metadata == null || metadata.ClrType == null) + { + return NotFound(new { error = $"Entity '{entityName}' not found" }); + } + + var dbSet = GetDbSet(metadata.ClrType); + var list = await ExecuteToListAsync(metadata.ClrType, dbSet); + + return Ok(list); + } + + private IQueryable GetDbSet(Type entityType) + { + var setMethod = typeof(DbContext) + .GetMethod(nameof(DbContext.Set), Type.EmptyTypes) + ?.MakeGenericMethod(entityType); + + if (setMethod == null) + { + throw new InvalidOperationException($"Failed to resolve Set method for type {entityType.Name}"); + } + + return (IQueryable)setMethod.Invoke(_context, null)!; + } + + [HttpGet("{entityName}/count")] + public async Task> GetEntityCount(string entityName) + { + var metadata = _metadataService.Find(entityName); + if (metadata == null || metadata.ClrType == null) + { + return NotFound(new { error = $"Entity '{entityName}' not found" }); + } + + var dbSet = GetDbSet(metadata.ClrType); + var count = await ExecuteCountAsync(metadata.ClrType, dbSet); + + return Ok(count); + } + + [HttpPost("{entityName}")] + public async Task CreateEntity(string entityName) + { + var metadata = _metadataService.Find(entityName); + if (metadata == null || metadata.ClrType == null) + { + return NotFound(new { error = $"Entity '{entityName}' not found" }); + } + + using var reader = new StreamReader(Request.Body); + var json = await reader.ReadToEndAsync(); + + if (string.IsNullOrWhiteSpace(json)) + { + return BadRequest(new { error = "Request body is empty" }); + } + + object? entity; + try + { + entity = JsonSerializer.Deserialize(json, metadata.ClrType, _jsonOptions); + } + catch (JsonException ex) + { + return BadRequest(new { error = $"Invalid JSON: {ex.Message}" }); + } + + if (entity == null) + { + return BadRequest(new { error = "Failed to deserialize entity" }); + } + + var dbSet = GetDbSet(metadata.ClrType); + var addMethod = dbSet.GetType().GetMethod("Add"); + if (addMethod == null) + { + throw new InvalidOperationException($"Failed to resolve Add method for type {metadata.ClrType.Name}"); + } + + addMethod.Invoke(dbSet, new[] { entity }); + await _context.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetEntities), + new { entityName = entityName }, + entity); + } + + [HttpGet("{entityName}/{id}")] + public async Task GetEntityById(string entityName, string id) + { + var metadata = _metadataService.Find(entityName); + if (metadata == null || metadata.ClrType == null) + { + return NotFound(new { error = $"Entity '{entityName}' not found" }); + } + + var pkProperty = GetPrimaryKeyProperty(metadata); + if (pkProperty == null) + { + return BadRequest(new { error = $"Entity '{entityName}' does not have a primary key defined" }); + } + + object? pkValue; + try + { + pkValue = ConvertPrimaryKeyValue(id, pkProperty); + } + catch (Exception ex) + { + return BadRequest(new { error = $"Invalid primary key value: {ex.Message}" }); + } + + var entity = await FindEntityByPrimaryKey(metadata.ClrType, pkValue); + if (entity == null) + { + return NotFound(new { error = $"Entity with id '{id}' not found" }); + } + + return Ok(entity); + } + + [HttpPut("{entityName}/{id}")] + public async Task UpdateEntity(string entityName, string id) + { + var metadata = _metadataService.Find(entityName); + if (metadata == null || metadata.ClrType == null) + { + return NotFound(new { error = $"Entity '{entityName}' not found" }); + } + + var pkProperty = GetPrimaryKeyProperty(metadata); + if (pkProperty == null) + { + return BadRequest(new { error = $"Entity '{entityName}' does not have a primary key defined" }); + } + + object? pkValue; + try + { + pkValue = ConvertPrimaryKeyValue(id, pkProperty); + } + catch (Exception ex) + { + return BadRequest(new { error = $"Invalid primary key value: {ex.Message}" }); + } + + var existingEntity = await FindEntityByPrimaryKey(metadata.ClrType, pkValue); + if (existingEntity == null) + { + return NotFound(new { error = $"Entity with id '{id}' not found" }); + } + + using var reader = new StreamReader(Request.Body); + var json = await reader.ReadToEndAsync(); + + if (string.IsNullOrWhiteSpace(json)) + { + return BadRequest(new { error = "Request body is empty" }); + } + + object? updatedEntity; + try + { + updatedEntity = JsonSerializer.Deserialize(json, metadata.ClrType, _jsonOptions); + } + catch (JsonException ex) + { + return BadRequest(new { error = $"Invalid JSON: {ex.Message}" }); + } + + if (updatedEntity == null) + { + return BadRequest(new { error = "Failed to deserialize entity" }); + } + + // Copy properties from updatedEntity to existingEntity + var properties = metadata.ClrType.GetProperties(BindingFlags.Public | BindingFlags.Instance); + foreach (var property in properties) + { + // Skip primary key and navigation properties + if (property.Name == pkProperty.Name || !property.CanWrite) + { + continue; + } + + var value = property.GetValue(updatedEntity); + property.SetValue(existingEntity, value); + } + + await _context.SaveChangesAsync(); + + return Ok(existingEntity); + } + + [HttpDelete("{entityName}/{id}")] + public async Task DeleteEntity(string entityName, string id) + { + var metadata = _metadataService.Find(entityName); + if (metadata == null || metadata.ClrType == null) + { + return NotFound(new { error = $"Entity '{entityName}' not found" }); + } + + var pkProperty = GetPrimaryKeyProperty(metadata); + if (pkProperty == null) + { + return BadRequest(new { error = $"Entity '{entityName}' does not have a primary key defined" }); + } + + object? pkValue; + try + { + pkValue = ConvertPrimaryKeyValue(id, pkProperty); + } + catch (Exception ex) + { + return BadRequest(new { error = $"Invalid primary key value: {ex.Message}" }); + } + + var entity = await FindEntityByPrimaryKey(metadata.ClrType, pkValue); + if (entity == null) + { + return NotFound(new { error = $"Entity with id '{id}' not found" }); + } + + var dbSet = GetDbSet(metadata.ClrType); + var removeMethod = dbSet.GetType().GetMethod("Remove"); + if (removeMethod == null) + { + throw new InvalidOperationException($"Failed to resolve Remove method for type {metadata.ClrType.Name}"); + } + + removeMethod.Invoke(dbSet, new[] { entity }); + await _context.SaveChangesAsync(); + + return NoContent(); + } + + private Models.AppDictionary.Property? GetPrimaryKeyProperty(EntityMetadata metadata) + { + return metadata.Definition.Properties? + .FirstOrDefault(p => p.IsPrimaryKey); + } + + private object ConvertPrimaryKeyValue(string id, Models.AppDictionary.Property pkProperty) + { + return pkProperty.Type.ToLowerInvariant() switch + { + "int" => int.Parse(id), + "long" => long.Parse(id), + "guid" => Guid.Parse(id), + "string" => id, + _ => throw new InvalidOperationException($"Unsupported primary key type: {pkProperty.Type}") + }; + } + + private async Task FindEntityByPrimaryKey(Type entityType, object pkValue) + { + var findAsyncMethod = typeof(DbContext) + .GetMethod(nameof(DbContext.FindAsync), new[] { typeof(Type), typeof(object[]) }); + + if (findAsyncMethod == null) + { + throw new InvalidOperationException($"Failed to resolve FindAsync method"); + } + + var valueTask = findAsyncMethod.Invoke(_context, new object[] { entityType, new[] { pkValue } }); + if (valueTask == null) + { + return null; + } + + // ValueTask needs to be awaited + var asTaskMethod = valueTask.GetType().GetMethod("AsTask"); + if (asTaskMethod == null) + { + throw new InvalidOperationException("Failed to resolve AsTask method on ValueTask"); + } + + var task = (Task)asTaskMethod.Invoke(valueTask, null)!; + await task; + + var resultProperty = task.GetType().GetProperty("Result"); + return resultProperty?.GetValue(task); + } + } +} diff --git a/Controllers/ProductsController.cs b/Controllers/ProductsController.cs deleted file mode 100644 index 0ee5b3f..0000000 --- a/Controllers/ProductsController.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using DotNetWebApp.Data; -using DotNetWebApp.Models; - -namespace DotNetWebApp.Controllers -{ - - [ApiController] - [Route("api/[controller]")] - public class ProductsController : ControllerBase { - private readonly AppDbContext _context; - - public ProductsController(AppDbContext context) { - _context = context; - } - - [HttpGet] - public async Task>> GetProducts() { - return await _context.Products.ToListAsync(); - } - - [HttpGet("count")] - public async Task> GetProductCount() { - return await _context.Products.CountAsync(); - } - - [HttpPost] - public async Task> AddProduct(Product product) { - _context.Products.Add(product); - await _context.SaveChangesAsync(); - return CreatedAtAction(nameof(GetProducts), new { id = product.Id }, product); - } - } -} \ No newline at end of file diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 2b077d9..f62d45b 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -1,27 +1,21 @@ -using DotNetWebApp.Data.Plugins; using DotNetWebApp.Data.Tenancy; using DotNetWebApp.Models; using Microsoft.EntityFrameworkCore; using System.Linq; +using System.Reflection; namespace DotNetWebApp.Data { public class AppDbContext : DbContext { - private readonly IEnumerable _modelPlugins; - public AppDbContext( DbContextOptions options, - ITenantSchemaAccessor tenantSchemaAccessor, - IEnumerable modelPlugins) : base(options) + ITenantSchemaAccessor tenantSchemaAccessor) : base(options) { Schema = tenantSchemaAccessor.Schema; - _modelPlugins = modelPlugins ?? Enumerable.Empty(); } public string Schema { get; } - public DbSet Products { get; set; } - protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); @@ -30,13 +24,36 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema(Schema); } - foreach (var plugin in _modelPlugins) + // Dynamically register all entities in the Generated namespace + // Scan the Models assembly instead of the executing assembly to support separated project structure + var modelsAssembly = typeof(EntityMetadata).Assembly; + var entityTypes = modelsAssembly.GetTypes() + .Where(t => t.IsClass && t.Namespace == "DotNetWebApp.Models.Generated"); + + foreach (var type in entityTypes) + { + modelBuilder.Entity(type) + .ToTable(ToPlural(type.Name)); + } + } + + private static string ToPlural(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return name; + } + + if (name.EndsWith("y", StringComparison.OrdinalIgnoreCase) && name.Length > 1) { - if (plugin.AppliesTo(Schema)) + var beforeY = name[name.Length - 2]; + if (!"aeiou".Contains(char.ToLowerInvariant(beforeY))) { - plugin.Configure(modelBuilder); + return name[..^1] + "ies"; } } + + return name.EndsWith("s", StringComparison.OrdinalIgnoreCase) ? name : $"{name}s"; } } } diff --git a/Data/AppDbContextFactory.cs b/Data/AppDbContextFactory.cs new file mode 100644 index 0000000..76a19e4 --- /dev/null +++ b/Data/AppDbContextFactory.cs @@ -0,0 +1,32 @@ +using DotNetWebApp.Data.Tenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; +using Microsoft.Extensions.Configuration; + +namespace DotNetWebApp.Data +{ + public class AppDbContextFactory : IDesignTimeDbContextFactory + { + public AppDbContext CreateDbContext(string[] args) + { + var configuration = new ConfigurationBuilder() + .SetBasePath(Directory.GetCurrentDirectory()) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .AddUserSecrets(optional: true) + .Build(); + + var optionsBuilder = new DbContextOptionsBuilder(); + var connectionString = configuration.GetConnectionString("DefaultConnection") + ?? "Server=localhost;Database=DotNetWebAppDb;Trusted_Connection=true;Encrypt=False;"; + + optionsBuilder.UseSqlServer(connectionString); + + return new AppDbContext(optionsBuilder.Options, new DesignTimeSchemaAccessor()); + } + + private sealed class DesignTimeSchemaAccessor : ITenantSchemaAccessor + { + public string Schema => "dbo"; + } + } +} diff --git a/Data/Plugins/DefaultProductModelPlugin.cs b/Data/Plugins/DefaultProductModelPlugin.cs deleted file mode 100644 index 1c1a13f..0000000 --- a/Data/Plugins/DefaultProductModelPlugin.cs +++ /dev/null @@ -1,20 +0,0 @@ -using DotNetWebApp.Models; -using Microsoft.EntityFrameworkCore; - -namespace DotNetWebApp.Data.Plugins -{ - public class DefaultProductModelPlugin : ICustomerModelPlugin - { - public bool AppliesTo(string schema) - { - return true; - } - - public void Configure(ModelBuilder modelBuilder) - { - modelBuilder.Entity() - .Property(p => p.Price) - .HasPrecision(18, 2); - } - } -} diff --git a/Data/Plugins/ICustomerModelPlugin.cs b/Data/Plugins/ICustomerModelPlugin.cs deleted file mode 100644 index 1376660..0000000 --- a/Data/Plugins/ICustomerModelPlugin.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace DotNetWebApp.Data.Plugins -{ - public interface ICustomerModelPlugin - { - bool AppliesTo(string schema); - void Configure(ModelBuilder modelBuilder); - } -} diff --git a/Data/Tenancy/AppModelCacheKeyFactory.cs b/Data/Tenancy/AppModelCacheKeyFactory.cs index d920a11..3669a40 100644 --- a/Data/Tenancy/AppModelCacheKeyFactory.cs +++ b/Data/Tenancy/AppModelCacheKeyFactory.cs @@ -1,3 +1,4 @@ +using DotNetWebApp.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; diff --git a/DdlParser/CreateTableVisitor.cs b/DdlParser/CreateTableVisitor.cs new file mode 100644 index 0000000..57246b4 --- /dev/null +++ b/DdlParser/CreateTableVisitor.cs @@ -0,0 +1,346 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; + +namespace DdlParser; + +public class CreateTableVisitor : TSqlFragmentVisitor +{ + public List Tables { get; } = new(); + + public override void Visit(CreateTableStatement node) + { + // Get table name - SchemaObjectName has .Name property + var tableName = GetIdentifierValue(node.SchemaObjectName) ?? "UnknownTable"; + var table = new TableMetadata { Name = tableName }; + + // Extract columns + if (node.Definition?.ColumnDefinitions != null) + { + foreach (var columnDef in node.Definition.ColumnDefinitions) + { + var column = ExtractColumn(columnDef); + if (column != null) + { + table.Columns.Add(column); + } + } + } + + // Extract table-level constraints (PRIMARY KEY, FOREIGN KEY) + if (node.Definition?.TableConstraints != null) + { + var primaryKeyColumns = new HashSet(); + + foreach (var constraint in node.Definition.TableConstraints) + { + // Handle primary key constraints + if (constraint is UniqueConstraintDefinition uniqueConstraint) + { + if (uniqueConstraint.IsPrimaryKey) + { + foreach (var col in uniqueConstraint.Columns) + { + var colName = GetColumnWithSortOrderName(col); + if (colName != null) + { + primaryKeyColumns.Add(colName); + } + } + } + } + // Handle foreign key constraints + else if (constraint is ForeignKeyConstraintDefinition fkConstraint) + { + ExtractForeignKey(table, fkConstraint); + } + } + + // Mark primary key columns + foreach (var col in table.Columns) + { + if (primaryKeyColumns.Contains(col.Name)) + { + col.IsPrimaryKey = true; + } + } + } + + Tables.Add(table); + base.Visit(node); + } + + private ColumnMetadata? ExtractColumn(ColumnDefinition columnDef) + { + var columnName = GetIdentifierValue(columnDef.ColumnIdentifier) ?? "UnknownColumn"; + var column = new ColumnMetadata { Name = columnName }; + + // Extract data type + if (columnDef.DataType != null) + { + column.SqlType = GetIdentifierValue(columnDef.DataType) ?? "unknown"; + + // Try to extract type parameters + if (columnDef.DataType is SqlDataTypeReference sqlDataType) + { + ExtractTypeParameters(column, sqlDataType); + } + } + + // Extract nullability + column.IsNullable = true; + if (columnDef.Constraints != null) + { + foreach (var constraint in columnDef.Constraints) + { + if (constraint is NullableConstraintDefinition nullConstraint) + { + column.IsNullable = nullConstraint.Nullable; + break; + } + } + } + + // Extract IDENTITY + if (columnDef.IdentityOptions != null) + { + column.IsIdentity = true; + } + + // Extract DEFAULT value + if (columnDef.DefaultConstraint != null) + { + column.DefaultValue = ExtractDefaultValue(columnDef.DefaultConstraint); + } + + // Check for PRIMARY KEY constraint on column itself + if (columnDef.Constraints != null) + { + foreach (var constraint in columnDef.Constraints) + { + if (constraint is UniqueConstraintDefinition uniqueConstraint && uniqueConstraint.IsPrimaryKey) + { + column.IsPrimaryKey = true; + break; + } + } + } + + return column; + } + + private void ExtractTypeParameters(ColumnMetadata column, SqlDataTypeReference sqlDataType) + { + var typeName = column.SqlType.ToLowerInvariant(); + + // Extract parameters based on type + if ((typeName == "varchar" || typeName == "nvarchar" || + typeName == "char" || typeName == "nchar") && + sqlDataType.Parameters.Count > 0) + { + var lengthValue = ExtractLiteralValue(sqlDataType.Parameters[0]); + if (lengthValue != null && int.TryParse(lengthValue, out var maxLength)) + { + column.MaxLength = maxLength; + } + } + else if ((typeName == "decimal" || typeName == "numeric") && + sqlDataType.Parameters.Count >= 1) + { + var precisionValue = ExtractLiteralValue(sqlDataType.Parameters[0]); + if (precisionValue != null && int.TryParse(precisionValue, out var precision)) + { + column.Precision = precision; + + if (sqlDataType.Parameters.Count > 1) + { + var scaleValue = ExtractLiteralValue(sqlDataType.Parameters[1]); + if (scaleValue != null && int.TryParse(scaleValue, out var scale)) + { + column.Scale = scale; + } + } + } + } + } + + private string? ExtractLiteralValue(ScalarExpression expr) + { + return expr switch + { + IntegerLiteral intLit => intLit.Value, + NumericLiteral numLit => numLit.Value, + StringLiteral strLit => strLit.Value, + _ => null + }; + } + + private string? ExtractDefaultValue(DefaultConstraintDefinition defaultConstraint) + { + if (defaultConstraint.Expression == null) + return null; + + return defaultConstraint.Expression switch + { + IntegerLiteral intLit => intLit.Value, + NumericLiteral numLit => numLit.Value, + StringLiteral strLit => strLit.Value, + FunctionCall funcCall => funcCall.FunctionName.Value, + _ => null + }; + } + + private void ExtractForeignKey(TableMetadata table, ForeignKeyConstraintDefinition fkConstraint) + { + if (fkConstraint.Columns.Count == 0) + return; + + // Get the foreign key column name + var firstColumn = fkConstraint.Columns[0]; + string? fkColumnName = ExtractColumnName(firstColumn); + + if (fkColumnName == null) + return; + + // Get the referenced table name + var refTableName = GetIdentifierValue(fkConstraint.ReferenceTableName); + if (refTableName == null) + return; + + // Get the referenced column name + string refColumnName = "Id"; + if (fkConstraint.ReferencedTableColumns.Count > 0) + { + refColumnName = GetIdentifierValue(fkConstraint.ReferencedTableColumns[0]) ?? "Id"; + } + + var fk = new ForeignKeyMetadata + { + ColumnName = fkColumnName, + ReferencedTable = refTableName, + ReferencedColumn = refColumnName + }; + + table.ForeignKeys.Add(fk); + } + + private string? ExtractColumnName(object? columnObj) + { + if (columnObj == null) + return null; + + // Check if it's a ColumnReferenceExpression + if (columnObj is ColumnReferenceExpression colRef) + { + if (colRef.MultiPartIdentifier?.Identifiers.Count > 0) + { + var identifiers = colRef.MultiPartIdentifier.Identifiers; + return identifiers[identifiers.Count - 1].Value; + } + } + + // Check if it's an Identifier + if (columnObj is Identifier identifier) + { + return identifier.Value; + } + + // Try reflection for Name property + try + { + var nameProperty = columnObj.GetType().GetProperty("Name"); + if (nameProperty != null && nameProperty.GetValue(columnObj) is Identifier nameId) + { + return nameId.Value; + } + } + catch + { + // Continue + } + + return null; + } + + private string? GetIdentifierValue(Identifier? identifier) + { + if (identifier == null) + return null; + + return identifier.Value; + } + + private string? GetIdentifierValue(SchemaObjectName? schemaObjectName) + { + if (schemaObjectName == null) + return null; + + // Try the most common properties + try + { + if (schemaObjectName.Identifiers != null && schemaObjectName.Identifiers.Count > 0) + { + // For schema.table format, get the last identifier (table name) + var lastId = schemaObjectName.Identifiers[schemaObjectName.Identifiers.Count - 1]; + return lastId.Value; + } + } + catch + { + // Fall through to try other properties + } + + // Alternative: try Name property + try + { + var nameProperty = typeof(SchemaObjectName).GetProperty("Name"); + if (nameProperty != null) + { + if (nameProperty.GetValue(schemaObjectName) is Identifier nameId) + { + return nameId.Value; + } + } + } + catch + { + // Continue + } + + return null; + } + + private string? GetIdentifierValue(DataTypeReference? dataType) + { + if (dataType == null) + return null; + + // For SqlDataTypeReference, get the Name + if (dataType is SqlDataTypeReference sqlDataType) + { + return GetIdentifierValue(sqlDataType.Name); + } + + // Try generic approach for other types + try + { + var nameProperty = dataType.GetType().GetProperty("Name"); + if (nameProperty != null && nameProperty.GetValue(dataType) is Identifier nameId) + { + return nameId.Value; + } + } + catch + { + // Continue + } + + return null; + } + + private string? GetColumnWithSortOrderName(ColumnWithSortOrder col) + { + if (col == null) + return null; + + return ExtractColumnName(col.Column); + } +} diff --git a/DdlParser/DdlParser.csproj b/DdlParser/DdlParser.csproj new file mode 100644 index 0000000..444adb6 --- /dev/null +++ b/DdlParser/DdlParser.csproj @@ -0,0 +1,16 @@ + + + Exe + net8.0 + enable + enable + + + + + + + + + + diff --git a/DdlParser/Program.cs b/DdlParser/Program.cs new file mode 100644 index 0000000..79c87b7 --- /dev/null +++ b/DdlParser/Program.cs @@ -0,0 +1,69 @@ +using DdlParser; +using System; +using System.IO; + +if (args.Length < 2) +{ + Console.WriteLine("Usage: DdlParser "); + Console.WriteLine("Example: DdlParser schema.sql app.yaml"); + Environment.Exit(1); +} + +var inputFile = args[0]; +var outputFile = args[1]; + +try +{ + // Validate input file exists + if (!File.Exists(inputFile)) + { + Console.Error.WriteLine($"Error: Input file not found: {inputFile}"); + Environment.Exit(1); + } + + // Read SQL content + Console.WriteLine($"Reading SQL file: {inputFile}"); + var sqlContent = File.ReadAllText(inputFile); + + // Parse SQL DDL + Console.WriteLine("Parsing SQL DDL..."); + var parser = new SqlDdlParser(); + var tables = parser.Parse(sqlContent); + + if (tables.Count == 0) + { + Console.WriteLine("Warning: No tables found in SQL file"); + } + else + { + Console.WriteLine($"Found {tables.Count} table(s):"); + foreach (var table in tables) + { + Console.WriteLine($" - {table.Name} ({table.Columns.Count} columns)"); + } + } + + // Generate YAML + Console.WriteLine("Generating YAML..."); + var generator = new YamlGenerator(); + var yaml = generator.Generate(tables); + + // Write output file + var outputDir = Path.GetDirectoryName(outputFile); + if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir)) + { + Directory.CreateDirectory(outputDir); + } + + File.WriteAllText(outputFile, yaml); + Console.WriteLine($"Successfully wrote YAML to: {outputFile}"); +} +catch (Exception ex) +{ + Console.Error.WriteLine($"Error: {ex.Message}"); + if (!string.IsNullOrEmpty(ex.InnerException?.Message)) + { + Console.Error.WriteLine($"Details: {ex.InnerException.Message}"); + } + Environment.Exit(1); +} diff --git a/DdlParser/README.md b/DdlParser/README.md new file mode 100644 index 0000000..8ad988b --- /dev/null +++ b/DdlParser/README.md @@ -0,0 +1,215 @@ +# DdlParser + +Converts SQL Server DDL (.sql files) into `app.yaml` format for the DotNetWebApp framework. + +## Usage + +```bash +cd DdlParser +dotnet run -- +``` + +## Example + +```bash +# Parse a SQL schema file and generate app.yaml +dotnet run -- database-schema.sql ../app.yaml + +# Then generate models from the YAML +cd ../ModelGenerator +dotnet run ../app.yaml + +# Build and run +cd .. +make build +make dev +``` + +## How It Works + +The DdlParser uses Microsoft's official SQL Server T-SQL parser (`Microsoft.SqlServer.TransactSql.ScriptDom`) to: + +1. **Parse** SQL DDL files into an Abstract Syntax Tree (AST) +2. **Extract** table metadata (columns, constraints, relationships) +3. **Convert** SQL types to app.yaml types +4. **Generate** valid `app.yaml` compatible with ModelGenerator + +### Pipeline + +``` +database.sql → DdlParser → app.yaml → ModelGenerator → Models/Generated/*.cs +``` + +## Supported Features + +### Data Types +- **Integer:** `int`, `bigint`, `smallint`, `tinyint` +- **String:** `varchar`, `nvarchar`, `char`, `nchar`, `text`, `ntext` +- **Decimal:** `decimal`, `numeric`, `money`, `smallmoney`, `float`, `real` +- **DateTime:** `datetime`, `datetime2`, `date`, `time`, `datetimeoffset`, `timestamp` +- **Boolean:** `bit` +- **GUID:** `uniqueidentifier` + +### DDL Elements +- CREATE TABLE statements +- Column definitions with data types +- NOT NULL constraints +- PRIMARY KEY constraints (single column) +- FOREIGN KEY constraints +- IDENTITY columns +- DEFAULT values +- VARCHAR/NVARCHAR max lengths +- DECIMAL precision and scale + +## Limitations + +- **Composite primary keys** - Only single-column primary keys are fully supported +- **CHECK constraints** - Ignored during parsing +- **UNIQUE constraints** - Ignored (not included in app.yaml schema) +- **Computed columns** - Not supported by app.yaml (will be ignored) +- **Multiple schemas** - All tables assumed to be in `dbo` schema +- **Schema-qualified names** - `dbo.Products` will be parsed as `Products` +- **Self-referencing foreign keys** - Should work but may need manual verification +- **Circular foreign keys** - May result in circular relationships in YAML + +## Example Input + +**schema.sql:** +```sql +CREATE TABLE Categories ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(50) NOT NULL +); + +CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(100) NOT NULL, + Description NVARCHAR(500) NULL, + Price DECIMAL(18,2) NULL, + CategoryId INT NULL, + CreatedAt DATETIME2 NULL DEFAULT GETDATE(), + FOREIGN KEY (CategoryId) REFERENCES Categories(Id) +); +``` + +## Example Output + +**app.yaml:** +```yaml +app: + name: ImportedApp + title: Imported Application + description: Generated from DDL file + logoUrl: /images/logo.png + +theme: + primaryColor: '#007bff' + secondaryColor: '#6c757d' + backgroundColor: '#ffffff' + textColor: '#212529' + +dataModel: + entities: + - name: Category + properties: + - name: Id + type: int + isPrimaryKey: true + isIdentity: true + isRequired: true + - name: Name + type: string + maxLength: 50 + isRequired: true + relationships: [] + + - name: Product + properties: + - name: Id + type: int + isPrimaryKey: true + isIdentity: true + isRequired: true + - name: Name + type: string + maxLength: 100 + isRequired: true + - name: Description + type: string + maxLength: 500 + isRequired: false + - name: Price + type: decimal + isRequired: false + - name: CategoryId + type: int + isRequired: false + - name: CreatedAt + type: datetime + isRequired: false + defaultValue: GETDATE() + relationships: + - type: one-to-many + targetEntity: Category + foreignKey: CategoryId + principalKey: Id +``` + +## Architecture + +### Components + +- **SqlDdlParser.cs** - Wraps Microsoft.SqlServer.TransactSql.ScriptDom parser +- **CreateTableVisitor.cs** - AST visitor that extracts CREATE TABLE statements +- **TypeMapper.cs** - Converts SQL types to app.yaml type strings +- **YamlGenerator.cs** - Converts parsed metadata to AppDefinition and serializes to YAML +- **Program.cs** - CLI entry point + +### Key Classes + +- `TableMetadata` - Represents a SQL table +- `ColumnMetadata` - Represents a table column +- `ForeignKeyMetadata` - Represents a foreign key relationship + +## Troubleshooting + +### SQL Parsing Errors +If you get parsing errors, ensure your SQL file contains valid T-SQL syntax. The parser is strict and may reject: +- Incomplete statements (missing semicolons) +- Invalid syntax +- Unsupported SQL Server features + +### Type Mapping Issues +If a SQL type is not recognized, it defaults to `string`. Check `TypeMapper.cs` and add mappings for any missing types. + +### Naming Issues +Entity names are singularized using basic rules (removing trailing 's', 'es', 'ies'). Complex pluralization may need manual adjustment in the generated `app.yaml`. + +## Integration with ModelGenerator + +After generating `app.yaml`: + +```bash +# Generate model classes +cd ../ModelGenerator +dotnet run ../app.yaml + +# Apply migrations (if using SQL Server) +cd .. +./dotnet-build.sh ef migrations add +make migrate + +# Build and verify +make build +``` + +## Future Enhancements + +Potential improvements for future versions: +- Support for composite primary keys +- Index definitions +- View definitions +- ALTER TABLE statements +- Multiple schema support +- Interactive configuration mode +- Validation warnings for unsupported features diff --git a/DdlParser/SqlDdlParser.cs b/DdlParser/SqlDdlParser.cs new file mode 100644 index 0000000..23d9387 --- /dev/null +++ b/DdlParser/SqlDdlParser.cs @@ -0,0 +1,62 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; + +namespace DdlParser; + +public class TableMetadata +{ + public string Name { get; set; } = string.Empty; + public List Columns { get; set; } = new(); + public List ForeignKeys { get; set; } = new(); +} + +public class ColumnMetadata +{ + public string Name { get; set; } = string.Empty; + public string SqlType { get; set; } = string.Empty; + public int? MaxLength { get; set; } + public int? Precision { get; set; } + public int? Scale { get; set; } + public bool IsNullable { get; set; } = true; + public bool IsPrimaryKey { get; set; } + public bool IsIdentity { get; set; } + public string? DefaultValue { get; set; } +} + +public class ForeignKeyMetadata +{ + public string ColumnName { get; set; } = string.Empty; + public string ReferencedTable { get; set; } = string.Empty; + public string ReferencedColumn { get; set; } = string.Empty; +} + +public class SqlDdlParser +{ + public List Parse(string sqlContent) + { + var parser = new TSql160Parser(initialQuotedIdentifiers: false); + + TSqlFragment fragment; + IList errors; + + using (var reader = new StringReader(sqlContent)) + { + fragment = parser.Parse(reader, out errors); + } + + if (errors.Count > 0) + { + var errorMessages = string.Join("\n", errors.Select(e => $"Line {e.Line}: {e.Message}")); + throw new InvalidOperationException($"SQL parsing errors:\n{errorMessages}"); + } + + if (fragment is not TSqlScript script) + { + throw new InvalidOperationException("Expected TSqlScript fragment"); + } + + var visitor = new CreateTableVisitor(); + fragment.Accept(visitor); + + return visitor.Tables; + } +} diff --git a/DdlParser/TypeMapper.cs b/DdlParser/TypeMapper.cs new file mode 100644 index 0000000..157aaa6 --- /dev/null +++ b/DdlParser/TypeMapper.cs @@ -0,0 +1,55 @@ +namespace DdlParser; + +public static class TypeMapper +{ + public static string SqlToYamlType(string sqlType) + { + return sqlType.ToLowerInvariant() switch + { + // Integer types + "int" => "int", + "integer" => "int", + "bigint" => "int", + "smallint" => "int", + "tinyint" => "int", + + // String types + "varchar" => "string", + "nvarchar" => "string", + "char" => "string", + "nchar" => "string", + "text" => "string", + "ntext" => "string", + "varbinary" => "string", + "binary" => "string", + + // Decimal types + "decimal" => "decimal", + "numeric" => "decimal", + "money" => "decimal", + "smallmoney" => "decimal", + + // Float/Double types + "float" => "decimal", + "real" => "decimal", + + // DateTime types + "datetime" => "datetime", + "datetime2" => "datetime", + "date" => "datetime", + "time" => "datetime", + "datetimeoffset" => "datetime", + "timestamp" => "datetime", + "smalldatetime" => "datetime", + + // Boolean type + "bit" => "bool", + + // GUID + "uniqueidentifier" => "string", + + // Default fallback + _ => "string" + }; + } +} diff --git a/DdlParser/YamlGenerator.cs b/DdlParser/YamlGenerator.cs new file mode 100644 index 0000000..f687228 --- /dev/null +++ b/DdlParser/YamlGenerator.cs @@ -0,0 +1,121 @@ +using DotNetWebApp.Models.AppDictionary; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace DdlParser; + +public class YamlGenerator +{ + public string Generate(List tables) + { + var appDefinition = new AppDefinition + { + App = new AppMetadata + { + Name = "ImportedApp", + Title = "Imported Application", + Description = "Generated from DDL file", + LogoUrl = "/images/logo.png" + }, + Theme = new Theme + { + PrimaryColor = "#007bff", + SecondaryColor = "#6c757d", + BackgroundColor = "#ffffff", + TextColor = "#212529" + }, + DataModel = new DataModel + { + Entities = ConvertTablesToEntities(tables) + } + }; + + var serializer = new SerializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + return serializer.Serialize(appDefinition); + } + + private List ConvertTablesToEntities(List tables) + { + var entities = new List(); + + foreach (var table in tables) + { + var entity = new Entity + { + Name = SingularizeName(table.Name), + Properties = ConvertColumnsToProperties(table.Columns), + Relationships = ConvertForeignKeysToRelationships(table.ForeignKeys) + }; + entities.Add(entity); + } + + return entities; + } + + private List ConvertColumnsToProperties(List columns) + { + var properties = new List(); + + foreach (var column in columns) + { + var property = new Property + { + Name = column.Name, + Type = TypeMapper.SqlToYamlType(column.SqlType), + IsPrimaryKey = column.IsPrimaryKey, + IsIdentity = column.IsIdentity, + IsRequired = !column.IsNullable, + MaxLength = column.MaxLength, + Precision = column.Precision, + Scale = column.Scale, + DefaultValue = column.DefaultValue + }; + properties.Add(property); + } + + return properties; + } + + private List ConvertForeignKeysToRelationships(List foreignKeys) + { + var relationships = new List(); + + foreach (var fk in foreignKeys) + { + var relationship = new Relationship + { + Type = "one-to-many", + TargetEntity = SingularizeName(fk.ReferencedTable), + ForeignKey = fk.ColumnName, + PrincipalKey = fk.ReferencedColumn + }; + relationships.Add(relationship); + } + + return relationships; + } + + private string SingularizeName(string pluralName) + { + // Simple pluralization rules (can be expanded as needed) + if (pluralName.EndsWith("ies", StringComparison.OrdinalIgnoreCase)) + { + return pluralName.Substring(0, pluralName.Length - 3) + "y"; + } + + if (pluralName.EndsWith("es", StringComparison.OrdinalIgnoreCase)) + { + return pluralName.Substring(0, pluralName.Length - 2); + } + + if (pluralName.EndsWith("s", StringComparison.OrdinalIgnoreCase)) + { + return pluralName.Substring(0, pluralName.Length - 1); + } + + return pluralName; + } +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..45a9c6d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,36 @@ + + + + true + true + + + true + + + true + + + + false + + + true + + + true + + + true + + + true + + + false + + + portable + embedded + + diff --git a/Models/AppCustomizationOptions.cs b/DotNetWebApp.Models/AppCustomizationOptions.cs similarity index 92% rename from Models/AppCustomizationOptions.cs rename to DotNetWebApp.Models/AppCustomizationOptions.cs index 7422c11..d965c51 100644 --- a/Models/AppCustomizationOptions.cs +++ b/DotNetWebApp.Models/AppCustomizationOptions.cs @@ -7,6 +7,7 @@ public class AppCustomizationOptions public string SourceLinkUrl { get; set; } = "https://github.com/devixlabs/DotNetWebApp/"; public BrandCustomization Branding { get; set; } = new(); public NavigationLabels Navigation { get; set; } = new(); + public bool EnableSpaExample { get; set; } = true; public SpaSectionLabels SpaSections { get; set; } = new(); } @@ -33,9 +34,7 @@ public class NavigationLabels public class SpaSectionLabels { public string DashboardNav { get; set; } = "Dashboard"; - public string ProductsNav { get; set; } = "Products"; public string SettingsNav { get; set; } = "Settings"; public string DashboardTitle { get; set; } = "Dashboard"; - public string ProductsTitle { get; set; } = "Products Management"; public string SettingsTitle { get; set; } = "Application Settings"; } diff --git a/DotNetWebApp.Models/AppDictionary/AppDefinition.cs b/DotNetWebApp.Models/AppDictionary/AppDefinition.cs new file mode 100644 index 0000000..c3bb3a3 --- /dev/null +++ b/DotNetWebApp.Models/AppDictionary/AppDefinition.cs @@ -0,0 +1,62 @@ +using YamlDotNet.Serialization; + +#nullable disable + +namespace DotNetWebApp.Models.AppDictionary +{ + public class AppDefinition + { + public AppMetadata App { get; set; } + public Theme Theme { get; set; } + public DataModel DataModel { get; set; } + } + + public class AppMetadata + { + public string Name { get; set; } + public string Title { get; set; } + public string Description { get; set; } + public string LogoUrl { get; set; } + } + + public class Theme + { + public string PrimaryColor { get; set; } + public string SecondaryColor { get; set; } + public string BackgroundColor { get; set; } + public string TextColor { get; set; } + } + + public class DataModel + { + public List Entities { get; set; } + } + + public class Entity + { + public string Name { get; set; } + public List Properties { get; set; } + public List Relationships { get; set; } + } + + public class Property + { + public string Name { get; set; } + public string Type { get; set; } + public bool IsPrimaryKey { get; set; } + public bool IsIdentity { get; set; } + public int? MaxLength { get; set; } + public bool IsRequired { get; set; } + public int? Precision { get; set; } + public int? Scale { get; set; } + public string DefaultValue { get; set; } + } + + public class Relationship + { + public string Type { get; set; } + public string TargetEntity { get; set; } + public string ForeignKey { get; set; } + public string PrincipalKey { get; set; } + } +} \ No newline at end of file diff --git a/Models/DashboardSummary.cs b/DotNetWebApp.Models/DashboardSummary.cs similarity index 67% rename from Models/DashboardSummary.cs rename to DotNetWebApp.Models/DashboardSummary.cs index 272315b..d3285d4 100644 --- a/Models/DashboardSummary.cs +++ b/DotNetWebApp.Models/DashboardSummary.cs @@ -2,11 +2,12 @@ namespace DotNetWebApp.Models; public class DashboardSummary { - public int TotalProducts { get; set; } + public IReadOnlyList EntityCounts { get; set; } = Array.Empty(); public decimal Revenue { get; set; } public int ActiveUsers { get; set; } public int GrowthPercent { get; set; } public IReadOnlyList RecentActivities { get; set; } = Array.Empty(); } +public sealed record EntityCountInfo(string EntityName, int Count); public sealed record ActivityItem(string When, string Description); diff --git a/DotNetWebApp.Models/DataSeederOptions.cs b/DotNetWebApp.Models/DataSeederOptions.cs new file mode 100644 index 0000000..28f7445 --- /dev/null +++ b/DotNetWebApp.Models/DataSeederOptions.cs @@ -0,0 +1,8 @@ +namespace DotNetWebApp.Models; + +public sealed class DataSeederOptions +{ + public const string SectionName = "DataSeeder"; + + public string SeedFileName { get; set; } = "seed.sql"; +} diff --git a/DotNetWebApp.Models/DotNetWebApp.Models.csproj b/DotNetWebApp.Models/DotNetWebApp.Models.csproj new file mode 100644 index 0000000..1d00652 --- /dev/null +++ b/DotNetWebApp.Models/DotNetWebApp.Models.csproj @@ -0,0 +1,11 @@ + + + net8.0 + enable + enable + + + + + + diff --git a/DotNetWebApp.Models/EntityMetadata.cs b/DotNetWebApp.Models/EntityMetadata.cs new file mode 100644 index 0000000..72309d8 --- /dev/null +++ b/DotNetWebApp.Models/EntityMetadata.cs @@ -0,0 +1,5 @@ +using DotNetWebApp.Models.AppDictionary; + +namespace DotNetWebApp.Models; + +public sealed record EntityMetadata(Entity Definition, Type? ClrType); diff --git a/DotNetWebApp.Models/Generated/.gitignore b/DotNetWebApp.Models/Generated/.gitignore new file mode 100644 index 0000000..377ccd3 --- /dev/null +++ b/DotNetWebApp.Models/Generated/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/DotNetWebApp.Models/Generated/.gitkeep b/DotNetWebApp.Models/Generated/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/DotNetWebApp.Models/Generated/.gitkeep @@ -0,0 +1 @@ + diff --git a/Models/SpaSection.cs b/DotNetWebApp.Models/SpaSection.cs similarity index 73% rename from Models/SpaSection.cs rename to DotNetWebApp.Models/SpaSection.cs index 98d16a6..ead51e2 100644 --- a/Models/SpaSection.cs +++ b/DotNetWebApp.Models/SpaSection.cs @@ -3,6 +3,6 @@ namespace DotNetWebApp.Models; public enum SpaSection { Dashboard, - Products, - Settings + Settings, + Entity } diff --git a/DotNetWebApp.Models/SpaSectionInfo.cs b/DotNetWebApp.Models/SpaSectionInfo.cs new file mode 100644 index 0000000..ea9fa10 --- /dev/null +++ b/DotNetWebApp.Models/SpaSectionInfo.cs @@ -0,0 +1,8 @@ +namespace DotNetWebApp.Models; + +public sealed record SpaSectionInfo( + SpaSection Section, + string NavLabel, + string Title, + string RouteSegment, + string? EntityName = null); diff --git a/DotNetWebApp.csproj b/DotNetWebApp.csproj index 1e17047..5d42c5d 100644 --- a/DotNetWebApp.csproj +++ b/DotNetWebApp.csproj @@ -8,9 +8,9 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -22,4 +22,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotNetWebApp.http b/DotNetWebApp.http deleted file mode 100644 index b896df8..0000000 --- a/DotNetWebApp.http +++ /dev/null @@ -1,6 +0,0 @@ -@DotNetWebApp_HostAddress = http://localhost:5210 - -GET {{DotNetWebApp_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/DotNetWebApp.sln b/DotNetWebApp.sln index bf145dc..75e6de4 100644 --- a/DotNetWebApp.sln +++ b/DotNetWebApp.sln @@ -1,9 +1,23 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.5.2.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetWebApp", "DotNetWebApp.csproj", "{CE160F80-C804-4DCB-3E6B-5D99DC7C094B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DdlParser", "DdlParser\DdlParser.csproj", "{DD3E8F9B-C8A9-4F2E-9C6D-8E5A3B7C2D1F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelGenerator", "ModelGenerator\ModelGenerator.csproj", "{A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetWebApp.Tests", "tests\DotNetWebApp.Tests\DotNetWebApp.Tests.csproj", "{5F1A08CA-3993-4A7A-A56E-1E333C2C06FB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{54E20A9E-ED73-485E-BAD6-C2FC3290BBDC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelGenerator.Tests", "tests\ModelGenerator.Tests\ModelGenerator.Tests.csproj", "{6F83E7CB-3C85-4D2D-97C5-D9C6DEEB85DD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetWebApp.Models", "DotNetWebApp.Models\DotNetWebApp.Models.csproj", "{2216A6C5-4061-4BC8-952F-21E04DBB6069}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DdlParser.Tests", "tests\DdlParser.Tests\DdlParser.Tests.csproj", "{AB65500C-C886-4A9D-A5BA-0010DBBB317C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -14,6 +28,30 @@ Global {CE160F80-C804-4DCB-3E6B-5D99DC7C094B}.Debug|Any CPU.Build.0 = Debug|Any CPU {CE160F80-C804-4DCB-3E6B-5D99DC7C094B}.Release|Any CPU.ActiveCfg = Release|Any CPU {CE160F80-C804-4DCB-3E6B-5D99DC7C094B}.Release|Any CPU.Build.0 = Release|Any CPU + {DD3E8F9B-C8A9-4F2E-9C6D-8E5A3B7C2D1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD3E8F9B-C8A9-4F2E-9C6D-8E5A3B7C2D1F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD3E8F9B-C8A9-4F2E-9C6D-8E5A3B7C2D1F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD3E8F9B-C8A9-4F2E-9C6D-8E5A3B7C2D1F}.Release|Any CPU.Build.0 = Release|Any CPU + {A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D}.Release|Any CPU.Build.0 = Release|Any CPU + {5F1A08CA-3993-4A7A-A56E-1E333C2C06FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F1A08CA-3993-4A7A-A56E-1E333C2C06FB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F1A08CA-3993-4A7A-A56E-1E333C2C06FB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F1A08CA-3993-4A7A-A56E-1E333C2C06FB}.Release|Any CPU.Build.0 = Release|Any CPU + {6F83E7CB-3C85-4D2D-97C5-D9C6DEEB85DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F83E7CB-3C85-4D2D-97C5-D9C6DEEB85DD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F83E7CB-3C85-4D2D-97C5-D9C6DEEB85DD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F83E7CB-3C85-4D2D-97C5-D9C6DEEB85DD}.Release|Any CPU.Build.0 = Release|Any CPU + {2216A6C5-4061-4BC8-952F-21E04DBB6069}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2216A6C5-4061-4BC8-952F-21E04DBB6069}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2216A6C5-4061-4BC8-952F-21E04DBB6069}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2216A6C5-4061-4BC8-952F-21E04DBB6069}.Release|Any CPU.Build.0 = Release|Any CPU + {AB65500C-C886-4A9D-A5BA-0010DBBB317C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB65500C-C886-4A9D-A5BA-0010DBBB317C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB65500C-C886-4A9D-A5BA-0010DBBB317C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB65500C-C886-4A9D-A5BA-0010DBBB317C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -21,4 +59,8 @@ Global GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {2944A184-D8E9-4648-83EE-D1A194AA65C0} EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {6F83E7CB-3C85-4D2D-97C5-D9C6DEEB85DD} = {54E20A9E-ED73-485E-BAD6-C2FC3290BBDC} + {AB65500C-C886-4A9D-A5BA-0010DBBB317C} = {54E20A9E-ED73-485E-BAD6-C2FC3290BBDC} + EndGlobalSection EndGlobal diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..5985480 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,113 @@ +# GEMINI Project Context: DotNetWebApp + +## Project Overview + +This is a .NET 8 web application built with a Blazor Server frontend and a Web API backend. It provides a SPA experience and supports multi-tenancy. Data access is via Entity Framework Core against SQL Server. The UI is built with Radzen Blazor components. + +**Key Technologies:** + +* **.NET 8:** Core framework for the application. +* **ASP.NET Core:** For the web server and API. +* **Blazor Server:** Reactive frontend UI. +* **Entity Framework Core:** Data access (migrations generated from DDL pipeline). +* **SQL Server:** Relational database. +* **Radzen.Blazor:** UI component library. +* **Docker:** Used for dev containers (app + database). + +**Architecture:** + +* **`Program.cs`:** Entry point and service registration. +* **`Components/`:** Blazor UI components. +* **`Controllers/`:** API controllers. +* **`Data/`:** `AppDbContext`, tenancy helpers, and dynamic model wiring. +* **`Models/`:** Entity models (including `Models/Generated`). +* **`Services/`:** Business logic and DI services. +* **`Migrations/`:** Generated EF Core migration files (ignored in repo). + +## Current Direction (DDL-first) + +The app uses SQL DDL as the source of truth, generating `app.yaml` that drives: +* app branding + theme +* dynamic model generation (`ModelGenerator`) +* API and UI entity navigation + +Generated entities live in `Models/Generated` and are wired into `AppDbContext` via reflection. Table names are pluralized (e.g., `Product` -> `Products`) to align with existing SQL tables. + +## Current State / Recent Fixes + +* DDL-driven metadata and model definitions are generated into `app.yaml`. +* `ModelGenerator` creates `Models/Generated`; optional value types are nullable to avoid forced defaults. +* `AppDictionaryService` exposes YAML metadata to the UI and navigation. +* UI uses Radzen panel menu components and includes a dynamic "Data" section. +* Generic entity pages load data via `GenericEntityPage.razor` with the route `api/{entity.Name}` and singular controllers. + +## Database / Migrations + +Migrations are generated by `make run-ddl-pipeline` from the SQL DDL schema. If you see errors like: +* `Invalid object name 'dbo.Category'` +* `Invalid column name 'CategoryId'` + +the database schema does not match the current DDL. Run `make db-start`, `make run-ddl-pipeline`, then `make migrate`. + +## Building and Running + +The project uses a `Makefile` to simplify common development tasks. + +### Prerequisites + +1. **Install SQL Server:** Run `./setup.sh` to install SQL Server via Docker or on the host machine. +2. **Install .NET EF Tools:** `dotnet tool install --global dotnet-ef --version 8.*` +3. **Use the wrapper:** `make` targets call `./dotnet-build.sh`, which sets `DOTNET_ROOT` for global tools and bypasses `global.json` locally. + +### Key Commands + +* **Check and Restore Dependencies:** + ```bash + make check + ``` + +* **Generate Schema Migration:** + ```bash + make run-ddl-pipeline + make migrate + ``` + +* **Build the Application:** + ```bash + make build + ``` + +* **Run in Development Mode (with hot reload):** + ```bash + make dev + ``` + +* **Run in Production-like Mode:** + ```bash + make run + ``` + +* **Run Tests:** + ```bash + make test + ``` + +* **Build Docker Image:** + ```bash + make docker-build + ``` + +## Development Conventions + +* **Dependency Injection:** Services are registered in `Program.cs` and injected into constructors. This is the standard pattern for .NET Core applications. +* **Async/Await:** Asynchronous programming is used for I/O operations, particularly in the service layer and controllers when interacting with the database. +* **Separation of Concerns:** The project is organized into distinct layers (UI, API, Services, Data) to keep the codebase clean and maintainable. +* **Configuration:** Application settings are managed in `appsettings.json` and `appsettings.Development.json`. Secrets are managed using the .NET User Secrets manager (see `SECRETS.md`). +* **Multi-Tenancy:** The `Data/Tenancy` folder and the `AppDbContext` show a mechanism for supporting multiple tenants with different database schemas. + +## Guardrails (Do Not Break) + +* `make check` runs `shellcheck` on `setup.sh` and `dotnet-build.sh` before the build. +* Do not modify or reinstall the system .NET runtime; use the `dotnet-build.sh` wrapper via `make`. +* Keep Radzen UI wiring intact (NavMenu and theme CSS). +* Ensure the DDL pipeline and migration are applied before debugging 500s in entity pages. diff --git a/HYBRID_ARCHITECTURE.md b/HYBRID_ARCHITECTURE.md new file mode 100644 index 0000000..9d13985 --- /dev/null +++ b/HYBRID_ARCHITECTURE.md @@ -0,0 +1,633 @@ +# Hybrid EF Core + Dapper Architecture for DotNetWebApp + +**Document Status:** SIMPLIFIED APPROACH (Updated 2026-01-26) +**Target Scale:** 200+ entities, multiple database schemas, small team +**Architecture Style:** Pragmatic hybrid (NOT full Clean Architecture layers) + +--- + +## Executive Summary + +This document defines the **simplified hybrid architecture** for DotNetWebApp, combining: +- **EF Core** for entity CRUD operations (200+ generated models) +- **Dapper** for complex SQL views (multi-table JOINs, reports, dashboards) +- **SQL-first philosophy** for both entities (DDL) and views (SELECT queries) + +**Key Decision:** We do NOT implement full Clean Architecture with 4 separate projects. Instead, we use namespace-based organization within a single project to balance complexity and team size. + +--- + +## Architecture Principles + +### 1. **SQL as Source of Truth** + +``` +┌─────────────────────────────────────────────┐ +│ SQL DDL (schema.sql) │ +│ → app.yaml │ +│ → Models/Generated/*.cs (EF entities) │ +│ → IEntityOperationService (dynamic CRUD) │ +└─────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────┐ +│ SQL SELECT (sql/views/*.sql) │ +│ → views.yaml │ +│ → Models/ViewModels/*.cs (Dapper DTOs) │ +│ → IViewService (typed queries) │ +└─────────────────────────────────────────────┘ +``` + +### 2. **Clear Separation of Concerns** + +| Layer | Technology | Purpose | Example | +|-------|------------|---------|---------| +| **Entity Models** | EF Core | Single-table CRUD for 200+ generated entities | `Product`, `Category`, `Order` | +| **View Models** | Dapper | Multi-table reads for UI components | `ProductSalesView`, `CustomerOrderHistoryView` | +| **Business Logic** | Blazor Server | C# event handlers (no JavaScript/AJAX) | `OnRestockAsync()`, `OnProcessOrderAsync()` | +| **Data Access** | `IEntityOperationService` (writes) + `IViewService` (reads) | Abstraction layer | Injected into Blazor components | + +### 3. **Multi-Tenancy via Shared Connection** + +```csharp +// Finbuckle.MultiTenant sets schema on EF Core connection +builder.Services.AddMultiTenant() + .WithHeaderStrategy("X-Customer-Schema"); + +// Dapper shares the SAME connection → automatic schema inheritance +builder.Services.AddScoped(sp => +{ + var dbContext = sp.GetRequiredService(); + return new DapperQueryService(dbContext); // ✅ Uses EF's connection +}); +``` + +**Result:** No manual schema injection needed. Tenant isolation is automatic for both ORMs. + +--- + +## Project Structure + +**Single-project organization with namespaces (NOT 4 separate projects):** + +``` +DotNetWebApp/ +├── sql/ +│ ├── schema.sql # DDL source (existing) +│ └── views/ # NEW: Complex SQL views +│ ├── ProductSalesView.sql +│ ├── CustomerOrderHistoryView.sql +│ └── InventoryDashboardView.sql +├── app.yaml # Entity definitions (existing) +├── views.yaml # NEW: View definitions +├── DotNetWebApp.Models/ +│ ├── Generated/ # EF Core entities (existing) +│ │ ├── Product.cs +│ │ ├── Category.cs +│ │ └── Order.cs +│ ├── ViewModels/ # NEW: Dapper DTOs +│ │ ├── ProductSalesView.cs +│ │ ├── CustomerOrderHistoryView.cs +│ │ └── InventoryDashboardView.cs +│ └── AppDictionary/ # YAML models (existing) +├── Services/ +│ ├── IEntityOperationService.cs # EF CRUD (REFACTOR.md Phase 1) +│ ├── EntityOperationService.cs +│ └── Views/ # NEW: Dapper view services +│ ├── IViewRegistry.cs +│ ├── ViewRegistry.cs +│ ├── IViewService.cs +│ └── ViewService.cs +├── Data/ +│ ├── AppDbContext.cs # EF Core (existing) +│ └── Dapper/ # NEW +│ ├── IDapperQueryService.cs +│ └── DapperQueryService.cs +├── Controllers/ +│ └── EntitiesController.cs # Dynamic CRUD API (existing) +├── Components/ +│ ├── Pages/ +│ │ ├── ProductDashboard.razor # NEW: Uses IViewService +│ │ ├── GenericEntityPage.razor # Existing: Uses IEntityOperationService +│ │ └── ... +│ └── Shared/ +│ ├── DynamicDataGrid.razor # Existing +│ └── ... +└── ModelGenerator/ + ├── EntityGenerator.cs # Existing + └── ViewModelGenerator.cs # NEW +``` + +--- + +## Data Access Patterns + +### Pattern 1: Entity CRUD (EF Core) + +**When to use:** Single-table operations, simple queries, writes + +```csharp +// Service layer (REFACTOR.md Phase 1) +public interface IEntityOperationService +{ + Task GetAllAsync(Type entityType, CancellationToken ct = default); + Task GetByIdAsync(Type entityType, object id, CancellationToken ct = default); + Task CreateAsync(Type entityType, object entity, CancellationToken ct = default); + Task UpdateAsync(Type entityType, object entity, CancellationToken ct = default); + Task DeleteAsync(Type entityType, object id, CancellationToken ct = default); +} + +// Usage in Blazor component +@inject IEntityOperationService EntityService + +@code { + private async Task OnRestockAsync(int productId) + { + var productType = typeof(Product); + var product = await EntityService.GetByIdAsync(productType, productId); + + if (product is Product p) + { + p.Stock += 100; // Business logic + await EntityService.UpdateAsync(productType, p); + } + } +} +``` + +**Why EF Core:** +- Change tracking (simplified updates) +- Navigation properties (if needed) +- Reflection-friendly (works with dynamic types for 200+ entities) +- Migrations for schema management + +--- + +### Pattern 2: Complex Views (Dapper) + +**When to use:** Multi-table JOINs, aggregations, reports, dashboards + +```csharp +// SQL file: sql/views/ProductSalesView.sql +SELECT + p.Id, + p.Name, + p.Price, + c.Name AS CategoryName, + SUM(od.Quantity) AS TotalSold, + SUM(od.Quantity * p.Price) AS TotalRevenue +FROM Products p +LEFT JOIN Categories c ON p.CategoryId = c.Id +LEFT JOIN OrderDetails od ON p.Id = od.ProductId +GROUP BY p.Id, p.Name, p.Price, c.Name +ORDER BY TotalSold DESC; + +// views.yaml definition +views: + - name: ProductSalesView + sql_file: "sql/views/ProductSalesView.sql" + properties: + - name: Id + type: int + - name: Name + type: string + - name: TotalSold + type: int + # ... + +// Generated: Models/ViewModels/ProductSalesView.cs +public class ProductSalesView +{ + public int Id { get; set; } + public string Name { get; set; } = null!; + public int TotalSold { get; set; } + // ... +} + +// Service layer +public interface IViewService +{ + Task> ExecuteViewAsync(string viewName, object? parameters = null); +} + +// Usage in Blazor component +@inject IViewService ViewService + +@code { + private IEnumerable? products; + + protected override async Task OnInitializedAsync() + { + products = await ViewService.ExecuteViewAsync( + "ProductSalesView", + new { TopN = 50 }); + } +} +``` + +**Why Dapper:** +- 2-5x faster for complex JOINs +- Full SQL control (CTEs, window functions, etc.) +- No N+1 query problems +- Read-only (no change tracking overhead) + +--- + +## Service Layer Architecture + +### Core Services (Existing + New) + +```csharp +// Existing services (KEEP AS-IS) +public interface IAppDictionaryService { /* loads app.yaml */ } +public interface IEntityMetadataService { /* maps entities to CLR types */ } + +// NEW: Phase 1 (REFACTOR.md) +public interface IEntityOperationService { /* EF CRUD operations */ } + +// NEW: Phase 2 (View Pipeline) +public interface IViewRegistry { /* loads views.yaml */ } +public interface IViewService { /* executes SQL views via Dapper */ } +public interface IDapperQueryService { /* low-level Dapper abstraction */ } +``` + +### Dependency Injection Registration + +```csharp +// Program.cs +var builder = WebApplication.CreateBuilder(args); + +// Existing services (singletons for cached data) +builder.Services.AddSingleton(/* ... */); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => +{ + var env = sp.GetRequiredService(); + var viewsYamlPath = Path.Combine(env.ContentRootPath, "views.yaml"); + return new ViewRegistry(viewsYamlPath, sp.GetRequiredService>()); +}); + +// Multi-tenancy (Finbuckle) +builder.Services.AddMultiTenant() + .WithHeaderStrategy("X-Customer-Schema") + .WithInMemoryStore(/* tenant config */); + +// EF Core (scoped per request) +builder.Services.AddDbContext(options => + options.UseSqlServer(connectionString, + sql => sql.CommandTimeout(30).EnableRetryOnFailure())); + +// Data access services (scoped) +builder.Services.AddScoped(); +builder.Services.AddScoped(); // Shares EF connection +builder.Services.AddScoped(); +``` + +--- + +## Transaction Coordination + +### Scenario: EF Write + Dapper Audit Log + +```csharp +public async Task ProcessOrderWithAuditAsync(int orderId) +{ + // Both operations share the same connection → same transaction + using var transaction = await _dbContext.Database.BeginTransactionAsync(); + + try + { + // EF Core write (change tracking) + var order = await _dbContext.Set().FindAsync(orderId); + order.Status = "Processed"; + order.ProcessedDate = DateTime.UtcNow; + + // Dapper write (fast batch operation) + const string auditSql = @" + INSERT INTO AuditLog (EntityType, EntityId, Action, Timestamp) + VALUES ('Order', @OrderId, 'Processed', GETUTCDATE())"; + + await _dapperQueryService.ExecuteAsync(auditSql, new { OrderId = orderId }); + + // Commit both atomically + await _dbContext.SaveChangesAsync(); + await transaction.CommitAsync(); + } + catch + { + await transaction.RollbackAsync(); + throw; + } +} +``` + +**Key:** `DapperQueryService` uses `_dbContext.Database.GetDbConnection()` → same connection, same transaction. + +--- + +## Code Generation Pipelines + +### Pipeline 1: Entity Generation (Existing) + +```bash +# Makefile target +make run-ddl-pipeline + +# Steps: +# 1. DdlParser reads schema.sql +# 2. Generates app.yaml +# 3. ModelGenerator reads app.yaml +# 4. Generates Models/Generated/*.cs +# 5. Run: dotnet ef migrations add +# 6. Run: dotnet ef database update +``` + +### Pipeline 2: View Generation (NEW - Phase 2) + +```bash +# Makefile target +make run-view-pipeline + +# Steps: +# 1. Create SQL file in sql/views/ +# 2. Add entry to views.yaml (or use auto-discovery tool) +# 3. Run: make run-view-pipeline +# 4. Generates Models/ViewModels/*.cs +# 5. Use IViewService in Blazor components +``` + +--- + +## Decision Matrix: EF vs. Dapper + +| Scenario | Use EF Core | Use Dapper | Rationale | +|----------|-------------|------------|-----------| +| Get single entity by ID | ✅ | ❌ | Simple, fast enough | +| Update single entity | ✅ | ❌ | Change tracking simplifies logic | +| Delete entity | ✅ | ❌ | Cascade deletes handled by EF | +| List all entities (no JOINs) | ✅ | ❌ | Dynamic via IEntityOperationService | +| Complex JOIN (3+ tables) | ❌ | ✅ | 2-5x faster, full SQL control | +| Aggregations (SUM, AVG, GROUP BY) | ❌ | ✅ | More efficient SQL | +| Reports/Dashboards | ❌ | ✅ | Read-only, optimized queries | +| Bulk operations (1000+ rows) | ❌ | ✅ | No change tracking overhead | +| Dynamic queries (user filters) | ✅ | ❌ | LINQ is safer than string concat | + +--- + +## Multi-Tenancy Strategy + +### Finbuckle Configuration + +```csharp +public class TenantInfo : ITenantInfo +{ + public string Id { get; set; } = null!; + public string Identifier { get; set; } = null!; + public string Name { get; set; } = null!; + public string Schema { get; set; } = "dbo"; // ⭐ Schema per tenant +} + +// Program.cs +builder.Services.AddMultiTenant() + .WithHeaderStrategy("X-Customer-Schema") // Existing header + .WithInMemoryStore(options => + { + options.Tenants.Add(new TenantInfo + { + Id = "1", + Identifier = "customer1", + Schema = "customer1" + }); + options.Tenants.Add(new TenantInfo + { + Id = "2", + Identifier = "customer2", + Schema = "customer2" + }); + }); +``` + +### AppDbContext Integration + +```csharp +public class AppDbContext : MultiTenantDbContext +{ + private readonly TenantInfo _tenant; + + public AppDbContext( + DbContextOptions options, + TenantInfo tenant) : base(options) + { + _tenant = tenant; + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // Set default schema from tenant context + if (!string.IsNullOrWhiteSpace(_tenant?.Schema)) + { + modelBuilder.HasDefaultSchema(_tenant.Schema); + } + + // Dynamic entity registration (existing code) + var entityTypes = Assembly.GetExecutingAssembly().GetTypes() + .Where(t => t.IsClass && t.Namespace == "DotNetWebApp.Models.Generated"); + + foreach (var type in entityTypes) + { + modelBuilder.Entity(type).ToTable(ToPlural(type.Name)); + } + } +} +``` + +### Dapper Automatic Schema Inheritance + +```csharp +public class DapperQueryService : IDapperQueryService +{ + private readonly AppDbContext _dbContext; // ⭐ Receives tenant-aware context + + public DapperQueryService(AppDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task> QueryAsync(string sql, object? param = null) + { + // Uses EF's connection → automatic tenant schema + var connection = _dbContext.Database.GetDbConnection(); + return await connection.QueryAsync(sql, param); + } +} +``` + +**No manual schema injection needed!** SQL queries like `SELECT * FROM Products` automatically resolve to the correct tenant schema. + +--- + +## Performance Optimization Guidelines + +### 1. Use Compiled Queries for Hot Paths + +```csharp +private static readonly Func> GetProductById = + EF.CompileAsyncQuery((AppDbContext ctx, int id) => + ctx.Set().FirstOrDefault(p => p.Id == id)); +``` + +### 2. Add Caching for Metadata Services + +```csharp +public class EntityMetadataService : IEntityMetadataService +{ + private readonly IMemoryCache _cache; + + public EntityMetadata? Find(string entityName) + { + return _cache.GetOrCreate($"meta:{entityName}", entry => + { + entry.SlidingExpiration = TimeSpan.FromHours(1); + return /* lookup logic */; + }); + } +} +``` + +### 3. Use Dapper for Read-Heavy Endpoints + +After profiling with Application Insights or MiniProfiler, convert slow EF queries to Dapper. + +### 4. Enable Query Splitting for Collections + +```csharp +modelBuilder.Entity() + .HasMany(p => p.OrderDetails) + .WithOne() + .AsSplitQuery(); // Prevents cartesian explosion +``` + +--- + +## Testing Strategy + +### Unit Tests + +```csharp +// EntityOperationService (EF Core) +[Fact] +public async Task GetAllAsync_ReturnsAllEntities() +{ + // Arrange: In-memory DbContext + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: "TestDb") + .Options; + + // Act & Assert + // ... +} + +// ViewRegistry (Dapper) +[Fact] +public async Task GetViewSqlAsync_LoadsFromFile() +{ + // Arrange: Mock file system or real views.yaml + var registry = new ViewRegistry("views.yaml", logger); + + // Act + var sql = await registry.GetViewSqlAsync("ProductSalesView"); + + // Assert + Assert.Contains("SELECT", sql); +} +``` + +### Integration Tests + +```csharp +[Fact] +public async Task ViewService_ExecutesViewWithTenantIsolation() +{ + // Arrange: Real SQL Server with multiple schemas + using var connection = new SqlConnection(connectionString); + + // Act: Execute view for tenant1 + SetTenantHeader("customer1"); + var results1 = await ViewService.ExecuteViewAsync("ProductSalesView"); + + // Act: Execute view for tenant2 + SetTenantHeader("customer2"); + var results2 = await ViewService.ExecuteViewAsync("ProductSalesView"); + + // Assert: Different results per tenant + Assert.NotEqual(results1.Count(), results2.Count()); +} +``` + +--- + +## Migration Path from Current Architecture + +### Phase 1: Foundation (REFACTOR.md Phases 1, 3, 5) +- Extract `IEntityOperationService` (EF CRUD) +- Migrate to Finbuckle.MultiTenant +- Configuration consolidation + +**Duration:** 2 weeks + +### Phase 2: View Pipeline (PHASE2_VIEW_PIPELINE.md) +- Create `views.yaml` and SQL view files +- Implement `ViewRegistry`, `ViewService`, `DapperQueryService` +- Generate view models +- Update Blazor components + +**Duration:** 1-2 weeks + +### Phase 3: Validation + Polish +- Add validation pipeline +- YAML immutability +- Performance testing + +**Duration:** 1 week + +**Total:** 4-5 weeks + +--- + +## What We DELIBERATELY Did NOT Implement + +### ❌ Full Clean Architecture (4 Separate Projects) + +**Why:** Overkill for small team. Namespace organization provides 80% of benefits. + +### ❌ Repository Pattern + +**Why:** `IEntityOperationService` + `IViewService` provide sufficient abstraction. + +### ❌ CQRS/Mediator Pattern + +**Why:** Adds complexity without benefits at this scale. Services are clear enough. + +### ❌ Domain-Driven Design (Aggregates, Value Objects) + +**Why:** This is a data-driven app, not a complex business domain. + +### ❌ OData for Dynamic Queries + +**Why:** Our reflection-based `IEntityOperationService` is simpler and sufficient. + +--- + +## References + +- **REFACTOR.md** - Complete refactoring plan (all phases) +- **PHASE2_VIEW_PIPELINE.md** - Detailed implementation guide for SQL-first views +- **CLAUDE.md** - Project context for future Claude sessions +- **SESSION_SUMMARY.md** - Development log + +--- + +**Document Version:** 2.0 (Simplified Approach) +**Last Updated:** 2026-01-26 +**Next Review:** After Phase 2 implementation diff --git a/Makefile b/Makefile index 1cae3fe..a1aa305 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ # shellcheck shell=bash -# shellcheck disable=SC2034 +# shellcheck disable=SC2034,SC1089,SC2288,SC2046,SC1072,SC1073 + DOTNET=./dotnet-build.sh # shellcheck disable=SC2034 IMAGE_NAME=dotnetwebapp @@ -9,42 +10,184 @@ TAG=latest DOTNET_ENVIRONMENT?=Development # shellcheck disable=SC2211,SC2276 ASPNETCORE_ENVIRONMENT?=Development +# Performance optimization: Skip global.json search since this project doesn't use it +# shellcheck disable=SC2211,SC2276 +export SKIP_GLOBAL_JSON_HANDLING?=true + +# Performance optimization: Use Debug builds by default for faster iteration +# Debug builds are 3-10x faster than Release builds for development work +# For production builds or CI/CD, use: BUILD_CONFIGURATION=Release make build +# shellcheck disable=SC2211,SC2276 +BUILD_CONFIGURATION?=Debug -.PHONY: clean check build migrate test docker-build run dev db-start db-stop db-logs db-drop +.PHONY: clean check restore build build-all build-release https migrate test run-ddl-pipeline verify-pipeline docker-build run dev stop-dev db-start db-stop db-logs db-drop ms-logs ms-drop cleanup-nested-dirs shutdown-build-servers clean: - $(DOTNET) clean + rm -f msbuild.binlog + $(DOTNET) clean DotNetWebApp.Models/DotNetWebApp.Models.csproj + $(DOTNET) clean DotNetWebApp.csproj + $(DOTNET) clean ModelGenerator/ModelGenerator.csproj + $(DOTNET) clean DdlParser/DdlParser.csproj + $(DOTNET) clean tests/DotNetWebApp.Tests/DotNetWebApp.Tests.csproj + $(DOTNET) clean tests/ModelGenerator.Tests/ModelGenerator.Tests.csproj + @$(MAKE) cleanup-nested-dirs + @$(MAKE) shutdown-build-servers + +# Internal helper: Remove nested project directories created by MSBuild during test/build-all +# Prevents inotify watch exhaustion on Linux (limit: 65,536) +cleanup-nested-dirs: + @find . -type d -path "*/bin/*/tests" -o -path "*/bin/*/DotNetWebApp.Models" -o -path "*/bin/*/ModelGenerator" -o -path "*/bin/*/DdlParser" | xargs rm -rf 2>/dev/null || true + +# Shutdown all MSBuild/Roslyn/Razor build servers to free memory and prevent process accumulation +# Run this after intensive build sessions or when dotnet processes are consuming too much memory +# Force-kills processes if they don't respond to shutdown command +shutdown-build-servers: + @echo "Shutting down .NET build servers..." + @$(DOTNET) build-server shutdown 2>/dev/null || true + @sleep 1 + @PIDS=$$(ps -ef | grep -e "MSBuild\.dll" -e "VBCSCompiler\.dll" -e "RazorServer\.dll" | grep -v grep | awk '{print $$2}'); \ + if [ -n "$$PIDS" ]; then \ + echo "Force-killing stuck build server processes: $$PIDS"; \ + kill -9 $$PIDS 2>/dev/null || true; \ + fi + @echo "Build servers stopped." + +https: + $(DOTNET) dev-certs https check: - shellcheck Makefile shellcheck setup.sh shellcheck dotnet-build.sh - $(DOTNET) restore - $(DOTNET) build --no-restore + shellcheck verify.sh + $(MAKE) restore + $(MAKE) build +restore: + $(DOTNET) restore DotNetWebApp.Models/DotNetWebApp.Models.csproj + $(DOTNET) restore DotNetWebApp.csproj + $(DOTNET) restore ModelGenerator/ModelGenerator.csproj + $(DOTNET) restore DdlParser/DdlParser.csproj + $(DOTNET) restore tests/DotNetWebApp.Tests/DotNetWebApp.Tests.csproj + $(DOTNET) restore tests/ModelGenerator.Tests/ModelGenerator.Tests.csproj + +# Build with configurable configuration (Debug by default for fast dev iteration) +# Builds main projects only (excludes test projects to avoid OOM on memory-limited systems) +# Note: Reduced parallelism (-maxcpucount:2) to prevent memory exhaustion +# If error(s) contain "Run a NuGet package restore", try 'make restore' build: - $(DOTNET) build --configuration Release + $(DOTNET) build DotNetWebApp.Models/DotNetWebApp.Models.csproj --configuration "$(BUILD_CONFIGURATION)" --no-restore -maxcpucount:2 --nologo + $(DOTNET) build DotNetWebApp.csproj --configuration "$(BUILD_CONFIGURATION)" --no-restore -maxcpucount:2 --nologo + $(DOTNET) build ModelGenerator/ModelGenerator.csproj --configuration "$(BUILD_CONFIGURATION)" --no-restore -maxcpucount:2 --nologo + $(DOTNET) build DdlParser/DdlParser.csproj --configuration "$(BUILD_CONFIGURATION)" --no-restore -maxcpucount:2 --nologo + +# Build everything including test projects (higher memory usage) +# Note: Cleans up nested project directories after build to prevent inotify exhaustion on Linux +build-all: + $(DOTNET) build DotNetWebApp.sln --configuration "$(BUILD_CONFIGURATION)" --no-restore -maxcpucount:2 --nologo + @$(MAKE) cleanup-nested-dirs -migrate: +# Build with Release configuration for production deployments +# This target always uses Release regardless of BUILD_CONFIGURATION variable +build-release: + $(DOTNET) build DotNetWebApp.Models/DotNetWebApp.Models.csproj --configuration Release --no-restore -maxcpucount:2 --nologo + $(DOTNET) build DotNetWebApp.csproj --configuration Release --no-restore -maxcpucount:2 --nologo + $(DOTNET) build ModelGenerator/ModelGenerator.csproj --configuration Release --no-restore -maxcpucount:2 --nologo + $(DOTNET) build DdlParser/DdlParser.csproj --configuration Release --no-restore -maxcpucount:2 --nologo + +migrate: build ASPNETCORE_ENVIRONMENT=$(ASPNETCORE_ENVIRONMENT) DOTNET_ENVIRONMENT=$(DOTNET_ENVIRONMENT) $(DOTNET) ef database update +seed: migrate + $(DOTNET) run --project DotNetWebApp.csproj -- --seed + +# Run tests with same configuration as build target for consistency +# Builds and runs test projects sequentially to avoid memory exhaustion +# Note: Cleans up nested project directories after build to prevent inotify exhaustion on Linux test: - $(DOTNET) test --configuration Release --no-build + $(DOTNET) build tests/DotNetWebApp.Tests/DotNetWebApp.Tests.csproj --configuration "$(BUILD_CONFIGURATION)" --no-restore --nologo + $(DOTNET) test tests/DotNetWebApp.Tests/DotNetWebApp.Tests.csproj --configuration "$(BUILD_CONFIGURATION)" --no-build --no-restore --nologo + $(DOTNET) build tests/ModelGenerator.Tests/ModelGenerator.Tests.csproj --configuration "$(BUILD_CONFIGURATION)" --no-restore --nologo + $(DOTNET) test tests/ModelGenerator.Tests/ModelGenerator.Tests.csproj --configuration "$(BUILD_CONFIGURATION)" --no-build --no-restore --nologo + $(DOTNET) build tests/DdlParser.Tests/DdlParser.Tests.csproj --configuration "$(BUILD_CONFIGURATION)" --no-restore --nologo + $(DOTNET) test tests/DdlParser.Tests/DdlParser.Tests.csproj --configuration "$(BUILD_CONFIGURATION)" --no-build --no-restore --nologo + @$(MAKE) cleanup-nested-dirs + +# Run the complete DDL → YAML → Model generation pipeline +# WARNING: This removes all existing migrations +run-ddl-pipeline: clean + @echo "Starting pipeline run..." + @echo " -- Parsing DDL to YAML..." + cd DdlParser && "../$(DOTNET)" run -- ../schema.sql ../app.yaml + @echo "" + @echo " -- Generating models from YAML..." + cd ModelGenerator && "../$(DOTNET)" run ../app.yaml + @echo "" + @echo " -- Regenerating EF Core migration..." + rm -f Migrations/*.cs + $(DOTNET) build DotNetWebApp.csproj --configuration "$(BUILD_CONFIGURATION)" --no-restore -maxcpucount:2 --nologo + $(DOTNET) ef migrations add InitialCreate --output-dir Migrations --context AppDbContext --no-build + @echo "" + @echo " -- Building project..." + $(MAKE) build + @echo "" + @echo "✅ DDL pipeline test completed!" + @echo "" + @echo "🚀 Next: Run 'make dev' to start the application" + +# Verify pipeline outputs are valid +# Validates that app.yaml, generated models, and migrations were created correctly +verify-pipeline: run-ddl-pipeline + @echo "" + @echo "Verifying pipeline outputs..." + @# Validate app.yaml exists and is not empty + @test -f app.yaml || (echo "❌ app.yaml not found" && exit 1) + @test -s app.yaml || (echo "❌ app.yaml is empty" && exit 1) + @echo "✅ app.yaml exists and is not empty" + @# Validate generated models directory exists + @test -d DotNetWebApp.Models/Generated || (echo "❌ Generated/ directory not found" && exit 1) + @# Validate at least one generated model file exists + @test -n "$$(find DotNetWebApp.Models/Generated -name '*.cs' 2>/dev/null)" || (echo "❌ No generated C# files found" && exit 1) + @echo "✅ Generated models exist" + @# Validate migrations were created + @test -d Migrations || (echo "❌ Migrations/ directory not found" && exit 1) + @test -n "$$(find Migrations -name '*.cs' 2>/dev/null)" || (echo "❌ No migration files found" && exit 1) + @echo "✅ Migrations created" + @# Validate YAML structure (basic check) + @grep -q "dataModel:" app.yaml || (echo "❌ app.yaml missing dataModel section" && exit 1) + @grep -q "entities:" app.yaml || (echo "❌ app.yaml missing entities section" && exit 1) + @echo "✅ app.yaml structure valid" + @echo "" + @echo "✅ All pipeline verifications passed!" + @echo "Pipeline is ready for use." docker-build: docker build -t "$(IMAGE_NAME):$(TAG)" . -# Run the application once without hot reload (use for production-like testing or CI/CD) +# Run the application once without hot reload (uses Debug by default unless BUILD_CONFIGURATION=Release) run: - $(DOTNET) run + $(DOTNET) run --project DotNetWebApp.csproj --configuration "$(BUILD_CONFIGURATION)" # Run the application with hot reload (use for active development - auto-reloads on file changes) +# Always uses Debug configuration for fastest rebuild times during watch mode dev: - $(DOTNET) watch run --project DotNetWebApp.csproj --launch-profile https + $(DOTNET) watch --project DotNetWebApp.csproj run --launch-profile https --configuration Debug + +# Stop any orphaned 'dotnet watch' processes from previous dev sessions +# Kills wrapper scripts, parent "dotnet watch" commands, and child dotnet-watch.dll processes +# Uses kill -9 because dotnet watch ignores SIGTERM for graceful shutdown handling +stop-dev: + @echo "Looking for orphaned 'dotnet watch' processes..." + @PIDS=$$(ps -ef | grep -e "dotnet-build\.sh watch" -e "dotnet watch --project DotNetWebApp.csproj" -e "dotnet-watch.dll --project DotNetWebApp.csproj" | grep -v grep | awk '{print $$2}'); \ + if [ -n "$$PIDS" ]; then \ + echo "Found PIDs: $$PIDS"; \ + kill -9 $$PIDS 2>/dev/null && echo "Force-stopped orphaned dev processes." || echo "Failed to stop some processes (may need sudo)."; \ + else \ + echo "No orphaned dev processes found."; \ + fi # Start the SQL Server Docker container used for local dev db-start: - @if docker ps -a --format '{{.Names}}' | grep -q '^sqlserver-dev$$'; then docker start sqlserver-dev; else echo "sqlserver-dev container not found. Run ./setup.sh and choose Docker." >&2; exit 1; fi + @docker start sqlserver-dev # Stop the SQL Server Docker container db-stop: @@ -54,6 +197,11 @@ db-stop: db-logs: @docker logs -f sqlserver-dev +# Tail native SQL Server logs (systemd + errorlog) +ms-logs: + @echo "Tailing systemd and errorlog (Ctrl+C to stop)..." + @sudo sh -c 'journalctl -u mssql-server -f --no-pager & tail -f /var/opt/mssql/log/errorlog; wait' + # Drop the local dev database (uses SA_PASSWORD or container MSSQL_SA_PASSWORD) db-drop: # shellcheck disable=SC2016 @@ -77,3 +225,27 @@ db-drop: $$SQLCMD -S localhost -U sa -P "$$PASSWORD" -C \ -Q "IF DB_ID('"'"'DotNetWebAppDb'"'"') IS NOT NULL BEGIN ALTER DATABASE [DotNetWebAppDb] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [DotNetWebAppDb]; END"; \ echo "Dropped database DotNetWebAppDb (if it existed)."' + +# Local install of MSSQL (no Docker) +ms-status: + systemctl status mssql-server + ss -ltnp | rg 1433 + +ms-start: + sudo systemctl start mssql-server + +# Drop the database from native MSSQL instance on Linux +ms-drop: + # shellcheck disable=SC2016 + @/bin/sh -c '\ + PASSWORD="$$SA_PASSWORD"; \ + if [ -z "$$PASSWORD" ] && [ -n "$$MSSQL_SA_PASSWORD" ]; then \ + PASSWORD="$$MSSQL_SA_PASSWORD"; \ + fi; \ + if [ -z "$$PASSWORD" ]; then \ + echo "SA_PASSWORD is required (export SA_PASSWORD=...)" >&2; \ + exit 1; \ + fi; \ + sqlcmd -S localhost -U sa -P "$$PASSWORD" -C \ + -Q "IF DB_ID('"'"'DotNetWebAppDb'"'"') IS NOT NULL BEGIN ALTER DATABASE [DotNetWebAppDb] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; DROP DATABASE [DotNetWebAppDb]; END"; \ + echo "Dropped database DotNetWebAppDb (if it existed)."' diff --git a/Migrations/.gitignore b/Migrations/.gitignore new file mode 100644 index 0000000..377ccd3 --- /dev/null +++ b/Migrations/.gitignore @@ -0,0 +1,2 @@ +* +!.gitkeep diff --git a/Migrations/.gitkeep b/Migrations/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Migrations/.gitkeep @@ -0,0 +1 @@ + diff --git a/Migrations/20250605191347_InitialCreate.Designer.cs b/Migrations/20250605191347_InitialCreate.Designer.cs deleted file mode 100644 index ff7e5ab..0000000 --- a/Migrations/20250605191347_InitialCreate.Designer.cs +++ /dev/null @@ -1,50 +0,0 @@ -// -using DotNetWebApp.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace DotNetWebApp.Migrations -{ - [DbContext(typeof(AppDbContext))] - [Migration("20250605191347_InitialCreate")] - partial class InitialCreate - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("DotNetWebApp.Models.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Price") - .HasPrecision(18, 2) - .HasColumnType("decimal(18,2)"); - - b.HasKey("Id"); - - b.ToTable("Products"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/Migrations/20250605191347_InitialCreate.cs b/Migrations/20250605191347_InitialCreate.cs deleted file mode 100644 index d7aafc8..0000000 --- a/Migrations/20250605191347_InitialCreate.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace DotNetWebApp.Migrations -{ - /// - public partial class InitialCreate : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Products", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - Name = table.Column(type: "nvarchar(max)", nullable: false), - Price = table.Column(type: "decimal(18,2)", precision: 18, scale: 2, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Products", x => x.Id); - }); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "Products"); - } - } -} diff --git a/Migrations/AppDbContextModelSnapshot.cs b/Migrations/AppDbContextModelSnapshot.cs deleted file mode 100644 index 1c71818..0000000 --- a/Migrations/AppDbContextModelSnapshot.cs +++ /dev/null @@ -1,47 +0,0 @@ -// -using DotNetWebApp.Data; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace DotNetWebApp.Migrations -{ - [DbContext(typeof(AppDbContext))] - partial class AppDbContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("DotNetWebApp.Models.Product", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Price") - .HasPrecision(18, 2) - .HasColumnType("decimal(18,2)"); - - b.HasKey("Id"); - - b.ToTable("Products"); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/ModelGenerator/EntityTemplate.scriban b/ModelGenerator/EntityTemplate.scriban new file mode 100644 index 0000000..ad4865e --- /dev/null +++ b/ModelGenerator/EntityTemplate.scriban @@ -0,0 +1,50 @@ +#nullable enable + +// Auto-generated by ModelGenerator. Do not edit. + +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace DotNetWebApp.Models.Generated +{ + [Table("{{ entity.name | string.capitalize }}")] + public class {{ entity.name | string.capitalize }} + { + public {{ entity.name | string.capitalize }}() + { + {{~ for property in entity.properties ~}} + {{~ if property.is_required && property.type == "string" ~}} + {{ property.name }} = string.Empty; + {{~ end ~}} + {{~ end ~}} + } + + {{~ for property in entity.properties ~}} + {{~ if property.is_primary_key ~}} + [Key] + {{~ if property.is_identity ~}} + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + {{~ end ~}} + {{~ end ~}} + {{~ if property.is_required && property.type == "string" ~}} + [Required] + {{~ end ~}} + {{~ if property.max_length > 0 ~}} + [MaxLength({{ property.max_length }})] + {{~ end ~}} + {{~ if property.type == "decimal" ~}} + [Column(TypeName = "decimal({{ property.precision }}, {{ property.scale }})")] + {{~ end ~}} + {{~ clr_type = property.type == "datetime" ? "DateTime" : property.type ~}} + {{~ is_value_type = clr_type != "string" ~}} + {{~ is_nullable = (!property.is_required) && !property.is_primary_key && !property.is_identity && (clr_type == "string" || is_value_type) ~}} + public {{ clr_type }}{{ is_nullable ? "?" : "" }} {{ property.name }} { get; set; } + {{~ "\n" ~}} + {{~ end ~}} + + {{~ for relationship in entity.relationships ~}} + [ForeignKey("{{ relationship.foreign_key }}")] + public virtual {{ relationship.target_entity | string.capitalize }}? {{ relationship.target_entity | string.capitalize }} { get; set; } + {{~ end ~}} + } +} diff --git a/ModelGenerator/ModelGenerator.csproj b/ModelGenerator/ModelGenerator.csproj new file mode 100644 index 0000000..7409e68 --- /dev/null +++ b/ModelGenerator/ModelGenerator.csproj @@ -0,0 +1,24 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + PreserveNewest + + + + + + + + diff --git a/ModelGenerator/Program.cs b/ModelGenerator/Program.cs new file mode 100644 index 0000000..833025e --- /dev/null +++ b/ModelGenerator/Program.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using DotNetWebApp.Models.AppDictionary; +using Scriban; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace ModelGenerator +{ + class Program + { + static void Main(string[] args) + { + if (args.Length == 0) + { + Console.WriteLine("Usage: ModelGenerator "); + return; + } + var yamlFilePath = args[0]; + var yamlContent = File.ReadAllText(yamlFilePath); + + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + var appDefinition = deserializer.Deserialize(yamlContent); + + var templatePath = Path.Combine(AppContext.BaseDirectory, "EntityTemplate.scriban"); + var templateContent = File.ReadAllText(templatePath); + var template = Template.Parse(templateContent); + + var outputDir = "../DotNetWebApp.Models/Generated"; + Directory.CreateDirectory(outputDir); + + foreach (var entity in appDefinition.DataModel.Entities) + { + var result = template.Render(new { entity }); + var outputPath = Path.Combine(outputDir, $"{entity.Name}.cs"); + File.WriteAllText(outputPath, result); + Console.WriteLine($"Generated {outputPath}"); + } + } + } +} \ No newline at end of file diff --git a/Models/Products.cs b/Models/Products.cs deleted file mode 100644 index 1c8ab41..0000000 --- a/Models/Products.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace DotNetWebApp.Models { - public class Product { - public int Id { get; set; } - public required string Name { get; set; } - public required decimal Price { get; set; } - } -} diff --git a/Models/SpaSectionInfo.cs b/Models/SpaSectionInfo.cs deleted file mode 100644 index 32c53e9..0000000 --- a/Models/SpaSectionInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace DotNetWebApp.Models; - -public sealed record SpaSectionInfo(SpaSection Section, string NavLabel, string Title, string RouteSegment); diff --git a/PHASE2_VIEW_PIPELINE.md b/PHASE2_VIEW_PIPELINE.md new file mode 100644 index 0000000..0434a33 --- /dev/null +++ b/PHASE2_VIEW_PIPELINE.md @@ -0,0 +1,940 @@ +# Phase 2: SQL-First View Pipeline Implementation Plan + +**Status:** Ready to implement (after REFACTOR.md Phases 1, 2, 3, 5 complete) +**Duration:** 1-2 weeks +**Priority:** HIGH (enables legacy SQL integration for 200+ entities) + +--- + +## Overview + +This phase implements a SQL-first view generation pipeline that mirrors the existing DDL-first entity generation pipeline. It enables developers to use legacy SQL queries as the source of truth for complex UI features (dashboards, reports, multi-table grids). + +### Architecture Vision + +``` +ENTITY MODELS (200+ tables) +SQL DDL → app.yaml → Models/Generated/*.cs → EF Core CRUD + ↓ + (existing pipeline - DO NOT CHANGE) + +VIEW MODELS (complex queries) +SQL SELECT → views.yaml → Models/ViewModels/*.cs → Dapper reads + ↓ + (NEW pipeline - Phase 2) + +BUSINESS LOGIC (user interactions) +Blazor Server components → C# event handlers → IEntityOperationService (writes) + IViewService (reads) +``` + +--- + +## Step 1: Create views.yaml Schema Definition (Day 1) + +### 1.1 Create views.yaml File + +**File:** `/home/jrade/code/devixlabs/DotNetWebApp/views.yaml` + +```yaml +# views.yaml - SQL View Definitions for Complex UI Components +# +# This file defines Dapper-based read-only views for Blazor/Radzen components. +# Each view corresponds to a SQL query file and generates a C# DTO class. +# +# Generation command: make run-view-pipeline +# Generated output: DotNetWebApp.Models/ViewModels/*.cs + +views: + - name: ProductSalesView + description: "Product sales summary with category and order totals" + sql_file: "sql/views/ProductSalesView.sql" + parameters: + - name: TopN + type: int + nullable: false + default: 10 + properties: + - name: Id + type: int + nullable: false + - name: Name + type: string + nullable: false + - name: Price + type: decimal + nullable: false + - name: CategoryName + type: string + nullable: true + - name: TotalSold + type: int + nullable: false + - name: TotalRevenue + type: decimal + nullable: false + + # Add more views as needed... +``` + +### 1.2 Create sql/views/ Directory Structure + +```bash +mkdir -p sql/views +``` + +### 1.3 Create Example SQL View File + +**File:** `sql/views/ProductSalesView.sql` + +```sql +-- ProductSalesView.sql +-- Product sales summary with category and order totals +-- Parameters: @TopN (default: 10) + +SELECT + p.Id, + p.Name, + p.Price, + c.Name AS CategoryName, + ISNULL(SUM(od.Quantity), 0) AS TotalSold, + ISNULL(SUM(od.Quantity * p.Price), 0) AS TotalRevenue +FROM Products p +LEFT JOIN Categories c ON p.CategoryId = c.Id +LEFT JOIN OrderDetails od ON p.Id = od.ProductId +GROUP BY p.Id, p.Name, p.Price, c.Name +ORDER BY TotalSold DESC +OFFSET 0 ROWS FETCH NEXT @TopN ROWS ONLY; +``` + +**Deliverable:** `views.yaml` schema file + example SQL view + +--- + +## Step 2: Extend ModelGenerator for View Models (Days 2-3) + +### 2.1 Create ViewModelGenerator Class + +**File:** `ModelGenerator/ViewModelGenerator.cs` + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Scriban; +using YamlDotNet.Serialization; + +namespace ModelGenerator +{ + public class ViewModelGenerator + { + private readonly string _viewsYamlPath; + private readonly string _outputPath; + private readonly ILogger _logger; + + public ViewModelGenerator(string viewsYamlPath, string outputPath, ILogger logger) + { + _viewsYamlPath = viewsYamlPath; + _outputPath = outputPath; + _logger = logger; + } + + public async Task GenerateAsync() + { + _logger.LogInformation("Loading views from {Path}", _viewsYamlPath); + + if (!File.Exists(_viewsYamlPath)) + { + _logger.LogWarning("views.yaml not found at {Path}. Skipping view generation.", _viewsYamlPath); + return; + } + + // Deserialize views.yaml + var deserializer = new DeserializerBuilder().Build(); + var yamlContent = await File.ReadAllTextAsync(_viewsYamlPath); + var viewDefinitions = deserializer.Deserialize(yamlContent); + + if (viewDefinitions?.Views == null || !viewDefinitions.Views.Any()) + { + _logger.LogWarning("No views found in {Path}", _viewsYamlPath); + return; + } + + _logger.LogInformation("Generating {Count} view models...", viewDefinitions.Views.Count); + + // Ensure output directory exists + Directory.CreateDirectory(_outputPath); + + foreach (var view in viewDefinitions.Views) + { + await GenerateViewModelAsync(view); + } + + _logger.LogInformation("View model generation complete. Output: {OutputPath}", _outputPath); + } + + private async Task GenerateViewModelAsync(ViewDefinition view) + { + var template = Template.Parse(GetViewModelTemplate()); + var output = await template.RenderAsync(new + { + View = view, + GeneratedDate = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"), + HasParameters = view.Parameters?.Any() ?? false + }); + + var outputFile = Path.Combine(_outputPath, $"{view.Name}.cs"); + await File.WriteAllTextAsync(outputFile, output); + + _logger.LogInformation("Generated view model: {FileName}", Path.GetFileName(outputFile)); + } + + private string GetViewModelTemplate() + { + return @"// +// This code was generated by ModelGenerator on {{ GeneratedDate }} UTC. +// SQL Source: {{ View.SqlFile }} +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// + +using System; +using System.Collections.Generic; + +namespace DotNetWebApp.Models.ViewModels +{ + /// + /// {{ View.Description }} + /// + /// + /// SQL Source: {{ View.SqlFile }} + {{~ if HasParameters ~}} + /// Parameters: + {{~ for param in View.Parameters ~}} + /// - @{{ param.Name }} ({{ param.Type }}{{ if param.Nullable }}?{{ end }}){{ if param.Default }} = {{ param.Default }}{{ end }} + {{~ end ~}} + {{~ end ~}} + /// + public class {{ View.Name }} + { + {{~ for property in View.Properties ~}} + public {{ property.Type }}{{ if property.Nullable }}?{{ end }} {{ property.Name }} { get; set; }}{{ if !property.Nullable && property.Type == 'string' }} = null!;{{ end }} + {{~ end ~}} + } +} +"; + } + } + + // YAML Model Classes + public class ViewsDefinition + { + public List Views { get; set; } = new(); + } + + public class ViewDefinition + { + public string Name { get; set; } = null!; + public string Description { get; set; } = null!; + public string SqlFile { get; set; } = null!; + public List? Parameters { get; set; } + public List Properties { get; set; } = new(); + } + + public class ViewParameter + { + public string Name { get; set; } = null!; + public string Type { get; set; } = null!; + public bool Nullable { get; set; } + public string? Default { get; set; } + } + + public class ViewProperty + { + public string Name { get; set; } = null!; + public string Type { get; set; } = null!; + public bool Nullable { get; set; } + } +} +``` + +### 2.2 Update ModelGenerator Program.cs + +**File:** `ModelGenerator/Program.cs` (add view generation mode) + +```csharp +// Add to existing Program.cs + +var mode = args.FirstOrDefault(a => a.StartsWith("--mode="))?.Split('=')[1] ?? "entities"; + +if (mode == "views") +{ + // View generation mode + var viewsYamlPath = args.FirstOrDefault(a => a.StartsWith("--views-yaml="))?.Split('=')[1] + ?? "views.yaml"; + var outputDir = args.FirstOrDefault(a => a.StartsWith("--output-dir="))?.Split('=')[1] + ?? "DotNetWebApp.Models/ViewModels"; + + var viewGenerator = new ViewModelGenerator(viewsYamlPath, outputDir, logger); + await viewGenerator.GenerateAsync(); +} +else +{ + // Existing entity generation mode + // ... existing code ... +} +``` + +**Deliverable:** ViewModelGenerator class + updated Program.cs + +--- + +## Step 3: Create View Registry Service (Days 4-5) + +### 3.1 Create IViewRegistry Interface + +**File:** `Services/Views/IViewRegistry.cs` + +```csharp +using DotNetWebApp.Models.ViewModels; + +namespace DotNetWebApp.Services.Views +{ + /// + /// Registry for SQL view definitions loaded from views.yaml + /// + public interface IViewRegistry + { + /// + /// Gets the SQL query for a registered view + /// + /// Name of the view (e.g., "ProductSalesView") + /// SQL query text + Task GetViewSqlAsync(string viewName); + + /// + /// Gets the view definition metadata + /// + /// Name of the view + /// View definition + ViewDefinition GetViewDefinition(string viewName); + + /// + /// Gets all registered view names + /// + IEnumerable GetAllViewNames(); + } +} +``` + +### 3.2 Create ViewRegistry Implementation + +**File:** `Services/Views/ViewRegistry.cs` + +```csharp +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; + +namespace DotNetWebApp.Services.Views +{ + /// + /// Singleton service that loads and caches SQL view definitions + /// + public class ViewRegistry : IViewRegistry + { + private readonly Dictionary _views; + private readonly Dictionary _sqlCache; + private readonly string _sqlBasePath; + private readonly ILogger _logger; + + public ViewRegistry(string viewsYamlPath, ILogger logger) + { + _logger = logger; + _views = new Dictionary(StringComparer.OrdinalIgnoreCase); + _sqlCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + _sqlBasePath = Path.GetDirectoryName(viewsYamlPath) ?? AppDomain.CurrentDomain.BaseDirectory; + + LoadViews(viewsYamlPath); + } + + private void LoadViews(string yamlPath) + { + if (!File.Exists(yamlPath)) + { + _logger.LogWarning("views.yaml not found at {Path}. No views registered.", yamlPath); + return; + } + + _logger.LogInformation("Loading views registry from {Path}", yamlPath); + + var deserializer = new DeserializerBuilder().Build(); + var yamlContent = File.ReadAllText(yamlPath); + var viewDef = deserializer.Deserialize(yamlContent); + + if (viewDef?.Views == null) + { + _logger.LogWarning("No views found in {Path}", yamlPath); + return; + } + + foreach (var view in viewDef.Views) + { + _views[view.Name] = view; + _logger.LogDebug("Registered view: {ViewName} (SQL: {SqlFile})", view.Name, view.SqlFile); + } + + _logger.LogInformation("Loaded {Count} views into registry", _views.Count); + } + + public async Task GetViewSqlAsync(string viewName) + { + if (_sqlCache.TryGetValue(viewName, out var cachedSql)) + { + return cachedSql; + } + + if (!_views.TryGetValue(viewName, out var view)) + { + throw new InvalidOperationException($"View '{viewName}' not found in registry. Registered views: {string.Join(", ", _views.Keys)}"); + } + + // Resolve SQL file path (relative to views.yaml location) + var sqlPath = Path.IsPathRooted(view.SqlFile) + ? view.SqlFile + : Path.Combine(_sqlBasePath, view.SqlFile); + + if (!File.Exists(sqlPath)) + { + throw new FileNotFoundException($"SQL file not found for view '{viewName}': {sqlPath}"); + } + + var sql = await File.ReadAllTextAsync(sqlPath); + _sqlCache[viewName] = sql; + + _logger.LogDebug("Loaded SQL for view {ViewName} from {SqlPath}", viewName, sqlPath); + return sql; + } + + public ViewDefinition GetViewDefinition(string viewName) + { + if (!_views.TryGetValue(viewName, out var view)) + { + throw new InvalidOperationException($"View '{viewName}' not found in registry"); + } + return view; + } + + public IEnumerable GetAllViewNames() + { + return _views.Keys; + } + } +} +``` + +**Deliverable:** IViewRegistry + ViewRegistry implementation + +--- + +## Step 4: Create Dapper Query Service (Day 5) + +### 4.1 Create IDapperQueryService Interface + +**File:** `Data/Dapper/IDapperQueryService.cs` + +```csharp +namespace DotNetWebApp.Data.Dapper +{ + /// + /// Read-only Dapper query service for complex SQL views + /// + public interface IDapperQueryService + { + /// + /// Executes a query and returns multiple results + /// + Task> QueryAsync(string sql, object? param = null); + + /// + /// Executes a query and returns a single result or default + /// + Task QuerySingleAsync(string sql, object? param = null); + } +} +``` + +### 4.2 Create DapperQueryService Implementation + +**File:** `Data/Dapper/DapperQueryService.cs` + +```csharp +using System; +using System.Collections.Generic; +using System.Data; +using System.Threading.Tasks; +using Dapper; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace DotNetWebApp.Data.Dapper +{ + /// + /// Read-only Dapper service that shares EF Core's connection + /// Automatically inherits tenant schema from EF Core context + /// + public class DapperQueryService : IDapperQueryService + { + private readonly AppDbContext _dbContext; + private readonly ILogger _logger; + + public DapperQueryService(AppDbContext dbContext, ILogger logger) + { + _dbContext = dbContext; + _logger = logger; + } + + public async Task> QueryAsync(string sql, object? param = null) + { + var connection = _dbContext.Database.GetDbConnection(); + + try + { + _logger.LogDebug("Executing Dapper query (Schema: {Schema}): {Sql}", + _dbContext.Schema, TruncateSql(sql)); + + // Connection state may be closed; Dapper will handle it + return await connection.QueryAsync(sql, param); + } + catch (Exception ex) + { + _logger.LogError(ex, "Dapper query failed (Schema: {Schema}): {Sql}", + _dbContext.Schema, TruncateSql(sql)); + throw new InvalidOperationException($"Query execution failed: {ex.Message}", ex); + } + } + + public async Task QuerySingleAsync(string sql, object? param = null) + { + var connection = _dbContext.Database.GetDbConnection(); + + try + { + _logger.LogDebug("Executing Dapper single query (Schema: {Schema}): {Sql}", + _dbContext.Schema, TruncateSql(sql)); + + return await connection.QuerySingleOrDefaultAsync(sql, param); + } + catch (Exception ex) + { + _logger.LogError(ex, "Dapper single query failed (Schema: {Schema}): {Sql}", + _dbContext.Schema, TruncateSql(sql)); + throw new InvalidOperationException($"Query execution failed: {ex.Message}", ex); + } + } + + private static string TruncateSql(string sql) + { + return sql.Length > 100 ? sql.Substring(0, 100) + "..." : sql; + } + } +} +``` + +**Deliverable:** IDapperQueryService + DapperQueryService implementation + +--- + +## Step 5: Create View Service (Days 6-7) + +### 5.1 Create IViewService Interface + +**File:** `Services/Views/IViewService.cs` + +```csharp +namespace DotNetWebApp.Services.Views +{ + /// + /// Service for executing registered SQL views with Dapper + /// + public interface IViewService + { + /// + /// Executes a registered view and returns multiple results + /// + /// View model type + /// Name of the registered view + /// Query parameters (optional) + Task> ExecuteViewAsync(string viewName, object? parameters = null); + + /// + /// Executes a registered view and returns a single result + /// + /// View model type + /// Name of the registered view + /// Query parameters (optional) + Task ExecuteViewSingleAsync(string viewName, object? parameters = null); + } +} +``` + +### 5.2 Create ViewService Implementation + +**File:** `Services/Views/ViewService.cs` + +```csharp +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using DotNetWebApp.Data.Dapper; +using Microsoft.Extensions.Logging; + +namespace DotNetWebApp.Services.Views +{ + /// + /// Service that executes SQL views via Dapper using the view registry + /// + public class ViewService : IViewService + { + private readonly IDapperQueryService _dapper; + private readonly IViewRegistry _registry; + private readonly ILogger _logger; + + public ViewService( + IDapperQueryService dapper, + IViewRegistry registry, + ILogger logger) + { + _dapper = dapper; + _registry = registry; + _logger = logger; + } + + public async Task> ExecuteViewAsync(string viewName, object? parameters = null) + { + var sql = await _registry.GetViewSqlAsync(viewName); + _logger.LogInformation("Executing view: {ViewName}", viewName); + + try + { + return await _dapper.QueryAsync(sql, parameters); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to execute view: {ViewName}", viewName); + throw new InvalidOperationException($"View execution failed: {viewName}", ex); + } + } + + public async Task ExecuteViewSingleAsync(string viewName, object? parameters = null) + { + var sql = await _registry.GetViewSqlAsync(viewName); + _logger.LogInformation("Executing view (single): {ViewName}", viewName); + + try + { + return await _dapper.QuerySingleAsync(sql, parameters); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to execute view (single): {ViewName}", viewName); + throw new InvalidOperationException($"View execution failed: {viewName}", ex); + } + } + } +} +``` + +**Deliverable:** IViewService + ViewService implementation + +--- + +## Step 6: Update Program.cs with DI Registration (Day 8) + +**File:** `Program.cs` (add after existing services) + +```csharp +// AFTER existing services... + +// Dapper infrastructure (read-only, shares EF connection) +builder.Services.AddScoped(); + +// View registry (singleton, loaded once at startup) +builder.Services.AddSingleton(sp => +{ + var env = sp.GetRequiredService(); + var viewsYamlPath = Path.Combine(env.ContentRootPath, "views.yaml"); + var logger = sp.GetRequiredService>(); + return new ViewRegistry(viewsYamlPath, logger); +}); + +// View service (scoped, executes views) +builder.Services.AddScoped(); + +_logger.LogInformation("View services registered"); +``` + +**Deliverable:** Updated Program.cs with view services + +--- + +## Step 7: Update Makefile (Day 8) + +**File:** `Makefile` (add new targets) + +```makefile +# Add after existing targets + +.PHONY: run-view-pipeline +run-view-pipeline: + @echo "Running view model generation pipeline..." + @$(DOTNET) run --project ModelGenerator -- \ + --mode=views \ + --views-yaml=views.yaml \ + --output-dir=DotNetWebApp.Models/ViewModels + @echo "View models generated in DotNetWebApp.Models/ViewModels/" + +.PHONY: run-all-pipelines +run-all-pipelines: run-ddl-pipeline run-view-pipeline + @echo "All generation pipelines complete" + @echo " - Entities: DotNetWebApp.Models/Generated/" + @echo " - Views: DotNetWebApp.Models/ViewModels/" +``` + +**Deliverable:** Updated Makefile with view pipeline targets + +--- + +## Step 8: Create Example Blazor Component (Day 9) + +**File:** `Components/Pages/ProductDashboard.razor` + +```razor +@page "/dashboard/products" +@inject IViewService ViewService +@inject ILogger Logger + +Product Sales Dashboard + +

Top Selling Products

+ +@if (isLoading) +{ +

Loading...

+} +else if (products != null) +{ + + + + + + + + + +} +else if (errorMessage != null) +{ +
@errorMessage
+} + +@code { + private IEnumerable? products; + private bool isLoading = true; + private string? errorMessage; + + protected override async Task OnInitializedAsync() + { + await LoadDataAsync(); + } + + private async Task LoadDataAsync() + { + isLoading = true; + errorMessage = null; + + try + { + // Execute the registered view via IViewService + products = await ViewService.ExecuteViewAsync( + "ProductSalesView", + new { TopN = 50 }); // Pass parameters + + Logger.LogInformation("Loaded {Count} products", products?.Count() ?? 0); + } + catch (Exception ex) + { + Logger.LogError(ex, "Failed to load product dashboard"); + errorMessage = "Failed to load dashboard data. Please try again."; + } + finally + { + isLoading = false; + } + } +} +``` + +**Deliverable:** Example Blazor component using IViewService + +--- + +## Step 9: Create ViewModels Directory Structure (Day 9) + +```bash +mkdir -p DotNetWebApp.Models/ViewModels +``` + +Add to `.gitignore`: + +```gitignore +# Generated view models +DotNetWebApp.Models/ViewModels/*.cs +!DotNetWebApp.Models/ViewModels/.gitkeep +``` + +Create `.gitkeep` file: + +```bash +touch DotNetWebApp.Models/ViewModels/.gitkeep +``` + +**Deliverable:** ViewModels directory structure + +--- + +## Step 10: Testing & Validation (Days 10) + +### 10.1 Create Integration Test + +**File:** `DotNetWebApp.Tests/ViewPipelineTests.cs` + +```csharp +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DotNetWebApp.Services.Views; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace DotNetWebApp.Tests +{ + public class ViewPipelineTests + { + [Fact] + public void ViewRegistry_LoadsViewsFromYaml() + { + // Arrange + var viewsYamlPath = Path.Combine(Directory.GetCurrentDirectory(), "views.yaml"); + var logger = LoggerFactory.Create(b => b.AddConsole()).CreateLogger(); + + // Act + var registry = new ViewRegistry(viewsYamlPath, logger); + var viewNames = registry.GetAllViewNames().ToList(); + + // Assert + Assert.NotEmpty(viewNames); + Assert.Contains("ProductSalesView", viewNames); + } + + [Fact] + public async Task ViewRegistry_GetViewSqlAsync_ReturnsValidSql() + { + // Arrange + var viewsYamlPath = Path.Combine(Directory.GetCurrentDirectory(), "views.yaml"); + var logger = LoggerFactory.Create(b => b.AddConsole()).CreateLogger(); + var registry = new ViewRegistry(viewsYamlPath, logger); + + // Act + var sql = await registry.GetViewSqlAsync("ProductSalesView"); + + // Assert + Assert.NotNull(sql); + Assert.Contains("SELECT", sql, StringComparison.OrdinalIgnoreCase); + Assert.Contains("FROM Products", sql, StringComparison.OrdinalIgnoreCase); + } + } +} +``` + +### 10.2 Manual Testing Checklist + +- [ ] Run `make run-view-pipeline` - verify ViewModels/*.cs generated +- [ ] Check generated classes have correct properties +- [ ] Start app with `make dev` +- [ ] Navigate to `/dashboard/products` (or your test page) +- [ ] Verify data loads correctly +- [ ] Test with different tenant schemas (X-Customer-Schema header) +- [ ] Verify logging shows correct view execution + +**Deliverable:** Integration tests + manual testing checklist + +--- + +## Phase 2 Completion Checklist + +### Infrastructure +- [ ] `views.yaml` created with schema definition +- [ ] `sql/views/` directory structure created +- [ ] Example SQL view file created +- [ ] ViewModelGenerator class implemented +- [ ] ModelGenerator updated with view mode +- [ ] ViewModels directory structure created + +### Services +- [ ] IViewRegistry interface created +- [ ] ViewRegistry implementation created +- [ ] IDapperQueryService interface created +- [ ] DapperQueryService implementation created +- [ ] IViewService interface created +- [ ] ViewService implementation created + +### Integration +- [ ] Program.cs updated with DI registration +- [ ] Makefile updated with view pipeline targets +- [ ] Example Blazor component created +- [ ] Integration tests created +- [ ] Manual testing complete + +### Documentation +- [ ] README.md updated with view pipeline usage +- [ ] REFACTOR.md updated with Phase 2 details +- [ ] EF_Dapper_Hybrid__Architecture.md updated + +--- + +## Success Criteria + +After Phase 2 completion: + +✅ Developers can drop legacy SQL files into `sql/views/` +✅ `make run-view-pipeline` generates type-safe C# view models +✅ Blazor components can consume views via `IViewService` +✅ Multi-tenant schema isolation works automatically (via Finbuckle + shared connection) +✅ No JavaScript/AJAX required (server-side C# event handlers) +✅ All 200+ entities supported via this pattern + +--- + +## Next Steps After Phase 2 + +1. **Create more SQL views** for existing UI features +2. **Migrate legacy JavaScript** to server-side C# event handlers +3. **Build SQL-to-YAML auto-discovery tool** (Phase 3, optional) +4. **Performance optimization** (query caching, compiled queries) + +--- + +**End of Phase 2 Implementation Plan** diff --git a/Program.cs b/Program.cs index 6299179..ffcdfc1 100644 --- a/Program.cs +++ b/Program.cs @@ -1,12 +1,13 @@ -using DotNetWebApp.Data; -using DotNetWebApp.Data.Plugins; -using DotNetWebApp.Data.Tenancy; -using DotNetWebApp.Models; -using DotNetWebApp.Services; -using Microsoft.AspNetCore.Components; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Radzen; +using System; +using System.Linq; +using DotNetWebApp.Data; +using DotNetWebApp.Data.Tenancy; +using DotNetWebApp.Models; +using DotNetWebApp.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Radzen; var builder = WebApplication.CreateBuilder(args); @@ -14,32 +15,57 @@ // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); -builder.Services.AddControllers(); -builder.Services.AddRazorPages(); -builder.Services.AddServerSideBlazor(); -builder.Services.AddRadzenComponents(); +builder.Services.AddControllers(); +builder.Services.AddRazorPages(); +builder.Services.AddServerSideBlazor(); +builder.Services.AddRadzenComponents(); builder.Services.Configure( builder.Configuration.GetSection("AppCustomization")); +builder.Services.Configure( + builder.Configuration.GetSection(DataSeederOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection("TenantSchema")); -builder.Services.AddHttpContextAccessor(); -builder.Services.AddScoped(sp => -{ - var navigationManager = sp.GetRequiredService(); - return new HttpClient { BaseAddress = new Uri(navigationManager.BaseUri) }; -}); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.AddDbContext(options => - options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(sp => +{ + var navigationManager = sp.GetRequiredService(); + var handler = new HttpClientHandler(); + if (builder.Environment.IsDevelopment()) + { + // Accept self-signed certificates in development + handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true; + } + return new HttpClient(handler) { BaseAddress = new Uri(navigationManager.BaseUri) }; +}); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => +{ + var env = sp.GetRequiredService(); + var yamlPath = Path.Combine(env.ContentRootPath, "app.yaml"); + return new AppDictionaryService(yamlPath); +}); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); +builder.Services.AddScoped(sp => sp.GetRequiredService()); +builder.Services.AddScoped(); +var seedMode = args.Any(arg => string.Equals(arg, "--seed", StringComparison.OrdinalIgnoreCase)); var app = builder.Build(); -// Configure the HTTP request pipeline. +if (seedMode) +{ + using var scope = app.Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); + await scope.ServiceProvider.GetRequiredService().SeedAsync(); + return; +} + if (app.Environment.IsDevelopment()) { app.UseSwagger(); diff --git a/README.md b/README.md index 0706362..632edcd 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,198 @@ # DotNetWebApp -.NET version 8 application manually created with the help of ChatGPT4. +.NET 8 Web API + Blazor Server application with **SQL DDL-driven data models** and a **DDL → YAML → C# pipeline**. -# Setup +> **Primary Goal:** Use SQL DDL as the source of truth and generate `app.yaml` + C# models for dynamic customization. -## 1. Install SQL Server -Run the setup script to install SQL Server (Docker or native Linux): +--- + +## Quick Start (5 minutes) + +### 1. Install SQL Server ```bash ./setup.sh ``` +Choose Docker or native Linux installation. -## Database (Docker) -If you chose Docker in `./setup.sh`, use these commands to manage the SQL Server container: +### 2. Install .NET tools ```bash -make db-start -make db-stop -make db-logs +dotnet tool install --global dotnet-ef --version 8.* ``` -## 2. Setup .NET tools and build +### 3. Build and run ```bash -dotnet tool install --global dotnet-ef --version 8.* -make check +make check # Lint scripts/Makefile, restore packages, build +make db-start # Start SQL Server (Docker only) +make run-ddl-pipeline # Generate app.yaml, models, and migration from SQL DDL +make migrate # Apply generated migration +make dev # Start dev server (https://localhost:7012 or http://localhost:5210) +``` + +**That's it!** Navigate to https://localhost:7012 (or http://localhost:5210) to see the app. + +--- + +## Feature: Bring Your Own Database Schema + +The **DdlParser** converts your SQL Server DDL files into `app.yaml` format, which then generates C# entity models automatically. + +### How It Works + +``` +your-schema.sql → DdlParser → app.yaml → ModelGenerator → DotNetWebApp.Models/Generated/*.cs → Migration → Build & Run +``` + +### Example: Parse Your Own Schema + +Create or replace `schema.sql`: +```sql +CREATE TABLE Companies ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(100) NOT NULL, + RegistrationNumber NVARCHAR(50) NOT NULL, + FoundedYear INT NULL +); + +CREATE TABLE Employees ( + Id INT PRIMARY KEY IDENTITY(1,1), + FirstName NVARCHAR(50) NOT NULL, + LastName NVARCHAR(50) NOT NULL, + Email NVARCHAR(100) NOT NULL, + Salary DECIMAL(18,2) NULL, + HireDate DATETIME2 NULL DEFAULT GETDATE(), + CompanyId INT NOT NULL, + FOREIGN KEY (CompanyId) REFERENCES Companies(Id) +); +``` + +Then run: +```bash +make run-ddl-pipeline make migrate +make dev ``` -If you're using native SQL Server (not Docker), ensure your connection string is set via User Secrets or an environment variable before running `make migrate`. See `SECRETS.md`. -# Build +The app now has **Companies** and **Employees** entities with: +- ✅ Auto-generated `DotNetWebApp.Models/Generated/Company.cs` and `DotNetWebApp.Models/Generated/Employee.cs` +- ✅ Database tables with correct types, constraints, and relationships +- ✅ Navigation UI automatically includes Company and Employee links +- ✅ Generic REST API endpoints (`/api/companies`, `/api/employees`) +- ✅ Dynamic CRUD UI pages with data grids + +**Visit https://localhost:7012 (or http://localhost:5210) → click "Data" in sidebar → select Company or Employee** + +--- + +## Project Structure + ``` -make build +DotNetWebApp/ +├── Components/ +│ ├── Pages/ # Blazor routable pages (Home.razor, SpaApp.razor) +│ ├── Sections/ # SPA components (Dashboard, Settings, Entity, etc.) +│ └── Shared/ # Shared UI components +├── Controllers/ # API endpoints (EntitiesController, etc.) +├── Data/ # EF Core DbContext +├── DdlParser/ # 🆕 SQL DDL → YAML converter +│ ├── Program.cs +│ ├── CreateTableVisitor.cs +│ └── TypeMapper.cs +├── DotNetWebApp.Models/ # 🔄 Separate models assembly +│ ├── Generated/ # 🔄 Auto-generated entities from app.yaml +│ ├── AppDictionary/ # YAML model classes +│ └── *.cs # Options classes (AppCustomizationOptions, DataSeederOptions, etc.) +├── ModelGenerator/ # YAML → C# entity generator +├── Migrations/ # Generated EF Core migrations (current baseline checked in; pipeline regenerates) +├── Pages/ # Host and layout pages +├── Services/ # Business logic and DI services +├── Shared/ # Layout and shared UI +├── tests/ # Test projects +│ ├── DotNetWebApp.Tests/ +│ └── ModelGenerator.Tests/ +├── wwwroot/ # Static files (CSS, JS, images) +├── app.yaml # 📋 Generated data model definition (from SQL DDL) +├── schema.sql # Source SQL DDL +├── seed.sql # Seed data +├── Makefile # Build automation +└── dotnet-build.sh # SDK version wrapper script +``` + +--- + +## Current State + +- ✅ `app.yaml` is generated from SQL DDL and drives app metadata, theme, and data model shape +- ✅ `ModelGenerator` produces entities in `DotNetWebApp.Models/Generated` with proper nullable types +- ✅ Models extracted to separate `DotNetWebApp.Models` assembly for better separation of concerns +- ✅ `AppDbContext` auto-discovers entities via reflection +- ✅ `EntitiesController` provides dynamic REST endpoints +- ✅ `GenericEntityPage.razor` + `DynamicDataGrid.razor` provide dynamic CRUD UI +- ✅ **DdlParser** converts SQL DDL files to `app.yaml` format +- ✅ Migrations generated from SQL DDL pipeline (current baseline checked in; pipeline regenerates) +- ⚠️ Branding currently from `appsettings.json` (can be moved to YAML) +- ✅ Tenant schema switching via `X-Customer-Schema` header (defaults to `dbo`) +- ✅ Dynamic API routes: `/api/entities/{entityName}` and `/api/entities/{entityName}/count` +- ✅ SPA example routes are optional via `AppCustomization:EnableSpaExample` (default true) + +--- + +## Commands Reference + +| Command | Purpose | +|---------|---------| +| `make check` | Lint scripts/Makefile, restore, build | +| `make restore` | Restore app, generator, parser, and test projects | +| `make build` | Build main projects (Debug by default; set `BUILD_CONFIGURATION`) | +| `make build-all` | Build full solution including tests | +| `make build-release` | Release build for main projects | +| `make clean` | Clean build outputs and binlog | +| `make run-ddl-pipeline` | Parse `schema.sql` → app.yaml → models → migration → build | +| `make migrate` | Apply generated migration | +| `make seed` | Apply migration and seed data | +| `make dev` | Start dev server with hot reload (https://localhost:7012 / http://localhost:5210) | +| `make run` | Start server without hot reload | +| `make test` | Run DotNetWebApp.Tests and ModelGenerator.Tests | +| `make db-start` | Start SQL Server container (Docker) | +| `make db-stop` | Stop SQL Server container (Docker) | +| `make db-logs` | Tail SQL Server container logs | +| `make db-drop` | Drop local dev database in Docker | +| `make ms-status` | Check native SQL Server status | +| `make ms-start` | Start native SQL Server | +| `make ms-logs` | Tail native SQL Server logs | +| `make ms-drop` | Drop local dev database in native SQL Server | +| `make docker-build` | Build Docker image | + +--- + +## Database Migrations + +After modifying `schema.sql` or running the DDL parser: + +```bash +# Start SQL Server +make db-start + +# Generate migration from DDL, then apply it +make run-ddl-pipeline +make migrate ``` +--- + +## Sample Seed Data + +`seed.sql` contains INSERT statements wrapped in `IF NOT EXISTS` guards so the script can safely run multiple times without duplicating rows. After running `make run-ddl-pipeline` + `make migrate`, populate the demo catalog data with: + +```bash +make seed +``` + +Then verify the data landed via the container's `sqlcmd` (see the Docker section for setup and example queries). + +The new `make seed` target executes `dotnet run --project DotNetWebApp.csproj -- --seed`. That mode of the application applies the generated migration (`Database.MigrateAsync()`) and then runs `seed.sql` via the `DataSeeder` service, which uses `ExecuteSqlRawAsync` under the current connection string. Ensure the migration has been generated from the DDL pipeline before seeding. You can still run `seed.sql` manually (e.g., `sqlcmd`, SSMS) if you need fine-grained control. + +--- + ## Docker ### Build the image @@ -38,32 +200,214 @@ make build make docker-build ``` -# Testing +### Run the container +```bash +docker run -d \ + -p 8080:80 \ + --name dotnetwebapp \ + dotnetwebapp:latest +``` + +### SQL Server tooling + example queries + +Run the following commands from your host (the first must be executed as `root` inside the container) to install the SQL Server CLI tooling (`sqlcmd`) and verify the `DotNetWebAppDb` demo data: + +```bash +docker exec -it --user root sqlserver-dev bash -lc "ACCEPT_EULA=Y apt-get update && \ + ACCEPT_EULA=Y apt-get install -y mssql-tools unixodbc-dev" +docker exec -it sqlserver-dev \ + /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" \ + -d DotNetWebAppDb -Q "SELECT Id, Name FROM dbo.Categories;" +docker exec -it sqlserver-dev \ + /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P "$SA_PASSWORD" \ + -d DotNetWebAppDb -Q "SELECT Name, Price, CategoryId FROM dbo.Products;" +``` + +These commands let you run `seed.sql` manually or troubleshoot seed data without installing SQL tooling on the host. + +--- + +## Development Setup + +### 1. Install SQL Server +```bash +./setup.sh +# Choose "1" for Docker or "2" for native Linux ``` -make test + +### 2. Install global .NET tools +```bash +dotnet tool install --global dotnet-ef --version 8.* ``` -# Running +### 3. Restore and build +```bash +make check +``` -For active development (with hot reload): +### 4. Start database and apply generated schema +```bash +make db-start # Only needed for Docker +make run-ddl-pipeline +make migrate ``` + +### 5. Run development server +```bash make dev ``` -For production-like testing (without hot reload): +Visit **https://localhost:7012** (or **http://localhost:5210**) in your browser. + +--- + +## Adding a New Data Entity from DDL + +### Step 1: Update your SQL schema file +File: `schema.sql` +```sql +CREATE TABLE Authors ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(100) NOT NULL, + Email NVARCHAR(100) NULL +); + +CREATE TABLE Books ( + Id INT PRIMARY KEY IDENTITY(1,1), + Title NVARCHAR(200) NOT NULL, + ISBN NVARCHAR(13) NOT NULL, + PublishedYear INT NULL, + AuthorId INT NOT NULL, + FOREIGN KEY (AuthorId) REFERENCES Authors(Id) +); ``` -make run + +### Step 2: Run the DDL → YAML → model pipeline +```bash +make run-ddl-pipeline ``` -### Run the container +Output: `app.yaml` now contains `Author` and `Book` entities. + +Generated files: +- `DotNetWebApp.Models/Generated/Author.cs` +- `DotNetWebApp.Models/Generated/Book.cs` + +### Step 3: Apply migration and run ```bash -docker run -d \ - -p 8080:80 \ - --name dotnetwebapp \ - dotnetwebapp:latest +make migrate +make dev +``` + +**Result:** +- ✅ REST API endpoints: `GET /api/authors`, `POST /api/books`, etc. +- ✅ UI: Click "Data" → "Author" or "Book" for CRUD pages +- ✅ Relationships: Book pages show Author name; Author pages list Books + +--- + +## Secrets Management + +Connection strings and API keys are stored in **User Secrets** (never in git): + +```bash +# Set connection string +dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=localhost;Database=DotNetWebApp;..." + +# View all secrets +dotnet user-secrets list + +# See SECRETS.md for details +cat SECRETS.md ``` -# Database migrations +--- + +## Troubleshooting + +### "Could not find SQL Server" +```bash +# Start SQL Server +make db-start ``` + +### "Invalid object name 'dbo.YourTable'" +```bash +# Regenerate schema from DDL and apply it +make run-ddl-pipeline make migrate ``` + +### Build errors after modifying `app.yaml` +```bash +# Regenerate models +cd ModelGenerator +../dotnet-build.sh run ../app.yaml +cd .. + +make build +``` + +### Port 7012/5210 already in use +```bash +# Change port in launchSettings.json or run on different port +make dev # Uses ports from launchSettings.json +``` + +--- + +## Key Files + +| File | Purpose | +|------|---------| +| `app.yaml` | 📋 Generated data model (from SQL DDL) plus app metadata | +| `schema.sql` | 📄 Source SQL DDL for the generation pipeline | +| `DotNetWebApp.Models/` | 🔄 Separate models assembly containing all data models | +| `DotNetWebApp.Models/Generated/` | 🔄 Auto-generated C# entities (don't edit directly) | +| `DotNetWebApp.Models/AppDictionary/` | YAML model classes for app.yaml structure | +| `Migrations/` | 📚 Generated schema history (current baseline checked in; pipeline regenerates) | +| `seed.sql` | 🧪 Seed data for the default schema (run after schema apply) | +| `DdlParser/` | 🆕 Converts SQL DDL → YAML | +| `ModelGenerator/` | 🔄 Converts YAML → C# entities | +| `SECRETS.md` | 🔐 Connection string setup guide | +| `SESSION_SUMMARY.md` | 📝 Documentation index | +| `SKILLS.md` | 📚 Comprehensive developer skill guides | + +--- + +## Next Steps + +1. **Parse your own database schema** → See "Adding a New Data Entity from DDL" above +2. **Customize theme colors** → Edit `app.yaml` theme section +3. **Add validation rules** → Update `ModelGenerator/EntityTemplate.scriban` (or `app.yaml` metadata) and regenerate +4. **Create custom pages** → Add `.razor` files to `Components/Pages/` +5. **Extend REST API** → Add custom controllers in `Controllers/` + +--- + +## Architecture + +- **Backend:** ASP.NET Core 8 Web API with Entity Framework Core +- **Frontend:** Blazor Server with Radzen UI components +- **Database:** SQL Server (Docker or native) +- **Configuration:** DDL-driven data models + JSON appsettings +- **Model Generation:** Automated from YAML via Scriban templates +- **Modular Design:** Models in separate `DotNetWebApp.Models` assembly for better separation of concerns + +--- + +## Development Notes + +- Keep `SESSION_SUMMARY.md` up to date; it is the living status document between LLM sessions +- `dotnet-build.sh` manages .NET SDK version conflicts; do not modify system .NET install +- `DdlParser` and `ModelGenerator` are part of `DotNetWebApp.sln`; use `make run-ddl-pipeline` to regenerate models/migrations +- Generated entities use nullable reference types (`#nullable enable`) +- All value types for optional properties are nullable (`int?`, `decimal?`, etc.) + +--- + +## Support + +- See `SECRETS.md` for connection string setup +- See `CLAUDE.md` for developer context +- Review `SESSION_SUMMARY.md` for current project state diff --git a/REFACTOR.csv b/REFACTOR.csv new file mode 100644 index 0000000..ff1d152 --- /dev/null +++ b/REFACTOR.csv @@ -0,0 +1,14 @@ +Project,URL,Stars,Last_Commit,Dapper_Support,SSH_URL +dotnet-ef,https://github.com/dotnet/efcore,"14,500+",2026,No,git@github.com:dotnet/efcore.git +EntityFrameworkCore.Generator,https://github.com/loresoft/EntityFrameworkCore.Generator,387,2026,No,git@github.com:loresoft/EntityFrameworkCore.Generator.git +CodegenCS,https://github.com/Drizin/CodegenCS,301,2024,Yes,git@github.com:Drizin/CodegenCS.git +POCOGenerator,https://github.com/jaklithn/POCOGenerator,73,2024,Yes,git@github.com:jaklithn/POCOGenerator.git +DapperCodeGenerator,https://github.com/spronkets/DapperCodeGenerator,51,2026,Yes,git@github.com:spronkets/DapperCodeGenerator.git +CatFactory.SqlServer,https://github.com/hherzl/CatFactory.SqlServer,41,2023,Yes,git@github.com:hherzl/CatFactory.SqlServer.git +DtoGenerator,https://github.com/luisllamasbinaburo/DtoGenerator,30,2018,No,git@github.com:luisllamasbinaburo/DtoGenerator.git +SqlGen,https://github.com/busterwood/SqlGen,5,2018,Yes,git@github.com:busterwood/SqlGen.git +EmilianoMusso/pocoGenerator,https://github.com/EmilianoMusso/pocoGenerator,2,2018,No,git@github.com:EmilianoMusso/pocoGenerator.git +IMujagic/sql-to-sharp,https://github.com/IMujagic/sql-to-sharp,2,2022,No,git@github.com:IMujagic/sql-to-sharp.git +manhng83/GenClassesFromDatabase,https://github.com/manhng83/GenClassesFromDatabase,1,2021,No,git@github.com:manhng83/GenClassesFromDatabase.git +ongyishen/DataModelGenerator,https://github.com/ongyishen/DataModelGenerator,0,2022,No,git@github.com:ongyishen/DataModelGenerator.git +faradaysage/Retro-Data-Mapper,https://github.com/faradaysage/Retro-Data-Mapper-Generator,0,2020,No,git@github.com:faradaysage/Retro-Data-Mapper-Generator.git diff --git a/REFACTOR.md b/REFACTOR.md new file mode 100644 index 0000000..d2bee03 --- /dev/null +++ b/REFACTOR.md @@ -0,0 +1,742 @@ +# Refactoring Plan: DotNetWebApp Architecture Analysis & Improvements + +## Executive Summary + +After comprehensive analysis of the DDL pipeline and comparison with the .NET ecosystem, I've identified that **DotNetWebApp fills a genuine gap** - no existing .NET solution provides the complete SQL DDL → YAML → Code → Dynamic API → Multi-tenant UI workflow. However, several areas need refactoring for improved maintainability, and specific components could benefit from mature third-party libraries. + +## Part 1: Current Architecture Assessment + +### DDL Pipeline (End-to-End Flow) + +``` +schema.sql (SQL DDL) + ↓ +DdlParser (TSql160Parser + CreateTableVisitor) + → TableMetadata objects + ↓ +YamlGenerator (converts to AppDefinition) + → app.yaml + ↓ +AppDictionaryService (singleton, loads YAML) + ↓ +EntityMetadataService (maps YAML entities → CLR types) + ↓ +ModelGenerator (Scriban templates) + → Models/Generated/*.cs + ↓ +AppDbContext (reflection-based entity discovery) + → DbSet auto-registration + ↓ +Controllers (EntitiesController) + → REST API endpoints + ↓ +Blazor Components (DynamicDataGrid, GenericEntityPage) + → Dynamic UI rendering +``` + +### Key Design Patterns Identified + +1. **Metadata/Registry Pattern** - EntityMetadataService as central registry ✅ +2. **Visitor Pattern** - CreateTableVisitor traverses T-SQL AST ✅ +3. **Strategy Pattern** - ITenantSchemaAccessor for schema resolution ✅ +4. **Dependency Injection** - Services properly registered ✅ +5. **Template Method** - Scriban-based code generation ✅ +6. **Record Pattern** - Immutable value types (EntityMetadata) ✅ + +### Code Quality Assessment + +| Aspect | Rating | Key Issues | +|--------|--------|------------| +| Immutability | 7/10 | YAML models use mutable properties; should use `init` accessors | +| Error Handling | 6/10 | Reflection invocations lack context in exceptions | +| Validation | 5/10 | No input validation in controllers before deserialization | +| Separation of Concerns | 7/10 | Reflection logic in controllers should be extracted to service | +| Code Duplication | 6/10 | Reflection patterns repeated across multiple methods | +| Tight Coupling | 6/10 | Controllers directly reference DbContext (no repository abstraction) | +| Configuration | 7/10 | Mixed sources (appsettings.json, app.yaml, hard-coded values) | +| Testability | 7/10 | Good test doubles, but DbContext coupling limits mocking | + +## Part 2: Framework & Library Alternatives + +### Component-by-Component Analysis + +#### 1. SQL DDL Parsing: **KEEP CURRENT** +- **Current:** Microsoft.SqlServer.TransactSql.ScriptDom v170.147.0 +- **Alternatives:** Gudu SQLParser (commercial), DacFx (different use case) +- **Verdict:** ✅ ScriptDom is optimal - official Microsoft parser with full T-SQL fidelity + +#### 2. Code Generation: **KEEP CURRENT** +- **Current:** Scriban v6.5.2 +- **Alternatives:** T4 Templates (legacy), Roslyn Source Generators (compile-time) +- **Verdict:** ✅ Scriban is optimal for runtime YAML-driven generation + +#### 3. Multi-Tenant Schema Switching: **RECOMMEND MIGRATION** +- **Current:** Custom ITenantSchemaAccessor + HeaderTenantSchemaAccessor +- **Alternative:** **Finbuckle.MultiTenant** (mature, actively maintained) +- **Verdict:** ⚠️ **MIGRATE TO FINBUCKLE** for: + - Better tenant resolution strategies (subdomain, route, claim, header) + - Robust data isolation patterns + - Active maintenance and community support + - ASP.NET Core Identity integration +- **Files to modify:** + - `/Data/Tenancy/ITenantSchemaAccessor.cs` + - `/Data/Tenancy/HeaderTenantSchemaAccessor.cs` + - `/Data/Tenancy/TenantSchemaOptions.cs` + - `/Data/AppDbContext.cs` (constructor) + - `Program.cs` (service registration) + +#### 4. Dynamic REST API: **KEEP WITH IMPROVEMENTS** +- **Current:** EntitiesController (dynamic, YAML-driven) +- **Alternatives:** OData (over-engineered), ServiceStack AutoQuery (commercial), EasyData (less flexible) +- **Verdict:** ✅ Current approach is simpler than OData, more flexible than EasyData +- **Improvement needed:** Extract reflection logic to IEntityOperationService +- **NOTE:** GenericController has been removed; EntitiesController is the active pattern. + +#### 5. Dynamic Blazor UI: **KEEP CURRENT** +- **Current:** Radzen Blazor + custom DynamicDataGrid +- **Alternatives:** MudBlazor (equivalent), Syncfusion (commercial) +- **Verdict:** ✅ Radzen is a solid choice +- **Enhancement opportunity:** Add dynamic form generation for Create/Edit operations + +### Unique Value Proposition + +**DotNetWebApp fills a gap:** No single .NET solution provides this complete workflow: +- ✅ DDL-first approach (not database-first like EF scaffolding) +- ✅ YAML intermediate metadata layer (enables runtime introspection) +- ✅ .NET-native throughout (unlike Hasura, PostgREST, Directus) +- ✅ Self-hosted, cloud-agnostic (unlike Azure Data API Builder) +- ✅ Simpler than OData, more flexible than low-code platforms +- ✅ Developer-centric with full code control + +## Part 3: Refactoring Recommendations + +### HIGH PRIORITY (Address First) + +#### 1. Extract Reflection Logic to Service Layer + +**PREREQUISITE:** ✅ COMPLETED (2026-01-25) - Missing CRUD operations (GetById, Update, Delete) have been implemented. This task is now unblocked. + +**Problem:** EntitiesController contains reflection logic scattered across multiple methods that should be encapsulated. + +**Files affected:** +- `/Controllers/EntitiesController.cs` (lines 30-56, 58-77, 94-106, 321-325, 327-337, 339-367) + +**Solution:** Create `IEntityOperationService` + +```csharp +// New file: /Services/IEntityOperationService.cs +public interface IEntityOperationService +{ + Task GetAllAsync(Type entityType, CancellationToken ct = default); + Task GetCountAsync(Type entityType, CancellationToken ct = default); + Task CreateAsync(Type entityType, object entity, CancellationToken ct = default); + Task GetByIdAsync(Type entityType, object id, CancellationToken ct = default); + Task UpdateAsync(Type entityType, object entity, CancellationToken ct = default); + Task DeleteAsync(Type entityType, object id, CancellationToken ct = default); +} +``` + +**Benefit:** Reduces EntitiesController from 369 lines to ~150-180 lines; centralizes reflection logic for reuse and testing. + +#### 2. SQL-First View Pipeline (NEW: Dapper for Complex Reads) + +**CONTEXT:** With 200+ entities and multiple database schemas, we need a scalable way to handle complex SQL queries (JOINs, aggregations, reports) without hand-writing services for each view. Legacy SQL queries should be the source of truth for UI features. + +**Problem:** +- Blazor/Radzen components need complex multi-table queries +- Hand-writing Dapper services for 200+ entities is not scalable +- Legacy SQL queries exist but have no C# type safety +- Need to avoid bloated JavaScript/AJAX on front-end + +**Solution:** Create SQL-first view generation pipeline (mirrors existing DDL-first entity pipeline) + +**Architecture:** +``` +ENTITY MODELS (200+ tables) +SQL DDL → app.yaml → Models/Generated/*.cs → EF Core CRUD (existing) + +VIEW MODELS (complex queries) +SQL SELECT → views.yaml → Models/ViewModels/*.cs → Dapper reads (NEW) +``` + +**Files to create:** +- `views.yaml` - YAML registry of SQL views +- `sql/views/*.sql` - SQL query files +- `ModelGenerator/ViewModelGenerator.cs` - Scriban-based view model generator +- `Services/Views/IViewRegistry.cs` - View definition registry (singleton) +- `Services/Views/ViewRegistry.cs` - Implementation +- `Services/Views/IViewService.cs` - View execution service +- `Services/Views/ViewService.cs` - Implementation +- `Data/Dapper/IDapperQueryService.cs` - Read-only Dapper abstraction +- `Data/Dapper/DapperQueryService.cs` - Implementation (shares EF connection) +- `Models/ViewModels/*.cs` - Generated view models (auto-generated) + +**Implementation steps:** +1. Create `views.yaml` schema and example SQL view files +2. Extend `ModelGenerator` to support view mode (`--mode=views`) +3. Create `ViewRegistry` service (loads views.yaml at startup) +4. Create `DapperQueryService` (shares EF Core's connection for automatic tenant schema) +5. Create `ViewService` (executes views by name) +6. Update `Program.cs` DI registration +7. Update Makefile with `run-view-pipeline` target +8. Create example Blazor component using `IViewService` + +**Usage in Blazor components:** +```csharp +@inject IViewService ViewService + +@code { + private IEnumerable? products; + + protected override async Task OnInitializedAsync() + { + // Execute registered view (SQL loaded from views.yaml) + products = await ViewService.ExecuteViewAsync( + "ProductSalesView", + new { TopN = 50 }); + } +} +``` + +**Benefits:** +- ✅ Legacy SQL as source of truth for complex features +- ✅ Generated C# models (type-safe, no manual writing) +- ✅ YAML registry for documentation and versioning +- ✅ Automatic multi-tenant schema isolation (via Finbuckle + shared EF connection) +- ✅ Scalable to 200+ entities +- ✅ No JavaScript/AJAX needed (server-side C# event handlers) +- ✅ Dapper used ONLY for complex reads (EF Core handles all writes via IEntityOperationService) + +**Detailed plan:** See `PHASE2_VIEW_PIPELINE.md` for complete implementation guide. + +**Duration:** 1-2 weeks + +#### 3. Add Input Validation Pipeline + +**Problem:** Controllers deserialize JSON without schema validation. + +**Files affected:** +- `/Controllers/EntitiesController.cs` (CreateEntity, UpdateEntity methods) + +**Solution:** Add FluentValidation or Data Annotations validation middleware + +```csharp +[HttpPost("{entityName}")] +public async Task CreateEntity(string entityName) +{ + var metadata = _metadataService.Find(entityName); + var entity = JsonSerializer.Deserialize(json, metadata.ClrType); + + // NEW: Validate before saving + var validationResults = new List(); + var context = new ValidationContext(entity); + if (!Validator.TryValidateObject(entity, context, validationResults, validateAllProperties: true)) + { + return BadRequest(new { errors = validationResults.Select(v => v.ErrorMessage) }); + } + + await _operationService.CreateAsync(metadata.ClrType, entity); + return CreatedAtAction(nameof(GetEntities), new { entityName }, entity); +} +``` + +**Benefit:** Prevents invalid data from reaching the database; respects [Required], [MaxLength], etc. attributes on generated models. + +#### 4. Migrate to Finbuckle.MultiTenant + +**Problem:** Custom multi-tenant implementation lacks advanced features and maintenance support. + +**Files to replace/modify:** +- DELETE: `/Data/Tenancy/ITenantSchemaAccessor.cs` +- DELETE: `/Data/Tenancy/HeaderTenantSchemaAccessor.cs` +- DELETE: `/Data/Tenancy/TenantSchemaOptions.cs` +- MODIFY: `/Data/AppDbContext.cs` (use Finbuckle's ITenantInfo) +- MODIFY: `Program.cs` (register Finbuckle services) + +**Implementation steps:** +1. Install NuGet package: `Finbuckle.MultiTenant.AspNetCore` v8.x +2. Configure tenant resolution strategy (header-based) +3. Implement `ITenantInfo` with Schema property +4. Update AppDbContext to use `MultiTenantDbContext` +5. Remove custom tenant accessor classes + +**Benefit:** Robust tenant resolution, better data isolation patterns, active community support. + +### MEDIUM PRIORITY + +#### 5. Implement Repository Pattern (OPTIONAL - DEFERRED) + +**DECISION:** SKIP this for now. The combination of `IEntityOperationService` (Phase 1) for EF Core writes and `IViewService` (Phase 2) for Dapper reads provides sufficient abstraction without adding a repository layer. + +**Rationale:** +- `IEntityOperationService` already centralizes EF Core operations +- Repository Pattern would add redundant abstraction +- Can be added later if needed (e.g., for unit testing with mocks) +- Small team benefits from simpler architecture + +**If implemented later:** +```csharp +public interface IRepository where TEntity : class +{ + Task> GetAllAsync(CancellationToken ct = default); + Task GetByIdAsync(object id, CancellationToken ct = default); + Task CreateAsync(TEntity entity, CancellationToken ct = default); + Task UpdateAsync(TEntity entity, CancellationToken ct = default); + Task DeleteAsync(object id, CancellationToken ct = default); + Task CountAsync(CancellationToken ct = default); +} +``` + +#### 6. Make YAML Models Immutable + +**Problem:** AppDefinition, Entity, Property classes and related nested classes use mutable properties. + +**Files affected:** +- `/DotNetWebApp.Models/AppDictionary/AppDefinition.cs` (contains all nested classes: AppMetadata, Theme, DataModel, Entity, Property, Relationship) + +**Solution:** Change all `set` accessors to `init` + +```csharp +public class AppDefinition +{ + public AppMetadata App { get; init; } = null!; + public Theme Theme { get; init; } = null!; + public DataModel DataModel { get; init; } = null!; +} +``` + +**Benefit:** Prevents accidental mutation after deserialization; better thread safety; clearer intent. + +#### 7. Consolidate Configuration Sources + +**AUDIT COMPLETE:** Configuration consolidation items are resolved. + +**Summary:** +- ✅ TenantSchemaOptions: Properly configured (defaults overridden by appsettings.json) +- ✅ DdlParser YamlGenerator: Defaults are appropriate for the generation tool +- ✅ DataSeeder.SeedFileName: Configured via `DataSeeder` section in appsettings.json + +**Problem:** Configuration scattered across appsettings.json, app.yaml, and hard-coded constants. + +**Files affected:** +- `appsettings.json` (DataSeeder section added) +- `/Services/DataSeeder.cs` (reads `DataSeederOptions` instead of const) +- `/Models/DataSeederOptions.cs` (new options class) + +**Solution:** Move all hard-coded values to configuration + +```csharp +// appsettings.json +{ + "DataSeeder": { + "SeedFileName": "seed.sql" + }, + "TenantSchema": { + "DefaultSchema": "dbo", + "HeaderName": "X-Customer-Schema" + } +} +``` + +**Benefit:** Single source of configuration truth; easier environment-specific overrides. + +### NICE-TO-HAVE (Future Enhancements) + +#### 8. Add Dynamic Form Generation + +**Enhancement:** Add BlazorDynamicForm or MudBlazor.Forms for Create/Edit operations. + +**New files:** +- `/Components/Shared/DynamicEntityForm.razor` + +**Benefit:** Complete CRUD UI without manual form coding. + +#### 9. Expression-Based Queries + +**Enhancement:** Replace reflection with expression trees for better performance. + +**Files affected:** +- New file: `/Services/ExpressionHelpers.cs` +- `/Services/EntityOperationService.cs` + +**Benefit:** Better performance, compile-time type safety. + +## Part 4: Critical Files for Refactoring + +### Tier 1 - Core Abstractions (Modify First) +1. `/Services/IEntityMetadataService.cs` - Metadata abstraction +2. `/Services/EntityMetadataService.cs` - Metadata implementation +3. `/Data/AppDbContext.cs` - Entity registration and tenant schema + +### Tier 2 - Controllers (Extract Logic) +4. `/Controllers/EntitiesController.cs` - Reflection-heavy dynamic API + +**NOTE:** Existing services (EntityApiService, DashboardService, SpaSectionService) do NOT require changes during refactoring. See TODO.txt #3 for service layer integration analysis. + +### Tier 3 - Multi-Tenancy (Replace with Finbuckle) +6. `/Data/Tenancy/ITenantSchemaAccessor.cs` +7. `/Data/Tenancy/HeaderTenantSchemaAccessor.cs` +8. `/Data/Tenancy/TenantSchemaOptions.cs` + +### Tier 4 - YAML Models (Add Immutability) +9. `/DotNetWebApp.Models/AppDictionary/AppDefinition.cs` (all nested classes) + +### Tier 5 - Services (New Abstractions) +10. NEW: `/Services/IEntityOperationService.cs` (Phase 1) +11. NEW: `/Services/EntityOperationService.cs` (Phase 1) +12. NEW: `/Services/Views/IViewRegistry.cs` (Phase 2) +13. NEW: `/Services/Views/ViewRegistry.cs` (Phase 2) +14. NEW: `/Services/Views/IViewService.cs` (Phase 2) +15. NEW: `/Services/Views/ViewService.cs` (Phase 2) +16. NEW: `/Data/Dapper/IDapperQueryService.cs` (Phase 2) +17. NEW: `/Data/Dapper/DapperQueryService.cs` (Phase 2) +18. NEW: `ModelGenerator/ViewModelGenerator.cs` (Phase 2) + +## Part 5: Implementation Sequence + +### Phase 1: Extract Reflection Logic (1-2 weeks) +1. Create `IEntityOperationService` interface +2. Implement `EntityOperationService` with all reflection logic +3. Update `EntitiesController` to use service +4. Add unit tests for `EntityOperationService` +5. Verify existing functionality unchanged + +### Phase 2: SQL-First View Pipeline (1-2 weeks) **[NEW]** +1. Create `views.yaml` schema definition and example SQL view files +2. Extend `ModelGenerator` to support view generation mode +3. Create `ViewRegistry` service (loads views.yaml) +4. Create `DapperQueryService` (shares EF Core connection) +5. Create `ViewService` (executes views by name) +6. Update `Program.cs` DI registration +7. Update Makefile with `run-view-pipeline` target +8. Create example Blazor component using `IViewService` +9. Add integration tests for view pipeline +10. Test with multiple tenant schemas + +**Detailed plan:** See `PHASE2_VIEW_PIPELINE.md` + +### Phase 3: Add Validation (1 day) +1. Add FluentValidation NuGet package (or use built-in Data Annotations) +2. Create validation pipeline in controllers +3. Add integration tests for validation scenarios +4. Verify invalid entities are rejected + +### Phase 4: Migrate to Finbuckle.MultiTenant (2-3 days) +1. Install `Finbuckle.MultiTenant.AspNetCore` NuGet package +2. Create `TenantInfo` class implementing `ITenantInfo` +3. Configure header-based tenant resolution +4. Update `AppDbContext` to inherit `MultiTenantDbContext` +5. Update `Program.cs` service registration +6. Remove custom tenant accessor classes +7. Test multi-tenant scenarios (different schemas via headers) +8. Verify Dapper queries inherit tenant schema automatically (via shared EF connection) + +### Phase 5: Configuration & Immutability (1 day) +1. Move hard-coded values to `appsettings.json` +2. Update YAML models to use `init` accessors +3. Verify YAML deserialization still works +4. Update tests for new configuration sources + +## Part 6: Testing Strategy + +### Unit Tests (New) +- `EntityOperationService` - All reflection methods (GetAllAsync, CreateAsync, etc.) +- `ViewRegistry` - YAML loading and SQL file resolution +- `ViewService` - View execution with parameters +- `DapperQueryService` - Query execution with shared connection +- `ValidationPipeline` - Valid/invalid entity scenarios + +### Integration Tests (Update) +- Multi-tenant scenarios with Finbuckle (different schemas for EF + Dapper) +- End-to-end API tests with validation +- View pipeline: SQL query → view model generation → Blazor component rendering +- Verify Dapper queries respect tenant schema automatically + +### Regression Tests +- Verify DDL pipeline still generates correct models (existing) +- Verify View pipeline generates correct view models (new) +- Verify existing API endpoints return same results +- Verify Blazor UI still renders correctly + +## Part 7: Risk Assessment + +| Change | Risk | Mitigation | +|--------|------|------------| +| Extract reflection logic | Low | Good test coverage; logic unchanged | +| View pipeline (SQL-first) | Low-Medium | Similar to existing DDL pipeline; test with multiple schemas | +| Add validation | Low | Existing data annotations already defined | +| Finbuckle migration | Medium | Test multi-tenant scenarios thoroughly; staged rollout; verify Dapper inherits schema | +| Immutable YAML models | Low | YamlDotNet handles `init` properties correctly | +| Dapper shared connection | Low | EF connection sharing is standard pattern; test transaction scenarios | + +## Part 8: Success Criteria + +After refactoring: +- ✅ EntitiesController reduced from 369 lines to ~150-180 lines +- ✅ Reflection logic centralized in EntityOperationService +- ✅ **SQL-first view pipeline operational (views.yaml → ViewModels/*.cs → IViewService)** [NEW] +- ✅ **Legacy SQL queries used as source of truth for complex UI features** [NEW] +- ✅ **Dapper integrated for complex reads; EF Core handles all writes** [NEW] +- ✅ All API endpoints validate input before persistence +- ✅ Multi-tenancy powered by Finbuckle.MultiTenant +- ✅ **Dapper queries automatically respect tenant schema (via shared EF connection)** [NEW] +- ✅ YAML models immutable (init accessors) +- ✅ All hard-coded values in configuration +- ✅ All existing tests passing +- ✅ Code coverage increased (new service/view tests) +- ✅ **Blazor components use server-side C# event handlers (no JavaScript/AJAX)** [NEW] +- ✅ Architecture documented in updated REFACTOR.md + PHASE2_VIEW_PIPELINE.md + +## Part 9: Architectural Strengths to Preserve + +**DO NOT CHANGE:** +1. ✅ SQL DDL → YAML → Code pipeline (unique advantage) +2. ✅ YAML metadata layer (enables runtime introspection) +3. ✅ Scriban-based code generation (optimal for this use case) +4. ✅ Dynamic entity discovery via reflection (scalable) +5. ✅ ScriptDom for SQL parsing (best-in-class) +6. ✅ Radzen Blazor for UI (solid choice) +7. ✅ Dependency injection patterns (clean) +8. ✅ Test doubles and integration tests (good foundation) + +## Part 10: Future Considerations (Beyond Current Scope) + +1. **SQL-to-YAML Auto-Discovery Tool:** Automatically generate views.yaml from legacy SQL files (reduces manual YAML writing) +2. **View Parameter Validation:** Add parameter validation for SQL views (prevent SQL injection via parameters) +3. **Compiled Queries:** Add EF compiled queries for frequently executed operations (performance optimization) +4. **View Caching:** Add IMemoryCache for ViewRegistry SQL cache (reduce file I/O) +5. **Row-Level Security:** Add tenant-aware query filters in DbContext +6. **Rate Limiting:** Add ASP.NET Core rate limiting middleware +7. **API Versioning:** Support versioned endpoints for breaking changes +8. **Audit Logging:** Track entity changes (created, modified, deleted) +9. **Soft Deletes:** Add IsDeleted flag and query filters +10. **Background Jobs:** Use Hangfire/Quartz for async data processing +11. **Event Sourcing:** Track all entity state changes +12. **Database Migrations per Tenant:** Automate schema migrations for multi-tenant databases + +## Verification Plan + +### Manual Testing +1. Run `make run-ddl-pipeline` - verify entity models generated correctly +2. **Run `make run-view-pipeline` - verify view models generated correctly** [NEW] +3. Run `make migrate` - verify EF Core migrations work +4. Run `make dev` - verify app starts and API endpoints respond +5. Test `/api/entities/Product` with different `X-Customer-Schema` headers +6. **Test view execution with different `X-Customer-Schema` headers (verify Dapper inherits schema)** [NEW] +7. Verify DynamicDataGrid renders correctly in Blazor UI +8. **Verify Blazor components using IViewService render correctly** [NEW] +9. Verify Create/Edit operations with valid and invalid data + +### Automated Testing +1. Run `make test` - verify all unit and integration tests pass +2. Run new EntityOperationService tests +3. **Run new ViewRegistry tests** [NEW] +4. **Run new ViewService tests** [NEW] +5. **Run new DapperQueryService tests** [NEW] +6. Run multi-tenant integration tests with Finbuckle (EF + Dapper) + +### Performance Testing +1. Benchmark EntityOperationService vs direct reflection (should be equivalent) +2. **Benchmark Dapper vs EF for complex JOIN queries (expect 2-5x improvement)** [NEW] +3. Verify no performance regression in API response times +4. Verify DbContext pooling still effective with Finbuckle + +--- + +## Source Code Verification Status + +This refactoring plan has been verified against the actual source code. Prior verification items +(controller architecture, service layer integration, and configuration consolidation) are resolved. + +## Recommended Next Steps + +**UPDATED for 200+ entities + multiple schemas + small team:** + +1. **Phase 1 (CRITICAL):** Extract Reflection Logic to IEntityOperationService (1-2 weeks) + - Centralizes CRUD for all 200+ generated entities + - Reduces controller complexity + - Foundation for all subsequent work + +2. **Phase 2 (CRITICAL):** Implement SQL-First View Pipeline (1-2 weeks) + - Enables legacy SQL queries as source of truth + - Generates type-safe view models + - Powers complex Blazor/Radzen components + - **See PHASE2_VIEW_PIPELINE.md for detailed plan** + +3. **Phase 3 (HIGH):** Add Validation Pipeline (1 day) + - Prevents invalid data entry + - Respects data annotations + +4. **Phase 4 (HIGH):** Migrate to Finbuckle.MultiTenant (2-3 days) + - Essential for multiple schema management at scale + - Automatic tenant isolation for EF + Dapper + +5. **Phase 5 (MEDIUM):** Configuration & Immutability (1 day) + - Code quality and maintainability improvements + +**Total timeline:** 3-4 weeks for Phases 1-5 + +**Incremental approach recommended:** +- Implement phases sequentially +- Test thoroughly between phases +- Don't skip Phase 1 and Phase 2 (foundation for scale) + +--- + +## Part 11: Test Coverage TODOs (Post Code Review) + +**Status:** Generated during code review (2026-01-26) +**Priority:** Complete before merging templify branch + +### Completed ✅ +1. **DdlParser.Tests Project** - Comprehensive test suite for SQL parsing + - SqlDdlParser tests (all data types, foreign keys, identity columns) + - YamlGenerator tests (round-trip serialization, relationships) + - TypeMapper tests (SQL to YAML type conversion) + - Coverage: 62 tests, all passing +2. **PipelineIntegrationTests** - End-to-end DDL pipeline validation + - Complete SQL → YAML → Model generation workflow + - Schema update scenarios + - Complex relationship handling +3. **Makefile Enhancements** + - `verify-pipeline` target for CI-friendly output validation + - DdlParser.Tests added to `make test` target + - Warning comment added to `run-ddl-pipeline` about migration deletion + +### Next Sprint (Items 4, 5, 6 from Code Review) + +#### TODO #4: Complete Service Layer Tests (High Priority) +**Estimated Time:** 1 week +**Files to Create:** +- `tests/DotNetWebApp.Tests/AppDictionaryServiceTests.cs` +- `tests/DotNetWebApp.Tests/EntityMetadataServiceTests.cs` + +**Test Scenarios:** +```csharp +// AppDictionaryService +[Fact] GetAppDefinition_ValidYaml_LoadsSuccessfully() +[Fact] GetAppDefinition_MissingFile_ThrowsException() +[Fact] GetAppDefinition_InvalidYaml_ThrowsException() +[Fact] GetAppDefinition_SecondCall_ReturnsCachedValue() +[Fact] GetEntity_ExistingEntity_ReturnsEntity() +[Fact] GetEntity_NonExistentEntity_ReturnsNull() + +// EntityMetadataService +[Fact] Constructor_ValidEntities_PopulatesMetadata() +[Fact] Find_ExistingEntity_ReturnsMetadata() +[Fact] Find_CaseInsensitive_ReturnsMetadata() +[Fact] Find_NonExistent_ReturnsNull() +[Fact] Entities_Property_ReturnsReadOnlyList() +``` + +**Success Criteria:** 80%+ code coverage on service layer + +#### TODO #5: Expand EntitiesController Tests (Medium Priority) +**Estimated Time:** 3 days +**File to Update:** `tests/DotNetWebApp.Tests/EntitiesControllerTests.cs` + +**Missing Test Scenarios:** +```csharp +// CRUD Operations +[Fact] UpdateEntity_ValidData_Returns200() +[Fact] UpdateEntity_NonExistentId_Returns404() +[Fact] UpdateEntity_InvalidData_Returns400() +[Fact] DeleteEntity_ExistingId_Returns204() +[Fact] DeleteEntity_NonExistentId_Returns404() + +// Pagination/Filtering +[Fact] GetEntities_WithPagination_ReturnsSubset() +[Fact] GetEntities_WithFilters_ReturnsFilteredResults() + +// Multi-Tenancy +[Fact] GetEntities_DifferentTenantSchema_ReturnsCorrectData() +[Fact] CreateEntity_WithTenantHeader_CreatesInCorrectSchema() + +// Concurrency +[Fact] UpdateEntity_ConcurrentModification_Returns409() +``` + +**Success Criteria:** All CRUD operations covered with positive and negative test cases + +#### TODO #6: Implement Validation Pipeline (High Priority - Phase 3) +**Estimated Time:** 1 day +**Reference:** REFACTOR.md Part 5 - Phase 3 + +**Implementation Steps:** +1. Add validation middleware to EntitiesController +2. Respect DataAnnotations from generated models +3. Return 400 Bad Request with validation error details + +**Test Scenarios:** +```csharp +// tests/DotNetWebApp.Tests/ValidationTests.cs +[Fact] CreateEntity_InvalidData_Returns400WithValidationErrors() +[Fact] CreateEntity_MissingRequiredField_Returns400() +[Fact] CreateEntity_ExceedsMaxLength_Returns400() +[Fact] CreateEntity_InvalidDataType_Returns400() +[Fact] UpdateEntity_InvalidData_Returns400WithValidationErrors() +``` + +**Success Criteria:** All entity operations validate before persistence + +### P2 - Medium Priority (Future Enhancement) + +#### TODO #7: Performance Tests (Low Priority) +**Estimated Time:** 2 days +**File to Create:** `tests/DotNetWebApp.Tests/PerformanceTests.cs` + +**Test Scenarios:** +```csharp +[Fact] ReflectionOverhead_200Entities_IsAcceptable() +[Fact] DapperVsEF_ComplexJoin_ShowsExpectedImprovement() +[Fact] ApiResponse_AverageLatency_UnderThreshold() +``` + +**Success Criteria:** Baseline performance metrics documented for future comparison + +### Known Issues (Document & Track) + +1. **research/ Directory Compilation** (Blocker for make test) + - **Issue:** DotNetWebApp.csproj compiles research/ files causing build errors + - **Fix:** Add to .csproj: `` + - **Note:** research/ is for LLM learning examples, not production code + +2. **Primary Key Nullability Detection** (Known Limitation) + - **Issue:** DdlParser doesn't automatically mark PRIMARY KEY columns as NOT NULL + - **Workaround:** Always explicitly add `NOT NULL` to PRIMARY KEY columns in SQL + - **Future:** Enhance CreateTableVisitor.cs to set IsNullable=false for PK columns + +3. **Composite Primary Keys Not Supported** (Known Limitation) + - **Issue:** YamlGenerator only handles single-column primary keys + - **Workaround:** Use surrogate keys (INT IDENTITY) for all entities + - **Future:** Add composite key support in Phase 2 if needed + +### Test Coverage Metrics + +**Current State (2026-01-26):** +``` +DotNetWebApp.Tests: ~60% coverage (Controllers, Services) +ModelGenerator.Tests: ~40% coverage (Path resolution only) +DdlParser.Tests: ~80% coverage (62 tests added) +Integration Tests: Basic E2E pipeline coverage +``` + +**Target State (After TODOs 4, 5, 6):** +``` +DotNetWebApp.Tests: 80%+ coverage +ModelGenerator.Tests: 80%+ coverage +DdlParser.Tests: 80%+ coverage +Integration Tests: All critical workflows covered +``` + +**Commands:** +```bash +# Run all tests +make test + +# Run specific test project +./dotnet-build.sh test tests/DdlParser.Tests/DdlParser.Tests.csproj --no-restore --nologo + +# Verify pipeline outputs +make verify-pipeline +``` diff --git a/SESSION_SUMMARY.md b/SESSION_SUMMARY.md new file mode 100644 index 0000000..e69de29 diff --git a/SKILLS.md b/SKILLS.md index 1f2b9b6..0608ef5 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -1,3 +1,458 @@ +# Skills Needed + +Comprehensive guides for developers at all skill levels. Status of each guide: + +- ✅ **[Front-End (Blazor/Radzen)](#front-end-skillsguide-blazorradzen)** - COMPLETE +- ✅ **[Database & DDL](#database--ddl)** - COMPLETE +- ✅ **[SQL Operations](#sql-operations)** - COMPLETE +- ✅ **[App Configuration & YAML](#app-configuration--yaml)** - COMPLETE +- 📋 **.NET/C# Data Layer** *(pending)* - Entity Framework Core, models, and data access +- 📋 **.NET/C# API & Services** *(pending)* - Controllers, services, and API endpoints + +--- + +# Database & DDL + +This guide covers SQL Server schema design and how DDL (Data Definition Language) files drive the data model in this project. + +## Overview + +The application uses a **DDL-first approach**: you define your database schema in SQL, and the system automatically generates everything else (YAML config, C# models, API endpoints, UI). + +### The Pipeline + +``` +schema.sql (YOUR DDL) + ↓ (run: make run-ddl-pipeline) +app.yaml (generated YAML config) + ↓ (automatic on startup) +Models/Generated/*.cs (C# entities) + ↓ (EF Core) +Database Migration & Tables +``` + +## File Locations + +| File | Purpose | +|------|---------| +| `schema.sql` | 📝 Your SQL DDL file - this is what you edit to define tables | +| `app.yaml` | 🔄 Auto-generated from `schema.sql` - never edit manually | +| `Models/Generated/` | 🔄 Auto-generated C# entity classes - never edit manually | +| `Migrations/` | 🔄 Auto-generated EF Core migrations - ignored in repo | + +## Writing Schema (schema.sql) + +### Basic Table Structure + +```sql +CREATE TABLE Categories ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(50) NOT NULL +); +``` + +**Key parts:** +- `CREATE TABLE TableName` - defines a table (will be pluralized to `Categories` in database) +- `Id INT PRIMARY KEY IDENTITY(1,1)` - unique identifier that auto-increments +- `NVARCHAR(50) NOT NULL` - text field up to 50 characters, required +- `NVARCHAR(500) NULL` - optional text field + +### Column Types + +Common SQL Server types and their C# equivalents: + +| SQL Type | C# Type | Notes | +|----------|---------|-------| +| `INT` | `int` | Whole numbers | +| `BIGINT` | `long` | Very large whole numbers | +| `DECIMAL(18,2)` | `decimal` | Money, prices (18 digits total, 2 after decimal) | +| `NVARCHAR(50)` | `string` | Text, max 50 characters | +| `NVARCHAR(MAX)` | `string` | Unlimited text | +| `DATETIME2` | `DateTime` | Date and time | +| `BIT` | `bool` | True/False | + +### NULL vs NOT NULL + +- `NOT NULL` - field is **required** (no empty values allowed) +- `NULL` - field is **optional** (can be empty) + +```sql +CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(100) NOT NULL, -- Required + Description NVARCHAR(500) NULL, -- Optional + Price DECIMAL(18,2) NULL -- Optional +); +``` + +### Foreign Keys (Relationships) + +Link one table to another: + +```sql +CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(100) NOT NULL, + CategoryId INT NULL, + FOREIGN KEY (CategoryId) REFERENCES Categories(Id) +); +``` + +This means: "Products.CategoryId must match a Categories.Id value (or be NULL)" + +### IDENTITY (Auto-Increment) + +```sql +Id INT PRIMARY KEY IDENTITY(1,1) +``` + +- `IDENTITY(1,1)` - start at 1, increment by 1 each time +- Automatically assigns unique IDs; you don't have to provide them + +### DEFAULT Values + +```sql +CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(100) NOT NULL, + CreatedAt DATETIME2 NULL DEFAULT GETDATE() +); +``` + +`DEFAULT GETDATE()` - automatically sets the current date/time when a row is inserted + +## Running the DDL Pipeline + +After editing `schema.sql`, regenerate everything: + +```bash +make run-ddl-pipeline +``` + +This: +1. Parses your `schema.sql` file +2. Generates `app.yaml` with entity definitions +3. Creates C# entity classes in `Models/Generated/` +4. Rebuilds the project +5. Generates a new EF Core migration + +Then apply the migration to your database: + +```bash +make migrate +``` + +## Troubleshooting + +**Q: My schema changes aren't showing up in the API** +- Run `make run-ddl-pipeline` - the pipeline must be re-run after editing schema.sql + +**Q: I get a migration error** +- Ensure `make db-start` is running (SQL Server container must be up) +- Ensure the previous migration has been applied + +**Q: Which tables are actually created in the database?** +- After running `make migrate`, query the database to verify tables exist + +## Important Notes + +- Always edit `schema.sql`, never edit `app.yaml` or `Models/Generated/` +- The DDL parser handles: tables, columns, types, nullability, primary keys, foreign keys, IDENTITY, DEFAULT +- Currently does NOT handle: composite primary keys, UNIQUE constraints, CHECK constraints, computed columns +- After major schema changes, you may need to drop and recreate the database: `make db-drop` then `make db-start` + +--- + +# SQL Operations + +This guide covers writing and debugging SQL queries in this project. + +## File Locations + +| File | Purpose | +|------|---------| +| `schema.sql` | DDL (table definitions) | +| `seed.sql` | DML (sample data to insert) | +| SQL Server (in Docker) | The actual running database | + +## Sample Data (seed.sql) + +The `seed.sql` file contains INSERT statements that populate the database with example data: + +```sql +INSERT INTO Categories (Name) VALUES ('Electronics'); +INSERT INTO Categories (Name) VALUES ('Books'); + +INSERT INTO Products (Name, Description, Price, CategoryId) +VALUES ('Laptop', 'High-performance laptop', 999.99, 1); +``` + +### Running Seed Data + +```bash +make seed +``` + +This: +1. Applies any pending migrations +2. Executes `seed.sql` to insert sample rows +3. Prevents duplicate inserts (guards against re-running) + +### Writing Good Seed Data + +- Keep it simple and representative +- Use meaningful names and values +- Follow the same order as table creation (dependencies first) +- Add comments explaining what the data represents: + +```sql +-- Sample Categories for testing +INSERT INTO Categories (Name) VALUES ('Electronics'); +INSERT INTO Categories (Name) VALUES ('Books'); + +-- Sample Products +INSERT INTO Products (Name, Description, Price, CategoryId) +VALUES ('Laptop', 'High-performance computer', 999.99, 1); +``` + +## Querying the Database + +### Using SQL Server Tools in Docker + +Access the SQL Server container: + +```bash +docker exec -it mssql bash +``` + +Then use `sqlcmd`: + +```sql +sqlcmd -S localhost -U sa -P YourPassword + +SELECT * FROM Categories; +SELECT * FROM Products WHERE Price > 100; +SELECT COUNT(*) FROM Products; +``` + +### Common Query Patterns + +**Get all rows:** +```sql +SELECT * FROM Products; +``` + +**Get specific columns:** +```sql +SELECT Id, Name, Price FROM Products; +``` + +**Filter with WHERE:** +```sql +SELECT * FROM Products WHERE CategoryId = 1; +SELECT * FROM Products WHERE Price > 50; +SELECT * FROM Products WHERE Name LIKE 'Laptop%'; +``` + +**Count rows:** +```sql +SELECT COUNT(*) FROM Products; +``` + +**Join related tables:** +```sql +SELECT p.Name, c.Name AS CategoryName +FROM Products p +JOIN Categories c ON p.CategoryId = c.Id; +``` + +**Order results:** +```sql +SELECT * FROM Products ORDER BY Price DESC; +``` + +## Troubleshooting + +**Q: The database is empty after seeding** +- Run `make migrate` first to apply schema +- Then run `make seed` to insert data +- Check `seed.sql` has correct table names and columns + +**Q: I'm getting "foreign key constraint" error** +- Ensure the referenced table exists +- Ensure the referenced ID actually exists in the parent table + +**Q: How do I clear the database and start over?** +- `make db-drop` - removes the database +- `make db-start` - creates a fresh database +- `make run-ddl-pipeline` - regenerates schema +- `make seed` - inserts sample data + +--- + +# App Configuration & YAML + +This guide covers understanding and editing the `app.yaml` configuration file. + +## Overview + +`app.yaml` is the **central configuration file** that defines: +- Application metadata (name, title, description) +- Theme colors (primary, secondary, background) +- Data model entity definitions (all your tables and fields) + +It is **automatically generated** from `schema.sql` by the DDL pipeline. **Do not edit it manually** - always regenerate it from your SQL schema. + +## File Location + +`app.yaml` - in the project root + +## Structure + +```yaml +app: + name: ImportedApp + title: Imported Application + description: Generated from DDL file + logoUrl: /images/logo.png + +theme: + primaryColor: '#007bff' + secondaryColor: '#6c757d' + backgroundColor: '#ffffff' + textColor: '#212529' + +dataModel: + entities: + - name: Category + properties: + - name: Id + type: int + isPrimaryKey: true + isIdentity: true + isRequired: false + - name: Name + type: string + isPrimaryKey: false + isIdentity: false + maxLength: 50 + isRequired: true + relationships: [] +``` + +## Understanding Each Section + +### `app` - Application Metadata + +```yaml +app: + name: ImportedApp # Internal identifier (used in code) + title: Imported Application # Display name for users + description: ... # What your app does + logoUrl: /images/logo.png # Logo path (relative to wwwroot) +``` + +### `theme` - UI Colors + +```yaml +theme: + primaryColor: '#007bff' # Main button/link color (blue) + secondaryColor: '#6c757d' # Muted elements (gray) + backgroundColor: '#ffffff' # Page background (white) + textColor: '#212529' # Text color (dark) +``` + +These use standard hex color codes. Tools like [color-hex.com](https://www.color-hex.com) help you find colors. + +### `dataModel.entities` - Your Tables + +Each entity represents a database table: + +```yaml +entities: +- name: Category # Table name (will be Category in code, Categories in DB) + properties: + - name: Id # Column name + type: int # C# type + isPrimaryKey: true # Is this the unique identifier? + isIdentity: true # Auto-increment? + isRequired: false # NOT NULL in SQL? + maxLength: null # Max length (for strings) + relationships: [] # Foreign key relationships +``` + +### Property Types + +| YAML type | C# Type | SQL Type | +|-----------|---------|----------| +| `int` | `int` | `INT` | +| `long` | `long` | `BIGINT` | +| `decimal` | `decimal` | `DECIMAL` | +| `string` | `string` | `NVARCHAR` | +| `DateTime` | `DateTime` | `DATETIME2` | +| `bool` | `bool` | `BIT` | + +### Relationships (Foreign Keys) + +```yaml +- name: Product + properties: [...] + relationships: + - name: Category + foreignKeyProperty: CategoryId + principalEntityName: Category +``` + +This tells the system: "Product has a CategoryId that references Category" + +## How It's Used + +1. **Startup** - Application loads `app.yaml` and caches it in `AppDictionaryService` +2. **UI Navigation** - `NavMenu.razor` reads entity names to build navigation +3. **Data Grid** - `DynamicDataGrid.razor` reads property definitions to display columns +4. **API** - `EntitiesController` reads entity metadata to route requests +5. **Code Generation** - `ModelGenerator` reads this file to create C# entity classes + +## Regenerating After Schema Changes + +Never edit `app.yaml` manually. Instead: + +1. Edit your `schema.sql` +2. Run `make run-ddl-pipeline` +3. The new `app.yaml` is generated automatically + +## What Can Actually Be Customized + +While `app.yaml` is auto-generated, you can modify these parts: + +```yaml +app: + title: My Custom Title # Change the display name + description: My Description # Change the description + logoUrl: /images/custom.png # Point to your own logo + +theme: + primaryColor: '#FF5733' # Change colors + secondaryColor: '#33FF57' +``` + +For other changes (adding entities, columns, types), edit `schema.sql` instead. + +## Troubleshooting + +**Q: My schema changes aren't in app.yaml** +- Run `make run-ddl-pipeline` to regenerate + +**Q: I accidentally edited app.yaml** +- Don't worry, run `make run-ddl-pipeline` to restore it from schema.sql + +**Q: How do I add a new entity?** +- Add a `CREATE TABLE` statement to `schema.sql` +- Run `make run-ddl-pipeline` +- The entity will automatically appear in app.yaml, navigation, and API + +--- + # Front-End Skills Guide (Blazor/Radzen) This guide helps with front-end changes to Razor/Blazor components and JavaScript interop. Read this BEFORE making front-end changes. @@ -323,6 +778,7 @@ If you need custom JS functions: ### API Calls with HttpClient + ```csharp @inject HttpClient Http @@ -439,6 +895,7 @@ private async Task LoadData() ## Quick Reference: Current Project Structure + ``` Components/ Pages/ @@ -446,22 +903,21 @@ Components/ Home.razor <- Landing page (route: /) Sections/ DashboardSection.razor <- Metrics cards - ProductsSection.razor <- DataGrid with products + EntitySection.razor <- Dynamic entity section SettingsSection.razor <- Config forms Shared/ MainLayout.razor <- Master layout (contains RadzenComponents) NavMenu.razor <- Navigation bar Models/ - Product.cs <- Data models go here + Generated/ <- Auto-generated entity models from app.yaml ``` ### Adding a New Section -1. Create `Components/Sections/NewSection.razor` -2. Add parameters if receiving data from parent -3. Add navigation button in `SpaApp.razor` -4. Add case in `SpaApp.razor` switch statement for rendering -5. Add loading logic in `LoadSection()` method + +1. Add a new entity to `app.yaml` (SPA sections are data-driven) +2. Regenerate models with `ModelGenerator` if needed +3. Verify the entity appears in `/app/{EntityName}` and the "Data" nav group ### Adding a New Radzen Component diff --git a/Services/AppDictionaryService.cs b/Services/AppDictionaryService.cs new file mode 100644 index 0000000..a20a905 --- /dev/null +++ b/Services/AppDictionaryService.cs @@ -0,0 +1,22 @@ +using DotNetWebApp.Models.AppDictionary; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace DotNetWebApp.Services +{ + public class AppDictionaryService : IAppDictionaryService + { + public AppDefinition AppDefinition { get; } + + public AppDictionaryService(string yamlFilePath) + { + var yamlContent = File.ReadAllText(yamlFilePath); + + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + AppDefinition = deserializer.Deserialize(yamlContent); + } + } +} diff --git a/Services/DashboardService.cs b/Services/DashboardService.cs index fcb4fd6..bc65af2 100644 --- a/Services/DashboardService.cs +++ b/Services/DashboardService.cs @@ -1,31 +1,58 @@ using DotNetWebApp.Models; +using Microsoft.Extensions.Logging; namespace DotNetWebApp.Services; public sealed class DashboardService : IDashboardService { - private readonly IProductService _productService; + private readonly IEntityApiService _entityApiService; + private readonly IEntityMetadataService _entityMetadataService; + private readonly ILogger _logger; - public DashboardService(IProductService productService) + public DashboardService( + IEntityApiService entityApiService, + IEntityMetadataService entityMetadataService, + ILogger logger) { - _productService = productService; + _entityApiService = entityApiService; + _entityMetadataService = entityMetadataService; + _logger = logger; } public async Task GetSummaryAsync(CancellationToken cancellationToken = default) { - var totalProducts = await _productService.GetProductCountAsync(cancellationToken); + var entities = _entityMetadataService.Entities; + + // Load counts in parallel + var countTasks = entities + .Select(async e => + { + try + { + var count = await _entityApiService.GetCountAsync(e.Definition.Name); + return new EntityCountInfo(e.Definition.Name, count); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error getting count for {EntityName}", e.Definition.Name); + return new EntityCountInfo(e.Definition.Name, 0); + } + }) + .ToArray(); + + var counts = await Task.WhenAll(countTasks); return new DashboardSummary { - TotalProducts = totalProducts, + EntityCounts = counts.ToList().AsReadOnly(), Revenue = 45789.50m, ActiveUsers = 1250, GrowthPercent = 15, RecentActivities = new[] { - new ActivityItem("2 min ago", "New product added"), + new ActivityItem("2 min ago", "New entity added"), new ActivityItem("15 min ago", "User registered"), - new ActivityItem("1 hour ago", "Order completed") + new ActivityItem("1 hour ago", "Operation completed") } }; } diff --git a/Services/DataSeeder.cs b/Services/DataSeeder.cs new file mode 100644 index 0000000..684b6cd --- /dev/null +++ b/Services/DataSeeder.cs @@ -0,0 +1,58 @@ +using System.IO; +using System.Threading; +using DotNetWebApp.Data; +using DotNetWebApp.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DotNetWebApp.Services; + +public sealed class DataSeeder +{ + private readonly DbContext _dbContext; + private readonly IHostEnvironment _environment; + private readonly ILogger _logger; + private readonly DataSeederOptions _options; + + public DataSeeder( + DbContext dbContext, + IHostEnvironment environment, + ILogger logger, + IOptions options) + { + _dbContext = dbContext; + _environment = environment; + _logger = logger; + _options = options.Value; + } + + public async Task SeedAsync(CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(_options.SeedFileName)) + { + _logger.LogWarning("Seed file name is not configured; skipping data seed."); + return; + } + + var seedPath = Path.Combine(_environment.ContentRootPath, _options.SeedFileName); + + if (!File.Exists(seedPath)) + { + _logger.LogWarning("Seed script {SeedFile} not found; skipping data seed.", seedPath); + return; + } + + var sql = await File.ReadAllTextAsync(seedPath, cancellationToken); + if (string.IsNullOrWhiteSpace(sql)) + { + _logger.LogWarning("Seed script {SeedFile} is empty; nothing to execute.", seedPath); + return; + } + + _logger.LogInformation("Applying seed data from {SeedFile}.", seedPath); + await _dbContext.Database.ExecuteSqlRawAsync(sql, cancellationToken); + _logger.LogInformation("Seed data applied."); + } +} diff --git a/Services/EntityApiService.cs b/Services/EntityApiService.cs new file mode 100644 index 0000000..fe1f137 --- /dev/null +++ b/Services/EntityApiService.cs @@ -0,0 +1,106 @@ +using System.Text.Json; + +namespace DotNetWebApp.Services; + +public sealed class EntityApiService : IEntityApiService +{ + private readonly HttpClient _httpClient; + private readonly IEntityMetadataService _metadataService; + + public EntityApiService(HttpClient httpClient, IEntityMetadataService metadataService) + { + _httpClient = httpClient; + _metadataService = metadataService; + } + + public async Task> GetEntitiesAsync(string entityName) + { + var metadata = _metadataService.Find(entityName); + if (metadata?.ClrType == null) + { + throw new InvalidOperationException($"Entity '{entityName}' not found or has no CLR type"); + } + + try + { + var response = await _httpClient.GetAsync($"api/entities/{entityName}"); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Failed to fetch {entityName} entities (HTTP {(int)response.StatusCode})"); + } + + var json = await response.Content.ReadAsStringAsync(); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var listType = typeof(List<>).MakeGenericType(metadata.ClrType); + var entities = (System.Collections.IEnumerable?)JsonSerializer.Deserialize(json, listType, options); + + return entities?.Cast() ?? Enumerable.Empty(); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to load {entityName} data: {ex.Message}", ex); + } + } + + public async Task GetCountAsync(string entityName) + { + var metadata = _metadataService.Find(entityName); + if (metadata?.ClrType == null) + { + throw new InvalidOperationException($"Entity '{entityName}' not found or has no CLR type"); + } + + try + { + var response = await _httpClient.GetAsync($"api/entities/{entityName}/count"); + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Failed to fetch {entityName} count (HTTP {(int)response.StatusCode})"); + } + + var json = await response.Content.ReadAsStringAsync(); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + if (int.TryParse(json.Trim('"'), out var count)) + { + return count; + } + + throw new InvalidOperationException("Invalid count response"); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to fetch {entityName} count: {ex.Message}", ex); + } + } + + public async Task CreateEntityAsync(string entityName, object entity) + { + var metadata = _metadataService.Find(entityName); + if (metadata?.ClrType == null) + { + throw new InvalidOperationException($"Entity '{entityName}' not found or has no CLR type"); + } + + try + { + var json = JsonSerializer.Serialize(entity); + var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + var response = await _httpClient.PostAsync($"api/entities/{entityName}", content); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"Failed to create {entityName} (HTTP {(int)response.StatusCode})"); + } + + var responseJson = await response.Content.ReadAsStringAsync(); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var result = JsonSerializer.Deserialize(responseJson, metadata.ClrType, options); + + return result ?? throw new InvalidOperationException("Failed to deserialize created entity"); + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to create {entityName}: {ex.Message}", ex); + } + } +} diff --git a/Services/EntityMetadataService.cs b/Services/EntityMetadataService.cs new file mode 100644 index 0000000..c305dd5 --- /dev/null +++ b/Services/EntityMetadataService.cs @@ -0,0 +1,39 @@ +using DotNetWebApp.Models; +using DotNetWebApp.Models.AppDictionary; + +namespace DotNetWebApp.Services; + +public sealed class EntityMetadataService : IEntityMetadataService +{ + private readonly IReadOnlyList _entities; + private readonly Dictionary _byName; + + public EntityMetadataService(IAppDictionaryService appDictionary) + { + var entityDefinitions = appDictionary.AppDefinition.DataModel?.Entities ?? new List(); + // Scan the Models assembly instead of the web app assembly to support separated project structure + var assembly = typeof(EntityMetadata).Assembly; + var entities = new List(entityDefinitions.Count); + + foreach (var entity in entityDefinitions) + { + var clrType = assembly.GetType($"DotNetWebApp.Models.Generated.{entity.Name}"); + entities.Add(new EntityMetadata(entity, clrType)); + } + + _entities = entities; + _byName = entities.ToDictionary(item => item.Definition.Name, StringComparer.OrdinalIgnoreCase); + } + + public IReadOnlyList Entities => _entities; + + public EntityMetadata? Find(string entityName) + { + if (string.IsNullOrWhiteSpace(entityName)) + { + return null; + } + + return _byName.TryGetValue(entityName, out var metadata) ? metadata : null; + } +} diff --git a/Services/IAppDictionaryService.cs b/Services/IAppDictionaryService.cs new file mode 100644 index 0000000..5fcfeee --- /dev/null +++ b/Services/IAppDictionaryService.cs @@ -0,0 +1,9 @@ +using DotNetWebApp.Models.AppDictionary; + +namespace DotNetWebApp.Services +{ + public interface IAppDictionaryService + { + AppDefinition AppDefinition { get; } + } +} diff --git a/Services/IEntityApiService.cs b/Services/IEntityApiService.cs new file mode 100644 index 0000000..328bc68 --- /dev/null +++ b/Services/IEntityApiService.cs @@ -0,0 +1,8 @@ +namespace DotNetWebApp.Services; + +public interface IEntityApiService +{ + Task> GetEntitiesAsync(string entityName); + Task GetCountAsync(string entityName); + Task CreateEntityAsync(string entityName, object entity); +} diff --git a/Services/IEntityMetadataService.cs b/Services/IEntityMetadataService.cs new file mode 100644 index 0000000..cfb751f --- /dev/null +++ b/Services/IEntityMetadataService.cs @@ -0,0 +1,9 @@ +using DotNetWebApp.Models; + +namespace DotNetWebApp.Services; + +public interface IEntityMetadataService +{ + IReadOnlyList Entities { get; } + EntityMetadata? Find(string entityName); +} diff --git a/Services/IProductService.cs b/Services/IProductService.cs deleted file mode 100644 index e6bc53c..0000000 --- a/Services/IProductService.cs +++ /dev/null @@ -1,9 +0,0 @@ -using DotNetWebApp.Models; - -namespace DotNetWebApp.Services; - -public interface IProductService -{ - Task> GetProductsAsync(CancellationToken cancellationToken = default); - Task GetProductCountAsync(CancellationToken cancellationToken = default); -} diff --git a/Services/ISpaSectionService.cs b/Services/ISpaSectionService.cs index d0f15d9..2210ab4 100644 --- a/Services/ISpaSectionService.cs +++ b/Services/ISpaSectionService.cs @@ -4,10 +4,10 @@ namespace DotNetWebApp.Services; public interface ISpaSectionService { - SpaSection DefaultSection { get; } + SpaSectionInfo? DefaultSection { get; } IReadOnlyList Sections { get; } - SpaSection? FromUri(string uri); - SpaSection? FromRouteSegment(string? segment); - SpaSectionInfo GetInfo(SpaSection section); - void NavigateTo(SpaSection section, bool replace = true); + SpaSectionInfo? FromUri(string uri); + SpaSectionInfo? FromRouteSegment(string? segment); + SpaSectionInfo? GetInfo(SpaSection section); + void NavigateTo(SpaSectionInfo section, bool replace = true); } diff --git a/Services/ProductService.cs b/Services/ProductService.cs deleted file mode 100644 index 6543802..0000000 --- a/Services/ProductService.cs +++ /dev/null @@ -1,38 +0,0 @@ -using DotNetWebApp.Models; - -namespace DotNetWebApp.Services; - -public sealed class ProductService : IProductService -{ - private readonly HttpClient _httpClient; - - public ProductService(HttpClient httpClient) - { - _httpClient = httpClient; - } - - public async Task> GetProductsAsync(CancellationToken cancellationToken = default) - { - try - { - var products = await _httpClient.GetFromJsonAsync>("api/products", cancellationToken); - return products ?? new List(); - } - catch (Exception) - { - return Array.Empty(); - } - } - - public async Task GetProductCountAsync(CancellationToken cancellationToken = default) - { - try - { - return await _httpClient.GetFromJsonAsync("api/products/count", cancellationToken); - } - catch (Exception) - { - return 0; - } - } -} diff --git a/Services/SpaSectionService.cs b/Services/SpaSectionService.cs index 1b85c16..feb548e 100644 --- a/Services/SpaSectionService.cs +++ b/Services/SpaSectionService.cs @@ -8,29 +8,48 @@ public sealed class SpaSectionService : ISpaSectionService { private readonly NavigationManager _navigationManager; private readonly IReadOnlyList _sections; - private readonly Dictionary _bySection; - private readonly Dictionary _byRouteSegment; + private readonly Dictionary _staticSections; + private readonly Dictionary _byRouteSegment; - public SpaSectionService(NavigationManager navigationManager, IOptions options) + public SpaSectionService( + NavigationManager navigationManager, + IOptions options, + IAppDictionaryService appDictionary) { _navigationManager = navigationManager; var labels = options.Value.SpaSections; - _sections = new List + var sections = new List(); + + if (options.Value.EnableSpaExample) { - new(SpaSection.Dashboard, labels.DashboardNav, labels.DashboardTitle, "dashboard"), - new(SpaSection.Products, labels.ProductsNav, labels.ProductsTitle, "products"), - new(SpaSection.Settings, labels.SettingsNav, labels.SettingsTitle, "settings") - }; + sections.Add(new(SpaSection.Dashboard, labels.DashboardNav, labels.DashboardTitle, "dashboard")); + + foreach (var entity in appDictionary.AppDefinition.DataModel.Entities) + { + if (string.IsNullOrWhiteSpace(entity.Name)) + { + continue; + } + + var label = entity.Name; + sections.Add(new(SpaSection.Entity, label, label, entity.Name, entity.Name)); + } + + sections.Add(new(SpaSection.Settings, labels.SettingsNav, labels.SettingsTitle, "settings")); + } - _bySection = _sections.ToDictionary(section => section.Section); - _byRouteSegment = _sections.ToDictionary(section => section.RouteSegment, section => section.Section, StringComparer.OrdinalIgnoreCase); + _sections = sections; + _staticSections = sections + .Where(section => section.Section != SpaSection.Entity) + .ToDictionary(section => section.Section); + _byRouteSegment = sections.ToDictionary(section => section.RouteSegment, StringComparer.OrdinalIgnoreCase); } - public SpaSection DefaultSection => SpaSection.Dashboard; + public SpaSectionInfo? DefaultSection => _sections.FirstOrDefault(); public IReadOnlyList Sections => _sections; - public SpaSection? FromUri(string uri) + public SpaSectionInfo? FromUri(string uri) { var relativePath = _navigationManager.ToBaseRelativePath(uri); if (string.IsNullOrWhiteSpace(relativePath)) @@ -53,7 +72,7 @@ public SpaSectionService(NavigationManager navigationManager, IOptions AppOptions +@inject IAppDictionaryService AppDictionary - - - @foreach (var item in SpaSections.Sections) + + @if (IsSpaEnabled && SpaSections.Sections.Count > 0) + { + + @foreach (var item in SpaSections.Sections) + { + + } + + } + + @foreach (var entity in AppDictionary.AppDefinition.DataModel.Entities) { - + } @code { + private bool IsSpaEnabled => AppOptions.Value.EnableSpaExample; + private string HomePath => IsSpaEnabled ? "/" : GetDefaultEntityPath(); + private static string GetSectionIcon(SpaSection section) { return section switch { SpaSection.Dashboard => "dashboard", - SpaSection.Products => "inventory_2", SpaSection.Settings => "settings", + SpaSection.Entity => "table_chart", _ => "apps" }; } - private static string GetSectionPath(SpaSectionInfo section) + private string GetSectionPath(SpaSectionInfo section) + { + return SpaSections.DefaultSection != null && section == SpaSections.DefaultSection + ? "app" + : $"app/{section.RouteSegment}"; + } + + private string GetDefaultEntityPath() { - return section.Section == SpaSection.Dashboard ? "app" : $"app/{section.RouteSegment}"; + var entity = AppDictionary.AppDefinition.DataModel.Entities.FirstOrDefault(); + return entity == null ? "/" : entity.Name; } } diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 0000000..e69de29 diff --git a/app.example.yaml b/app.example.yaml new file mode 100644 index 0000000..e0cfe09 --- /dev/null +++ b/app.example.yaml @@ -0,0 +1,57 @@ +# Application Dictionary for the .NET Web App Framework + +# Basic Application Metadata +app: + name: "MyApp" + title: "My Application" + description: "A dynamic and configurable web application." + logoUrl: "/images/logo.png" + +# Theme and Branding +theme: + primaryColor: "#007bff" + secondaryColor: "#6c757d" + backgroundColor: "#ffffff" + textColor: "#212529" + +# Data Model Definition +dataModel: + entities: + - name: "Product" + properties: + - name: "Id" + type: "int" + isPrimaryKey: true + isIdentity: true + - name: "Name" + type: "string" + maxLength: 100 + isRequired: true + - name: "Description" + type: "string" + maxLength: 500 + - name: "Price" + type: "decimal" + precision: 18 + scale: 2 + - name: "CategoryId" + type: "int" + - name: "CreatedAt" + type: "datetime" + defaultValue: "CURRENT_TIMESTAMP" + relationships: + - type: "one-to-many" + targetEntity: "Category" + foreignKey: "CategoryId" + principalKey: "Id" + + - name: "Category" + properties: + - name: "Id" + type: "int" + isPrimaryKey: true + isIdentity: true + - name: "Name" + type: "string" + maxLength: 50 + isRequired: true diff --git a/appsettings.json b/appsettings.json index d90245a..2e14890 100644 --- a/appsettings.json +++ b/appsettings.json @@ -13,14 +13,17 @@ "DefaultSchema": "dbo", "HeaderName": "X-Customer-Schema" }, + "DataSeeder": { + "SeedFileName": "seed.sql" + }, "AppCustomization": { "AppTitle": "DotNetWebApp", "SourceLinkText": "Source Code", "SourceLinkUrl": "https://github.com/devixlabs/DotNetWebApp/", "Branding": { "LogoText": "DN", - "LogoUrl": "/openai.png", - "LogoAlt": "OpenAI logo", + "LogoUrl": "/logo.png", + "LogoAlt": "Placeholder-Logo", "FontFamily": "\"Space Grotesk\", \"Segoe UI\", sans-serif", "PrimaryColor": "#0f766e", "AccentColor": "#14b8a6", @@ -33,12 +36,11 @@ "Home": "Home", "Application": "Application" }, + "EnableSpaExample": true, "SpaSections": { "DashboardNav": "Dashboard", - "ProductsNav": "Products", "SettingsNav": "Settings", "DashboardTitle": "Dashboard", - "ProductsTitle": "Products Management", "SettingsTitle": "Application Settings" } } diff --git a/dotnet-build.sh b/dotnet-build.sh index 4a2907f..8ffd62a 100755 --- a/dotnet-build.sh +++ b/dotnet-build.sh @@ -17,13 +17,32 @@ if ! command -v dotnet &> /dev/null; then exit 1 fi +# Ensure DOTNET_ROOT is set so global tools can find the runtime. +if [ -z "$DOTNET_ROOT" ]; then + DOTNET_PATH=$(command -v dotnet) + DOTNET_ROOT=$(dirname "$(readlink -f "$DOTNET_PATH")") + export DOTNET_ROOT +fi + # Check if dotnet-ef is installed if ! command -v dotnet-ef &> /dev/null; then echo "Error: dotnet-ef CLI not found in PATH" >&2 exit 1 fi +# Performance optimization: Skip global.json handling if explicitly disabled +# Set SKIP_GLOBAL_JSON_HANDLING=true in your environment or Makefile to bypass +# the filesystem search when you know your project doesn't use global.json +if [ "${SKIP_GLOBAL_JSON_HANDLING:-false}" = "true" ]; then + dotnet "$@" + exit $? +fi + # Search for global.json up the directory tree +# This search happens on every dotnet command, so we optimize by: +# 1. Checking only 3 levels (current, parent, grandparent) +# 2. Breaking immediately when found +# 3. Skipping mv operations entirely if not found GLOBAL_JSON_PATH="" for dir in . .. ../..; do if [ -f "$dir/global.json" ]; then @@ -32,13 +51,16 @@ for dir in . .. ../..; do fi done -# Temporarily hide global.json to bypass version constraint enforcement +# Only perform mv operations if global.json was actually found +# This avoids unnecessary filesystem operations for projects without global.json if [ -n "$GLOBAL_JSON_PATH" ]; then + # Temporarily hide global.json to bypass version constraint enforcement mv "$GLOBAL_JSON_PATH" "$GLOBAL_JSON_PATH.temp" dotnet "$@" EXIT_CODE=$? mv "$GLOBAL_JSON_PATH.temp" "$GLOBAL_JSON_PATH" exit $EXIT_CODE else + # No global.json found - run dotnet directly without any file operations dotnet "$@" -fi \ No newline at end of file +fi diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..fba2bfe --- /dev/null +++ b/schema.sql @@ -0,0 +1,37 @@ +CREATE TABLE Categories ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(50) NOT NULL +); + +CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(100) NOT NULL, + Description NVARCHAR(500) NULL, + Price DECIMAL(18,2) NULL, + CategoryId INT NULL, + CreatedAt DATETIME2 NULL DEFAULT GETDATE(), + FOREIGN KEY (CategoryId) REFERENCES Categories(Id) +); + +CREATE TABLE Companies ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(200) NOT NULL, + Address NVARCHAR(500) NULL, + City NVARCHAR(100) NULL, + State NVARCHAR(50) NULL, + PostalCode NVARCHAR(20) NULL, + Country NVARCHAR(100) NULL, + Email NVARCHAR(100) NULL, + Phone NVARCHAR(20) NULL, + Website NVARCHAR(200) NULL, + CreatedAt DATETIME2 NULL DEFAULT GETDATE() +); + +CREATE TABLE CompanyProducts ( + Id INT PRIMARY KEY IDENTITY(1,1), + CompanyId INT NOT NULL, + ProductId INT NOT NULL, + CreatedAt DATETIME2 NULL DEFAULT GETDATE(), + FOREIGN KEY (CompanyId) REFERENCES Companies(Id), + FOREIGN KEY (ProductId) REFERENCES Products(Id) +); diff --git a/seed.sql b/seed.sql new file mode 100644 index 0000000..305f9d6 --- /dev/null +++ b/seed.sql @@ -0,0 +1,126 @@ + +INSERT INTO Categories (Name) +SELECT 'Electronics' +WHERE NOT EXISTS (SELECT 1 FROM Categories WHERE Name = 'Electronics'); + +INSERT INTO Categories (Name) +SELECT 'Books' +WHERE NOT EXISTS (SELECT 1 FROM Categories WHERE Name = 'Books'); + +INSERT INTO Categories (Name) +SELECT 'Outdoor' +WHERE NOT EXISTS (SELECT 1 FROM Categories WHERE Name = 'Outdoor'); + +INSERT INTO Products (Name, Description, Price, CategoryId) +SELECT + 'Wireless Mouse', + 'Ergonomic Bluetooth mouse', + 29.99, + (SELECT Id FROM Categories WHERE Name = 'Electronics') +WHERE EXISTS (SELECT 1 FROM Categories WHERE Name = 'Electronics') + AND NOT EXISTS (SELECT 1 FROM Products WHERE Name = 'Wireless Mouse'); + +INSERT INTO Products (Name, Description, Price, CategoryId) +SELECT + 'Programming in C#', + 'Updated guide to modern C#', + 49.95, + (SELECT Id FROM Categories WHERE Name = 'Books') +WHERE EXISTS (SELECT 1 FROM Categories WHERE Name = 'Books') + AND NOT EXISTS (SELECT 1 FROM Products WHERE Name = 'Programming in C#'); + +INSERT INTO Products (Name, Description, Price, CategoryId) +SELECT + 'Camping Lantern', + 'Rechargeable LED lantern', + 35.00, + (SELECT Id FROM Categories WHERE Name = 'Outdoor') +WHERE EXISTS (SELECT 1 FROM Categories WHERE Name = 'Outdoor') + AND NOT EXISTS (SELECT 1 FROM Products WHERE Name = 'Camping Lantern'); + +INSERT INTO Companies (Name, Address, City, State, PostalCode, Country, Email, Phone, Website) +SELECT + 'TechCorp Industries', + '123 Innovation Drive', + 'San Francisco', + 'CA', + '94105', + 'USA', + 'contact@techcorp.com', + '(415) 555-0100', + 'www.techcorp.com' +WHERE NOT EXISTS (SELECT 1 FROM Companies WHERE Name = 'TechCorp Industries'); + +INSERT INTO Companies (Name, Address, City, State, PostalCode, Country, Email, Phone, Website) +SELECT + 'Global Books Publishing', + '456 Library Lane', + 'New York', + 'NY', + '10001', + 'USA', + 'info@globalbooks.com', + '(212) 555-0200', + 'www.globalbooks.com' +WHERE NOT EXISTS (SELECT 1 FROM Companies WHERE Name = 'Global Books Publishing'); + +INSERT INTO Companies (Name, Address, City, State, PostalCode, Country, Email, Phone, Website) +SELECT + 'Outdoor Adventures Inc', + '789 Nature Path', + 'Denver', + 'CO', + '80202', + 'USA', + 'sales@outdooradventures.com', + '(720) 555-0300', + 'www.outdooradventures.com' +WHERE NOT EXISTS (SELECT 1 FROM Companies WHERE Name = 'Outdoor Adventures Inc'); + +INSERT INTO CompanyProducts (CompanyId, ProductId) +SELECT + (SELECT Id FROM Companies WHERE Name = 'TechCorp Industries'), + (SELECT Id FROM Products WHERE Name = 'Wireless Mouse') +WHERE EXISTS (SELECT 1 FROM Companies WHERE Name = 'TechCorp Industries') + AND EXISTS (SELECT 1 FROM Products WHERE Name = 'Wireless Mouse') + AND NOT EXISTS ( + SELECT 1 FROM CompanyProducts + WHERE CompanyId = (SELECT Id FROM Companies WHERE Name = 'TechCorp Industries') + AND ProductId = (SELECT Id FROM Products WHERE Name = 'Wireless Mouse') + ); + +INSERT INTO CompanyProducts (CompanyId, ProductId) +SELECT + (SELECT Id FROM Companies WHERE Name = 'Global Books Publishing'), + (SELECT Id FROM Products WHERE Name = 'Programming in C#') +WHERE EXISTS (SELECT 1 FROM Companies WHERE Name = 'Global Books Publishing') + AND EXISTS (SELECT 1 FROM Products WHERE Name = 'Programming in C#') + AND NOT EXISTS ( + SELECT 1 FROM CompanyProducts + WHERE CompanyId = (SELECT Id FROM Companies WHERE Name = 'Global Books Publishing') + AND ProductId = (SELECT Id FROM Products WHERE Name = 'Programming in C#') + ); + +INSERT INTO CompanyProducts (CompanyId, ProductId) +SELECT + (SELECT Id FROM Companies WHERE Name = 'Outdoor Adventures Inc'), + (SELECT Id FROM Products WHERE Name = 'Camping Lantern') +WHERE EXISTS (SELECT 1 FROM Companies WHERE Name = 'Outdoor Adventures Inc') + AND EXISTS (SELECT 1 FROM Products WHERE Name = 'Camping Lantern') + AND NOT EXISTS ( + SELECT 1 FROM CompanyProducts + WHERE CompanyId = (SELECT Id FROM Companies WHERE Name = 'Outdoor Adventures Inc') + AND ProductId = (SELECT Id FROM Products WHERE Name = 'Camping Lantern') + ); + +INSERT INTO CompanyProducts (CompanyId, ProductId) +SELECT + (SELECT Id FROM Companies WHERE Name = 'TechCorp Industries'), + (SELECT Id FROM Products WHERE Name = 'Programming in C#') +WHERE EXISTS (SELECT 1 FROM Companies WHERE Name = 'TechCorp Industries') + AND EXISTS (SELECT 1 FROM Products WHERE Name = 'Programming in C#') + AND NOT EXISTS ( + SELECT 1 FROM CompanyProducts + WHERE CompanyId = (SELECT Id FROM Companies WHERE Name = 'TechCorp Industries') + AND ProductId = (SELECT Id FROM Products WHERE Name = 'Programming in C#') + ); diff --git a/tests/DdlParser.Tests/DdlParser.Tests.csproj b/tests/DdlParser.Tests/DdlParser.Tests.csproj new file mode 100644 index 0000000..adc4171 --- /dev/null +++ b/tests/DdlParser.Tests/DdlParser.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + diff --git a/tests/DdlParser.Tests/GlobalUsings.cs b/tests/DdlParser.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/tests/DdlParser.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/tests/DdlParser.Tests/SqlDdlParserTests.cs b/tests/DdlParser.Tests/SqlDdlParserTests.cs new file mode 100644 index 0000000..0978ef2 --- /dev/null +++ b/tests/DdlParser.Tests/SqlDdlParserTests.cs @@ -0,0 +1,321 @@ +using DdlParser; +using Xunit; + +namespace DdlParser.Tests; + +public class SqlDdlParserTests +{ + [Fact] + public void Parse_SimpleTable_ReturnsTableMetadata() + { + // Arrange + var sql = @" + CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1) NOT NULL, + Name NVARCHAR(100) NOT NULL + );"; + var parser = new SqlDdlParser(); + + // Act + var tables = parser.Parse(sql); + + // Assert + Assert.Single(tables); + var table = tables[0]; + Assert.Equal("Products", table.Name); + Assert.Equal(2, table.Columns.Count); + + var idColumn = table.Columns.FirstOrDefault(c => c.Name == "Id"); + Assert.NotNull(idColumn); + Assert.Equal("INT", idColumn.SqlType.ToUpperInvariant()); + Assert.True(idColumn.IsPrimaryKey); + Assert.True(idColumn.IsIdentity); + Assert.False(idColumn.IsNullable); + + var nameColumn = table.Columns.FirstOrDefault(c => c.Name == "Name"); + Assert.NotNull(nameColumn); + Assert.Equal("NVARCHAR", nameColumn.SqlType.ToUpperInvariant()); + Assert.Equal(100, nameColumn.MaxLength); + Assert.False(nameColumn.IsNullable); + } + + [Fact] + public void Parse_TableWithForeignKeys_ExtractsRelationships() + { + // Arrange + var sql = @" + CREATE TABLE Categories ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(50) NOT NULL + ); + + CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1), + CategoryId INT NULL, + FOREIGN KEY (CategoryId) REFERENCES Categories(Id) + );"; + var parser = new SqlDdlParser(); + + // Act + var tables = parser.Parse(sql); + + // Assert + Assert.Equal(2, tables.Count); + + var productsTable = tables.FirstOrDefault(t => t.Name == "Products"); + Assert.NotNull(productsTable); + Assert.Single(productsTable.ForeignKeys); + + var fk = productsTable.ForeignKeys[0]; + Assert.Equal("CategoryId", fk.ColumnName); + Assert.Equal("Categories", fk.ReferencedTable); + Assert.Equal("Id", fk.ReferencedColumn); + } + + [Fact] + public void Parse_IdentityColumn_SetsIsIdentityTrue() + { + // Arrange + var sql = @" + CREATE TABLE TestTable ( + Id INT IDENTITY(1,1) PRIMARY KEY, + Code NVARCHAR(10) NOT NULL + );"; + var parser = new SqlDdlParser(); + + // Act + var tables = parser.Parse(sql); + + // Assert + var table = tables[0]; + var idColumn = table.Columns.FirstOrDefault(c => c.Name == "Id"); + Assert.NotNull(idColumn); + Assert.True(idColumn.IsIdentity); + } + + [Fact] + public void Parse_DefaultValue_ExtractsDefaultValue() + { + // Arrange + var sql = @" + CREATE TABLE Orders ( + Id INT PRIMARY KEY IDENTITY(1,1), + CreatedAt DATETIME2 NULL DEFAULT GETDATE() + );"; + var parser = new SqlDdlParser(); + + // Act + var tables = parser.Parse(sql); + + // Assert + var table = tables[0]; + var createdAtColumn = table.Columns.FirstOrDefault(c => c.Name == "CreatedAt"); + Assert.NotNull(createdAtColumn); + Assert.NotNull(createdAtColumn.DefaultValue); + Assert.Contains("GETDATE", createdAtColumn.DefaultValue.ToUpperInvariant()); + } + + [Fact] + public void Parse_NullableColumn_SetsIsNullableTrue() + { + // Arrange + var sql = @" + CREATE TABLE TestTable ( + Id INT PRIMARY KEY, + OptionalField NVARCHAR(100) NULL, + RequiredField NVARCHAR(100) NOT NULL + );"; + var parser = new SqlDdlParser(); + + // Act + var tables = parser.Parse(sql); + + // Assert + var table = tables[0]; + + var optionalField = table.Columns.FirstOrDefault(c => c.Name == "OptionalField"); + Assert.NotNull(optionalField); + Assert.True(optionalField.IsNullable); + + var requiredField = table.Columns.FirstOrDefault(c => c.Name == "RequiredField"); + Assert.NotNull(requiredField); + Assert.False(requiredField.IsNullable); + } + + [Fact] + public void Parse_DecimalWithPrecisionScale_ExtractsParameters() + { + // Arrange + var sql = @" + CREATE TABLE Products ( + Id INT PRIMARY KEY, + Price DECIMAL(18,2) NOT NULL + );"; + var parser = new SqlDdlParser(); + + // Act + var tables = parser.Parse(sql); + + // Assert + var table = tables[0]; + var priceColumn = table.Columns.FirstOrDefault(c => c.Name == "Price"); + Assert.NotNull(priceColumn); + Assert.Equal("DECIMAL", priceColumn.SqlType.ToUpperInvariant()); + Assert.Equal(18, priceColumn.Precision); + Assert.Equal(2, priceColumn.Scale); + } + + [Fact] + public void Parse_MultipleTablesInOneScript_ReturnsAllTables() + { + // Arrange + var sql = @" + CREATE TABLE Categories ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(50) NOT NULL + ); + + CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(100) NOT NULL + ); + + CREATE TABLE Orders ( + Id INT PRIMARY KEY IDENTITY(1,1), + OrderDate DATETIME2 NOT NULL + );"; + var parser = new SqlDdlParser(); + + // Act + var tables = parser.Parse(sql); + + // Assert + Assert.Equal(3, tables.Count); + Assert.Contains(tables, t => t.Name == "Categories"); + Assert.Contains(tables, t => t.Name == "Products"); + Assert.Contains(tables, t => t.Name == "Orders"); + } + + [Fact] + public void Parse_MalformedSql_ThrowsInvalidOperationException() + { + // Arrange + var sql = "CREATE TABLE InvalidTable (Id INT NOTAVALIDKEYWORD);"; + var parser = new SqlDdlParser(); + + // Act & Assert + var exception = Assert.Throws(() => parser.Parse(sql)); + Assert.Contains("SQL parsing errors", exception.Message); + } + + [Fact] + public void Parse_EmptyString_ReturnsEmptyList() + { + // Arrange + var sql = ""; + var parser = new SqlDdlParser(); + + // Act + var tables = parser.Parse(sql); + + // Assert + Assert.Empty(tables); + } + + [Fact] + public void Parse_TableWithMultipleForeignKeys_ExtractsAllRelationships() + { + // Arrange + var sql = @" + CREATE TABLE Companies ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(200) NOT NULL + ); + + CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1), + Name NVARCHAR(100) NOT NULL + ); + + CREATE TABLE CompanyProducts ( + Id INT PRIMARY KEY IDENTITY(1,1), + CompanyId INT NOT NULL, + ProductId INT NOT NULL, + FOREIGN KEY (CompanyId) REFERENCES Companies(Id), + FOREIGN KEY (ProductId) REFERENCES Products(Id) + );"; + var parser = new SqlDdlParser(); + + // Act + var tables = parser.Parse(sql); + + // Assert + var companyProductsTable = tables.FirstOrDefault(t => t.Name == "CompanyProducts"); + Assert.NotNull(companyProductsTable); + Assert.Equal(2, companyProductsTable.ForeignKeys.Count); + + Assert.Contains(companyProductsTable.ForeignKeys, fk => + fk.ColumnName == "CompanyId" && fk.ReferencedTable == "Companies"); + Assert.Contains(companyProductsTable.ForeignKeys, fk => + fk.ColumnName == "ProductId" && fk.ReferencedTable == "Products"); + } + + [Fact] + public void Parse_TableLevelPrimaryKey_MarksPrimaryKeyColumns() + { + // Arrange + var sql = @" + CREATE TABLE TestTable ( + Id INT NOT NULL, + Code NVARCHAR(10) NOT NULL, + PRIMARY KEY (Id) + );"; + var parser = new SqlDdlParser(); + + // Act + var tables = parser.Parse(sql); + + // Assert + var table = tables[0]; + var idColumn = table.Columns.FirstOrDefault(c => c.Name == "Id"); + Assert.NotNull(idColumn); + Assert.True(idColumn.IsPrimaryKey); + + var codeColumn = table.Columns.FirstOrDefault(c => c.Name == "Code"); + Assert.NotNull(codeColumn); + Assert.False(codeColumn.IsPrimaryKey); + } + + [Fact] + public void Parse_VariousDataTypes_ParsesCorrectly() + { + // Arrange + var sql = @" + CREATE TABLE DataTypeTest ( + IntField INT, + BigIntField BIGINT, + VarcharField VARCHAR(255), + NVarcharField NVARCHAR(MAX), + DateField DATE, + DateTime2Field DATETIME2, + BitField BIT, + DecimalField DECIMAL(10,5), + MoneyField MONEY + );"; + var parser = new SqlDdlParser(); + + // Act + var tables = parser.Parse(sql); + + // Assert + var table = tables[0]; + Assert.Equal(9, table.Columns.Count); + + Assert.Contains(table.Columns, c => c.Name == "IntField" && c.SqlType.ToUpperInvariant() == "INT"); + Assert.Contains(table.Columns, c => c.Name == "BigIntField" && c.SqlType.ToUpperInvariant() == "BIGINT"); + Assert.Contains(table.Columns, c => c.Name == "VarcharField" && c.SqlType.ToUpperInvariant() == "VARCHAR"); + Assert.Contains(table.Columns, c => c.Name == "DateField" && c.SqlType.ToUpperInvariant() == "DATE"); + Assert.Contains(table.Columns, c => c.Name == "BitField" && c.SqlType.ToUpperInvariant() == "BIT"); + Assert.Contains(table.Columns, c => c.Name == "MoneyField" && c.SqlType.ToUpperInvariant() == "MONEY"); + } +} diff --git a/tests/DdlParser.Tests/TypeMapperTests.cs b/tests/DdlParser.Tests/TypeMapperTests.cs new file mode 100644 index 0000000..08b5d3d --- /dev/null +++ b/tests/DdlParser.Tests/TypeMapperTests.cs @@ -0,0 +1,121 @@ +using DdlParser; +using Xunit; + +namespace DdlParser.Tests; + +public class TypeMapperTests +{ + [Theory] + [InlineData("INT", "int")] + [InlineData("INTEGER", "int")] + [InlineData("BIGINT", "int")] + [InlineData("SMALLINT", "int")] + [InlineData("TINYINT", "int")] + [InlineData("int", "int")] + [InlineData("bigint", "int")] + public void SqlToYamlType_IntegerTypes_ReturnsInt(string sqlType, string expected) + { + // Act + var result = TypeMapper.SqlToYamlType(sqlType); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("VARCHAR", "string")] + [InlineData("NVARCHAR", "string")] + [InlineData("CHAR", "string")] + [InlineData("NCHAR", "string")] + [InlineData("TEXT", "string")] + [InlineData("NTEXT", "string")] + [InlineData("VARBINARY", "string")] + [InlineData("BINARY", "string")] + [InlineData("UNIQUEIDENTIFIER", "string")] + [InlineData("varchar", "string")] + [InlineData("nvarchar", "string")] + public void SqlToYamlType_StringTypes_ReturnsString(string sqlType, string expected) + { + // Act + var result = TypeMapper.SqlToYamlType(sqlType); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("DECIMAL", "decimal")] + [InlineData("NUMERIC", "decimal")] + [InlineData("MONEY", "decimal")] + [InlineData("SMALLMONEY", "decimal")] + [InlineData("FLOAT", "decimal")] + [InlineData("REAL", "decimal")] + [InlineData("decimal", "decimal")] + [InlineData("money", "decimal")] + public void SqlToYamlType_DecimalTypes_ReturnsDecimal(string sqlType, string expected) + { + // Act + var result = TypeMapper.SqlToYamlType(sqlType); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("DATETIME", "datetime")] + [InlineData("DATETIME2", "datetime")] + [InlineData("DATE", "datetime")] + [InlineData("TIME", "datetime")] + [InlineData("DATETIMEOFFSET", "datetime")] + [InlineData("TIMESTAMP", "datetime")] + [InlineData("SMALLDATETIME", "datetime")] + [InlineData("datetime", "datetime")] + [InlineData("datetime2", "datetime")] + public void SqlToYamlType_DateTimeTypes_ReturnsDateTime(string sqlType, string expected) + { + // Act + var result = TypeMapper.SqlToYamlType(sqlType); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("BIT", "bool")] + [InlineData("bit", "bool")] + public void SqlToYamlType_BooleanType_ReturnsBool(string sqlType, string expected) + { + // Act + var result = TypeMapper.SqlToYamlType(sqlType); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("UNKNOWN_TYPE")] + [InlineData("CUSTOM_TYPE")] + [InlineData("")] + public void SqlToYamlType_UnknownType_ReturnsString(string sqlType) + { + // Act + var result = TypeMapper.SqlToYamlType(sqlType); + + // Assert - Unknown types default to string + Assert.Equal("string", result); + } + + [Fact] + public void SqlToYamlType_CaseInsensitive_WorksCorrectly() + { + // Arrange & Act + var upperResult = TypeMapper.SqlToYamlType("VARCHAR"); + var lowerResult = TypeMapper.SqlToYamlType("varchar"); + var mixedResult = TypeMapper.SqlToYamlType("VarChar"); + + // Assert + Assert.Equal("string", upperResult); + Assert.Equal("string", lowerResult); + Assert.Equal("string", mixedResult); + } +} diff --git a/tests/DdlParser.Tests/YamlGeneratorTests.cs b/tests/DdlParser.Tests/YamlGeneratorTests.cs new file mode 100644 index 0000000..1b84758 --- /dev/null +++ b/tests/DdlParser.Tests/YamlGeneratorTests.cs @@ -0,0 +1,387 @@ +using DdlParser; +using DotNetWebApp.Models.AppDictionary; +using Xunit; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace DdlParser.Tests; + +public class YamlGeneratorTests +{ + [Fact] + public void Generate_SimpleEntity_ProducesValidYaml() + { + // Arrange + var tables = new List + { + new TableMetadata + { + Name = "Product", + Columns = new List + { + new ColumnMetadata + { + Name = "Id", + SqlType = "INT", + IsNullable = false, + IsPrimaryKey = true, + IsIdentity = true + }, + new ColumnMetadata + { + Name = "Name", + SqlType = "NVARCHAR", + MaxLength = 100, + IsNullable = false + } + } + } + }; + var generator = new YamlGenerator(); + + // Act + var yaml = generator.Generate(tables); + + // Assert + Assert.NotEmpty(yaml); + Assert.Contains("name: Product", yaml, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name: Id", yaml, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name: Name", yaml, StringComparison.OrdinalIgnoreCase); + Assert.Contains("isPrimaryKey: true", yaml, StringComparison.OrdinalIgnoreCase); + Assert.Contains("isIdentity: true", yaml, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Generate_EntityWithRelationships_IncludesForeignKeys() + { + // Arrange + var tables = new List + { + new TableMetadata + { + Name = "Category", + Columns = new List + { + new ColumnMetadata { Name = "Id", SqlType = "INT", IsPrimaryKey = true, IsIdentity = true, IsNullable = false } + } + }, + new TableMetadata + { + Name = "Product", + Columns = new List + { + new ColumnMetadata { Name = "Id", SqlType = "INT", IsPrimaryKey = true, IsIdentity = true, IsNullable = false }, + new ColumnMetadata { Name = "CategoryId", SqlType = "INT", IsNullable = true } + }, + ForeignKeys = new List + { + new ForeignKeyMetadata + { + ColumnName = "CategoryId", + ReferencedTable = "Category", + ReferencedColumn = "Id" + } + } + } + }; + var generator = new YamlGenerator(); + + // Act + var yaml = generator.Generate(tables); + + // Assert + Assert.Contains("relationships:", yaml, StringComparison.OrdinalIgnoreCase); + Assert.Contains("targetEntity: Category", yaml, StringComparison.OrdinalIgnoreCase); + Assert.Contains("foreignKey: CategoryId", yaml, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Generate_MultipleEntities_OrdersCorrectly() + { + // Arrange + var tables = new List + { + new TableMetadata + { + Name = "ZebraTable", + Columns = new List + { + new ColumnMetadata { Name = "Id", SqlType = "INT", IsPrimaryKey = true, IsNullable = false } + } + }, + new TableMetadata + { + Name = "AppleTable", + Columns = new List + { + new ColumnMetadata { Name = "Id", SqlType = "INT", IsPrimaryKey = true, IsNullable = false } + } + } + }; + var generator = new YamlGenerator(); + + // Act + var yaml = generator.Generate(tables); + + // Assert + Assert.NotEmpty(yaml); + Assert.Contains("name: ZebraTable", yaml, StringComparison.OrdinalIgnoreCase); + Assert.Contains("name: AppleTable", yaml, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Generate_EmptyTableList_ReturnsMinimalYaml() + { + // Arrange + var tables = new List(); + var generator = new YamlGenerator(); + + // Act + var yaml = generator.Generate(tables); + + // Assert + Assert.NotEmpty(yaml); + Assert.Contains("app:", yaml, StringComparison.OrdinalIgnoreCase); + Assert.Contains("dataModel:", yaml, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Generated_Yaml_CanBeDeserialized() + { + // Arrange + var tables = new List + { + new TableMetadata + { + Name = "Product", + Columns = new List + { + new ColumnMetadata + { + Name = "Id", + SqlType = "INT", + IsNullable = false, + IsPrimaryKey = true, + IsIdentity = true + }, + new ColumnMetadata + { + Name = "Name", + SqlType = "NVARCHAR", + MaxLength = 100, + IsNullable = false + }, + new ColumnMetadata + { + Name = "Price", + SqlType = "DECIMAL", + Precision = 18, + Scale = 2, + IsNullable = true + } + } + } + }; + var generator = new YamlGenerator(); + + // Act + var yaml = generator.Generate(tables); + + // Assert - Verify round-trip deserialization + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + var appDefinition = deserializer.Deserialize(yaml); + + Assert.NotNull(appDefinition); + Assert.NotNull(appDefinition.DataModel); + Assert.Single(appDefinition.DataModel.Entities); + + var entity = appDefinition.DataModel.Entities[0]; + Assert.Equal("Product", entity.Name); + Assert.Equal(3, entity.Properties.Count); + + var idProp = entity.Properties.FirstOrDefault(p => p.Name == "Id"); + Assert.NotNull(idProp); + Assert.True(idProp.IsPrimaryKey); + Assert.True(idProp.IsIdentity); + + var priceProp = entity.Properties.FirstOrDefault(p => p.Name == "Price"); + Assert.NotNull(priceProp); + Assert.Equal(18, priceProp.Precision); + Assert.Equal(2, priceProp.Scale); + } + + [Fact] + public void Generate_DecimalWithPrecisionScale_PreservesParameters() + { + // Arrange + var tables = new List + { + new TableMetadata + { + Name = "Product", + Columns = new List + { + new ColumnMetadata + { + Name = "Id", + SqlType = "INT", + IsPrimaryKey = true, + IsNullable = false + }, + new ColumnMetadata + { + Name = "Price", + SqlType = "DECIMAL", + Precision = 18, + Scale = 2, + IsNullable = false + } + } + } + }; + var generator = new YamlGenerator(); + + // Act + var yaml = generator.Generate(tables); + + // Assert + Assert.Contains("precision: 18", yaml, StringComparison.OrdinalIgnoreCase); + Assert.Contains("scale: 2", yaml, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Generate_StringWithMaxLength_PreservesMaxLength() + { + // Arrange + var tables = new List + { + new TableMetadata + { + Name = "Product", + Columns = new List + { + new ColumnMetadata + { + Name = "Id", + SqlType = "INT", + IsPrimaryKey = true, + IsNullable = false + }, + new ColumnMetadata + { + Name = "Name", + SqlType = "NVARCHAR", + MaxLength = 100, + IsNullable = false + } + } + } + }; + var generator = new YamlGenerator(); + + // Act + var yaml = generator.Generate(tables); + + // Assert + Assert.Contains("maxLength: 100", yaml, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Generate_ColumnWithDefaultValue_IncludesDefaultValue() + { + // Arrange + var tables = new List + { + new TableMetadata + { + Name = "Order", + Columns = new List + { + new ColumnMetadata + { + Name = "Id", + SqlType = "INT", + IsPrimaryKey = true, + IsNullable = false + }, + new ColumnMetadata + { + Name = "CreatedAt", + SqlType = "DATETIME2", + IsNullable = true, + DefaultValue = "GETDATE()" + } + } + } + }; + var generator = new YamlGenerator(); + + // Act + var yaml = generator.Generate(tables); + + // Assert + Assert.Contains("defaultValue:", yaml, StringComparison.OrdinalIgnoreCase); + Assert.Contains("GETDATE", yaml, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Generate_ComplexSchema_ProducesCompleteYaml() + { + // Arrange - Full schema from the project's schema.sql + var tables = new List + { + new TableMetadata + { + Name = "Categories", + Columns = new List + { + new ColumnMetadata { Name = "Id", SqlType = "INT", IsPrimaryKey = true, IsIdentity = true, IsNullable = false }, + new ColumnMetadata { Name = "Name", SqlType = "NVARCHAR", MaxLength = 50, IsNullable = false } + } + }, + new TableMetadata + { + Name = "Products", + Columns = new List + { + new ColumnMetadata { Name = "Id", SqlType = "INT", IsPrimaryKey = true, IsIdentity = true, IsNullable = false }, + new ColumnMetadata { Name = "Name", SqlType = "NVARCHAR", MaxLength = 100, IsNullable = false }, + new ColumnMetadata { Name = "Description", SqlType = "NVARCHAR", MaxLength = 500, IsNullable = true }, + new ColumnMetadata { Name = "Price", SqlType = "DECIMAL", Precision = 18, Scale = 2, IsNullable = true }, + new ColumnMetadata { Name = "CategoryId", SqlType = "INT", IsNullable = true }, + new ColumnMetadata { Name = "CreatedAt", SqlType = "DATETIME2", IsNullable = true, DefaultValue = "GETDATE()" } + }, + ForeignKeys = new List + { + new ForeignKeyMetadata { ColumnName = "CategoryId", ReferencedTable = "Categories", ReferencedColumn = "Id" } + } + } + }; + var generator = new YamlGenerator(); + + // Act + var yaml = generator.Generate(tables); + + // Assert - Verify deserialization of complex schema + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + var appDefinition = deserializer.Deserialize(yaml); + + Assert.Equal(2, appDefinition.DataModel.Entities.Count); + + // YamlGenerator singularizes table names (Products -> Product) + var productsEntity = appDefinition.DataModel.Entities.FirstOrDefault(e => e.Name == "Product"); + Assert.NotNull(productsEntity); + Assert.Equal(6, productsEntity.Properties.Count); + Assert.Single(productsEntity.Relationships); + + var relationship = productsEntity.Relationships[0]; + Assert.Equal("Category", relationship.TargetEntity); // Singularized + Assert.Equal("CategoryId", relationship.ForeignKey); + } +} diff --git a/tests/DotNetWebApp.Tests/DataSeederTests.cs b/tests/DotNetWebApp.Tests/DataSeederTests.cs new file mode 100644 index 0000000..4cc7e19 --- /dev/null +++ b/tests/DotNetWebApp.Tests/DataSeederTests.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using DotNetWebApp.Data; +using DotNetWebApp.Data.Tenancy; +using DotNetWebApp.Models; +using DotNetWebApp.Services; +using DotNetWebApp.Tests.TestEntities; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace DotNetWebApp.Tests; + +public class DataSeederTests +{ + private const string SeedFileName = "seed.sql"; + + [Fact] + public async Task SeedAsync_AddsRows_WhenScriptExists() + { + var tempDir = CreateTemporaryDirectory(); + try + { + var sqlPath = Path.Combine(tempDir, SeedFileName); + await File.WriteAllTextAsync(sqlPath, "INSERT INTO Categories (Name) VALUES ('Seeded');"); + + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new TestAppDbContext(options, new TestTenantSchemaAccessor("dbo")); + await context.Database.EnsureCreatedAsync(); + + var seeder = new DataSeeder( + context, + new TestHostEnvironment(tempDir), + NullLogger.Instance, + Options.Create(new DataSeederOptions { SeedFileName = SeedFileName })); + await seeder.SeedAsync(); + + var seeded = await context.Set().SingleOrDefaultAsync(c => c.Name == "Seeded"); + Assert.NotNull(seeded); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task SeedAsync_Skips_WhenScriptMissing() + { + var tempDir = CreateTemporaryDirectory(); + try + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new TestAppDbContext(options, new TestTenantSchemaAccessor("dbo")); + await context.Database.EnsureCreatedAsync(); + + var seeder = new DataSeeder( + context, + new TestHostEnvironment(tempDir), + NullLogger.Instance, + Options.Create(new DataSeederOptions { SeedFileName = SeedFileName })); + await seeder.SeedAsync(); + + var count = await context.Set().CountAsync(); + Assert.Equal(0, count); + } + finally + { + Directory.Delete(tempDir, true); + } + } + + private static string CreateTemporaryDirectory() + { + var path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + return path; + } + + private sealed class TestTenantSchemaAccessor : ITenantSchemaAccessor + { + public TestTenantSchemaAccessor(string schema) => Schema = schema; + public string Schema { get; } + } + + private sealed class TestHostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } + public string ApplicationName { get; set; } + public string ContentRootPath { get; set; } + public string WebRootPath { get; set; } + public IFileProvider ContentRootFileProvider { get; set; } + public IFileProvider WebRootFileProvider { get; set; } + + public TestHostEnvironment(string contentRootPath) + { + EnvironmentName = "Test"; + ApplicationName = "DotNetWebApp.Tests"; + WebRootPath = string.Empty; + ContentRootPath = contentRootPath; + ContentRootFileProvider = new PhysicalFileProvider(contentRootPath); + WebRootFileProvider = new NullFileProvider(); + } + } +} diff --git a/tests/DotNetWebApp.Tests/DotNetWebApp.Tests.csproj b/tests/DotNetWebApp.Tests/DotNetWebApp.Tests.csproj new file mode 100644 index 0000000..b32c029 --- /dev/null +++ b/tests/DotNetWebApp.Tests/DotNetWebApp.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + false + enable + + + + + contentFiles + + + + + + + + + + + + + + + diff --git a/tests/DotNetWebApp.Tests/EntitiesControllerTests.cs b/tests/DotNetWebApp.Tests/EntitiesControllerTests.cs new file mode 100644 index 0000000..f2ea908 --- /dev/null +++ b/tests/DotNetWebApp.Tests/EntitiesControllerTests.cs @@ -0,0 +1,272 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using DotNetWebApp.Controllers; +using DotNetWebApp.Data; +using DotNetWebApp.Data.Tenancy; +using DotNetWebApp.Models; +using DotNetWebApp.Models.AppDictionary; +using DotNetWebApp.Services; +using DotNetWebApp.Tests.TestEntities; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace DotNetWebApp.Tests; + +public class EntitiesControllerTests +{ + [Fact] + public async Task GetEntities_ReturnsProducts_WhenEntityExists() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new TestAppDbContext(options, new TestTenantSchemaAccessor("dbo")); + await context.Database.EnsureCreatedAsync(); + + context.Set().Add(new Product { Name = "Test Product", Price = 10.99m }); + await context.SaveChangesAsync(); + + var metadataService = new TestEntityMetadataService(typeof(Product), "Product"); + var controller = new EntitiesController(context, metadataService); + + var result = await controller.GetEntities("product"); + + var okResult = Assert.IsType(result); + var products = Assert.IsAssignableFrom>(okResult.Value); + Assert.Single(products); + Assert.Equal("Test Product", products.First().Name); + } + + [Fact] + public async Task GetEntities_ReturnsCategories_WhenEntityExists() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new TestAppDbContext(options, new TestTenantSchemaAccessor("dbo")); + await context.Database.EnsureCreatedAsync(); + + context.Set().Add(new Category { Name = "Test Category" }); + await context.SaveChangesAsync(); + + var metadataService = new TestEntityMetadataService(typeof(Category), "Category"); + var controller = new EntitiesController(context, metadataService); + + var result = await controller.GetEntities("category"); + + var okResult = Assert.IsType(result); + var categories = Assert.IsAssignableFrom>(okResult.Value); + Assert.Single(categories); + Assert.Equal("Test Category", categories.First().Name); + } + + [Fact] + public async Task GetEntities_Returns404_WhenEntityNotFound() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new TestAppDbContext(options, new TestTenantSchemaAccessor("dbo")); + var metadataService = new TestEntityMetadataService(null, null); + var controller = new EntitiesController(context, metadataService); + + var result = await controller.GetEntities("invalid"); + + var notFoundResult = Assert.IsType(result); + Assert.NotNull(notFoundResult.Value); + } + + [Fact] + public async Task GetEntityCount_ReturnsCount_WhenEntityExists() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new TestAppDbContext(options, new TestTenantSchemaAccessor("dbo")); + await context.Database.EnsureCreatedAsync(); + + context.Set().AddRange( + new Product { Name = "Product 1", Price = 10.99m }, + new Product { Name = "Product 2", Price = 20.99m }, + new Product { Name = "Product 3", Price = 30.99m } + ); + await context.SaveChangesAsync(); + + var metadataService = new TestEntityMetadataService(typeof(Product), "Product"); + var controller = new EntitiesController(context, metadataService); + + var result = await controller.GetEntityCount("product"); + + var okResult = Assert.IsType(result.Result); + Assert.Equal(3, okResult.Value); + } + + [Fact] + public async Task GetEntityCount_Returns404_WhenEntityNotFound() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new TestAppDbContext(options, new TestTenantSchemaAccessor("dbo")); + var metadataService = new TestEntityMetadataService(null, null); + var controller = new EntitiesController(context, metadataService); + + var result = await controller.GetEntityCount("invalid"); + + var notFoundResult = Assert.IsType(result.Result); + Assert.NotNull(notFoundResult.Value); + } + + [Fact] + public async Task CreateEntity_CreatesAndReturnsEntity_WhenValidJson() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new TestAppDbContext(options, new TestTenantSchemaAccessor("dbo")); + await context.Database.EnsureCreatedAsync(); + + var metadataService = new TestEntityMetadataService(typeof(Category), "Category"); + var controller = new EntitiesController(context, metadataService); + + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + var jsonBody = "{\"Name\":\"New Category\"}"; + httpContext.Request.Body = new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes(jsonBody)); + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + + var result = await controller.CreateEntity("category"); + + var createdResult = Assert.IsType(result); + var category = Assert.IsType(createdResult.Value); + Assert.Equal("New Category", category.Name); + + var savedCount = await context.Set().CountAsync(); + Assert.Equal(1, savedCount); + } + + [Fact] + public async Task CreateEntity_Returns404_WhenEntityNotFound() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new TestAppDbContext(options, new TestTenantSchemaAccessor("dbo")); + var metadataService = new TestEntityMetadataService(null, null); + var controller = new EntitiesController(context, metadataService); + + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + httpContext.Request.Body = new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes("{}")); + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + + var result = await controller.CreateEntity("invalid"); + + Assert.IsType(result); + } + + [Fact] + public async Task CreateEntity_ReturnsBadRequest_WhenEmptyBody() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new TestAppDbContext(options, new TestTenantSchemaAccessor("dbo")); + var metadataService = new TestEntityMetadataService(typeof(Category), "Category"); + var controller = new EntitiesController(context, metadataService); + + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + httpContext.Request.Body = new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes("")); + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + + var result = await controller.CreateEntity("category"); + + var badRequestResult = Assert.IsType(result); + Assert.NotNull(badRequestResult.Value); + } + + [Fact] + public async Task CreateEntity_ReturnsBadRequest_WhenInvalidJson() + { + await using var connection = new SqliteConnection("DataSource=:memory:"); + await connection.OpenAsync(); + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + await using var context = new TestAppDbContext(options, new TestTenantSchemaAccessor("dbo")); + var metadataService = new TestEntityMetadataService(typeof(Category), "Category"); + var controller = new EntitiesController(context, metadataService); + + var httpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext(); + httpContext.Request.Body = new System.IO.MemoryStream(System.Text.Encoding.UTF8.GetBytes("{invalid json}")); + controller.ControllerContext = new ControllerContext + { + HttpContext = httpContext + }; + + var result = await controller.CreateEntity("category"); + + var badRequestResult = Assert.IsType(result); + Assert.NotNull(badRequestResult.Value); + } + + private sealed class TestTenantSchemaAccessor : ITenantSchemaAccessor + { + public TestTenantSchemaAccessor(string schema) => Schema = schema; + public string Schema { get; } + } + + private sealed class TestEntityMetadataService : IEntityMetadataService + { + private readonly EntityMetadata? _metadata; + + public TestEntityMetadataService(System.Type? clrType, string? entityName) + { + if (clrType != null && entityName != null) + { + var entity = new Entity { Name = entityName, Properties = new List() }; + _metadata = new EntityMetadata(entity, clrType); + } + } + + public IReadOnlyList Entities => + _metadata != null ? new[] { _metadata } : System.Array.Empty(); + + public EntityMetadata? Find(string entityName) => _metadata; + } +} diff --git a/tests/DotNetWebApp.Tests/EntityApiServiceTests.cs b/tests/DotNetWebApp.Tests/EntityApiServiceTests.cs new file mode 100644 index 0000000..d4a3bd2 --- /dev/null +++ b/tests/DotNetWebApp.Tests/EntityApiServiceTests.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using DotNetWebApp.Models; +using DotNetWebApp.Models.AppDictionary; +using DotNetWebApp.Services; +using DotNetWebApp.Tests.TestEntities; +using Xunit; + +namespace DotNetWebApp.Tests; + +public class EntityApiServiceTests +{ + [Fact] + public async Task GetEntitiesAsync_ReturnsProducts_WhenEntityExists() + { + var product1 = new Product { Id = 1, Name = "Product 1", Price = 10.99m }; + var product2 = new Product { Id = 2, Name = "Product 2", Price = 20.99m }; + var json = JsonSerializer.Serialize(new[] { product1, product2 }); + + var httpClient = new HttpClient(new FakeHttpMessageHandler(HttpStatusCode.OK, json)) + { + BaseAddress = new Uri("http://localhost/") + }; + var metadataService = new TestEntityMetadataService(typeof(Product), "Product"); + var service = new EntityApiService(httpClient, metadataService); + + var result = await service.GetEntitiesAsync("Product"); + + Assert.NotNull(result); + var entities = result.Cast().ToList(); + Assert.Equal(2, entities.Count); + Assert.Equal("Product 1", entities[0].Name); + Assert.Equal("Product 2", entities[1].Name); + } + + [Fact] + public async Task GetEntitiesAsync_ThrowsException_WhenEntityNotFound() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(HttpStatusCode.OK, "[]")) + { + BaseAddress = new Uri("http://localhost/") + }; + var metadataService = new TestEntityMetadataService(null, null); + var service = new EntityApiService(httpClient, metadataService); + + await Assert.ThrowsAsync( + () => service.GetEntitiesAsync("Unknown")); + } + + [Fact] + public async Task GetEntitiesAsync_ThrowsException_WhenHttpRequestFails() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(HttpStatusCode.NotFound, "")) + { + BaseAddress = new Uri("http://localhost/") + }; + var metadataService = new TestEntityMetadataService(typeof(Product), "Product"); + var service = new EntityApiService(httpClient, metadataService); + + await Assert.ThrowsAsync( + () => service.GetEntitiesAsync("Product")); + } + + [Fact] + public async Task GetCountAsync_ReturnsCount_WhenEntityExists() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(HttpStatusCode.OK, "42")) + { + BaseAddress = new Uri("http://localhost/") + }; + var metadataService = new TestEntityMetadataService(typeof(Product), "Product"); + var service = new EntityApiService(httpClient, metadataService); + + var result = await service.GetCountAsync("Product"); + + Assert.Equal(42, result); + } + + [Fact] + public async Task GetCountAsync_ThrowsException_WhenEntityNotFound() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(HttpStatusCode.OK, "0")) + { + BaseAddress = new Uri("http://localhost/") + }; + var metadataService = new TestEntityMetadataService(null, null); + var service = new EntityApiService(httpClient, metadataService); + + await Assert.ThrowsAsync( + () => service.GetCountAsync("Unknown")); + } + + [Fact] + public async Task GetCountAsync_ThrowsException_WhenHttpRequestFails() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(HttpStatusCode.InternalServerError, "")) + { + BaseAddress = new Uri("http://localhost/") + }; + var metadataService = new TestEntityMetadataService(typeof(Product), "Product"); + var service = new EntityApiService(httpClient, metadataService); + + await Assert.ThrowsAsync( + () => service.GetCountAsync("Product")); + } + + [Fact] + public async Task CreateEntityAsync_ReturnsCreatedEntity_WhenValid() + { + var createdProduct = new Product { Id = 1, Name = "New Product", Price = 15.99m }; + var responseJson = JsonSerializer.Serialize(createdProduct); + + var httpClient = new HttpClient(new FakeHttpMessageHandler(HttpStatusCode.Created, responseJson)) + { + BaseAddress = new Uri("http://localhost/") + }; + var metadataService = new TestEntityMetadataService(typeof(Product), "Product"); + var service = new EntityApiService(httpClient, metadataService); + + var newProduct = new Product { Name = "New Product", Price = 15.99m }; + var result = await service.CreateEntityAsync("Product", newProduct); + + var createdProductResult = Assert.IsType(result); + Assert.Equal(1, createdProductResult.Id); + Assert.Equal("New Product", createdProductResult.Name); + } + + [Fact] + public async Task CreateEntityAsync_ThrowsException_WhenEntityNotFound() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(HttpStatusCode.Created, "{}")) + { + BaseAddress = new Uri("http://localhost/") + }; + var metadataService = new TestEntityMetadataService(null, null); + var service = new EntityApiService(httpClient, metadataService); + + var newProduct = new Product { Name = "New Product", Price = 15.99m }; + + await Assert.ThrowsAsync( + () => service.CreateEntityAsync("Unknown", newProduct)); + } + + [Fact] + public async Task CreateEntityAsync_ThrowsException_WhenHttpRequestFails() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(HttpStatusCode.BadRequest, "")) + { + BaseAddress = new Uri("http://localhost/") + }; + var metadataService = new TestEntityMetadataService(typeof(Product), "Product"); + var service = new EntityApiService(httpClient, metadataService); + + var newProduct = new Product { Name = "New Product", Price = 15.99m }; + + await Assert.ThrowsAsync( + () => service.CreateEntityAsync("Product", newProduct)); + } + + private sealed class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + private readonly string _content; + + public FakeHttpMessageHandler(HttpStatusCode statusCode, string content) + { + _statusCode = statusCode; + _content = content; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = new HttpResponseMessage(_statusCode) + { + Content = new StringContent(_content) + }; + return Task.FromResult(response); + } + } + + private sealed class TestEntityMetadataService : IEntityMetadataService + { + private readonly EntityMetadata? _metadata; + + public TestEntityMetadataService(System.Type? clrType, string? entityName) + { + if (clrType != null && entityName != null) + { + var entity = new Entity { Name = entityName, Properties = new List() }; + _metadata = new EntityMetadata(entity, clrType); + } + } + + public IReadOnlyList Entities => + _metadata != null ? new[] { _metadata } : System.Array.Empty(); + + public EntityMetadata? Find(string entityName) => _metadata; + } +} diff --git a/tests/DotNetWebApp.Tests/PipelineIntegrationTests.cs b/tests/DotNetWebApp.Tests/PipelineIntegrationTests.cs new file mode 100644 index 0000000..fb4aecd --- /dev/null +++ b/tests/DotNetWebApp.Tests/PipelineIntegrationTests.cs @@ -0,0 +1,290 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using DdlParser; +using DotNetWebApp.Models.AppDictionary; +using Xunit; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace DotNetWebApp.Tests; + +public class PipelineIntegrationTests +{ + [Fact] + public async Task RunDdlPipeline_EndToEnd_GeneratesWorkingModels() + { + // Arrange: Create temp SQL file + var sql = @" +CREATE TABLE Categories ( + Id INT PRIMARY KEY IDENTITY(1,1) NOT NULL, + Name NVARCHAR(50) NOT NULL +); + +CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1) NOT NULL, + Name NVARCHAR(100) NOT NULL, + Description NVARCHAR(500) NULL, + Price DECIMAL(18,2) NULL, + CategoryId INT NULL, + CreatedAt DATETIME2 NULL DEFAULT GETDATE(), + FOREIGN KEY (CategoryId) REFERENCES Categories(Id) +);"; + var tempSql = Path.GetTempFileName() + ".sql"; + var tempYaml = Path.GetTempFileName() + ".yaml"; + + try + { + await File.WriteAllTextAsync(tempSql, sql); + + // Act Phase 1: Run DdlParser (SQL -> YAML) + var parser = new SqlDdlParser(); + var tables = parser.Parse(sql); + var generator = new YamlGenerator(); + var yaml = generator.Generate(tables); + await File.WriteAllTextAsync(tempYaml, yaml); + + // Assert Phase 1: Verify SQL parsing + Assert.Equal(2, tables.Count); + Assert.Contains(tables, t => t.Name == "Categories"); + Assert.Contains(tables, t => t.Name == "Products"); + + var productsTable = tables.FirstOrDefault(t => t.Name == "Products"); + Assert.NotNull(productsTable); + Assert.Equal(6, productsTable.Columns.Count); + Assert.Single(productsTable.ForeignKeys); + + // Assert Phase 2: Verify YAML is valid and complete + Assert.NotEmpty(yaml); + Assert.Contains("name: Product", yaml, StringComparison.OrdinalIgnoreCase); // Singularized + Assert.Contains("name: Category", yaml, StringComparison.OrdinalIgnoreCase); // Singularized + + // Act Phase 3: Verify YAML can be deserialized + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + var appDefinition = deserializer.Deserialize(yaml); + + // Assert Phase 3: Verify deserialized structure + Assert.NotNull(appDefinition); + Assert.NotNull(appDefinition.App); + Assert.NotNull(appDefinition.Theme); + Assert.NotNull(appDefinition.DataModel); + Assert.Equal(2, appDefinition.DataModel.Entities.Count); + + // Verify Category entity + var categoryEntity = appDefinition.DataModel.Entities.FirstOrDefault(e => e.Name == "Category"); + Assert.NotNull(categoryEntity); + Assert.Equal(2, categoryEntity.Properties.Count); + + var categoryIdProp = categoryEntity.Properties.FirstOrDefault(p => p.Name == "Id"); + Assert.NotNull(categoryIdProp); + Assert.Equal("int", categoryIdProp.Type); + Assert.True(categoryIdProp.IsPrimaryKey); + Assert.True(categoryIdProp.IsIdentity); + Assert.True(categoryIdProp.IsRequired); + + // Verify Product entity + var productEntity = appDefinition.DataModel.Entities.FirstOrDefault(e => e.Name == "Product"); + Assert.NotNull(productEntity); + Assert.Equal(6, productEntity.Properties.Count); + + var productIdProp = productEntity.Properties.FirstOrDefault(p => p.Name == "Id"); + Assert.NotNull(productIdProp); + Assert.True(productIdProp.IsPrimaryKey); + Assert.True(productIdProp.IsIdentity); + + var priceProp = productEntity.Properties.FirstOrDefault(p => p.Name == "Price"); + Assert.NotNull(priceProp); + Assert.Equal("decimal", priceProp.Type); + Assert.Equal(18, priceProp.Precision); + Assert.Equal(2, priceProp.Scale); + Assert.False(priceProp.IsRequired); // Nullable + + var createdAtProp = productEntity.Properties.FirstOrDefault(p => p.Name == "CreatedAt"); + Assert.NotNull(createdAtProp); + Assert.NotNull(createdAtProp.DefaultValue); + Assert.Contains("GETDATE", createdAtProp.DefaultValue.ToUpperInvariant()); + + // Verify relationship + Assert.Single(productEntity.Relationships); + var relationship = productEntity.Relationships[0]; + Assert.Equal("Category", relationship.TargetEntity); + Assert.Equal("CategoryId", relationship.ForeignKey); + Assert.Equal("Id", relationship.PrincipalKey); + + // Assert Phase 4: Verify generated YAML would work with ModelGenerator + // This verifies the complete pipeline readiness for code generation + Assert.NotEmpty(appDefinition.App.Name); + Assert.NotEmpty(appDefinition.Theme.PrimaryColor); + } + finally + { + // Cleanup temp files + if (File.Exists(tempSql)) + File.Delete(tempSql); + if (File.Exists(tempYaml)) + File.Delete(tempYaml); + } + } + + [Fact] + public async Task RunDdlPipeline_InvalidSql_ReportsErrorClearly() + { + // Arrange: Create malformed SQL + var invalidSql = "CREATE TABLE InvalidTable (Id INT NOTAVALIDKEYWORD);"; + var tempSql = Path.GetTempFileName() + ".sql"; + + try + { + await File.WriteAllTextAsync(tempSql, invalidSql); + + // Act & Assert: Verify parser throws with clear message + var parser = new SqlDdlParser(); + var exception = Assert.Throws(() => parser.Parse(invalidSql)); + Assert.Contains("SQL parsing errors", exception.Message); + } + finally + { + if (File.Exists(tempSql)) + File.Delete(tempSql); + } + } + + [Fact] + public async Task RunDdlPipeline_UpdatedSchema_RegeneratesCorrectly() + { + // Arrange: Create initial schema + var initialSql = @" +CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1) NOT NULL, + Name NVARCHAR(100) NOT NULL +);"; + + // Arrange: Create updated schema with new column + var updatedSql = @" +CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1) NOT NULL, + Name NVARCHAR(100) NOT NULL, + Price DECIMAL(18,2) NULL +);"; + + var tempYaml = Path.GetTempFileName() + ".yaml"; + + try + { + // Act: Parse initial schema + var parser = new SqlDdlParser(); + var generator = new YamlGenerator(); + + var initialTables = parser.Parse(initialSql); + var initialYaml = generator.Generate(initialTables); + + // Act: Parse updated schema + var updatedTables = parser.Parse(updatedSql); + var updatedYaml = generator.Generate(updatedTables); + + // Assert: Verify initial has 2 properties + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + var initialAppDef = deserializer.Deserialize(initialYaml); + var initialProduct = initialAppDef.DataModel.Entities.FirstOrDefault(e => e.Name == "Product"); + Assert.NotNull(initialProduct); + Assert.Equal(2, initialProduct.Properties.Count); + + // Assert: Verify updated has 3 properties + var updatedAppDef = deserializer.Deserialize(updatedYaml); + var updatedProduct = updatedAppDef.DataModel.Entities.FirstOrDefault(e => e.Name == "Product"); + Assert.NotNull(updatedProduct); + Assert.Equal(3, updatedProduct.Properties.Count); + + var priceProperty = updatedProduct.Properties.FirstOrDefault(p => p.Name == "Price"); + Assert.NotNull(priceProperty); + Assert.Equal("decimal", priceProperty.Type); + } + finally + { + if (File.Exists(tempYaml)) + File.Delete(tempYaml); + } + } + + [Fact] + public void RunDdlPipeline_ComplexSchema_HandlesMultipleRelationships() + { + // Arrange: Schema with multiple foreign keys + var sql = @" +CREATE TABLE Companies ( + Id INT PRIMARY KEY IDENTITY(1,1) NOT NULL, + Name NVARCHAR(200) NOT NULL +); + +CREATE TABLE Products ( + Id INT PRIMARY KEY IDENTITY(1,1) NOT NULL, + Name NVARCHAR(100) NOT NULL +); + +CREATE TABLE CompanyProducts ( + Id INT PRIMARY KEY IDENTITY(1,1) NOT NULL, + CompanyId INT NOT NULL, + ProductId INT NOT NULL, + FOREIGN KEY (CompanyId) REFERENCES Companies(Id), + FOREIGN KEY (ProductId) REFERENCES Products(Id) +);"; + + // Act + var parser = new SqlDdlParser(); + var tables = parser.Parse(sql); + var generator = new YamlGenerator(); + var yaml = generator.Generate(tables); + + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + var appDefinition = deserializer.Deserialize(yaml); + + // Assert + var companyProductEntity = appDefinition.DataModel.Entities + .FirstOrDefault(e => e.Name == "CompanyProduct"); // Singularized + + Assert.NotNull(companyProductEntity); + Assert.Equal(2, companyProductEntity.Relationships.Count); + + Assert.Contains(companyProductEntity.Relationships, r => + r.ForeignKey == "CompanyId" && r.TargetEntity == "Company"); + Assert.Contains(companyProductEntity.Relationships, r => + r.ForeignKey == "ProductId" && r.TargetEntity == "Product"); + } + + [Fact] + public void RunDdlPipeline_EmptySql_ProducesMinimalYaml() + { + // Arrange + var emptySql = ""; + var parser = new SqlDdlParser(); + var generator = new YamlGenerator(); + + // Act + var tables = parser.Parse(emptySql); + var yaml = generator.Generate(tables); + + // Assert + Assert.Empty(tables); + Assert.NotEmpty(yaml); + Assert.Contains("app:", yaml, StringComparison.OrdinalIgnoreCase); + Assert.Contains("dataModel:", yaml, StringComparison.OrdinalIgnoreCase); + + var deserializer = new DeserializerBuilder() + .WithNamingConvention(CamelCaseNamingConvention.Instance) + .Build(); + + var appDefinition = deserializer.Deserialize(yaml); + Assert.NotNull(appDefinition); + Assert.Empty(appDefinition.DataModel.Entities); + } +} diff --git a/tests/DotNetWebApp.Tests/TestAppDbContext.cs b/tests/DotNetWebApp.Tests/TestAppDbContext.cs new file mode 100644 index 0000000..52ca3e8 --- /dev/null +++ b/tests/DotNetWebApp.Tests/TestAppDbContext.cs @@ -0,0 +1,35 @@ +using DotNetWebApp.Data; +using DotNetWebApp.Data.Tenancy; +using DotNetWebApp.Tests.TestEntities; +using Microsoft.EntityFrameworkCore; + +namespace DotNetWebApp.Tests +{ + public class TestAppDbContext : DbContext + { + public TestAppDbContext( + DbContextOptions options, + ITenantSchemaAccessor tenantSchemaAccessor) : base(options) + { + Schema = tenantSchemaAccessor.Schema; + } + + public string Schema { get; } + + public DbSet Products { get; set; } + public DbSet Categories { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + if (!string.IsNullOrWhiteSpace(Schema)) + { + modelBuilder.HasDefaultSchema(Schema); + } + + modelBuilder.Entity().ToTable("Products"); + modelBuilder.Entity().ToTable("Categories"); + } + } +} diff --git a/tests/DotNetWebApp.Tests/TestEntities/Category.cs b/tests/DotNetWebApp.Tests/TestEntities/Category.cs new file mode 100644 index 0000000..aa14ae6 --- /dev/null +++ b/tests/DotNetWebApp.Tests/TestEntities/Category.cs @@ -0,0 +1,8 @@ +namespace DotNetWebApp.Tests.TestEntities +{ + public class Category + { + public int Id { get; set; } + public string? Name { get; set; } + } +} diff --git a/tests/DotNetWebApp.Tests/TestEntities/Product.cs b/tests/DotNetWebApp.Tests/TestEntities/Product.cs new file mode 100644 index 0000000..91eeb37 --- /dev/null +++ b/tests/DotNetWebApp.Tests/TestEntities/Product.cs @@ -0,0 +1,9 @@ +namespace DotNetWebApp.Tests.TestEntities +{ + public class Product + { + public int Id { get; set; } + public string? Name { get; set; } + public decimal Price { get; set; } + } +} diff --git a/tests/ModelGenerator.Tests/ModelGenerator.Tests.csproj b/tests/ModelGenerator.Tests/ModelGenerator.Tests.csproj new file mode 100644 index 0000000..976b901 --- /dev/null +++ b/tests/ModelGenerator.Tests/ModelGenerator.Tests.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + false + enable + + + + + contentFiles + + + + + + + + + + diff --git a/tests/ModelGenerator.Tests/PathResolutionTests.cs b/tests/ModelGenerator.Tests/PathResolutionTests.cs new file mode 100644 index 0000000..0517b31 --- /dev/null +++ b/tests/ModelGenerator.Tests/PathResolutionTests.cs @@ -0,0 +1,116 @@ +using System; +using System.Diagnostics; +using System.IO; +using Xunit; + +namespace ModelGenerator.Tests +{ + public class PathResolutionTests + { + [Fact] + public void ModelGenerator_ShouldOutputToCorrectPath_NotNestedStructure() + { + // Arrange: Find the repository root (DotNetWebApp/) + var testDir = Directory.GetCurrentDirectory(); + var repoRoot = FindRepositoryRoot(testDir); + Assert.NotNull(repoRoot); + + var modelGeneratorExe = Path.Combine(repoRoot, "ModelGenerator", "bin", "Release", "net8.0", "ModelGenerator.dll"); + var testYamlPath = Path.Combine(repoRoot, "app.yaml"); + var expectedOutputDir = Path.Combine(repoRoot, "DotNetWebApp.Models", "Generated"); + var incorrectOutputDir = Path.Combine(repoRoot, "Models", "Generated"); + + // Act: Run ModelGenerator + var processInfo = new ProcessStartInfo + { + FileName = "dotnet", + Arguments = $"\"{modelGeneratorExe}\" \"{testYamlPath}\"", + WorkingDirectory = Path.Combine(repoRoot, "ModelGenerator"), + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = Process.Start(processInfo); + Assert.NotNull(process); + + process.WaitForExit(); + var output = process.StandardOutput.ReadToEnd(); + var error = process.StandardError.ReadToEnd(); + + // Assert: Files should be in correct location + Assert.True(Directory.Exists(expectedOutputDir), + $"Expected output directory does not exist: {expectedOutputDir}"); + + Assert.False(Directory.Exists(incorrectOutputDir), + $"Incorrect nested directory should not exist: {incorrectOutputDir}"); + + // Verify at least one generated file exists in the correct location + var generatedFiles = Directory.GetFiles(expectedOutputDir, "*.cs"); + Assert.True(generatedFiles.Length > 0, + $"No generated files found in {expectedOutputDir} - try running 'make run-ddl-pipeline'"); + + // Verify no files in the incorrect nested location + if (Directory.Exists(incorrectOutputDir)) + { + var incorrectFiles = Directory.GetFiles(incorrectOutputDir, "*.cs"); + Assert.True(incorrectFiles.Length == 0, + $"Generated files should not exist in nested directory: {incorrectOutputDir}"); + } + } + + [Fact] + public void PathResolution_CorrectPathShouldNotCreateNestedStructure() + { + // Arrange: Simulate ModelGenerator's working directory + var repoRoot = FindRepositoryRoot(Directory.GetCurrentDirectory()); + Assert.NotNull(repoRoot); + + var modelGeneratorDir = Path.Combine(repoRoot, "ModelGenerator"); + + // Act: Resolve the correct path "../DotNetWebApp.Models/Generated" from ModelGenerator/ + var correctRelativePath = "../DotNetWebApp.Models/Generated"; + var resolvedCorrectPath = Path.GetFullPath(Path.Combine(modelGeneratorDir, correctRelativePath)); + + // Assert: Should resolve to DotNetWebApp.Models/Generated + Assert.EndsWith(Path.Combine("DotNetWebApp.Models", "Generated"), resolvedCorrectPath); + // Verify it resolves to the expected location, not to nested DotNetWebApp/DotNetWebApp/... + Assert.Contains("DotNetWebApp.Models", resolvedCorrectPath); + } + + [Fact] + public void PathResolution_IncorrectPathWouldOutputToWrongLocation() + { + // Arrange: Simulate the scenario with wrong path + var repoRoot = FindRepositoryRoot(Directory.GetCurrentDirectory()); + Assert.NotNull(repoRoot); + + var modelGeneratorDir = Path.Combine(repoRoot, "ModelGenerator"); + + // Act: Resolve the old INCORRECT path "../Models/Generated" (before extraction to DotNetWebApp.Models/) + var incorrectRelativePath = "../Models/Generated"; + var resolvedIncorrectPath = Path.GetFullPath(Path.Combine(modelGeneratorDir, incorrectRelativePath)); + + // Assert: The old path would output to the repository root Models/, not DotNetWebApp.Models/ + Assert.EndsWith(Path.Combine("Models", "Generated"), resolvedIncorrectPath); + Assert.DoesNotContain("DotNetWebApp.Models", resolvedIncorrectPath); + } + + private static string? FindRepositoryRoot(string startPath) + { + var dir = new DirectoryInfo(startPath); + while (dir != null) + { + // Look for .git directory or DotNetWebApp.sln file + if (Directory.Exists(Path.Combine(dir.FullName, ".git")) || + File.Exists(Path.Combine(dir.FullName, "DotNetWebApp.sln"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + return null; + } + } +} diff --git a/verify.sh b/verify.sh new file mode 100755 index 0000000..801bdbd --- /dev/null +++ b/verify.sh @@ -0,0 +1,249 @@ +#!/usr/bin/env bash +set -e + +echo "================================" +echo "DotNetWebApp CRUD Verification" +echo "================================" +echo "" + +# Colors for output +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print status +print_status() { + echo -e "${GREEN}[✓]${NC} $1" +} + +print_error() { + echo -e "${RED}[✗]${NC} $1" +} + +print_info() { + echo -e "${YELLOW}[→]${NC} $1" +} + +# Function to cleanup on exit +cleanup() { + if [ -n "$SERVER_PID" ]; then + print_info "Stopping dev server (PID: $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi +} + +trap cleanup EXIT + +# Step 1: Check and build +print_info "Step 1: Running make check..." +make check +print_status "Build check passed" +echo "" + +# Step 2: Run tests +print_info "Step 2: Running make test..." +make test +print_status "All tests passed" +echo "" + +# Step 3: Drop database +print_info "Step 3: Dropping database (make db-drop)..." +make db-drop || print_info "Docker database drop attempted (may not exist - running 'make ms-drop' for MSSQL Server)" +make ms-drop || print_info "MSSQL Server database drop attempted" +print_status "Database(s) dropped? ¯\_(ツ)_/¯" +echo "" + +# Step 4: Run DDL pipeline +print_info "Step 4: Running DDL pipeline (make run-ddl-pipeline)..." +make run-ddl-pipeline +print_status "DDL pipeline completed" +echo "" + +# Step 5: Apply migrations +print_info "Step 5: Applying migrations (make migrate)..." +make migrate +print_status "Migrations applied" +echo "" + +# Step 6: Seed data +print_info "Step 6: Seeding data (make seed)..." +make seed +print_status "Data seeded" +echo "" + +# Step 7: Start dev server +print_info "Step 7: Starting dev server (make dev)..." +make dev > /tmp/dotnet-dev.log 2>&1 & +SERVER_PID=$! +print_status "Dev server started (PID: $SERVER_PID)" + +# Wait for server to be ready +print_info "Waiting for server to be ready..." +MAX_WAIT=30 +WAIT_COUNT=0 +while [ $WAIT_COUNT -lt $MAX_WAIT ]; do + if curl -k -s https://localhost:7012/api/entities/product > /dev/null 2>&1; then + print_status "Server is ready!" + break + fi + sleep 1 + WAIT_COUNT=$((WAIT_COUNT + 1)) + echo -n "." +done +echo "" + +if [ $WAIT_COUNT -eq $MAX_WAIT ]; then + print_error "Server failed to start within ${MAX_WAIT} seconds" + exit 1 +fi + +echo "" +echo "================================" +echo "Testing CRUD Operations" +echo "================================" +echo "" + +# Test 1: GET all products +print_info "Test 1: GET all products" +RESPONSE=$(curl -k -s https://localhost:7012/api/entities/product) +COUNT=$(echo "$RESPONSE" | jq 'length') +print_status "Found $COUNT products" +echo "$RESPONSE" | jq '.[0:2]' # Show first 2 products +echo "" + +# Test 2: GET product by ID +print_info "Test 2: GET product by ID (id=1)" +RESPONSE=$(curl -k -s https://localhost:7012/api/entities/product/1) +PRODUCT_NAME=$(echo "$RESPONSE" | jq -r '.name') +print_status "Retrieved product: $PRODUCT_NAME" +echo "$RESPONSE" | jq . +echo "" + +# Test 3: GET count +print_info "Test 3: GET product count" +COUNT=$(curl -k -s https://localhost:7012/api/entities/product/count) +print_status "Product count: $COUNT" +echo "" + +# Test 4: POST - Create new product +print_info "Test 4: POST - Create new product" +RESPONSE=$(curl -k -s -X POST https://localhost:7012/api/entities/product \ + -H "Content-Type: application/json" \ + -d '{ + "Name": "Verification Test Product", + "Description": "Created by verify-crud.sh script", + "Price": 123.45, + "CategoryId": 1 + }') +NEW_ID=$(echo "$RESPONSE" | jq -r '.id') +print_status "Created product with ID: $NEW_ID" +echo "$RESPONSE" | jq . +echo "" + +# Test 5: GET the newly created product +print_info "Test 5: GET newly created product (id=$NEW_ID)" +RESPONSE=$(curl -k -s https://localhost:7012/api/entities/product/"$NEW_ID") +PRODUCT_NAME=$(echo "$RESPONSE" | jq -r '.name') +print_status "Retrieved: $PRODUCT_NAME" +echo "$RESPONSE" | jq . +echo "" + +# Test 6: PUT - Update the product +print_info "Test 6: PUT - Update product (id=$NEW_ID)" +RESPONSE=$(curl -k -s -X PUT https://localhost:7012/api/entities/product/"$NEW_ID" \ + -H "Content-Type: application/json" \ + -d '{ + "Name": "UPDATED Test Product", + "Description": "Updated by verify-crud.sh script", + "Price": 999.99, + "CategoryId": 2 + }') +UPDATED_NAME=$(echo "$RESPONSE" | jq -r '.name') +UPDATED_PRICE=$(echo "$RESPONSE" | jq -r '.price') +print_status "Updated: $UPDATED_NAME - Price: \$$UPDATED_PRICE" +echo "$RESPONSE" | jq . +echo "" + +# Test 7: Verify update persisted +print_info "Test 7: GET updated product to verify persistence (id=$NEW_ID)" +RESPONSE=$(curl -k -s https://localhost:7012/api/entities/product/"$NEW_ID") +PRODUCT_NAME=$(echo "$RESPONSE" | jq -r '.name') +PRODUCT_PRICE=$(echo "$RESPONSE" | jq -r '.price') +if [ "$PRODUCT_NAME" = "UPDATED Test Product" ] && [ "$PRODUCT_PRICE" = "999.99" ]; then + print_status "Update verified: $PRODUCT_NAME - \$$PRODUCT_PRICE" +else + print_error "Update verification failed!" + exit 1 +fi +echo "$RESPONSE" | jq . +echo "" + +# Test 8: DELETE the product +print_info "Test 8: DELETE product (id=$NEW_ID)" +HTTP_CODE=$(curl -k -s -X DELETE https://localhost:7012/api/entities/product/"$NEW_ID" \ + -w "%{http_code}" -o /dev/null) +if [ "$HTTP_CODE" = "204" ]; then + print_status "Product deleted (HTTP 204 No Content)" +else + print_error "Delete failed (HTTP $HTTP_CODE)" + exit 1 +fi +echo "" + +# Test 9: Verify deletion (should return 404) +print_info "Test 9: GET deleted product (should return 404)" +HTTP_CODE=$(curl -k -s https://localhost:7012/api/entities/product/"$NEW_ID" \ + -w "%{http_code}" -o /tmp/delete-check.json) +RESPONSE=$(cat /tmp/delete-check.json) +if [ "$HTTP_CODE" = "404" ]; then + ERROR_MSG=$(echo "$RESPONSE" | jq -r '.error') + print_status "Deletion verified: $ERROR_MSG (HTTP 404)" +else + print_error "Deletion verification failed (HTTP $HTTP_CODE)" + exit 1 +fi +echo "" + +# Test 10: Error handling - Non-existent ID +print_info "Test 10: GET non-existent product (id=99999)" +HTTP_CODE=$(curl -k -s https://localhost:7012/api/entities/product/99999 \ + -w "%{http_code}" -o /tmp/notfound-check.json) +RESPONSE=$(cat /tmp/notfound-check.json) +if [ "$HTTP_CODE" = "404" ]; then + ERROR_MSG=$(echo "$RESPONSE" | jq -r '.error') + print_status "Error handling verified: $ERROR_MSG (HTTP 404)" +else + print_error "Error handling test failed (HTTP $HTTP_CODE)" + exit 1 +fi +echo "" + +# Test 11: Error handling - Invalid entity name +print_info "Test 11: GET invalid entity name" +HTTP_CODE=$(curl -k -s https://localhost:7012/api/entities/invalidEntity/1 \ + -w "%{http_code}" -o /tmp/invalid-check.json) +RESPONSE=$(cat /tmp/invalid-check.json) +if [ "$HTTP_CODE" = "404" ]; then + ERROR_MSG=$(echo "$RESPONSE" | jq -r '.error') + print_status "Entity validation verified: $ERROR_MSG (HTTP 404)" +else + print_error "Entity validation test failed (HTTP $HTTP_CODE)" + exit 1 +fi +echo "" + +# Final count verification +print_info "Final verification: Product count" +FINAL_COUNT=$(curl -k -s https://localhost:7012/api/entities/product/count) +print_status "Final product count: $FINAL_COUNT (should equal initial count)" +echo "" + +echo "================================" +echo "✅ ALL CRUD OPERATIONS VERIFIED" +echo "================================" +echo "" +print_status "All 11 tests passed successfully!" +print_info "Server logs available at: /tmp/dotnet-dev.log" +echo "" diff --git a/wwwroot/css/SKILLS.md b/wwwroot/css/SKILLS.md index fd0ee74..94eae4d 100644 --- a/wwwroot/css/SKILLS.md +++ b/wwwroot/css/SKILLS.md @@ -10,6 +10,8 @@ This project uses **Radzen Blazor components and themes only**. Bootstrap is not **Goal**: Keep styling consistent with the active Radzen theme and avoid hardcoded colors on layout containers. + + --- ## CSS File Structure @@ -28,6 +30,8 @@ wwwroot/css/ - Any custom component tweaks - CSS animations (`@keyframes pulse`, `spin`, `slideIn`) + + **Rule**: Avoid hardcoded background or text colors on layout containers unless the Radzen theme is explicitly updated to match. --- @@ -58,12 +62,13 @@ Radzen offers multiple theme families: ### Layout Structure + ``` Shared/ MainLayout.razor <- RadzenLayout, RadzenHeader, RadzenSidebar, RadzenBody, RadzenComponents NavMenu.razor <- RadzenPanelMenu Components/Sections/ - ProductsSection.razor <- Product management UI with RadzenDataGrid + EntitySection.razor <- Dynamic entity UI with RadzenDataGrid SettingsSection.razor <- Settings UI with RadzenStack, RadzenCard, etc. ``` @@ -181,9 +186,11 @@ After making CSS changes, verify: This project includes several key section components for the SPA: -- `Components/Sections/ProductsSection.razor` - Product management with RadzenDataGrid +- `Components/Sections/EntitySection.razor` - Dynamic entity management with RadzenDataGrid - `Components/Sections/SettingsSection.razor` - Application settings interface + + These are loaded dynamically by the main SPA page and styled with scoped CSS blocks. --- diff --git a/wwwroot/openai.png b/wwwroot/logo.png similarity index 100% rename from wwwroot/openai.png rename to wwwroot/logo.png