Fix category shadow foreign keys and .NET 10 Docker build context
Build MoneyMap image / build-and-push (push) Failing after 7s

This commit is contained in:
aj
2026-09-12 17:38:38 -04:00
parent 08e54a4d31
commit df09dd8d53
6 changed files with 133 additions and 4 deletions
+28
View File
@@ -0,0 +1,28 @@
# Build with the repository root as context.
.git
**/bin
**/obj
**/.vs
**/.idea
**/TestResults
**/*.user
**/*.suo
**/*.log
**/node_modules
# Runtime configuration and credentials must be supplied outside the image.
**/appsettings*.json
**/secrets.json
**/.env
**/.env.*
**/*.pfx
**/*.pem
**/*.key
# Local receipts, database files, and backups are not application assets.
**/receipts
**/*.db
**/*.db-*
**/*.mdf
**/*.ldf
**/*.bak
+3 -2
View File
@@ -7,7 +7,8 @@ on:
- "MoneyMap/**" - "MoneyMap/**"
- "MoneyMap.Core/**" - "MoneyMap.Core/**"
- "MoneyMap.Mcp/**" - "MoneyMap.Mcp/**"
- "Dockerfile" - "MoneyMap.sln"
- ".dockerignore"
- ".gitea/workflows/build-moneymap.yml" - ".gitea/workflows/build-moneymap.yml"
workflow_dispatch: {} workflow_dispatch: {}
@@ -21,7 +22,7 @@ jobs:
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thecozycat.net -u "${{ gitea.actor }}" --password-stdin run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.thecozycat.net -u "${{ gitea.actor }}" --password-stdin
- name: Build image - name: Build image
run: docker build -f MoneyMap/Dockerfile -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:latest -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:${{ gitea.sha }} MoneyMap/ run: docker build -f MoneyMap/Dockerfile -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:latest -t git.thecozycat.net/${{ gitea.repository_owner }}/moneymap:${{ gitea.sha }} .
- name: Push image - name: Push image
run: | run: |
+28
View File
@@ -1801,6 +1801,34 @@ var importer = new TransactionImporter(mockDb.Object, mockCardResolver.Object);
## Deployment ## Deployment
### Docker image build (.NET 10)
Run from the repository root (not `MoneyMap/`):
```bash
docker build -f MoneyMap/Dockerfile -t moneymap:local .
```
The Dockerfile uses the .NET 10 SDK and ASP.NET Core 10 runtime. The root
context is required to copy `MoneyMap.sln` and the shared `MoneyMap.Core`
project. The root `.dockerignore` excludes local build output, receipts,
database files, and all `appsettings*.json`/credential files. Supply runtime
configuration separately via environment variables (for example
`ConnectionStrings__MoneyMapDb`, `OPENAI_API_KEY`) or a mounted
configuration file; credentials are not baked into the image. Building does
not start the application or apply migrations.
### Category relationship mapping
`Transaction.Category` and `CategoryMapping.Category` remain string category
names. The corresponding collections on `Category` are `[NotMapped]` so EF
cannot infer nonexistent `CategoryId` shadow foreign keys. The real
`RecurringPlace.CategoryId` and parent/child category relationships remain
mapped. This is a model correction only, with no database schema change or
migration required. SQL Server model/query regression tests compile both
receipt duplicate-check queries without connecting to a database.
### Database Migration ### Database Migration
```bash ```bash
# Create migration # Create migration
+4
View File
@@ -25,7 +25,11 @@ public class Category
public ICollection<Category> Children { get; set; } = new List<Category>(); public ICollection<Category> Children { get; set; } = new List<Category>();
public ICollection<RecurringPlace> RecurringPlaces { get; set; } = new List<RecurringPlace>(); public ICollection<RecurringPlace> RecurringPlaces { get; set; } = new List<RecurringPlace>();
// These entities store category names, not Category foreign keys. Mapping these
// collections would make EF invent CategoryId columns absent from the schema.
[NotMapped]
public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>(); public ICollection<CategoryMapping> CategoryMappings { get; set; } = new List<CategoryMapping>();
[NotMapped]
public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>(); public ICollection<Transaction> Transactions { get; set; } = new List<Transaction>();
[NotMapped] [NotMapped]
+68
View File
@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using MoneyMap.Data;
using MoneyMap.Models;
namespace MoneyMap.Tests.Data;
public class CategoryModelTests
{
// Model/query compilation only: never opens a connection or reads application configuration.
private static MoneyMapContext CreateContext() => new(
new DbContextOptionsBuilder<MoneyMapContext>()
.UseSqlServer("Server=localhost;Database=ModelOnly;Integrated Security=true;TrustServerCertificate=true")
.Options);
[Theory]
[InlineData(typeof(Transaction))]
[InlineData(typeof(CategoryMapping))]
public void StringCategoryEntities_DoNotHaveCategoryForeignKeys(Type entityType)
{
using var context = CreateContext();
var entity = context.Model.FindEntityType(entityType)!;
Assert.Null(entity.FindProperty("CategoryId"));
Assert.DoesNotContain(entity.GetProperties(), p => p.IsShadowProperty());
Assert.Equal(typeof(string), entity.FindProperty("Category")!.ClrType);
Assert.DoesNotContain(entity.GetForeignKeys(), fk => fk.PrincipalEntityType.ClrType == typeof(Category));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ReceiptDuplicateQueries_DoNotSelectCategoryId(bool matchHash)
{
using var context = CreateContext();
var query = context.Receipts.Include(r => r.Transaction);
var sql = (matchHash
? query.Where(r => r.FileHashSha256 == "test-hash")
: query.Where(r => r.FileName == "receipt.png" && r.FileSizeBytes == 8))
.ToQueryString();
Assert.Contains("LEFT JOIN [Transactions]", sql);
Assert.Contains("[Category]", sql);
Assert.DoesNotContain("[CategoryId]", sql);
}
[Fact]
public void CategoryMappingQuery_DoesNotSelectCategoryId()
{
using var context = CreateContext();
var sql = context.CategoryMappings.ToQueryString();
Assert.Contains("[Category]", sql);
Assert.DoesNotContain("[CategoryId]", sql);
}
[Fact]
public void IntendedCategoryRelationships_ArePreserved()
{
using var context = CreateContext();
var recurringPlace = context.Model.FindEntityType(typeof(RecurringPlace))!;
Assert.Contains(recurringPlace.GetForeignKeys(), fk =>
fk.PrincipalEntityType.ClrType == typeof(Category) &&
fk.Properties.Single().Name == "CategoryId" && !fk.Properties.Single().IsShadowProperty());
var category = context.Model.FindEntityType(typeof(Category))!;
Assert.Contains(category.GetForeignKeys(), fk =>
fk.PrincipalEntityType.ClrType == typeof(Category) &&
fk.Properties.Single().Name == "ParentCategoryId");
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
# Build stage # Build stage
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src WORKDIR /src
# Copy solution and project files for restore # Copy solution and project files for restore
@@ -19,7 +19,7 @@ RUN libman restore
RUN dotnet publish -c Release -o /app/publish RUN dotnet publish -c Release -o /app/publish
# Runtime stage # Runtime stage
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
# Install ImageMagick dependencies for Magick.NET # Install ImageMagick dependencies for Magick.NET