diff --git a/migrations/migration.sql b/migrations/migration.sql index f4ef165..67a66e6 100644 --- a/migrations/migration.sql +++ b/migrations/migration.sql @@ -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; diff --git a/src/WebApi/Assembly.cs b/src/WebApi/Assembly.cs new file mode 100644 index 0000000..d66756a --- /dev/null +++ b/src/WebApi/Assembly.cs @@ -0,0 +1,3 @@ +using StronglyTypedIds; + +[assembly: StronglyTypedIdDefaults(Template.Guid, "guid-efcore")] \ No newline at end of file diff --git a/src/WebApi/Cars/Car.cs b/src/WebApi/Cars/Car.cs index da27e1d..6e72bc4 100644 --- a/src/WebApi/Cars/Car.cs +++ b/src/WebApi/Cars/Car.cs @@ -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 Consumptions { get; set; } = []; } public class CarTableConfiguration : IEntityTypeConfiguration @@ -23,6 +26,9 @@ public class CarTableConfiguration : IEntityTypeConfiguration { builder.HasKey(x => x.Id); + builder.Property(x => x.Id) + .HasConversion(); + builder.Property(x => x.Name) .IsRequired() .HasMaxLength(NameMaxLength); diff --git a/src/WebApi/Cars/CarId.cs b/src/WebApi/Cars/CarId.cs new file mode 100644 index 0000000..f901160 --- /dev/null +++ b/src/WebApi/Cars/CarId.cs @@ -0,0 +1,6 @@ +using StronglyTypedIds; + +namespace Vegasco.WebApi.Cars; + +[StronglyTypedId] +public partial struct CarId; diff --git a/src/WebApi/Cars/CreateCar.cs b/src/WebApi/Cars/CreateCar.cs index 2bbbb74..ebcb7e2 100644 --- a/src/WebApi/Cars/CreateCar.cs +++ b/src/WebApi/Cars/CreateCar.cs @@ -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); } } diff --git a/src/WebApi/Cars/DeleteCar.cs b/src/WebApi/Cars/DeleteCar.cs index 9febdd8..05048d7 100644 --- a/src/WebApi/Cars/DeleteCar.cs +++ b/src/WebApi/Cars/DeleteCar.cs @@ -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) { diff --git a/src/WebApi/Cars/GetCar.cs b/src/WebApi/Cars/GetCar.cs index ef954ef..14018a6 100644 --- a/src/WebApi/Cars/GetCar.cs +++ b/src/WebApi/Cars/GetCar.cs @@ -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); } } diff --git a/src/WebApi/Cars/GetCars.cs b/src/WebApi/Cars/GetCars.cs index 027b026..4bbeb1a 100644 --- a/src/WebApi/Cars/GetCars.cs +++ b/src/WebApi/Cars/GetCars.cs @@ -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); diff --git a/src/WebApi/Cars/UpdateCar.cs b/src/WebApi/Cars/UpdateCar.cs index 6d5ae6b..38b27a9 100644 --- a/src/WebApi/Cars/UpdateCar.cs +++ b/src/WebApi/Cars/UpdateCar.cs @@ -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); } } diff --git a/src/WebApi/Consumptions/Consumption.cs b/src/WebApi/Consumptions/Consumption.cs new file mode 100644 index 0000000..3476e31 --- /dev/null +++ b/src/WebApi/Consumptions/Consumption.cs @@ -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 +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(x => x.Id); + + builder.Property(x => x.Id) + .HasConversion(); + + 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(); + + builder.HasOne(x => x.Car) + .WithMany(x => x.Consumptions); + } +} diff --git a/src/WebApi/Consumptions/ConsumptionId.cs b/src/WebApi/Consumptions/ConsumptionId.cs new file mode 100644 index 0000000..5494b4a --- /dev/null +++ b/src/WebApi/Consumptions/ConsumptionId.cs @@ -0,0 +1,7 @@ +using StronglyTypedIds; + +namespace Vegasco.WebApi.Consumptions; + + +[StronglyTypedId] +public partial struct ConsumptionId; diff --git a/src/WebApi/Persistence/Migrations/20240803173803_AddCarsAndUsers.Designer.cs b/src/WebApi/Persistence/Migrations/20240817153531_Initial.Designer.cs similarity index 59% rename from src/WebApi/Persistence/Migrations/20240803173803_AddCarsAndUsers.Designer.cs rename to src/WebApi/Persistence/Migrations/20240817153531_Initial.Designer.cs index e4149a5..88ce60d 100644 --- a/src/WebApi/Persistence/Migrations/20240803173803_AddCarsAndUsers.Designer.cs +++ b/src/WebApi/Persistence/Migrations/20240817153531_Initial.Designer.cs @@ -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 { /// 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("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Name") @@ -47,6 +46,33 @@ namespace Vegasco.WebApi.Persistence.Migrations b.ToTable("Cars"); }); + modelBuilder.Entity("Vegasco.WebApi.Consumptions.Consumption", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("double precision"); + + b.Property("CarId") + .HasColumnType("uuid"); + + b.Property("DateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Distance") + .HasColumnType("double precision"); + + b.Property("IgnoreInCalculation") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("CarId"); + + b.ToTable("Consumption"); + }); + modelBuilder.Entity("Vegasco.WebApi.Users.User", b => { b.Property("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"); diff --git a/src/WebApi/Persistence/Migrations/20240803173803_AddCarsAndUsers.cs b/src/WebApi/Persistence/Migrations/20240817153531_Initial.cs similarity index 57% rename from src/WebApi/Persistence/Migrations/20240803173803_AddCarsAndUsers.cs rename to src/WebApi/Persistence/Migrations/20240817153531_Initial.cs index a3cceaf..57755c7 100644 --- a/src/WebApi/Persistence/Migrations/20240803173803_AddCarsAndUsers.cs +++ b/src/WebApi/Persistence/Migrations/20240817153531_Initial.cs @@ -6,7 +6,7 @@ using Microsoft.EntityFrameworkCore.Migrations; namespace Vegasco.WebApi.Persistence.Migrations { /// - public partial class AddCarsAndUsers : Migration + public partial class Initial : Migration { /// 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(type: "uuid", nullable: false), + DateTime = table.Column(type: "timestamp with time zone", nullable: false), + Distance = table.Column(type: "double precision", nullable: false), + Amount = table.Column(type: "double precision", nullable: false), + IgnoreInCalculation = table.Column(type: "boolean", nullable: false), + CarId = table.Column(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"); } /// protected override void Down(MigrationBuilder migrationBuilder) { + migrationBuilder.DropTable( + name: "Consumption"); + migrationBuilder.DropTable( name: "Cars"); diff --git a/src/WebApi/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/WebApi/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index c29b621..9e44e83 100644 --- a/src/WebApi/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/WebApi/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -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("Id") - .ValueGeneratedOnAdd() .HasColumnType("uuid"); b.Property("Name") @@ -44,6 +43,33 @@ namespace Vegasco.WebApi.Persistence.Migrations b.ToTable("Cars"); }); + modelBuilder.Entity("Vegasco.WebApi.Consumptions.Consumption", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("double precision"); + + b.Property("CarId") + .HasColumnType("uuid"); + + b.Property("DateTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Distance") + .HasColumnType("double precision"); + + b.Property("IgnoreInCalculation") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("CarId"); + + b.ToTable("Consumption"); + }); + modelBuilder.Entity("Vegasco.WebApi.Users.User", b => { b.Property("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"); diff --git a/src/WebApi/WebApi.csproj b/src/WebApi/WebApi.csproj index 9790ef8..dcbadf5 100644 --- a/src/WebApi/WebApi.csproj +++ b/src/WebApi/WebApi.csproj @@ -14,10 +14,10 @@ - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all @@ -28,6 +28,8 @@ + + diff --git a/tests/WebApi.Tests.Integration/Cars/CreateCarTests.cs b/tests/WebApi.Tests.Integration/Cars/CreateCarTests.cs index 4376218..b9226af 100644 --- a/tests/WebApi.Tests.Integration/Cars/CreateCarTests.cs +++ b/tests/WebApi.Tests.Integration/Cars/CreateCarTests.cs @@ -38,7 +38,8 @@ public class CreateCarTests : IAsyncLifetime var createdCar = await response.Content.ReadFromJsonAsync(); 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] diff --git a/tests/WebApi.Tests.Integration/Cars/UpdateCarTests.cs b/tests/WebApi.Tests.Integration/Cars/UpdateCarTests.cs index bc0ca8b..56a81e7 100644 --- a/tests/WebApi.Tests.Integration/Cars/UpdateCarTests.cs +++ b/tests/WebApi.Tests.Integration/Cars/UpdateCarTests.cs @@ -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()); } diff --git a/tests/WebApi.Tests.Integration/WebApi.Tests.Integration.csproj b/tests/WebApi.Tests.Integration/WebApi.Tests.Integration.csproj index 9f191f4..355b9b1 100644 --- a/tests/WebApi.Tests.Integration/WebApi.Tests.Integration.csproj +++ b/tests/WebApi.Tests.Integration/WebApi.Tests.Integration.csproj @@ -11,14 +11,20 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - - + + - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/tests/WebApi.Tests.System/WebApi.Tests.System.csproj b/tests/WebApi.Tests.System/WebApi.Tests.System.csproj index 2e0f217..4ab0a38 100644 --- a/tests/WebApi.Tests.System/WebApi.Tests.System.csproj +++ b/tests/WebApi.Tests.System/WebApi.Tests.System.csproj @@ -10,11 +10,17 @@ - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + - - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive +