Add consumption entity and use strongly typed ids

This commit is contained in:
2024-08-17 18:00:23 +02:00
parent 4bfc57ef9f
commit d47e4c1971
19 changed files with 265 additions and 36 deletions

View File

@@ -9,7 +9,7 @@ START TRANSACTION;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240803173803_AddCarsAndUsers') THEN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240817153531_Initial') THEN
CREATE TABLE "Users" (
"Id" text NOT NULL,
CONSTRAINT "PK_Users" PRIMARY KEY ("Id")
@@ -19,7 +19,7 @@ END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240803173803_AddCarsAndUsers') THEN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240817153531_Initial') THEN
CREATE TABLE "Cars" (
"Id" uuid NOT NULL,
"Name" character varying(50) NOT NULL,
@@ -32,16 +32,39 @@ END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240803173803_AddCarsAndUsers') THEN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240817153531_Initial') THEN
CREATE TABLE "Consumption" (
"Id" uuid NOT NULL,
"DateTime" timestamp with time zone NOT NULL,
"Distance" double precision NOT NULL,
"Amount" double precision NOT NULL,
"IgnoreInCalculation" boolean NOT NULL,
"CarId" uuid NOT NULL,
CONSTRAINT "PK_Consumption" PRIMARY KEY ("Id"),
CONSTRAINT "FK_Consumption_Cars_CarId" FOREIGN KEY ("CarId") REFERENCES "Cars" ("Id") ON DELETE CASCADE
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240817153531_Initial') THEN
CREATE INDEX "IX_Cars_UserId" ON "Cars" ("UserId");
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240803173803_AddCarsAndUsers') THEN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240817153531_Initial') THEN
CREATE INDEX "IX_Consumption_CarId" ON "Consumption" ("CarId");
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240817153531_Initial') THEN
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20240803173803_AddCarsAndUsers', '8.0.7');
VALUES ('20240817153531_Initial', '8.0.8');
END IF;
END $EF$;
COMMIT;

3
src/WebApi/Assembly.cs Normal file
View File

@@ -0,0 +1,3 @@
using StronglyTypedIds;
[assembly: StronglyTypedIdDefaults(Template.Guid, "guid-efcore")]

View File

@@ -1,18 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Vegasco.WebApi.Consumptions;
using Vegasco.WebApi.Users;
namespace Vegasco.WebApi.Cars;
public class Car
{
public Guid Id { get; set; } = Guid.NewGuid();
public CarId Id { get; set; } = CarId.New();
public string Name { get; set; } = "";
public string UserId { get; set; } = "";
public virtual User User { get; set; } = null!;
public virtual ICollection<Consumption> Consumptions { get; set; } = [];
}
public class CarTableConfiguration : IEntityTypeConfiguration<Car>
@@ -23,6 +26,9 @@ public class CarTableConfiguration : IEntityTypeConfiguration<Car>
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.HasConversion<CarId.EfCoreValueConverter>();
builder.Property(x => x.Name)
.IsRequired()
.HasMaxLength(NameMaxLength);

6
src/WebApi/Cars/CarId.cs Normal file
View File

@@ -0,0 +1,6 @@
using StronglyTypedIds;
namespace Vegasco.WebApi.Cars;
[StronglyTypedId]
public partial struct CarId;

View File

@@ -63,7 +63,7 @@ public static class CreateCar
await dbContext.Cars.AddAsync(car, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
Response response = new(car.Id, car.Name);
Response response = new(car.Id.Value, car.Name);
return TypedResults.Created($"/v1/cars/{car.Id}", response);
}
}

View File

@@ -16,7 +16,7 @@ public static class DeleteCar
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
var car = await dbContext.Cars.FindAsync([id], cancellationToken: cancellationToken);
var car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
if (car is null)
{

View File

@@ -18,14 +18,14 @@ public static class GetCar
ApplicationDbContext dbContext,
CancellationToken cancellationToken)
{
var car = await dbContext.Cars.FindAsync([id], cancellationToken: cancellationToken);
var car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
if (car is null)
{
return TypedResults.NotFound();
}
var response = new Response(car.Id, car.Name);
var response = new Response(car.Id.Value, car.Name);
return TypedResults.Ok(response);
}
}

View File

