include cache update

This commit is contained in:
Fox 2026-06-24 11:51:02 +01:00
parent 689276ea7d
commit a2a6c15808
64 changed files with 744 additions and 1104 deletions

View File

@ -13,4 +13,4 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0
VOLUME /data
WORKDIR /App
COPY --from=build /App/out .
ENTRYPOINT ["dotnet", "SolicitorsApi.dll"]
ENTRYPOINT ["dotnet", "Solicitors.Api.dll"]

View File

@ -1,4 +1,7 @@
<Solution>
<Folder Name="/app/">
<Project Path="app/Solicitors.Api/Solicitors.Api.csproj" />
</Folder>
<Folder Name="/libs/">
<Project Path="libs/Solicitors.CacheBuild/Solicitors.CacheBuild.csproj" />
<Project Path="libs/Solicitors.Core/Solicitors.Core.csproj" />
@ -8,6 +11,4 @@
<Folder Name="/tests/">
<Project Path="tests/Solicitors.HtmlParsing.Tests/Solicitors.HtmlParsing.Tests.csproj" />
</Folder>
<Project Path="SolicitorsApi/SolicitorsApi.csproj" />
<Project Path="Tester/Tester.csproj" />
</Solution>

View File

@ -1,176 +0,0 @@
using System.ComponentModel;
using Microsoft.AspNetCore.Mvc;
using Solicitors.CacheBuild;
using Solicitors.Core;
using Solicitors.Core.Misc;
using Solicitors.Core.Models;
using Solicitors.Data;
using Solicitors.Data.RepositorySetup.Sqlite;
using Solicitors.HtmlParsing;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddCore();
builder.Services.AddData(new SqliteDbSetupOptions("demo"));
builder.Services.AddCacheBuild();
builder.Services.AddHtmlParsing();
builder.Services.AddHttpClient();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy => policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin());
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseCors();
app.MapGet(
"/conveyancors",
(
[FromServices] IReadOnlySolicitorService solicitorService,
CancellationToken cancellationToken,
[FromQuery] uint pageNumber = 1,
[FromQuery] uint pageSize = 20,
[FromQuery] string? nameFilter = null,
[FromQuery] decimal minRating = 0,
[FromQuery] string[]? cities = null,
[FromQuery] string ratingsProvider = "Solicitors.com",
[FromQuery] string? orderingType = null) =>
{
IFilter<Solicitor>? filter = null;
if (!string.IsNullOrEmpty(nameFilter))
filter = new WrapperFilter<Solicitor>(filter, solicitor => solicitor.Name.Contains(nameFilter));
if (cities is not null && cities.Length > 0)
filter = new WrapperFilter<Solicitor>(filter, solicitor => cities.Any(city => solicitor.Cities.Any(solCity => solCity.Name == city)));
if (minRating > 0)
filter = new WrapperFilter<Solicitor>(
filter,
solicitor => solicitor.Ratings.Any(rating => (rating.Provider == ratingsProvider) && (rating.Value / rating.Maximum >= minRating / 5.0m)));
IComparer<Solicitor>? ordering = orderingType switch
{
"rating-asc" => new RatingComparer(ratingsProvider, true),
"rating-desc" => new RatingComparer(ratingsProvider, false),
"alphabet-asc" => new NameComparer(true),
"alphabet-desc" => new NameComparer(false),
_ => null
};
return solicitorService.GetSolicitorSummariesAsync(
new Pagination(pageNumber, pageSize),
ratingsProvider,
filter,
ordering,
cancellationToken: cancellationToken);
}
);
app.MapGet(
"/cities",
(
[FromServices] IReadOnlyCitiesService citiesService,
CancellationToken cancellationToken) =>
{
return citiesService.GetAllCitiesAsync(cancellationToken);
}
);
app.MapGet(
"/conveyancors/{id}",
(
[FromServices] IReadOnlySolicitorService solicitorService,
[FromRoute] Guid id,
CancellationToken cancellationToken
) => solicitorService.GetSolicitorInfoByIdAsync(id, cancellationToken)
);
app.MapGet(
"/ratingsProviders",
(
[FromServices] IRatingsProviderService service,
CancellationToken cancellationToken
) =>
{
return service.GetRatingsProvidersAsync(cancellationToken);
}
);
using (var scope = app.Services.CreateScope())
{
var solicitors = scope.ServiceProvider.GetRequiredService<ISolicitorService>();
var first = await solicitors
.GetSolicitorSummariesAsync(
new Pagination(1, 1),
"Solicitors.com",
cancellationToken: CancellationToken.None);
if (first.Data.Length == 0)
await scope.ServiceProvider
.GetRequiredService<ISolicitorImporter>()
.RunFullImport(CancellationToken.None);
}
app.Run();
public class RatingComparer(string ratingsProvider, bool ascending) : IComparer<Solicitor>
{
public int Compare(Solicitor? x, Solicitor? y)
{
var xRating = x?.Ratings?.FirstOrDefault(r => r.Provider == ratingsProvider);
var yRating = y?.Ratings?.FirstOrDefault(r => r.Provider == ratingsProvider);
decimal compare;
if (xRating is null)
compare = yRating is null ? 0 : 1;
else if (yRating is null)
compare = -1;
else
{
compare = (xRating.Value / xRating.Maximum) - (yRating.Value / yRating.Maximum);
if (!ascending)
compare *= -1;
}
if (compare >= 0)
return (int)Math.Ceiling(compare);
else
return (int)Math.Floor(compare);
}
}
public class NameComparer(bool ascending) : IComparer<Solicitor>
{
public int Compare(Solicitor? x, Solicitor? y)
{
int compare;
if (ReferenceEquals(x, y))
compare = 0;
else if (y is null)
compare = 1;
else if (x is null)
compare = -1;
else
{
compare = string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
if (!ascending)
compare *= -1;
}
return compare;
}
}

View File

