Convert db to EF Core; start on view page
Also returning skills with profile inquiries now, though that particular query is failing (current WIP) #2
This commit is contained in:
@@ -4,7 +4,6 @@ using JobsJobsJobs.Shared.Api;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NodaTime;
|
||||
using Npgsql;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
@@ -27,17 +26,17 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
private readonly IClock _clock;
|
||||
|
||||
/// <summary>
|
||||
/// The data connection to use for this request
|
||||
/// The data context to use for this request
|
||||
/// </summary>
|
||||
private readonly NpgsqlConnection _db;
|
||||
private readonly JobsDbContext _db;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
/// <param name="config">The authorization configuration section</param>
|
||||
/// <param name="clock">The NodaTime clock instance</param>
|
||||
/// <param name="db">The data connection to use for this request</param>
|
||||
public CitizenController(IConfiguration config, IClock clock, NpgsqlConnection db)
|
||||
/// <param name="db">The data context to use for this request</param>
|
||||
public CitizenController(IConfiguration config, IClock clock, JobsDbContext db)
|
||||
{
|
||||
_config = config.GetSection("Auth");
|
||||
_clock = clock;
|
||||
@@ -56,7 +55,6 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
var account = accountResult.Ok;
|
||||
var now = _clock.GetCurrentInstant();
|
||||
|
||||
await _db.OpenAsync();
|
||||
var citizen = await _db.FindCitizenByNAUser(account.Username);
|
||||
if (citizen == null)
|
||||
{
|
||||
@@ -71,13 +69,21 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
DisplayName = account.DisplayName,
|
||||
LastSeenOn = now
|
||||
};
|
||||
await _db.UpdateCitizenOnLogOn(citizen);
|
||||
_db.UpdateCitizen(citizen);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Step 3 - Generate JWT
|
||||
var jwt = Auth.CreateJwt(citizen, _config);
|
||||
|
||||
return new JsonResult(new LogOnSuccess(jwt, citizen.Id.ToString(), citizen.DisplayName));
|
||||
}
|
||||
|
||||
[HttpGet("get/{id}")]
|
||||
public async Task<IActionResult> GetCitizenById([FromRoute] string id)
|
||||
{
|
||||
var citizen = await _db.FindCitizenById(CitizenId.Parse(id));
|
||||
return citizen == null ? NotFound() : Ok(citizen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using JobsJobsJobs.Server.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Npgsql;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
@@ -15,24 +14,21 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
public class ContinentController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The database connection to use for this request
|
||||
/// The data context to use for this request
|
||||
/// </summary>
|
||||
private readonly NpgsqlConnection _db;
|
||||
private readonly JobsDbContext _db;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
/// <param name="db">The database connection to use for this request</param>
|
||||
public ContinentController(NpgsqlConnection db)
|
||||
/// <param name="db">The data context to use for this request</param>
|
||||
public ContinentController(JobsDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
[HttpGet("all")]
|
||||
public async Task<IActionResult> All()
|
||||
{
|
||||
await _db.OpenAsync();
|
||||
return Ok(await _db.AllContinents());
|
||||
}
|
||||
public async Task<IActionResult> All() =>
|
||||
Ok(await _db.AllContinents());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,8 @@
|
||||
using JobsJobsJobs.Shared;
|
||||
using JobsJobsJobs.Shared.Api;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NodaTime;
|
||||
using Npgsql;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
@@ -24,9 +20,9 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
public class ProfileController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The database connection
|
||||
/// The data context
|
||||
/// </summary>
|
||||
private readonly NpgsqlConnection _db;
|
||||
private readonly JobsDbContext _db;
|
||||
|
||||
/// <summary>
|
||||
/// The NodaTime clock instance
|
||||
@@ -36,8 +32,8 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
/// <param name="db">The database connection to use for this request</param>
|
||||
public ProfileController(NpgsqlConnection db, IClock clock)
|
||||
/// <param name="db">The data context to use for this request</param>
|
||||
public ProfileController(JobsDbContext db, IClock clock)
|
||||
{
|
||||
_db = db;
|
||||
_clock = clock;
|
||||
@@ -48,10 +44,12 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
/// </summary>
|
||||
private CitizenId CurrentCitizenId => CitizenId.Parse(User.FindFirst(ClaimTypes.NameIdentifier)!.Value);
|
||||
|
||||
// This returns 204 to indicate that there is no profile data for the current citizen (if, of course, that is
|
||||
// the case. The version where an ID is specified returns 404, which is an error condition, as it should not
|
||||
// occur unless someone is messing with a URL.
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
await _db.OpenAsync();
|
||||
var profile = await _db.FindProfileByCitizen(CurrentCitizenId);
|
||||
return profile == null ? NoContent() : Ok(profile);
|
||||
}
|
||||
@@ -59,9 +57,6 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
[HttpPost("save")]
|
||||
public async Task<IActionResult> Save(ProfileForm form)
|
||||
{
|
||||
await _db.OpenAsync();
|
||||
var txn = await _db.BeginTransactionAsync();
|
||||
|
||||
// Profile
|
||||
var existing = await _db.FindProfileByCitizen(CurrentCitizenId);
|
||||
var profile = existing == null
|
||||
@@ -93,29 +88,27 @@ namespace JobsJobsJobs.Server.Areas.Api.Controllers
|
||||
foreach (var skill in skills) await _db.SaveSkill(skill);
|
||||
await _db.DeleteMissingSkills(CurrentCitizenId, skills.Select(s => s.Id));
|
||||
|
||||
await txn.CommitAsync();
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpGet("skills")]
|
||||
public async Task<IActionResult> GetSkills()
|
||||
{
|
||||
await _db.OpenAsync();
|
||||
return Ok(await _db.FindSkillsByCitizen(CurrentCitizenId));
|
||||
}
|
||||
public async Task<IActionResult> GetSkills() =>
|
||||
Ok(await _db.FindSkillsByCitizen(CurrentCitizenId));
|
||||
|
||||
[HttpGet("count")]
|
||||
public async Task<IActionResult> GetProfileCount()
|
||||
{
|
||||
await _db.OpenAsync();
|
||||
return Ok(new Count(await _db.CountProfiles()));
|
||||
}
|
||||
public async Task<IActionResult> GetProfileCount() =>
|
||||
Ok(new Count(await _db.CountProfiles()));
|
||||
|
||||
[HttpGet("skill-count")]
|
||||
public async Task<IActionResult> GetSkillCount()
|
||||
public async Task<IActionResult> GetSkillCount() =>
|
||||
Ok(new Count(await _db.CountSkillsByCitizen(CurrentCitizenId)));
|
||||
|
||||
[HttpGet("get/{id}")]
|
||||
public async Task<IActionResult> GetProfileForCitizen([FromRoute] string id)
|
||||
{
|
||||
await _db.OpenAsync();
|
||||
return Ok(new Count(await _db.CountSkills(CurrentCitizenId)));
|
||||
var profile = await _db.FindProfileByCitizen(CitizenId.Parse(id));
|
||||
return profile == null ? NotFound() : Ok(profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +1,46 @@
|
||||
using JobsJobsJobs.Shared;
|
||||
using Npgsql;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions to the NpgslConnection type supporting the manipulation of citizens
|
||||
/// Extensions to JobsDbContext supporting the manipulation of citizens
|
||||
/// </summary>
|
||||
public static class CitizenExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Populate a citizen object from the given data reader
|
||||
/// Retrieve a citizen by their Jobs, Jobs, Jobs ID
|
||||
/// </summary>
|
||||
/// <param name="rdr">The data reader from which the values should be obtained</param>
|
||||
/// <returns>A populated citizen</returns>
|
||||
private static Citizen ToCitizen(NpgsqlDataReader rdr) =>
|
||||
new Citizen(CitizenId.Parse(rdr.GetString("id")), rdr.GetString("na_user"), rdr.GetString("display_name"),
|
||||
rdr.GetString("profile_url"), rdr.GetInstant("joined_on"), rdr.GetInstant("last_seen_on"));
|
||||
/// <param name="citizenId">The ID of the citizen to retrieve</param>
|
||||
/// <returns>The citizen, or null if not found</returns>
|
||||
public static async Task<Citizen?> FindCitizenById(this JobsDbContext db, CitizenId citizenId) =>
|
||||
await db.Citizens.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.Id == citizenId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve a citizen by their No Agenda Social user name
|
||||
/// </summary>
|
||||
/// <param name="naUser">The NAS user name</param>
|
||||
/// <returns>The citizen, or null if not found</returns>
|
||||
public static async Task<Citizen?> FindCitizenByNAUser(this NpgsqlConnection conn, string naUser)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT * FROM citizen WHERE na_user = @na_user";
|
||||
cmd.AddString("na_user", naUser);
|
||||
|
||||
using NpgsqlDataReader rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
if (await rdr.ReadAsync().ConfigureAwait(false)) return ToCitizen(rdr);
|
||||
|
||||
return null;
|
||||
}
|
||||
public static async Task<Citizen?> FindCitizenByNAUser(this JobsDbContext db, string naUser) =>
|
||||
await db.Citizens.AsNoTracking()
|
||||
.SingleOrDefaultAsync(c => c.NaUser == naUser)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Add a citizen
|
||||
/// </summary>
|
||||
/// <param name="citizen">The citizen to be added</param>
|
||||
public static async Task AddCitizen(this NpgsqlConnection conn, Citizen citizen)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
@"INSERT INTO citizen (
|
||||
na_user, display_name, profile_url, joined_on, last_seen_on, id
|
||||
) VALUES(
|
||||
@na_user, @display_name, @profile_url, @joined_on, @last_seen_on, @id
|
||||
)";
|
||||
cmd.AddString("id", citizen.Id);
|
||||
cmd.AddString("na_user", citizen.NaUser);
|
||||
cmd.AddString("display_name", citizen.DisplayName);
|
||||
cmd.AddString("profile_url", citizen.ProfileUrl);
|
||||
cmd.AddInstant("joined_on", citizen.JoinedOn);
|
||||
cmd.AddInstant("last_seen_on", citizen.LastSeenOn);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
public static async Task AddCitizen(this JobsDbContext db, Citizen citizen) =>
|
||||
await db.Citizens.AddAsync(citizen);
|
||||
|
||||
/// <summary>
|
||||
/// Update a citizen after they have logged on (update last seen, sync display name)
|
||||
/// </summary>
|
||||
/// <param name="citizen">The updated citizen</param>
|
||||
public static async Task UpdateCitizenOnLogOn(this NpgsqlConnection conn, Citizen citizen)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
@"UPDATE citizen
|
||||
SET display_name = @display_name,
|
||||
last_seen_on = @last_seen_on
|
||||
WHERE id = @id";
|
||||
cmd.AddString("id", citizen.Id);
|
||||
cmd.AddString("display_name", citizen.DisplayName);
|
||||
cmd.AddInstant("last_seen_on", citizen.LastSeenOn);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
}
|
||||
public static void UpdateCitizen(this JobsDbContext db, Citizen citizen) =>
|
||||
db.Entry(citizen).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using JobsJobsJobs.Shared;
|
||||
using Npgsql;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Data
|
||||
@@ -10,31 +11,21 @@ namespace JobsJobsJobs.Server.Data
|
||||
/// </summary>
|
||||
public static class ContinentExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a continent from the current row in the data reader
|
||||
/// </summary>
|
||||
/// <param name="rdr">The data reader</param>
|
||||
/// <returns>The current row's values as a continent object</returns>
|
||||
private static Continent ToContinent(NpgsqlDataReader rdr) =>
|
||||
new Continent(ContinentId.Parse(rdr.GetString("id")), rdr.GetString("name"));
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve all continents
|
||||
/// </summary>
|
||||
/// <returns>All continents</returns>
|
||||
public static async Task<IEnumerable<Continent>> AllContinents(this NpgsqlConnection conn)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT * FROM continent ORDER BY name";
|
||||
public static async Task<IEnumerable<Continent>> AllContinents(this JobsDbContext db) =>
|
||||
await db.Continents.AsNoTracking().OrderBy(c => c.Name).ToListAsync().ConfigureAwait(false);
|
||||
|
||||
using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
var continents = new List<Continent>();
|
||||
while (await rdr.ReadAsync())
|
||||
{
|
||||
continents.Add(ToContinent(rdr));
|
||||
}
|
||||
|
||||
return continents;
|
||||
}
|
||||
/// <summary>
|
||||
/// Retrieve a continent by its ID
|
||||
/// </summary>
|
||||
/// <param name="continentId">The ID of the continent to retrieve</param>
|
||||
/// <returns>The continent matching the ID</returns>
|
||||
public static async Task<Continent> FindContinentById(this JobsDbContext db, ContinentId continentId) =>
|
||||
await db.Continents.AsNoTracking()
|
||||
.SingleAsync(c => c.Id == continentId)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
53
src/JobsJobsJobs/Server/Data/Converters.cs
Normal file
53
src/JobsJobsJobs/Server/Data/Converters.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
using JobsJobsJobs.Shared;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Converters used to translate between database and domain types
|
||||
/// </summary>
|
||||
public static class Converters
|
||||
{
|
||||
/// <summary>
|
||||
/// Citizen ID converter
|
||||
/// </summary>
|
||||
public static readonly ValueConverter<CitizenId, string> CitizenIdConverter =
|
||||
new ValueConverter<CitizenId, string>(v => v.ToString(), v => CitizenId.Parse(v));
|
||||
|
||||
/// <summary>
|
||||
/// Continent ID converter
|
||||
/// </summary>
|
||||
public static readonly ValueConverter<ContinentId, string> ContinentIdConverter =
|
||||
new ValueConverter<ContinentId, string>(v => v.ToString(), v => ContinentId.Parse(v));
|
||||
|
||||
/// <summary>
|
||||
/// Markdown converter
|
||||
/// </summary>
|
||||
public static readonly ValueConverter<MarkdownString, string> MarkdownStringConverter =
|
||||
new ValueConverter<MarkdownString, string>(v => v.Text, v => new MarkdownString(v));
|
||||
|
||||
/// <summary>
|
||||
/// Markdown converter for possibly-null values
|
||||
/// </summary>
|
||||
public static readonly ValueConverter<MarkdownString?, string?> OptionalMarkdownStringConverter =
|
||||
new ValueConverter<MarkdownString?, string?>(
|
||||
v => v == null ? null : v.Text,
|
||||
v => v == null ? null : new MarkdownString(v));
|
||||
|
||||
/// <summary>
|
||||
/// Skill ID converter
|
||||
/// </summary>
|
||||
public static readonly ValueConverter<SkillId, string> SkillIdConverter =
|
||||
new ValueConverter<SkillId, string>(v => v.ToString(), v => SkillId.Parse(v));
|
||||
|
||||
/// <summary>
|
||||
/// Success ID converter
|
||||
/// </summary>
|
||||
public static readonly ValueConverter<SuccessId, string> SuccessIdConverter =
|
||||
new ValueConverter<SuccessId, string>(v => v.ToString(), v => SuccessId.Parse(v));
|
||||
}
|
||||
}
|
||||
111
src/JobsJobsJobs/Server/Data/JobsDbContext.cs
Normal file
111
src/JobsJobsJobs/Server/Data/JobsDbContext.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using JobsJobsJobs.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobsJobsJobs.Server.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Data context for Jobs, Jobs, Jobs
|
||||
/// </summary>
|
||||
public class JobsDbContext : DbContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Citizens (users known to us)
|
||||
/// </summary>
|
||||
public DbSet<Citizen> Citizens { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Continents (large land masses - 7 of them!)
|
||||
/// </summary>
|
||||
public DbSet<Continent> Continents { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Employment profiles
|
||||
/// </summary>
|
||||
public DbSet<Profile> Profiles { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Skills held by citizens of Gitmo Nation
|
||||
/// </summary>
|
||||
public DbSet<Skill> Skills { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Success stories from the site
|
||||
/// </summary>
|
||||
public DbSet<Success> Successes { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
/// <param name="options">The options to use to configure this instance</param>
|
||||
public JobsDbContext(DbContextOptions<JobsDbContext> options) : base(options) { }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Citizen>(m =>
|
||||
{
|
||||
m.ToTable("citizen", "jjj").HasKey(e => e.Id);
|
||||
m.Property(e => e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.CitizenIdConverter);
|
||||
m.Property(e => e.NaUser).HasColumnName("na_user").IsRequired().HasMaxLength(50);
|
||||
m.Property(e => e.DisplayName).HasColumnName("display_name").IsRequired().HasMaxLength(255);
|
||||
m.Property(e => e.ProfileUrl).HasColumnName("profile_url").IsRequired().HasMaxLength(1_024);
|
||||
m.Property(e => e.JoinedOn).HasColumnName("joined_on").IsRequired();
|
||||
m.Property(e => e.LastSeenOn).HasColumnName("last_seen_on").IsRequired();
|
||||
m.HasIndex(e => e.NaUser).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Continent>(m =>
|
||||
{
|
||||
m.ToTable("continent", "jjj").HasKey(e => e.Id);
|
||||
m.Property(e => e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.ContinentIdConverter);
|
||||
m.Property(e => e.Name).HasColumnName("name").IsRequired().HasMaxLength(255);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Profile>(m =>
|
||||
{
|
||||
m.ToTable("profile", "jjj").HasKey(e => e.Id);
|
||||
m.Property(e => e.Id).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.CitizenIdConverter);
|
||||
m.Property(e => e.SeekingEmployment).HasColumnName("seeking_employment").IsRequired();
|
||||
m.Property(e => e.IsPublic).HasColumnName("is_public").IsRequired();
|
||||
m.Property(e => e.ContinentId).HasColumnName("continent_id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.ContinentIdConverter);
|
||||
m.Property(e => e.Region).HasColumnName("region").IsRequired().HasMaxLength(255);
|
||||
m.Property(e => e.RemoteWork).HasColumnName("remote_work").IsRequired();
|
||||
m.Property(e => e.FullTime).HasColumnName("full_time").IsRequired();
|
||||
m.Property(e => e.Biography).HasColumnName("biography").IsRequired()
|
||||
.HasConversion(Converters.MarkdownStringConverter);
|
||||
m.Property(e => e.LastUpdatedOn).HasColumnName("last_updated_on").IsRequired();
|
||||
m.Property(e => e.Experience).HasColumnName("experience")
|
||||
.HasConversion(Converters.OptionalMarkdownStringConverter);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Skill>(m =>
|
||||
{
|
||||
m.ToTable("skill", "jjj").HasKey(e => e.Id);
|
||||
m.Property(e => e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.SkillIdConverter);
|
||||
m.Property(e => e.CitizenId).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.CitizenIdConverter);
|
||||
m.Property(e => e.Description).HasColumnName("skill").IsRequired().HasMaxLength(100);
|
||||
m.Property(e => e.Notes).HasColumnName("notes").HasMaxLength(100);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Success>(m =>
|
||||
{
|
||||
m.ToTable("success", "jjj").HasKey(e => e.Id);
|
||||
m.Property(e => e.Id).HasColumnName("id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.SuccessIdConverter);
|
||||
m.Property(e => e.CitizenId).HasColumnName("citizen_id").IsRequired().HasMaxLength(12)
|
||||
.HasConversion(Converters.CitizenIdConverter);
|
||||
m.Property(e => e.RecordedOn).HasColumnName("recorded_on").IsRequired();
|
||||
m.Property(e => e.FromHere).HasColumnName("from_here").IsRequired();
|
||||
m.Property(e => e.Story).HasColumnName("story")
|
||||
.HasConversion(Converters.OptionalMarkdownStringConverter);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
using JobsJobsJobs.Shared;
|
||||
using NodaTime;
|
||||
using Npgsql;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace JobsJobsJobs.Server.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions to the Npgsql data reader
|
||||
/// </summary>
|
||||
public static class NpgsqlExtensions
|
||||
{
|
||||
#region Data Reader
|
||||
|
||||
/// <summary>
|
||||
/// Get a boolean by its name
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the field to be retrieved as a boolean</param>
|
||||
/// <returns>The specified field as a boolean</returns>
|
||||
public static bool GetBoolean(this NpgsqlDataReader rdr, string name) => rdr.GetBoolean(rdr.GetOrdinal(name));
|
||||
|
||||
/// <summary>
|
||||
/// Get an Instant by its name
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the field to be retrieved as an Instant</param>
|
||||
/// <returns>The specified field as an Instant</returns>
|
||||
public static Instant GetInstant(this NpgsqlDataReader rdr, string name) =>
|
||||
rdr.GetFieldValue<Instant>(rdr.GetOrdinal(name));
|
||||
|
||||
/// <summary>
|
||||
/// Get a 64-bit integer by its name
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the field to be retrieved as a 64-bit integer</param>
|
||||
/// <returns>The specified field as a 64-bit integer</returns>
|
||||
public static long GetInt64(this NpgsqlDataReader rdr, string name) => rdr.GetInt64(rdr.GetOrdinal(name));
|
||||
|
||||
/// <summary>
|
||||
/// Get a string by its name
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the field to be retrieved as a string</param>
|
||||
/// <returns>The specified field as a string</returns>
|
||||
public static string GetString(this NpgsqlDataReader rdr, string name) => rdr.GetString(rdr.GetOrdinal(name));
|
||||
|
||||
/// <summary>
|
||||
/// Determine if a column is null
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the column to check</param>
|
||||
/// <returns>True if the column is null, false if not</returns>
|
||||
public static bool IsDBNull(this NpgsqlDataReader rdr, string name) => rdr.IsDBNull(rdr.GetOrdinal(name));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Command
|
||||
|
||||
/// <summary>
|
||||
/// Add a string parameter
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the parameter</param>
|
||||
/// <param name="value">The value of the parameter</param>
|
||||
public static void AddString(this NpgsqlCommand cmd, string name, object value) =>
|
||||
cmd.Parameters.Add(
|
||||
new NpgsqlParameter<string>($"@{name}", value is string @val ? @val : value.ToString()!));
|
||||
|
||||
/// <summary>
|
||||
/// Add a boolean parameter
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the parameter</param>
|
||||
/// <param name="value">The value of the parameter</param>
|
||||
public static void AddBool(this NpgsqlCommand cmd, string name, bool value) =>
|
||||
cmd.Parameters.Add(new NpgsqlParameter<bool>($"@{name}", value));
|
||||
|
||||
/// <summary>
|
||||
/// Add an Instant parameter
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the parameter</param>
|
||||
/// <param name="value">The value of the parameter</param>
|
||||
public static void AddInstant(this NpgsqlCommand cmd, string name, Instant value) =>
|
||||
cmd.Parameters.Add(new NpgsqlParameter<Instant>($"@{name}", value));
|
||||
|
||||
/// <summary>
|
||||
/// Add a parameter that may be null
|
||||
/// </summary>
|
||||
/// <param name="name">The name of the parameter</param>
|
||||
/// <param name="value">The value of the parameter</param>
|
||||
public static void AddMaybeNull(this NpgsqlCommand cmd, string name, object? value) =>
|
||||
cmd.Parameters.Add(new NpgsqlParameter($"@{name}", value == null ? DBNull.Value : value));
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using JobsJobsJobs.Shared;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
@@ -8,93 +10,47 @@ using System.Threading.Tasks;
|
||||
namespace JobsJobsJobs.Server.Data
|
||||
{
|
||||
/// <summary>
|
||||
/// Extensions to the Connection type to support manipulation of profiles
|
||||
/// Extensions to JobsDbContext to support manipulation of profiles
|
||||
/// </summary>
|
||||
public static class ProfileExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Populate a profile object from the given data reader
|
||||
/// </summary>
|
||||
/// <param name="rdr">The data reader from which values should be obtained</param>
|
||||
/// <returns>The populated profile</returns>
|
||||
private static Profile ToProfile(NpgsqlDataReader rdr)
|
||||
{
|
||||
var continentId = ContinentId.Parse(rdr.GetString("continent_id"));
|
||||
return new Profile(CitizenId.Parse(rdr.GetString("citizen_id")), rdr.GetBoolean("seeking_employment"),
|
||||
rdr.GetBoolean("is_public"), continentId, rdr.GetString("region"), rdr.GetBoolean("remote_work"),
|
||||
rdr.GetBoolean("full_time"), new MarkdownString(rdr.GetString("biography")),
|
||||
rdr.GetInstant("last_updated_on"),
|
||||
rdr.IsDBNull("experience") ? null : new MarkdownString(rdr.GetString("experience")))
|
||||
{
|
||||
Continent = new Continent(continentId, rdr.GetString("continent_name"))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populate a skill object from the given data reader
|
||||
/// </summary>
|
||||
/// <param name="rdr">The data reader from which values should be obtained</param>
|
||||
/// <returns>The populated skill</returns>
|
||||
private static Skill ToSkill(NpgsqlDataReader rdr) =>
|
||||
new Skill(SkillId.Parse(rdr.GetString("id")), CitizenId.Parse(rdr.GetString("citizen_id")),
|
||||
rdr.GetString("skill"), rdr.IsDBNull("notes") ? null : rdr.GetString("notes"));
|
||||
|
||||
/// <summary>
|
||||
/// Retrieve an employment profile by a citizen ID
|
||||
/// </summary>
|
||||
/// <param name="citizen">The ID of the citizen whose profile should be retrieved</param>
|
||||
/// <param name="citizenId">The ID of the citizen whose profile should be retrieved</param>
|
||||
/// <returns>The profile, or null if it does not exist</returns>
|
||||
public static async Task<Profile?> FindProfileByCitizen(this NpgsqlConnection conn, CitizenId citizen)
|
||||
public static async Task<Profile?> FindProfileByCitizen(this JobsDbContext db, CitizenId citizenId)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
@"SELECT p.*, c.name AS continent_name
|
||||
FROM profile p
|
||||
INNER JOIN continent c ON p.continent_id = c.id
|
||||
WHERE citizen_id = @id";
|
||||
cmd.AddString("id", citizen.Id);
|
||||
var profile = await db.Profiles.AsNoTracking()
|
||||
.SingleOrDefaultAsync(p => p.Id == citizenId)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
return await rdr.ReadAsync().ConfigureAwait(false) ? ToProfile(rdr) : null;
|
||||
if (profile != null)
|
||||
{
|
||||
return profile with
|
||||
{
|
||||
Continent = await db.FindContinentById(profile.ContinentId).ConfigureAwait(false),
|
||||
Skills = (await db.FindSkillsByCitizen(citizenId).ConfigureAwait(false)).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save a profile
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to be saved</param>
|
||||
public static async Task SaveProfile(this NpgsqlConnection conn, Profile profile)
|
||||
public static async Task SaveProfile(this JobsDbContext db, Profile profile)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
@"INSERT INTO profile (
|
||||
citizen_id, seeking_employment, is_public, continent_id, region, remote_work, full_time,
|
||||
biography, last_updated_on, experience
|
||||
) VALUES (
|
||||
@citizen_id, @seeking_employment, @is_public, @continent_id, @region, @remote_work, @full_time,
|
||||
@biography, @last_updated_on, @experience
|
||||
) ON CONFLICT (citizen_id) DO UPDATE
|
||||
SET seeking_employment = @seeking_employment,
|
||||
is_public = @is_public,
|
||||
continent_id = @continent_id,
|
||||
region = @region,
|
||||
remote_work = @remote_work,
|
||||
full_time = @full_time,
|
||||
biography = @biography,
|
||||
last_updated_on = @last_updated_on,
|
||||
experience = @experience
|
||||
WHERE profile.citizen_id = excluded.citizen_id";
|
||||
cmd.AddString("citizen_id", profile.Id);
|
||||
cmd.AddBool("seeking_employment", profile.SeekingEmployment);
|
||||
cmd.AddBool("is_public", profile.IsPublic);
|
||||
cmd.AddString("continent_id", profile.ContinentId);
|
||||
cmd.AddString("region", profile.Region);
|
||||
cmd.AddBool("remote_work", profile.RemoteWork);
|
||||
cmd.AddBool("full_time", profile.FullTime);
|
||||
cmd.AddString("biography", profile.Biography.Text);
|
||||
cmd.AddInstant("last_updated_on", profile.LastUpdatedOn);
|
||||
cmd.AddMaybeNull("experience", profile.Experience);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
if (await db.Profiles.CountAsync(p => p.Id == profile.Id).ConfigureAwait(false) == 0)
|
||||
{
|
||||
await db.AddAsync(profile).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
db.Entry(profile).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -102,45 +58,25 @@ namespace JobsJobsJobs.Server.Data
|
||||
/// </summary>
|
||||
/// <param name="citizenId">The ID of the citizen whose skills should be retrieved</param>
|
||||
/// <returns>The skills defined for this citizen</returns>
|
||||
public static async Task<IEnumerable<Skill>> FindSkillsByCitizen(this NpgsqlConnection conn,
|
||||
CitizenId citizenId)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT * FROM skill WHERE citizen_id = @citizen_id";
|
||||
cmd.AddString("citizen_id", citizenId);
|
||||
|
||||
var result = new List<Skill>();
|
||||
using var rdr = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
|
||||
while (await rdr.ReadAsync().ConfigureAwait(false))
|
||||
{
|
||||
result.Add(ToSkill(rdr));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
public static async Task<IEnumerable<Skill>> FindSkillsByCitizen(this JobsDbContext db, CitizenId citizenId) =>
|
||||
await db.Skills.AsNoTracking()
|
||||
.Where(s => s.CitizenId == citizenId)
|
||||
.ToListAsync().ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Save a skill
|
||||
/// </summary>
|
||||
/// <param name="skill">The skill to be saved</param>
|
||||
public static async Task SaveSkill(this NpgsqlConnection conn, Skill skill)
|
||||
public static async Task SaveSkill(this JobsDbContext db, Skill skill)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
@"INSERT INTO skill (
|
||||
id, citizen_id, skill, notes
|
||||
) VALUES (
|
||||
@id, @citizen_id, @skill, @notes
|
||||
) ON CONFLICT (id) DO UPDATE
|
||||
SET skill = @skill,
|
||||
notes = @notes
|
||||
WHERE skill.id = excluded.id";
|
||||
cmd.AddString("id", skill.Id);
|
||||
cmd.AddString("citizen_id", skill.CitizenId);
|
||||
cmd.AddString("skill", skill.Description);
|
||||
cmd.AddMaybeNull("notes", skill.Notes);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
if (await db.Skills.CountAsync(s => s.Id == skill.Id).ConfigureAwait(false) == 0)
|
||||
{
|
||||
await db.Skills.AddAsync(skill).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
db.Entry(skill).State = EntityState.Modified;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -148,50 +84,29 @@ namespace JobsJobsJobs.Server.Data
|
||||
/// </summary>
|
||||
/// <param name="citizenId">The ID of the citizen to whom the skills belong</param>
|
||||
/// <param name="ids">The IDs of their current skills</param>
|
||||
public static async Task DeleteMissingSkills(this NpgsqlConnection conn, CitizenId citizenId,
|
||||
public static async Task DeleteMissingSkills(this JobsDbContext db, CitizenId citizenId,
|
||||
IEnumerable<SkillId> ids)
|
||||
{
|
||||
if (!ids.Any()) return;
|
||||
|
||||
var count = 0;
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = new StringBuilder("DELETE FROM skill WHERE citizen_id = @citizen_id AND id NOT IN (")
|
||||
.Append(string.Join(", ", ids.Select(_ => $"@id{count++}").ToArray()))
|
||||
.Append(')')
|
||||
.ToString();
|
||||
cmd.AddString("citizen_id", citizenId);
|
||||
count = 0;
|
||||
foreach (var id in ids) cmd.AddString($"id{count++}", id);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync().ConfigureAwait(false);
|
||||
db.Skills.RemoveRange(await db.Skills.AsNoTracking()
|
||||
.Where(s => !ids.Contains(s.Id)).ToListAsync()
|
||||
.ConfigureAwait(false));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a count of the citizens with profiles
|
||||
/// </summary>
|
||||
/// <returns>The number of citizens with profiles</returns>
|
||||
public static async Task<long> CountProfiles(this NpgsqlConnection conn)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(citizen_id) FROM profile";
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false);
|
||||
return result == null ? 0L : (long)result;
|
||||
}
|
||||
public static async Task<int> CountProfiles(this JobsDbContext db) =>
|
||||
await db.Profiles.CountAsync().ConfigureAwait(false);
|
||||
|
||||
/// <summary>
|
||||
/// Count the skills for the given citizen
|
||||
/// </summary>
|
||||
/// <param name="citizenId">The ID of the citizen whose skills should be counted</param>
|
||||
/// <returns>The count of skills for the given citizen</returns>
|
||||
public static async Task<long> CountSkills(this NpgsqlConnection conn, CitizenId citizenId)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT COUNT(id) FROM skill WHERE citizen_id = @citizen_id";
|
||||
cmd.AddString("citizen_id", citizenId);
|
||||
|
||||
var result = await cmd.ExecuteScalarAsync().ConfigureAwait(false);
|
||||
return result == null ? 0L : (long)result;
|
||||
}
|
||||
public static async Task<int> CountSkillsByCitizen(this JobsDbContext db, CitizenId citizenId) =>
|
||||
await db.Skills.CountAsync(s => s.CitizenId == citizenId).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="5.0.1" />
|
||||
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.0.0" />
|
||||
<PackageReference Include="Npgsql" Version="5.0.1.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="5.0.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="5.0.1" />
|
||||
<PackageReference Include="Npgsql.NodaTime" Version="5.0.1.1" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.8.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using JobsJobsJobs.Server.Data;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.HttpsPolicy;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
@@ -30,7 +32,11 @@ namespace JobsJobsJobs.Server
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
// TODO: configure JSON serialization for NodaTime
|
||||
services.AddScoped(_ => new NpgsqlConnection(Configuration.GetConnectionString("JobsDb")));
|
||||
services.AddDbContext<JobsDbContext>(options =>
|
||||
{
|
||||
options.UseNpgsql(Configuration.GetConnectionString("JobsDb"), o => o.UseNodaTime());
|
||||
options.LogTo(System.Console.WriteLine, Microsoft.Extensions.Logging.LogLevel.Information);
|
||||
});
|
||||
services.AddSingleton<IClock>(SystemClock.Instance);
|
||||
services.AddLogging();
|
||||
services.AddControllersWithViews();
|
||||
|
||||
Reference in New Issue
Block a user