@@ -19,7 +19,7 @@ public static class GetCars
CancellationToken cancellationToken)
{
var cars = await dbContext.Cars
.Select(x => new Response(x.Id, x.Name))
.Select(x => new Response(x.Id.Value, x.Name))
.ToListAsync(cancellationToken);
return TypedResults.Ok(cars);

View File

@@ -42,7 +42,7 @@ public static class UpdateCar
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
}
var car = await dbContext.Cars.FindAsync([id], cancellationToken: cancellationToken);
Car? car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
if (car is null)
{
@@ -52,7 +52,7 @@ public static class UpdateCar
car.Name = request.Name;
await dbContext.SaveChangesAsync(cancellationToken);
Response response = new(car.Id, car.Name);
Response response = new(car.Id.Value, car.Name);
return TypedResults.Ok(response);
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Vegasco.WebApi.Cars;
namespace Vegasco.WebApi.Consumptions;
public class Consumption
{
public ConsumptionId Id { get; set; } = ConsumptionId.New();
public DateTimeOffset DateTime { get; set; }
public double Distance { get; set; }
public double Amount { get; set; }
public bool IgnoreInCalculation { get; set; }
public CarId CarId { get; set; }
public virtual Car Car { get; set; } = null!;
}
public class ConsumptionTableConfiguration : IEntityTypeConfiguration<Consumption>
{
public void Configure(EntityTypeBuilder<Consumption> builder)
{
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.HasConversion<ConsumptionId.EfCoreValueConverter>();
builder.Property(x => x.DateTime)
.IsRequired();
builder.Property(x => x.Distance)
.IsRequired();
builder.Property(x => x.Amount)
.IsRequired();
builder.Property(x => x.IgnoreInCalculation)
.IsRequired();
builder.Property(x => x.CarId)
.IsRequired()
.HasConversion<CarId.EfCoreValueConverter>();
builder.HasOne(x => x.Car)
.WithMany(x => x.Consumptions);
}
}

View File

@@ -0,0 +1,7 @@
using StronglyTypedIds;
namespace Vegasco.WebApi.Consumptions;
[StronglyTypedId]
public partial struct ConsumptionId;

View File

@@ -12,15 +12,15 @@ using Vegasco.WebApi.Persistence;
namespace Vegasco.WebApi.Persistence.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240803173803_AddCarsAndUsers")]
partial class AddCarsAndUsers
[Migration("20240817153531_Initial")]
partial class Initial
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("ProductVersion", "8.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -28,7 +28,6 @@ namespace Vegasco.WebApi.Persistence.Migrations
modelBuilder.Entity("Vegasco.WebApi.Cars.Car", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
@@ -47,6 +46,33 @@ namespace Vegasco.WebApi.Persistence.Migrations
b.ToTable("Cars");
});
modelBuilder.Entity("Vegasco.WebApi.Consumptions.Consumption", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<double>("Amount")
.HasColumnType("double precision");
b.Property<Guid>("CarId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DateTime")
.HasColumnType("timestamp with time zone");
b.Property<double>("Distance")
.HasColumnType("double precision");
b.Property<bool>("IgnoreInCalculation")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("CarId");
b.ToTable("Consumption");
});
modelBuilder.Entity("Vegasco.WebApi.Users.User", b =>
{
b.Property<string>("Id")
@@ -68,6 +94,22 @@ namespace Vegasco.WebApi.Persistence.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Vegasco.WebApi.Consumptions.Consumption", b =>
{
b.HasOne("Vegasco.WebApi.Cars.Car", "Car")
.WithMany("Consumptions")
.HasForeignKey("CarId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Car");
});
modelBuilder.Entity("Vegasco.WebApi.Cars.Car", b =>
{
b.Navigation("Consumptions");
});
modelBuilder.Entity("Vegasco.WebApi.Users.User", b =>
{
b.Navigation("Cars");

View File

@@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace Vegasco.WebApi.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddCarsAndUsers : Migration
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
@@ -41,15 +41,45 @@ namespace Vegasco.WebApi.Persistence.Migrations
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Consumption",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
DateTime = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
Distance = table.Column<double>(type: "double precision", nullable: false),
Amount = table.Column<double>(type: "double precision", nullable: false),
IgnoreInCalculation = table.Column<bool>(type: "boolean", nullable: false),
CarId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Consumption", x => x.Id);
table.ForeignKey(
name: "FK_Consumption_Cars_CarId",
column: x => x.CarId,
principalTable: "Cars",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Cars_UserId",
table: "Cars",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Consumption_CarId",
table: "Consumption",
column: "CarId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Consumption");
migrationBuilder.DropTable(
name: "Cars");

View File

@@ -17,7 +17,7 @@ namespace Vegasco.WebApi.Persistence.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("ProductVersion", "8.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -25,7 +25,6 @@ namespace Vegasco.WebApi.Persistence.Migrations
modelBuilder.Entity("Vegasco.WebApi.Cars.Car", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
@@ -44,6 +43,33 @@ namespace Vegasco.WebApi.Persistence.Migrations
b.ToTable("Cars");
});
modelBuilder.Entity("Vegasco.WebApi.Consumptions.Consumption", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<double>("Amount")
.HasColumnType("double precision");
b.Property<Guid>("CarId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("DateTime")
.HasColumnType("timestamp with time zone");
b.Property<double>("Distance")
.HasColumnType("double precision");
b.Property<bool>("IgnoreInCalculation")
.HasColumnType("boolean");
b.HasKey("Id");
b.HasIndex("CarId");
b.ToTable("Consumption");
});
modelBuilder.Entity("Vegasco.WebApi.Users.User", b =>
{
b.Property<string>("Id")
@@ -65,6 +91,22 @@ namespace Vegasco.WebApi.Persistence.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Vegasco.WebApi.Consumptions.Consumption", b =>
{
b.HasOne("Vegasco.WebApi.Cars.Car", "Car")
.WithMany("Consumptions")
.HasForeignKey("CarId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Car");
});
modelBuilder.Entity("Vegasco.WebApi.Cars.Car", b =>
{
b.Navigation("Consumptions");
});
modelBuilder.Entity("Vegasco.WebApi.Users.User", b =>
{
b.Navigation("Cars");

View File

@@ -14,10 +14,10 @@
<PackageReference Include="Asp.Versioning.Http" Version="8.1.0" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.7" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
@@ -28,6 +28,8 @@
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.9.0" />
<PackageReference Include="StronglyTypedId" Version="1.0.0-beta08" PrivateAssets="all" ExcludeAssets="runtime" />
<PackageReference Include="StronglyTypedId.Templates" Version="1.0.0-beta08" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.0" />
</ItemGroup>

View File

@@ -38,7 +38,8 @@ public class CreateCarTests : IAsyncLifetime
var createdCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>();
createdCar.Should().BeEquivalentTo(createCarRequest, o => o.ExcludingMissingMembers());
_dbContext.Cars.Should().ContainEquivalentOf(createdCar);
_dbContext.Cars.Should().ContainEquivalentOf(createdCar, o => o.Excluding(x => x!.Id))
.Which.Id.Value.Should().Be(createdCar!.Id);
}
[Fact]

View File

@@ -44,7 +44,10 @@ public class UpdateCarTests : IAsyncLifetime
updatedCar!.Id.Should().Be(createdCar.Id);
updatedCar.Should().BeEquivalentTo(updateCarRequest, o => o.ExcludingMissingMembers());
_dbContext.Cars.Should().ContainEquivalentOf(updatedCar, o => o.ExcludingMissingMembers());
_dbContext.Cars.Should().ContainEquivalentOf(updatedCar, o =>
o.ExcludingMissingMembers()
.Excluding(x => x.Id))
.Which.Id.Value.Should().Be(updatedCar.Id);
}
[Fact]
@@ -67,7 +70,7 @@ public class UpdateCarTests : IAsyncLifetime
validationProblemDetails!.Errors.Keys.Should().Contain(x =>
x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase));
_dbContext.Cars.Should().ContainSingle(x => x.Id == createdCar.Id)
_dbContext.Cars.Should().ContainSingle(x => x.Id.Value == createdCar.Id)
.Which
.Should().NotBeEquivalentTo(updateCarRequest, o => o.ExcludingMissingMembers());
}

View File

@@ -11,14 +11,20 @@
<ItemGroup>
<PackageReference Include="Bogus" Version="35.6.0" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.7" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="Respawn" Version="6.2.1" />
<PackageReference Include="Testcontainers.PostgreSql" Version="3.9.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>

View File

@@ -10,11 +10,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Ductus.FluentDocker" Version="2.10.59" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.9.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>