@ -1,6 +0,0 @@
@SolicitorsApi_HostAddress = http://localhost:5084
GET {{SolicitorsApi_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -1,10 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using Solicitors.HtmlParsing;
var _html = File.ReadAllText("/var/home/fox/main.html");
var services = new ServiceCollection();
services.AddHtmlParsing();
var parser = services.BuildServiceProvider().GetRequiredService<IHtmlParser>();
var htmlNodes = parser.ParseHtml(_html).ToArray();
Console.ReadKey();

View File

@ -1,24 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.DependencyInjection.Abstractions">
<HintPath>..\..\..\..\..\..\..\..\var\home\fox\.dotnet\shared\Microsoft.AspNetCore.App\10.0.7\Microsoft.Extensions.DependencyInjection.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\libs\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc;
using Solicitors.Core;
namespace Solicitors.Api.Endpoints;
public static class CitiesEndpoints
{
public static void MapCities(this IEndpointRouteBuilder app)
{
app.MapGet(
"/cities",
(
[FromServices] IReadOnlyCitiesService citiesService,
CancellationToken cancellationToken
) => citiesService.GetAllCitiesAsync(cancellationToken));
}
}

View File

@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc;
using Solicitors.Core;
namespace Solicitors.Api.Endpoints;
public static class RatingsProvidersEndpoints
{
public static void MapRatingsProviders(this IEndpointRouteBuilder app)
{
app.MapGet(
"/ratingsProviders",
(
[FromServices] IRatingsProviderService service,
CancellationToken cancellationToken
) => service.GetRatingsProvidersAsync(cancellationToken));
}
}

View File

@ -0,0 +1,60 @@
using Microsoft.AspNetCore.Mvc;
using Solicitors.Core;
using Solicitors.Core.Misc;
using Solicitors.Core.Models;
using Solicitors.Core.Ordering;
namespace Solicitors.Api.Endpoints;
public static class SolicitorsEndpoints
{
public static void MapSolicitors(this IEndpointRouteBuilder app)
{
app.MapGet(
"/solicitors",
(
[FromServices] IReadOnlySolicitorService solicitorService,
[FromServices] ISolicitorComparerFactory solicitorComparerFactory,
CancellationToken cancellationToken,
[FromQuery] uint pageNumber = 1,
[FromQuery] uint pageSize = 20,
[FromQuery] string? nameFilter = null,
[FromQuery] decimal minRating = 0,
[FromQuery] string[]? cities = null,
[FromQuery] string ratingsProvider = "Solicitors.com",
[FromQuery] OrderingType? orderingType = null) =>
{
IFilter<Solicitor>? filter = null;
if (!string.IsNullOrEmpty(nameFilter))
filter = new WrapperFilter<Solicitor>(filter, solicitor => solicitor.Name.Contains(nameFilter));
if (cities is not null && cities.Length > 0)
filter = new WrapperFilter<Solicitor>(filter, solicitor => cities.Any(city => solicitor.Cities.Any(solCity => solCity.Name == city)));
if (minRating > 0)
filter = new WrapperFilter<Solicitor>(
filter,
solicitor => solicitor.Ratings.Any(rating => rating.Provider == ratingsProvider && rating.Value / rating.Maximum >= minRating / 5.0m));
var ordering = solicitorComparerFactory.GetComparer(orderingType, ratingsProvider);
return solicitorService.GetSolicitorSummariesAsync(
new Pagination(pageNumber, pageSize),
ratingsProvider,
filter,
ordering,
cancellationToken: cancellationToken);
}
);
app.MapGet(
"/solicitors/{id:guid}",
(
[FromServices] IReadOnlySolicitorService solicitorService,
[FromRoute] Guid id,
CancellationToken cancellationToken
) => solicitorService.GetSolicitorInfoByIdAsync(id, cancellationToken)
);
}
}

View File

@ -0,0 +1,41 @@
using Solicitors.Api.Endpoints;
using Solicitors.CacheBuild;
using Solicitors.Core;
using Solicitors.Data;
using Solicitors.HtmlParsing;
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddJsonFile("/data/apiSettings.json", optional: true);
builder.Services.AddOpenApi();
builder.Services.AddCore();
builder.Services.AddData(builder.Configuration);
builder.Services.AddCacheBuild();
builder.Services.Configure<ImportConfiguration>(builder.Configuration.GetSection("Imports"));
builder.Services.AddHtmlParsing();
builder.Services.AddHttpClient();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy => policy.AllowAnyHeader().AllowAnyMethod().AllowAnyOrigin());
});
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.MapSolicitors();
app.MapCities();
app.MapRatingsProviders();
using (var scope = app.Services.CreateScope())
{
await scope.UseCacheBuildAsync(CancellationToken.None);
}
app.Run();

View File

@ -5,7 +5,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5084",
"applicationUrl": "http://localhost:5254",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
@ -14,7 +14,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7128;http://localhost:5084",
"applicationUrl": "https://localhost:7226;http://localhost:5254",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View File

@ -15,10 +15,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\libs\Solicitors.CacheBuild\Solicitors.CacheBuild.csproj" />
<ProjectReference Include="..\libs\Solicitors.Core\Solicitors.Core.csproj" />
<ProjectReference Include="..\libs\Solicitors.Data\Solicitors.Data.csproj" />
<ProjectReference Include="..\libs\Solicitors.HtmlParsing\Solicitors.HtmlParsing.csproj" />
<ProjectReference Include="..\..\libs\Solicitors.CacheBuild\Solicitors.CacheBuild.csproj" />
<ProjectReference Include="..\..\libs\Solicitors.Data\Solicitors.Data.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,6 @@
@Solicitors.Api_HostAddress = http://localhost:5254
GET {{Solicitors.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@ -6,4 +6,4 @@
}
},
"AllowedHosts": "*"
}
}

View File

@ -1,5 +1,7 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Quartz;
namespace Solicitors.CacheBuild;
@ -10,6 +12,43 @@ public static class DIExtensions
{
return services
.AddScoped<ISolicitorParser, SolicitorsDotCom.SolicitorParser>()
.AddScoped<ISolicitorImporter, SolicitorImporter>();
.AddScoped<ISolicitorImporter, SolicitorImporter>()
.AddQuartz()
.AddQuartzHostedService(opt => opt.WaitForJobsToComplete = true);
}
public static async Task UseCacheBuildAsync(this IServiceScope scope, CancellationToken cancellationToken)
{
var factory = scope.ServiceProvider.GetRequiredService<ISchedulerFactory>();
var options = scope.ServiceProvider.GetRequiredService<IOptions<ImportConfiguration>>().Value;
var scheduler = await factory.GetScheduler(cancellationToken);
var importJob = JobBuilder.Create<ImportRunner>()
.WithIdentity("ImportJob", "ImportGroup")
.Build();
var importTrigger = TriggerBuilder.Create()
.WithIdentity("ImportRunner", "ImportGroup")
.StartNow()
.WithSimpleSchedule(builder => builder
.WithIntervalInMinutes(options.ImportMinutes)
.RepeatForever())
.Build();
await scheduler.ScheduleJob(importJob, importTrigger, cancellationToken);
var staleJob = JobBuilder.Create<StaleDataRemover>()
.WithIdentity("StaleDataRemover", "RemoveGroup")
.Build();
var staleTrigger = TriggerBuilder.Create()
.WithIdentity("StaleDataTrigger", "RemoveGroup")
.StartNow()
.WithSimpleSchedule(builder => builder
.WithIntervalInMinutes(options.RemoveMinutes)
.RepeatForever())
.Build();
await scheduler.ScheduleJob(staleJob, staleTrigger, cancellationToken);
}
}

View File

@ -0,0 +1,8 @@
namespace Solicitors.CacheBuild;
public class ImportConfiguration
{
public int StaleMinutes { get; set; }
public int RemoveMinutes { get; set; }
public int ImportMinutes { get; set; }
}

View File

@ -0,0 +1,15 @@
using Quartz;
using Solicitors.Core.Data;
namespace Solicitors.CacheBuild;
internal class ImportRunner(
ISolicitorImporter importer,
ISolicitorRepository repository) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
await repository.EnsureCreatedAsync(context.CancellationToken);
await importer.RunFullImport(context.CancellationToken);
}
}

View File

@ -3,27 +3,18 @@ using Solicitors.Core.Models.Imports;
namespace Solicitors.CacheBuild;
internal class SolicitorImporter : ISolicitorImporter
internal class SolicitorImporter(
ISolicitorParser parser,
ISolicitorRepository repository) : ISolicitorImporter
{
private readonly ISolicitorParser _parser;
private readonly ISolicitorRepository _repository;
public SolicitorImporter(
ISolicitorParser parser,
ISolicitorRepository repository)
{
_parser = parser;
_repository = repository;
}
public async Task RunFullImport(CancellationToken cancellationToken)
{
foreach (var solicitor in await _parser.GetSolicitorsAsync(cancellationToken))
foreach (var solicitor in await parser.GetSolicitorsAsync(cancellationToken))
{
await RunImport(solicitor, cancellationToken);
}
}
private Task RunImport(SolicitorData solicitor, CancellationToken cancellationToken)
=> _repository.AddOrUpdateSolicitorAsync(solicitor, cancellationToken);
=> repository.AddOrUpdateSolicitorAsync(solicitor, cancellationToken);
}

View File

@ -13,6 +13,15 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
<PackageReference Include="Quartz" Version="3.18.1" />
<PackageReference Include="Quartz.Extensions.DependencyInjection" Version="3.18.1" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
</ItemGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.Hosting.Abstractions">
<HintPath>..\..\..\..\..\..\..\.dotnet\shared\Microsoft.AspNetCore.App\10.0.7\Microsoft.Extensions.Hosting.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
</Project>

View File

