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:
2021-01-06 23:18:54 -05:00
parent 97b3de1cea
commit ef12da01dc
26 changed files with 515 additions and 508 deletions

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View 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));
}
}

View 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);
});
}
}
}

View File

@@ -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
}
}

View File

@@ -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);
}
}

View File

@@ -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>

View File

@@ -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();