@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
using Solicitors.Core.Misc;
using Solicitors.Core.Models.Imports;
using Solicitors.HtmlParsing;
@ -154,7 +153,7 @@ internal class SolicitorParser : ISolicitorParser
ParseSidebar(sidebarChildren, out phone, out email, out website, out ratings);
}
return new SolicitorData()
return new SolicitorData
{
Name = builder.Name,
UrlPath = builder.Path,
@ -168,14 +167,14 @@ internal class SolicitorParser : ISolicitorParser
};
}
Location[] ParseOffices(IEnumerable<IHtmlNode> officeNodes)
private Location[] ParseOffices(IEnumerable<IHtmlNode> officeNodes)
=> officeNodes
.Select(ParseOffice)
.Where(x => x is not null)
.Cast<Location>()
.ToArray();
Location? ParseOffice(IHtmlNode officeNode)
private Location? ParseOffice(IHtmlNode officeNode)
{
if (!officeNode.TryGetChildren(out var officeChildren)
|| !officeNode.TryGetByTagName("address", out var addressNode)
@ -183,7 +182,7 @@ internal class SolicitorParser : ISolicitorParser
return null;
Location? location = null;
string? address = "";
var address = "";
foreach (var line in addressLines)
{
if (line.TryGetText(out var lineText))
@ -214,7 +213,7 @@ internal class SolicitorParser : ISolicitorParser
return location;
}
void ParseSidebar(
private void ParseSidebar(
IEnumerable<IHtmlNode> sidebarChildren,
out string? phone,
out string? email,
@ -253,7 +252,7 @@ internal class SolicitorParser : ISolicitorParser
foreach (var child in childrenArray.Where(x => x.TryGetByClass("rev-box", out _, true)))
{
if (child.TryGetAttributeValue("title", out var ratingString)
&& TryParseRatingString(ratingString, out decimal rating, out decimal maxRating)
&& TryParseRatingString(ratingString, out var rating, out var maxRating)
&& child.TryGetByTagNameAndClass("img", "rev-logo", out var imgNode)
&& imgNode.TryGetAttributeValue("src", out var imgSrc)
&& TryParseRatingSrc(imgSrc, out var ratingProvider))
@ -290,15 +289,15 @@ internal class SolicitorParser : ISolicitorParser
return decimal.TryParse(parts[0], out rating) && decimal.TryParse(parts[1], out maxRating);
}
bool TryParseRatingSrc(string imgSrc, out string provider)
private bool TryParseRatingSrc(string imgSrc, out string provider)
{
provider = "";
imgSrc = imgSrc.Trim('"');
var prefix = "/images/logo-";
const string prefix = "/images/logo-";
if (!imgSrc.StartsWith(prefix))
return false;
var providerAndExtension = imgSrc.Substring(prefix.Length);
var providerAndExtension = imgSrc[prefix.Length..];
provider = providerAndExtension.Split('.').First();
return true;
}
@ -306,11 +305,11 @@ internal class SolicitorParser : ISolicitorParser
bool TryParseOwnRatingString(string ratingString, out decimal rating)
{
rating = 0;
var prefix = "Average review score : ";
const string prefix = "Average review score : ";
if (!ratingString.StartsWith(prefix))
return false;
var ratingPart = ratingString.Substring(prefix.Length);
var ratingPart = ratingString[prefix.Length..];
return decimal.TryParse(ratingPart, out rating);
}
}

View File

@ -0,0 +1,19 @@
using Microsoft.Extensions.Options;
using Quartz;
using Solicitors.Core.Data;
namespace Solicitors.CacheBuild;
internal class StaleDataRemover(
IOptions<ImportConfiguration> config,
ISolicitorRepository repository) : IJob
{
private readonly ImportConfiguration _config = config.Value;
public Task Execute(IJobExecutionContext context)
{
return repository.RemoveStaleEntriesAsync(
TimeSpan.FromMinutes(_config.StaleMinutes),
context.CancellationToken);
}
}

View File

@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using Solicitors.Core.Ordering;
namespace Solicitors.Core;
@ -12,6 +13,7 @@ public static class DIExtensions
.AddScoped<ISolicitorService, SolicitorService>()
.AddScoped<IReadOnlySolicitorService>(sp => sp.GetRequiredService<ISolicitorService>())
.AddScoped<IReadOnlyCitiesService, ReadOnlyCitiesService>()
.AddScoped<IRatingsProviderService, RatingsProviderService>();
.AddScoped<IRatingsProviderService, RatingsProviderService>()
.AddScoped<ISolicitorComparerFactory, SolicitorComparerFactory>();
}
}

View File

@ -10,5 +10,5 @@ public interface IReadOnlySolicitorRepository
public interface IReadOnlyCitiesRepository
{
IAsyncEnumerable<string> GetCitiesAsync(CancellationToken cancellationToken);
IAsyncEnumerable<string> GetCitiesAsync();
}

View File

@ -4,5 +4,7 @@ namespace Solicitors.Core.Data;
public interface ISolicitorRepository : IReadOnlySolicitorRepository
{
Task EnsureCreatedAsync(CancellationToken cancellationToken);
Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken);
Task RemoveStaleEntriesAsync(TimeSpan staleAge, CancellationToken cancellationToken);
}

View File

@ -1,5 +1,6 @@
using Solicitors.Core.Misc;
using Solicitors.Core.Models;
using Solicitors.Core.Models.View;
namespace Solicitors.Core;

View File

@ -1,6 +1,3 @@
namespace Solicitors.Core;
public interface ISolicitorService : IReadOnlySolicitorService
{
}
public interface ISolicitorService : IReadOnlySolicitorService;

View File

@ -1,29 +1,19 @@
namespace Solicitors.Core.Misc;
public interface IFilter<T>
public interface IFilter<in T>
{
bool Filter(T item);
}
public class WrapperFilter<T> : IFilter<T>
public class WrapperFilter<T>(
IFilter<T>? inner,
Func<T, bool> filter) : IFilter<T>
{
private readonly Func<T, bool> _filter;
private readonly IFilter<T>? _inner;
public WrapperFilter(
IFilter<T>? inner,
Func<T, bool> filter)
{
_inner = inner;
_filter = filter;
}
public bool Filter(T item)
{
if (_inner is not null)
return _inner.Filter(item) && _filter(item);
if (inner is not null)
return inner.Filter(item) && filter(item);
return _filter(item);
return filter(item);
}
}

View File

@ -4,6 +4,7 @@ public record City
{
public Guid CityId { get; set; }
public required string Name { get; set; }
public DateTime LastModified { get; set; }
public List<Solicitor> Solicitors { get; } = new();
public List<Solicitor> Solicitors { get; } = [];
}

View File

@ -1,8 +1,8 @@
namespace Solicitors.Core.Models.Imports;
public record Location(string address, string phone, Rating[] locationRatings)
public record Location(string Address, string Phone, Rating[] LocationRatings)
{
public string Address { get; } = address;
public string Phone { get; } = phone;
public Rating[] LocationRatings { get; } = locationRatings;
public string Address { get; } = Address;
public string Phone { get; } = Phone;
public Rating[] LocationRatings { get; } = LocationRatings;
}

View File

@ -1,9 +1,9 @@
namespace Solicitors.Core.Models.Imports;
public record Rating(decimal value, decimal maxValue, string provider, string imgSrc)
public record Rating(decimal Value, decimal MaxValue, string RatingProvider, string RatingProviderImgSrc)
{
public decimal Value { get; } = value;
public decimal MaxValue { get; } = maxValue;
public string RatingProvider { get; } = provider;
public string RatingProviderImgSrc { get; } = imgSrc;
public decimal Value { get; } = Value;
public decimal MaxValue { get; } = MaxValue;
public string RatingProvider { get; } = RatingProvider;
public string RatingProviderImgSrc { get; } = RatingProviderImgSrc;
}

View File

@ -11,8 +11,9 @@ public record Solicitor
public string? Email { get; set; }
public string? Website { get; set; }
public string? ShortDescription { get; set; }
public DateTime LastModified { get; set; }
public List<City> Cities { get; } = new();
public List<SolicitorRating> Ratings { get; } = new();
public List<SolicitorLocation> Locations { get; } = new();
public List<City> Cities { get; } = [];
public List<SolicitorRating> Ratings { get; } = [];
public List<SolicitorLocation> Locations { get; } = [];
}

View File

@ -1,3 +1,5 @@
using Solicitors.Core.Models.View;
namespace Solicitors.Core.Models;
public record SolicitorInfo
@ -25,7 +27,7 @@ public record SolicitorInfo
}
public string Name { get; set; }
public string ShortDescription { get; set; }
public string? ShortDescription { get; set; }
public Guid Id { get; set; }
public string? Phone { get; set; }

View File

@ -6,8 +6,9 @@ public record SolicitorLocation
public required string Address { get; set; }
public required string Phone { get; set; }
public DateTime LastModified { get; set; }
public List<SolicitorLocationRating> LocationRatings { get; } = new();
public List<SolicitorLocationRating> LocationRatings { get; } = [];
public Guid SolicitorId { get; set; }
public required Solicitor Solicitor { get; set; }

View File

@ -7,6 +7,7 @@ public record SolicitorLocationRating : IRating
public required decimal Value { get; set; }
public required decimal Maximum { get; set; }
public required string Provider { get; set; }
public DateTime LastModified { get; set; }
public Guid SolicitorLocationId { get; set; }
public required SolicitorLocation SolicitorLocation { get; set; }

View File

@ -7,6 +7,7 @@ public record SolicitorRating : IRating
public required decimal Value { get; set; }
public required decimal Maximum { get; set; }
public required string Provider { get; set; }
public DateTime LastModified { get; set; }
public Guid SolicitorId { get; set; }
public required Solicitor Solicitor { get; set; }

View File

@ -1,4 +1,4 @@
namespace Solicitors.Core.Models;
namespace Solicitors.Core.Models.View;
public record LocationInfo
{

View File

@ -1,4 +1,4 @@
namespace Solicitors.Core.Models;
namespace Solicitors.Core.Models.View;
public record RatingInfo
{

View File

@ -1,4 +1,4 @@
namespace Solicitors.Core.Models;
namespace Solicitors.Core.Models.View;
public record SolicitorSummary
{

View File

@ -1,6 +1,6 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Misc;
namespace Solicitors.Core.Ordering;
internal class DefaultSolicitorDataOrdering : IComparer<Solicitor>
{

View File

@ -0,0 +1,10 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Ordering;
public interface ISolicitorComparerFactory
{
IComparer<Solicitor> GetComparer(
OrderingType? orderingType,
string ratingsProvider);
}

View File

@ -0,0 +1,26 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Ordering;
internal class NameComparer(bool ascending) : IComparer<Solicitor>
{
public int Compare(Solicitor? x, Solicitor? y)
{
int compare;
if (ReferenceEquals(x, y))
compare = 0;
else if (y is null)
compare = 1;
else if (x is null)
compare = -1;
else
{
compare = string.Compare(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
if (!ascending)
compare *= -1;
}
return compare;
}
}

View File

@ -0,0 +1,9 @@
namespace Solicitors.Core.Ordering;
public enum OrderingType
{
RatingDescending,
RatingAscending,
AlphabetAscending,
AlphabetDescending
}

View File

@ -0,0 +1,32 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Ordering;
internal class RatingComparer(string ratingsProvider, bool ascending) : IComparer<Solicitor>
{
public int Compare(Solicitor? x, Solicitor? y)
{
var xRating = x?.Ratings.FirstOrDefault(r => r.Provider == ratingsProvider);
var yRating = y?.Ratings.FirstOrDefault(r => r.Provider == ratingsProvider);
decimal compare;
if (xRating is null)
compare = yRating is null ? 0 : 1;
else if (yRating is null)
compare = -1;
else
{
compare = (xRating.Value / xRating.Maximum) - (yRating.Value / yRating.Maximum);
if (!ascending)
compare *= -1;
}
if (compare >= 0)
return (int)Math.Ceiling(compare);
else
return (int)Math.Floor(compare);
}
}

View File

@ -0,0 +1,20 @@
using Solicitors.Core.Models;
namespace Solicitors.Core.Ordering;
internal class SolicitorComparerFactory : ISolicitorComparerFactory
{
public IComparer<Solicitor> GetComparer(
OrderingType? orderingType,
string ratingsProvider)
{
return orderingType switch
{
OrderingType.RatingAscending => new RatingComparer(ratingsProvider, true),
OrderingType.RatingDescending => new RatingComparer(ratingsProvider, false),
OrderingType.AlphabetAscending => new NameComparer(true),
OrderingType.AlphabetDescending => new NameComparer(false),
_ => DefaultSolicitorDataOrdering.Instance
};
}
}

View File

@ -2,17 +2,10 @@ using Solicitors.Core.Data;
namespace Solicitors.Core;
internal class RatingsProviderService : IRatingsProviderService
internal class RatingsProviderService(IRatingsProviderRepository repo) : IRatingsProviderService
{
private readonly IRatingsProviderRepository _repo;
public RatingsProviderService(IRatingsProviderRepository repo)
{
_repo = repo;
}
public async Task<string[]> GetRatingsProvidersAsync(CancellationToken cancellationToken)
{
return await _repo.GetAllRatingsProvidersAsync().ToArrayAsync(cancellationToken);
return await repo.GetAllRatingsProvidersAsync().ToArrayAsync(cancellationToken);
}
}

View File

@ -2,18 +2,11 @@ using Solicitors.Core.Data;
namespace Solicitors.Core;
internal class ReadOnlyCitiesService : IReadOnlyCitiesService
internal class ReadOnlyCitiesService(IReadOnlyCitiesRepository repo) : IReadOnlyCitiesService
{
private readonly IReadOnlyCitiesRepository _repo;
public ReadOnlyCitiesService(IReadOnlyCitiesRepository repo)
{
_repo = repo;
}
public async Task<string[]> GetAllCitiesAsync(CancellationToken cancellationToken)
{
var cities = _repo.GetCitiesAsync(cancellationToken);
var cities = repo.GetCitiesAsync();
return await cities.ToArrayAsync(cancellationToken);
}
}

View File

@ -1,18 +1,13 @@
using Solicitors.Core.Data;
using Solicitors.Core.Misc;
using Solicitors.Core.Models;
using Solicitors.Core.Models.View;
using Solicitors.Core.Ordering;
namespace Solicitors.Core;
internal class SolicitorService : ISolicitorService
internal class SolicitorService(IReadOnlySolicitorRepository repo) : ISolicitorService
{
private readonly IReadOnlySolicitorRepository _repo;
public SolicitorService(IReadOnlySolicitorRepository repo)
{
_repo = repo;
}
public async Task<PaginationResponse<SolicitorSummary>> GetSolicitorSummariesAsync(
Pagination pagination,
string ratingsProvider,
@ -20,14 +15,13 @@ internal class SolicitorService : ISolicitorService
IComparer<Solicitor>? ordering = null,
CancellationToken cancellationToken = default)
{
var allSolicitors = _repo.GetAllSolicitorsAsync();
var allSolicitors = repo.GetAllSolicitorsAsync();
if (filter is not null)
allSolicitors = allSolicitors.Where(filter.Filter);
var matchingSolicitors = await allSolicitors.ToArrayAsync(cancellationToken);
if (ordering is null)
ordering = DefaultSolicitorDataOrdering.Instance;
ordering ??= DefaultSolicitorDataOrdering.Instance;
var results = matchingSolicitors
.OrderBy(x => x, ordering)
@ -41,7 +35,7 @@ internal class SolicitorService : ISolicitorService
public async Task<SolicitorInfo?> GetSolicitorInfoByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
var data = await _repo.GetSolicitorByIdAsync(id, cancellationToken);
var data = await repo.GetSolicitorByIdAsync(id, cancellationToken);
if (data is null)
return null;

View File

@ -1,7 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Solicitors.Core;
using Solicitors.Core.Data;
using Solicitors.Data.RepositorySetup;
using Solicitors.Data.RepositorySetup.InMemory;
@ -12,7 +11,7 @@ namespace Solicitors.Data;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class DIExtensions
{
public static IServiceCollection AddData(this IServiceCollection services, IRepoSetupOptions options)
public static IServiceCollection AddData(this IServiceCollection services, IConfiguration configuration)
{
return services
.AddScoped<SolicitorsRepository>()
@ -20,17 +19,30 @@ public static class DIExtensions
.AddScoped<IReadOnlySolicitorRepository>(sp => sp.GetRequiredService<ISolicitorRepository>())
.AddScoped<IReadOnlyCitiesRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
.AddScoped<IRatingsProviderRepository>(sp => sp.GetRequiredService<SolicitorsRepository>())
.AddSingleton(GetSetupService(options));
.AddSingleton(GetSetupService(configuration));
}
private static IRepoSetupService GetSetupService(IRepoSetupOptions options)
private static IRepoSetupService GetSetupService(IConfiguration configuration)
{
if (options is InMemoryDbSetupOptions inMemOptions)
return new InMemorySetupService(inMemOptions);
var options = ReadConfigForOptions(configuration);
if (options is SqliteDbSetupOptions sqliteOptions)
return new SqliteSetupService(sqliteOptions);
return options switch
{
InMemoryDbSetupOptions inMemOptions => new InMemorySetupService(inMemOptions),
SqliteDbSetupOptions sqliteOptions => new SqliteSetupService(sqliteOptions),
_ => throw new ArgumentException("Options type not supported", nameof(configuration))
};
}
throw new ArgumentException("Options type not supported", nameof(options));
private static IRepoSetupOptions? ReadConfigForOptions(IConfiguration configuration)
{
var dbConfig = configuration.GetSection("Database");
var dbType = dbConfig["Type"]?.ToLower();
return dbType switch
{
"inmemory" => dbConfig.Get<InMemoryDbSetupOptions>(),
"sqlite" => dbConfig.Get<SqliteDbSetupOptions>(),
_ => null
};
}
}

View File

@ -1,222 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Solicitors.Data;
#nullable disable
namespace Solicitors.Data.Migrations
{
[DbContext(typeof(SolicitorsRepository))]
[Migration("20260622195820_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("CitySolicitor", b =>
{
b.Property<Guid>("CitiesCityId")
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorsSolicitorId")
.HasColumnType("TEXT");
b.HasKey("CitiesCityId", "SolicitorsSolicitorId");
b.HasIndex("SolicitorsSolicitorId");
b.ToTable("CitySolicitor");
});
modelBuilder.Entity("Solicitors.Core.Models.City", b =>
{
b.Property<Guid>("CityId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("CityId");
b.ToTable("Cities");
});
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
{
b.Property<Guid>("SolicitorId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Phone")
.HasColumnType("TEXT");
b.Property<string>("RelativeUrl")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ShortDescription")
.HasColumnType("TEXT");
b.Property<string>("Website")
.HasColumnType("TEXT");
b.HasKey("SolicitorId");
b.ToTable("Solicitors");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
{
b.Property<Guid>("SolicitorLocationId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Address")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorId")
.HasColumnType("TEXT");
b.HasKey("SolicitorLocationId");
b.HasIndex("SolicitorId");
b.ToTable("SolicitorLocations");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
{
b.Property<Guid>("SolicitorLocationRatingId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<decimal>("Maximum")
.HasColumnType("TEXT");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorLocationId")
.HasColumnType("TEXT");
b.Property<decimal>("Value")
.HasColumnType("TEXT");
b.HasKey("SolicitorLocationRatingId");
b.HasIndex("SolicitorLocationId");
b.ToTable("SolicitorLocationRatings");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
{
b.Property<Guid>("SolicitorRatingId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<decimal>("Maximum")
.HasColumnType("TEXT");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorId")
.HasColumnType("TEXT");
b.Property<decimal>("Value")
.HasColumnType("TEXT");
b.HasKey("SolicitorRatingId");
b.HasIndex("SolicitorId");
b.ToTable("SolicitorRatings");
});
modelBuilder.Entity("CitySolicitor", b =>
{
b.HasOne("Solicitors.Core.Models.City", null)
.WithMany()
.HasForeignKey("CitiesCityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Solicitors.Core.Models.Solicitor", null)
.WithMany()
.HasForeignKey("SolicitorsSolicitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
{
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
.WithMany("Locations")
.HasForeignKey("SolicitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Solicitor");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
{
b.HasOne("Solicitors.Core.Models.SolicitorLocation", "SolicitorLocation")
.WithMany("LocationRatings")
.HasForeignKey("SolicitorLocationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SolicitorLocation");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
{
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
.WithMany("Ratings")
.HasForeignKey("SolicitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Solicitor");
});
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
{
b.Navigation("Locations");
b.Navigation("Ratings");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
{
b.Navigation("LocationRatings");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,172 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Solicitors.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Cities",
columns: table => new
{
CityId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Cities", x => x.CityId);
});
migrationBuilder.CreateTable(
name: "Solicitors",
columns: table => new
{
SolicitorId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
RelativeUrl = table.Column<string>(type: "TEXT", nullable: false),
Phone = table.Column<string>(type: "TEXT", nullable: true),
Email = table.Column<string>(type: "TEXT", nullable: true),
Website = table.Column<string>(type: "TEXT", nullable: true),
ShortDescription = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Solicitors", x => x.SolicitorId);
});
migrationBuilder.CreateTable(
name: "CitySolicitor",
columns: table => new
{
CitiesCityId = table.Column<Guid>(type: "TEXT", nullable: false),
SolicitorsSolicitorId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CitySolicitor", x => new { x.CitiesCityId, x.SolicitorsSolicitorId });
table.ForeignKey(
name: "FK_CitySolicitor_Cities_CitiesCityId",
column: x => x.CitiesCityId,
principalTable: "Cities",
principalColumn: "CityId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CitySolicitor_Solicitors_SolicitorsSolicitorId",
column: x => x.SolicitorsSolicitorId,
principalTable: "Solicitors",
principalColumn: "SolicitorId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SolicitorLocations",
columns: table => new
{
SolicitorLocationId = table.Column<Guid>(type: "TEXT", nullable: false),
Address = table.Column<string>(type: "TEXT", nullable: false),
Phone = table.Column<string>(type: "TEXT", nullable: false),
SolicitorId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SolicitorLocations", x => x.SolicitorLocationId);
table.ForeignKey(
name: "FK_SolicitorLocations_Solicitors_SolicitorId",
column: x => x.SolicitorId,
principalTable: "Solicitors",
principalColumn: "SolicitorId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SolicitorRatings",
columns: table => new
{
SolicitorRatingId = table.Column<Guid>(type: "TEXT", nullable: false),
Value = table.Column<decimal>(type: "TEXT", nullable: false),
Maximum = table.Column<decimal>(type: "TEXT", nullable: false),
Provider = table.Column<string>(type: "TEXT", nullable: false),
SolicitorId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SolicitorRatings", x => x.SolicitorRatingId);
table.ForeignKey(
name: "FK_SolicitorRatings_Solicitors_SolicitorId",
column: x => x.SolicitorId,
principalTable: "Solicitors",
principalColumn: "SolicitorId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SolicitorLocationRatings",
columns: table => new
{
SolicitorLocationRatingId = table.Column<Guid>(type: "TEXT", nullable: false),
Value = table.Column<decimal>(type: "TEXT", nullable: false),
Maximum = table.Column<decimal>(type: "TEXT", nullable: false),
Provider = table.Column<string>(type: "TEXT", nullable: false),
SolicitorLocationId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_SolicitorLocationRatings", x => x.SolicitorLocationRatingId);
table.ForeignKey(
name: "FK_SolicitorLocationRatings_SolicitorLocations_SolicitorLocationId",
column: x => x.SolicitorLocationId,
principalTable: "SolicitorLocations",
principalColumn: "SolicitorLocationId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_CitySolicitor_SolicitorsSolicitorId",
table: "CitySolicitor",
column: "SolicitorsSolicitorId");
migrationBuilder.CreateIndex(
name: "IX_SolicitorLocationRatings_SolicitorLocationId",
table: "SolicitorLocationRatings",
column: "SolicitorLocationId");
migrationBuilder.CreateIndex(
name: "IX_SolicitorLocations_SolicitorId",
table: "SolicitorLocations",
column: "SolicitorId");
migrationBuilder.CreateIndex(
name: "IX_SolicitorRatings_SolicitorId",
table: "SolicitorRatings",
column: "SolicitorId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CitySolicitor");
migrationBuilder.DropTable(
name: "SolicitorLocationRatings");
migrationBuilder.DropTable(
name: "SolicitorRatings");
migrationBuilder.DropTable(
name: "Cities");
migrationBuilder.DropTable(
name: "SolicitorLocations");
migrationBuilder.DropTable(
name: "Solicitors");
}
}
}

View File

@ -1,219 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Solicitors.Data;
#nullable disable
namespace Solicitors.Data.Migrations
{
[DbContext(typeof(SolicitorsRepository))]
partial class SolicitorsRepositoryModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.9");
modelBuilder.Entity("CitySolicitor", b =>
{
b.Property<Guid>("CitiesCityId")
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorsSolicitorId")
.HasColumnType("TEXT");
b.HasKey("CitiesCityId", "SolicitorsSolicitorId");
b.HasIndex("SolicitorsSolicitorId");
b.ToTable("CitySolicitor");
});
modelBuilder.Entity("Solicitors.Core.Models.City", b =>
{
b.Property<Guid>("CityId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("CityId");
b.ToTable("Cities");
});
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
{
b.Property<Guid>("SolicitorId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Phone")
.HasColumnType("TEXT");
b.Property<string>("RelativeUrl")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ShortDescription")
.HasColumnType("TEXT");
b.Property<string>("Website")
.HasColumnType("TEXT");
b.HasKey("SolicitorId");
b.ToTable("Solicitors");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
{
b.Property<Guid>("SolicitorLocationId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Address")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorId")
.HasColumnType("TEXT");
b.HasKey("SolicitorLocationId");
b.HasIndex("SolicitorId");
b.ToTable("SolicitorLocations");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
{
b.Property<Guid>("SolicitorLocationRatingId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<decimal>("Maximum")
.HasColumnType("TEXT");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorLocationId")
.HasColumnType("TEXT");
b.Property<decimal>("Value")
.HasColumnType("TEXT");
b.HasKey("SolicitorLocationRatingId");
b.HasIndex("SolicitorLocationId");
b.ToTable("SolicitorLocationRatings");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
{
b.Property<Guid>("SolicitorRatingId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<decimal>("Maximum")
.HasColumnType("TEXT");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("SolicitorId")
.HasColumnType("TEXT");
b.Property<decimal>("Value")
.HasColumnType("TEXT");
b.HasKey("SolicitorRatingId");
b.HasIndex("SolicitorId");
b.ToTable("SolicitorRatings");
});
modelBuilder.Entity("CitySolicitor", b =>
{
b.HasOne("Solicitors.Core.Models.City", null)
.WithMany()
.HasForeignKey("CitiesCityId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Solicitors.Core.Models.Solicitor", null)
.WithMany()
.HasForeignKey("SolicitorsSolicitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
{
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
.WithMany("Locations")
.HasForeignKey("SolicitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Solicitor");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocationRating", b =>
{
b.HasOne("Solicitors.Core.Models.SolicitorLocation", "SolicitorLocation")
.WithMany("LocationRatings")
.HasForeignKey("SolicitorLocationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SolicitorLocation");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorRating", b =>
{
b.HasOne("Solicitors.Core.Models.Solicitor", "Solicitor")
.WithMany("Ratings")
.HasForeignKey("SolicitorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Solicitor");
});
modelBuilder.Entity("Solicitors.Core.Models.Solicitor", b =>
{
b.Navigation("Locations");
b.Navigation("Ratings");
});
modelBuilder.Entity("Solicitors.Core.Models.SolicitorLocation", b =>
{
b.Navigation("LocationRatings");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,6 +1,3 @@
namespace Solicitors.Data.RepositorySetup;
public interface IRepoSetupOptions
{
}
public interface IRepoSetupOptions;

View File

@ -1,6 +1,6 @@
namespace Solicitors.Data.RepositorySetup.Sqlite;
public class SqliteDbSetupOptions(string dbName) : IRepoSetupOptions
public class SqliteDbSetupOptions(string dbPath) : IRepoSetupOptions
{
public string DbName { get; } = dbName;
public string DbPath { get; } = dbPath;
}

View File

@ -6,11 +6,6 @@ internal class SqliteSetupService(SqliteDbSetupOptions config) : IRepoSetupServi
{
public void OnConfiguring(DbContextOptionsBuilder options)
{
var folder = Environment.SpecialFolder.MyDocuments;
var path = Environment.GetFolderPath(folder);
path = Path.Combine(path, "/data");
var dbPath = System.IO.Path.Join(path, $"{config.DbName}.db");
var dbString = $"Data Source={dbPath}";
options.UseSqlite(dbString);
options.UseSqlite($"Data Source={config.DbPath}");
}
}

View File

@ -14,6 +14,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
</ItemGroup>

View File

@ -1,5 +1,4 @@
using Microsoft.EntityFrameworkCore;
using Solicitors.Core;
using Solicitors.Core.Data;
using Solicitors.Core.Models;
using Solicitors.Core.Models.Imports;
@ -7,15 +6,9 @@ using Solicitors.Data.RepositorySetup;
namespace Solicitors.Data;
internal class SolicitorsRepository : DbContext, ISolicitorRepository, IReadOnlyCitiesRepository, IRatingsProviderRepository
internal class SolicitorsRepository(IRepoSetupService setup)
: DbContext, ISolicitorRepository, IReadOnlyCitiesRepository, IRatingsProviderRepository
{
private readonly IRepoSetupService _setup;
public SolicitorsRepository(IRepoSetupService setup)
{
_setup = setup;
}
public DbSet<Solicitor> Solicitors { get; set; }
public DbSet<SolicitorRating> SolicitorRatings { get; set; }
public DbSet<SolicitorLocation> SolicitorLocations { get; set; }
@ -23,7 +16,10 @@ internal class SolicitorsRepository : DbContext, ISolicitorRepository, IReadOnly
public DbSet<City> Cities { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> _setup.OnConfiguring(optionsBuilder);
=> setup.OnConfiguring(optionsBuilder);
public Task EnsureCreatedAsync(CancellationToken cancellationToken)
=> Database.EnsureCreatedAsync(cancellationToken);
public IAsyncEnumerable<Solicitor> GetAllSolicitorsAsync()
=> Solicitors
@ -41,104 +37,7 @@ internal class SolicitorsRepository : DbContext, ISolicitorRepository, IReadOnly
.Include(solicitor => solicitor.Ratings)
.FirstOrDefaultAsync(x => x.SolicitorId == id, cancellationToken);
public async Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken)
{
var matchingSolicitor = await Solicitors
.FirstOrDefaultAsync(
item => item.Name == solicitor.Name,
cancellationToken: cancellationToken);
if (matchingSolicitor is null)
await AddNewSolicitor(solicitor, cancellationToken);
else
await UpdateExistingSolicitor(solicitor, matchingSolicitor, cancellationToken);
}
private async Task AddNewSolicitor(
SolicitorData solicitor,
CancellationToken cancellationToken)
{
var newSol = new Solicitor()
{
Name = solicitor.Name,
RelativeUrl = solicitor.UrlPath,
Phone = solicitor.Phone,
Email = solicitor.Email,
Website = solicitor.Website,
ShortDescription = solicitor.ShortDescription
};
Solicitors.Add(newSol);
var cityTasks = solicitor.Cities
.Distinct()
.Select(async (city) =>
{
var matchingCity = await Cities.FirstOrDefaultAsync(
item => item.Name == city,
cancellationToken: cancellationToken);
if (matchingCity is null)
{
matchingCity = new City() { Name = city };
Cities.Add(matchingCity);
newSol.Cities.Add(matchingCity);
}
matchingCity.Solicitors.Add(newSol);
});
await Task.WhenAll(cityTasks);
foreach (var rating in solicitor.Ratings)
{
var newRating = new SolicitorRating()
{
Value = rating.Value,
Maximum = rating.MaxValue,
Provider = rating.RatingProvider,
Solicitor = newSol
};
SolicitorRatings.Add(newRating);
newSol.Ratings.Add(newRating);
}
foreach (var location in solicitor.Offices)
{
var newLocation = new SolicitorLocation()
{
Address = location.Address,
Phone = location.Phone,
Solicitor = newSol
};
SolicitorLocations.Add(newLocation);
newSol.Locations.Add(newLocation);
foreach (var rating in location.LocationRatings)
{
var newLocRating = new SolicitorLocationRating()
{
Value = rating.value,
Maximum = rating.MaxValue,
Provider = rating.RatingProvider,
SolicitorLocation = newLocation
};
SolicitorLocationRatings.Add(newLocRating);
newLocation.LocationRatings.Add(newLocRating);
}
}
await SaveChangesAsync(cancellationToken);
}
private Task UpdateExistingSolicitor(
SolicitorData solicitor,
Solicitor matchingSolicitor,
CancellationToken cancellationToken)
{
// TODO
return Task.CompletedTask;
}
public IAsyncEnumerable<string> GetCitiesAsync(CancellationToken cancellationToken)
public IAsyncEnumerable<string> GetCitiesAsync()
{
return Cities
.Select(city => city.Name)
@ -154,4 +53,286 @@ internal class SolicitorsRepository : DbContext, ISolicitorRepository, IReadOnly
.ThenBy(x => x)
.AsAsyncEnumerable();
}
public async Task AddOrUpdateSolicitorAsync(SolicitorData solicitor, CancellationToken cancellationToken)
{
var matchingSolicitor = await Solicitors
.Include(existing => existing.Cities)
.Include(existing => existing.Locations)
.ThenInclude(location => location.LocationRatings)
.Include(existing => existing.Ratings)
.FirstOrDefaultAsync(
item => item.Name == solicitor.Name,
cancellationToken: cancellationToken);
if (matchingSolicitor is null)
await AddNewSolicitor(solicitor, cancellationToken);
else
UpdateExistingSolicitor(solicitor, matchingSolicitor);
try
{
await SaveChangesAsync(cancellationToken);
}
catch (Exception e)
{
Console.WriteLine(e);
throw;
}
}
public Task RemoveStaleEntriesAsync(TimeSpan staleAge, CancellationToken cancellationToken)
{
var staleTime = DateTime.UtcNow - staleAge;
Solicitors.RemoveRange(Solicitors.Where(x => x.LastModified < staleTime));
SolicitorLocations.RemoveRange(SolicitorLocations.Where(x => x.LastModified < staleTime));
SolicitorRatings.RemoveRange(SolicitorRatings.Where(x => x.LastModified < staleTime));
SolicitorLocationRatings.RemoveRange(SolicitorLocationRatings.Where(x => x.LastModified < staleTime));
Cities.RemoveRange(Cities.Where(x => x.LastModified < staleTime));
return SaveChangesAsync(cancellationToken);
}
private async Task AddNewSolicitor(
SolicitorData solicitor,
CancellationToken cancellationToken)
{
var newSol = new Solicitor
{
Name = solicitor.Name,
RelativeUrl = solicitor.UrlPath
};
UpdateSolicitorRootData(newSol, solicitor);
Solicitors.Add(newSol);
var cityTasks = solicitor.Cities
.Distinct()
.Select(async city =>
{
var matchingCity = await GetCityByNameAsync(city, cancellationToken);
if (matchingCity is null)
AddNewCityToSolicitor(city, newSol);
else
UpdateExistingCity(matchingCity, newSol);
});
await Task.WhenAll(cityTasks);
foreach (var rating in solicitor.Ratings)
AddNewRatingToSolicitor(rating, newSol);
foreach (var location in solicitor.Offices)
AddNewLocationToSolicitor(location, newSol);
}
private void UpdateSolicitorRootData(Solicitor solicitor, SolicitorData data)
{
solicitor.Name = data.Name;
solicitor.RelativeUrl = data.UrlPath;
solicitor.Phone = data.Phone;
solicitor.Email = data.Email;
solicitor.Website = data.Website;
solicitor.ShortDescription = data.ShortDescription;
solicitor.LastModified = DateTime.UtcNow;
}
private void AddNewCityToSolicitor(string cityName, Solicitor solicitor)
{
var newCity = new City
{
Name = cityName
};
Cities.Add(newCity);
UpdateExistingCity(newCity, solicitor);
}
private void AddNewRatingToSolicitor(Rating rating, Solicitor solicitor)
{
var newRating = new SolicitorRating
{
Value = rating.Value,
Maximum = rating.MaxValue,
Provider = rating.RatingProvider,
Solicitor = solicitor,
LastModified = DateTime.UtcNow
};
SolicitorRatings.Add(newRating);
solicitor.Ratings.Add(newRating);
}
private void AddNewLocationToSolicitor(Location location, Solicitor solicitor)
{
var newLocation = new SolicitorLocation
{
Address = location.Address,
Phone = location.Phone,
Solicitor = solicitor,
LastModified = DateTime.UtcNow
};
SolicitorLocations.Add(newLocation);
solicitor.Locations.Add(newLocation);
foreach (var rating in location.LocationRatings)
AddNewRatingToLocation(rating, newLocation);
}
private void AddNewRatingToLocation(Rating rating, SolicitorLocation location)
{
var newLocRating = new SolicitorLocationRating
{
Value = rating.Value,
Maximum = rating.MaxValue,
Provider = rating.RatingProvider,
SolicitorLocation = location,
LastModified = DateTime.UtcNow
};
SolicitorLocationRatings.Add(newLocRating);
location.LocationRatings.Add(newLocRating);
}
private void UpdateExistingSolicitor(
SolicitorData solicitor,
Solicitor matchingSolicitor)
{
UpdateSolicitorRootData(matchingSolicitor, solicitor);
UpdateExistingCities(solicitor, matchingSolicitor);
UpdateExistingRatings(solicitor, matchingSolicitor);
UpdateExistingLocations(solicitor, matchingSolicitor);
}
private void UpdateExistingCities(
SolicitorData solicitor,
Solicitor matchingSolicitor)
{
var lostCities = matchingSolicitor.Cities
.Where(city => solicitor.Cities.All(cityName => cityName != city.Name))
.ToArray();
foreach (var city in lostCities)
{
matchingSolicitor.Cities.Remove(city);
city.Solicitors.Remove(matchingSolicitor);
}
foreach (var cityName in solicitor.Cities.Distinct())
{
var matchingCity = matchingSolicitor.Cities
.FirstOrDefault(city => cityName == city.Name);
if (matchingCity is null)
AddNewCityToSolicitor(cityName, matchingSolicitor);
else
UpdateExistingCity(matchingCity, matchingSolicitor);
}
}
private void UpdateExistingRatings(
SolicitorData solicitor,
Solicitor matchingSolicitor)
{
var lostRatings = matchingSolicitor.Ratings
.Where(solicitorRating => solicitor.Ratings.All(rating => rating.RatingProvider != solicitorRating.Provider))
.ToArray();
foreach (var rating in lostRatings)
{
matchingSolicitor.Ratings.Remove(rating);
SolicitorRatings.Remove(rating);
}
foreach (var rating in solicitor.Ratings)
{
var matchingRating = matchingSolicitor.Ratings
.FirstOrDefault(solicitorRating => rating.RatingProvider == solicitorRating.Provider);
if (matchingRating is null)
AddNewRatingToSolicitor(rating, matchingSolicitor);
else
UpdateExistingRating(rating, matchingRating);
}
}
private void UpdateExistingRating(Rating rating, SolicitorRating existingRating)
{
existingRating.Value = rating.Value;
existingRating.Maximum = rating.MaxValue;
existingRating.LastModified = DateTime.UtcNow;
}
private void UpdateExistingLocations(
SolicitorData solicitor,
Solicitor matchingSolicitor)
{
var lostLocations = matchingSolicitor.Locations
.Where(location => solicitor.Offices.All(office => office.Address != location.Address))
.ToArray();
foreach (var location in lostLocations)
{
matchingSolicitor.Locations.Remove(location);
SolicitorLocations.Remove(location);
}
foreach (var office in solicitor.Offices)
{
var location = matchingSolicitor.Locations
.FirstOrDefault(location => office.Address == location.Address);
if (location is null)
AddNewLocationToSolicitor(office, matchingSolicitor);
else
UpdateExistingLocation(office, location);
}
}
private void UpdateExistingLocation(Location location, SolicitorLocation existingLocation)
{
existingLocation.Phone = location.Phone;
existingLocation.LastModified = DateTime.UtcNow;
var lostRatings = existingLocation.LocationRatings
.Where(locationRating => location.LocationRatings.All(rating => locationRating.Provider != rating.RatingProvider))
.ToArray();
foreach (var rating in lostRatings)
{
existingLocation.LocationRatings.Remove(rating);
SolicitorLocationRatings.Remove(rating);
}
foreach (var rating in location.LocationRatings)
{
var existingRating = existingLocation.LocationRatings
.FirstOrDefault(locationRating => locationRating.Provider == rating.RatingProvider);
if (existingRating is null)
AddNewRatingToLocation(rating, existingLocation);
else
UpdateExistingLocationRating(rating, existingRating);
}
}
private void UpdateExistingLocationRating(Rating rating, SolicitorLocationRating existingRating)
{
existingRating.Value = rating.Value;
existingRating.Maximum = rating.MaxValue;
existingRating.LastModified = DateTime.UtcNow;
}
private void UpdateExistingCity(City city, Solicitor solicitor)
{
city.Solicitors.Add(solicitor);
solicitor.Cities.Add(city);
city.LastModified = DateTime.UtcNow;
}
private Task<City?> GetCityByNameAsync(string cityName, CancellationToken cancellationToken)
{
return Cities
.Include(city => city.Solicitors)
.FirstOrDefaultAsync(
city => city.Name == cityName,
cancellationToken: cancellationToken);
}
}

View File

@ -1,7 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
namespace Solicitors.HtmlParsing;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public static class DIExtensions
{
public static IServiceCollection AddHtmlParsing(this IServiceCollection services)

View File

@ -33,7 +33,7 @@ internal class HtmlParser : IHtmlParser
return ParseHtml(tokenEnumerator);
}
private IEnumerable<string> LexHtml(IEnumerator<char> content)
private IEnumerable<string> LexHtml(CharEnumerator content)
{
var work = "";
while (content.MoveNext())
@ -85,14 +85,14 @@ internal class HtmlParser : IHtmlParser
}
}
bool IsVoidOrSelfClosingElement(string token)
private bool IsVoidOrSelfClosingElement(string token)
{
if (token.StartsWith("<!") || token.EndsWith("/>"))
return true;
var tagName = token.Split(' ').First().Substring(1);
var tagName = token.Split(' ').First()[1..];
if (tagName.EndsWith('>'))
tagName = tagName.Substring(0, tagName.Length - 1);
tagName = tagName[..^1];
return _voidElements.Contains(tagName);
}
@ -100,8 +100,8 @@ internal class HtmlParser : IHtmlParser
{
var attributesString = string.Join(' ', tokenNoBraces.Split(' ').Skip(1));
var attString = "";
bool inQuote = false;
foreach (char c in attributesString)
var inQuote = false;
foreach (var c in attributesString)
{
if (!inQuote && c == ' ')
{

View File

@ -2,19 +2,16 @@ using System.Diagnostics.CodeAnalysis;
namespace Solicitors.HtmlParsing.Models;
internal class HtmlNode(string tagName, IHtmlAttribute[] attributes)
internal class HtmlNode(string name, IHtmlAttribute[] attributes)
: IHtmlNode
{
private readonly IHtmlAttribute[] _attributes = attributes;
private readonly string _tagName = tagName;
public virtual bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
bool noChildren = false)
{
node = null;
if (tagName == _tagName)
if (tagName == name)
node = this;
return node is not null;
@ -24,7 +21,7 @@ internal class HtmlNode(string tagName, IHtmlAttribute[] attributes)
{
get
{
var classAttribute = _attributes
var classAttribute = attributes
.Where(a => a is HtmlValueAttribute)
.Select(a => (a as HtmlValueAttribute)!)
.FirstOrDefault(a => a.Name.Equals("class", StringComparison.CurrentCultureIgnoreCase));
@ -55,7 +52,7 @@ internal class HtmlNode(string tagName, IHtmlAttribute[] attributes)
bool noChildren = false)
{
node = null;
if (tagName == _tagName && Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
if (tagName == name && Classes.Contains(className, StringComparer.InvariantCultureIgnoreCase))
node = this;
return node is not null;
@ -75,7 +72,7 @@ internal class HtmlNode(string tagName, IHtmlAttribute[] attributes)
public bool HasAttribute(string attributeName)
{
return _attributes
return attributes
.Any(a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase));
}
@ -83,7 +80,7 @@ internal class HtmlNode(string tagName, IHtmlAttribute[] attributes)
{
attributeValue = null;
var matchingAttribute = _attributes.FirstOrDefault(
var matchingAttribute = attributes.FirstOrDefault(
a => a.Name.Equals(attributeName, StringComparison.InvariantCultureIgnoreCase)
);

View File

@ -5,8 +5,6 @@ namespace Solicitors.HtmlParsing.Models;
internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtmlNode[] children)
: HtmlNode(tagName, attributes)
{
private readonly IHtmlNode[] _children = children;
public override bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
@ -15,7 +13,7 @@ internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtml
node = null;
if (!base.TryGetByTagName(tagName, out node, noChildren) && !noChildren)
{
var nodesToCheck = new Queue<IHtmlNode>(_children);
var nodesToCheck = new Queue<IHtmlNode>(children);
while (nodesToCheck.TryDequeue(out var child))
{
if (child.TryGetByTagName(tagName, out var childNode, true))
@ -41,7 +39,7 @@ internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtml
node = null;
if (!base.TryGetByClass(className, out node, noChildren) && !noChildren)
{
var nodesToCheck = new Queue<IHtmlNode>(_children);
var nodesToCheck = new Queue<IHtmlNode>(children);
while (nodesToCheck.TryDequeue(out var child))
{
if (child.TryGetByClass(className, out var childNode, true))
@ -68,7 +66,7 @@ internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtml
node = null;
if (!base.TryGetByTagNameAndClass(tagName, className, out node, noChildren) && !noChildren)
{
var nodesToCheck = new Queue<IHtmlNode>(_children);
var nodesToCheck = new Queue<IHtmlNode>(children);
while (nodesToCheck.TryDequeue(out var child))
{
if (child.TryGetByTagNameAndClass(tagName, className, out var childNode, true))
@ -86,9 +84,9 @@ internal class ParentHtmlNode(string tagName, IHtmlAttribute[] attributes, IHtml
return node is not null;
}
public override bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children)
public override bool TryGetChildren([NotNullWhen(true)] out IEnumerable<IHtmlNode>? children1)
{
children = _children;
return _children.Length != 0;
children1 = children;
return children.Length != 0;
}
}

View File

@ -5,8 +5,6 @@ namespace Solicitors.HtmlParsing.Models;
internal class StringNode(string content)
: IHtmlNode
{
private readonly string _content = content;
public bool TryGetByTagName(
string tagName,
[NotNullWhen(true)] out IHtmlNode? node,
@ -37,7 +35,7 @@ internal class StringNode(string content)
public bool TryGetText([NotNullWhen(true)] out string? text)
{
text = _content;
text = content;
return !string.IsNullOrEmpty(text);
}

View File

@ -2,6 +2,6 @@ import { SolicitorInfo } from "../models/solicitorSummary";
import api from "./axios";
export default async function GetSolicitor(id : string) : Promise<SolicitorInfo | undefined> {
const response = await api.get(`/conveyancors/${id}`);
const response = await api.get(`/solicitors/${id}`);
return response.data as SolicitorInfo;
}

View File

@ -17,6 +17,6 @@ export default async function GetSolicitors(filters : FilterState) {
if (filters.sortBy != null) {
queryString += `&orderingType=${filters.sortBy}`;
}
const response = await api.get(`/conveyancors?${queryString}`);
const response = await api.get(`/solicitors?${queryString}`);
return response.data as PaginationResponse<SolicitorSummary>;
}