mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-07-17 09:47:23 +00:00
begin work
This commit is contained in:
@@ -10,8 +10,15 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
|
<PackageReference Include="idunno.Password.Generator" Version="1.0.1" />
|
||||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||||
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.9" />
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
<PackageReference Include="dbup" Version="5.0.41" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
remove this override once microsoft ships official fix to vulnerability
|
||||||
|
https://github.com/advisories/GHSA-2m69-gcr7-jv3q
|
||||||
|
-->
|
||||||
|
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
54
web/backend/Database/Database.Seed/DatabaseHelpers.cs
Normal file
54
web/backend/Database/Database.Seed/DatabaseHelpers.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
|
namespace Database.Seed;
|
||||||
|
|
||||||
|
public class ConnectionStrings
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
||||||
|
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
||||||
|
/// environment variables.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A fully built SQL Server connection string.</returns>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// Thrown when <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>, or <c>DB_PASSWORD</c>
|
||||||
|
/// is not set.
|
||||||
|
/// </exception>
|
||||||
|
private static string GetSqlServerConnectionString()
|
||||||
|
{
|
||||||
|
string server =
|
||||||
|
Environment.GetEnvironmentVariable("DB_SERVER")
|
||||||
|
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
||||||
|
|
||||||
|
string dbName =
|
||||||
|
Environment.GetEnvironmentVariable("DB_NAME")
|
||||||
|
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
||||||
|
|
||||||
|
string user =
|
||||||
|
Environment.GetEnvironmentVariable("DB_USER")
|
||||||
|
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
||||||
|
|
||||||
|
string password =
|
||||||
|
Environment.GetEnvironmentVariable("DB_PASSWORD")
|
||||||
|
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
||||||
|
|
||||||
|
string trustServerCertificate =
|
||||||
|
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
|
||||||
|
|
||||||
|
SqlConnectionStringBuilder builder = new()
|
||||||
|
{
|
||||||
|
DataSource = server,
|
||||||
|
InitialCatalog = dbName,
|
||||||
|
UserID = user,
|
||||||
|
Password = password,
|
||||||
|
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
||||||
|
Encrypt = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
return builder.ConnectionString;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static string SqlServerConnectionString => GetSqlServerConnectionString();
|
||||||
|
public static string SqliteConnectionString => "Data Source=seed.sqlite";
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using Microsoft.Data.SqlClient;
|
|
||||||
|
|
||||||
namespace Database.Seed;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Defines a unit of seed data that can be applied to the database. Implementations
|
|
||||||
/// should be safe to run against an already-seeded database (e.g. by checking for
|
|
||||||
/// existing data before inserting) and may depend on data created by seeders that run
|
|
||||||
/// before them.
|
|
||||||
/// </summary>
|
|
||||||
internal interface ISeeder
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Inserts this seeder's data into the database using the supplied open connection.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
|
||||||
/// <returns>A task that completes when seeding is finished.</returns>
|
|
||||||
Task SeedAsync(SqlConnection connection);
|
|
||||||
}
|
|
||||||
@@ -1,341 +0,0 @@
|
|||||||
using System.Data;
|
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
|
|
||||||
namespace Database.Seed;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Seeds the location hierarchy (countries, states/provinces, and cities) used to
|
|
||||||
/// associate other entities (e.g. breweries, users) with a geographic location.
|
|
||||||
/// Countries must be seeded before states/provinces, which must be seeded before
|
|
||||||
/// cities, since each level references its parent by code. The underlying stored
|
|
||||||
/// procedures (<c>USP_CreateCountry</c>, <c>USP_CreateStateProvince</c>,
|
|
||||||
/// <c>USP_CreateCity</c>) are expected to be idempotent, so re-running this seeder
|
|
||||||
/// against an already-seeded database is safe.
|
|
||||||
/// </summary>
|
|
||||||
internal class LocationSeeder : ISeeder
|
|
||||||
{
|
|
||||||
/// <summary>The set of countries to seed, identified by name and ISO 3166-1 code.</summary>
|
|
||||||
private static readonly IReadOnlyList<(string CountryName, string CountryCode)> Countries =
|
|
||||||
[
|
|
||||||
("Canada", "CA"),
|
|
||||||
("Mexico", "MX"),
|
|
||||||
("United States", "US"),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The set of states/provinces to seed, each identified by name, ISO 3166-2 code,
|
|
||||||
/// and the ISO 3166-1 code of its parent country.
|
|
||||||
/// </summary>
|
|
||||||
private static IReadOnlyList<(
|
|
||||||
string StateProvinceName,
|
|
||||||
string StateProvinceCode,
|
|
||||||
string CountryCode
|
|
||||||
)> States { get; } =
|
|
||||||
[
|
|
||||||
("Alabama", "US-AL", "US"),
|
|
||||||
("Alaska", "US-AK", "US"),
|
|
||||||
("Arizona", "US-AZ", "US"),
|
|
||||||
("Arkansas", "US-AR", "US"),
|
|
||||||
("California", "US-CA", "US"),
|
|
||||||
("Colorado", "US-CO", "US"),
|
|
||||||
("Connecticut", "US-CT", "US"),
|
|
||||||
("Delaware", "US-DE", "US"),
|
|
||||||
("Florida", "US-FL", "US"),
|
|
||||||
("Georgia", "US-GA", "US"),
|
|
||||||
("Hawaii", "US-HI", "US"),
|
|
||||||
("Idaho", "US-ID", "US"),
|
|
||||||
("Illinois", "US-IL", "US"),
|
|
||||||
("Indiana", "US-IN", "US"),
|
|
||||||
("Iowa", "US-IA", "US"),
|
|
||||||
("Kansas", "US-KS", "US"),
|
|
||||||
("Kentucky", "US-KY", "US"),
|
|
||||||
("Louisiana", "US-LA", "US"),
|
|
||||||
("Maine", "US-ME", "US"),
|
|
||||||
("Maryland", "US-MD", "US"),
|
|
||||||
("Massachusetts", "US-MA", "US"),
|
|
||||||
("Michigan", "US-MI", "US"),
|
|
||||||
("Minnesota", "US-MN", "US"),
|
|
||||||
("Mississippi", "US-MS", "US"),
|
|
||||||
("Missouri", "US-MO", "US"),
|
|
||||||
("Montana", "US-MT", "US"),
|
|
||||||
("Nebraska", "US-NE", "US"),
|
|
||||||
("Nevada", "US-NV", "US"),
|
|
||||||
("New Hampshire", "US-NH", "US"),
|
|
||||||
("New Jersey", "US-NJ", "US"),
|
|
||||||
("New Mexico", "US-NM", "US"),
|
|
||||||
("New York", "US-NY", "US"),
|
|
||||||
("North Carolina", "US-NC", "US"),
|
|
||||||
("North Dakota", "US-ND", "US"),
|
|
||||||
("Ohio", "US-OH", "US"),
|
|
||||||
("Oklahoma", "US-OK", "US"),
|
|
||||||
("Oregon", "US-OR", "US"),
|
|
||||||
("Pennsylvania", "US-PA", "US"),
|
|
||||||
("Rhode Island", "US-RI", "US"),
|
|
||||||
("South Carolina", "US-SC", "US"),
|
|
||||||
("South Dakota", "US-SD", "US"),
|
|
||||||
("Tennessee", "US-TN", "US"),
|
|
||||||
("Texas", "US-TX", "US"),
|
|
||||||
("Utah", "US-UT", "US"),
|
|
||||||
("Vermont", "US-VT", "US"),
|
|
||||||
("Virginia", "US-VA", "US"),
|
|
||||||
("Washington", "US-WA", "US"),
|
|
||||||
("West Virginia", "US-WV", "US"),
|
|
||||||
("Wisconsin", "US-WI", "US"),
|
|
||||||
("Wyoming", "US-WY", "US"),
|
|
||||||
("District of Columbia", "US-DC", "US"),
|
|
||||||
("Puerto Rico", "US-PR", "US"),
|
|
||||||
("U.S. Virgin Islands", "US-VI", "US"),
|
|
||||||
("Guam", "US-GU", "US"),
|
|
||||||
("Northern Mariana Islands", "US-MP", "US"),
|
|
||||||
("American Samoa", "US-AS", "US"),
|
|
||||||
("Ontario", "CA-ON", "CA"),
|
|
||||||
("Québec", "CA-QC", "CA"),
|
|
||||||
("Nova Scotia", "CA-NS", "CA"),
|
|
||||||
("New Brunswick", "CA-NB", "CA"),
|
|
||||||
("Manitoba", "CA-MB", "CA"),
|
|
||||||
("British Columbia", "CA-BC", "CA"),
|
|
||||||
("Prince Edward Island", "CA-PE", "CA"),
|
|
||||||
("Saskatchewan", "CA-SK", "CA"),
|
|
||||||
("Alberta", "CA-AB", "CA"),
|
|
||||||
("Newfoundland and Labrador", "CA-NL", "CA"),
|
|
||||||
("Northwest Territories", "CA-NT", "CA"),
|
|
||||||
("Yukon", "CA-YT", "CA"),
|
|
||||||
("Nunavut", "CA-NU", "CA"),
|
|
||||||
("Aguascalientes", "MX-AGU", "MX"),
|
|
||||||
("Baja California", "MX-BCN", "MX"),
|
|
||||||
("Baja California Sur", "MX-BCS", "MX"),
|
|
||||||
("Campeche", "MX-CAM", "MX"),
|
|
||||||
("Chiapas", "MX-CHP", "MX"),
|
|
||||||
("Chihuahua", "MX-CHH", "MX"),
|
|
||||||
("Coahuila de Zaragoza", "MX-COA", "MX"),
|
|
||||||
("Colima", "MX-COL", "MX"),
|
|
||||||
("Durango", "MX-DUR", "MX"),
|
|
||||||
("Guanajuato", "MX-GUA", "MX"),
|
|
||||||
("Guerrero", "MX-GRO", "MX"),
|
|
||||||
("Hidalgo", "MX-HID", "MX"),
|
|
||||||
("Jalisco", "MX-JAL", "MX"),
|
|
||||||
("México State", "MX-MEX", "MX"),
|
|
||||||
("Michoacán de Ocampo", "MX-MIC", "MX"),
|
|
||||||
("Morelos", "MX-MOR", "MX"),
|
|
||||||
("Nayarit", "MX-NAY", "MX"),
|
|
||||||
("Nuevo León", "MX-NLE", "MX"),
|
|
||||||
("Oaxaca", "MX-OAX", "MX"),
|
|
||||||
("Puebla", "MX-PUE", "MX"),
|
|
||||||
("Querétaro", "MX-QUE", "MX"),
|
|
||||||
("Quintana Roo", "MX-ROO", "MX"),
|
|
||||||
("San Luis Potosí", "MX-SLP", "MX"),
|
|
||||||
("Sinaloa", "MX-SIN", "MX"),
|
|
||||||
("Sonora", "MX-SON", "MX"),
|
|
||||||
("Tabasco", "MX-TAB", "MX"),
|
|
||||||
("Tamaulipas", "MX-TAM", "MX"),
|
|
||||||
("Tlaxcala", "MX-TLA", "MX"),
|
|
||||||
("Veracruz de Ignacio de la Llave", "MX-VER", "MX"),
|
|
||||||
("Yucatán", "MX-YUC", "MX"),
|
|
||||||
("Zacatecas", "MX-ZAC", "MX"),
|
|
||||||
("Ciudad de México", "MX-CMX", "MX"),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The set of cities to seed, each identified by name and the ISO 3166-2 code of its
|
|
||||||
/// parent state/province.
|
|
||||||
/// </summary>
|
|
||||||
private static IReadOnlyList<(string StateProvinceCode, string CityName)> Cities { get; } =
|
|
||||||
[
|
|
||||||
("US-CA", "Los Angeles"),
|
|
||||||
("US-CA", "San Diego"),
|
|
||||||
("US-CA", "San Francisco"),
|
|
||||||
("US-CA", "Sacramento"),
|
|
||||||
("US-TX", "Houston"),
|
|
||||||
("US-TX", "Dallas"),
|
|
||||||
("US-TX", "Austin"),
|
|
||||||
("US-TX", "San Antonio"),
|
|
||||||
("US-FL", "Miami"),
|
|
||||||
("US-FL", "Orlando"),
|
|
||||||
("US-FL", "Tampa"),
|
|
||||||
("US-NY", "New York"),
|
|
||||||
("US-NY", "Buffalo"),
|
|
||||||
("US-NY", "Rochester"),
|
|
||||||
("US-IL", "Chicago"),
|
|
||||||
("US-IL", "Springfield"),
|
|
||||||
("US-PA", "Philadelphia"),
|
|
||||||
("US-PA", "Pittsburgh"),
|
|
||||||
("US-AZ", "Phoenix"),
|
|
||||||
("US-AZ", "Tucson"),
|
|
||||||
("US-CO", "Denver"),
|
|
||||||
("US-CO", "Colorado Springs"),
|
|
||||||
("US-MA", "Boston"),
|
|
||||||
("US-MA", "Worcester"),
|
|
||||||
("US-WA", "Seattle"),
|
|
||||||
("US-WA", "Spokane"),
|
|
||||||
("US-GA", "Atlanta"),
|
|
||||||
("US-GA", "Savannah"),
|
|
||||||
("US-NV", "Las Vegas"),
|
|
||||||
("US-NV", "Reno"),
|
|
||||||
("US-MI", "Detroit"),
|
|
||||||
("US-MI", "Grand Rapids"),
|
|
||||||
("US-MN", "Minneapolis"),
|
|
||||||
("US-MN", "Saint Paul"),
|
|
||||||
("US-OH", "Columbus"),
|
|
||||||
("US-OH", "Cleveland"),
|
|
||||||
("US-OR", "Portland"),
|
|
||||||
("US-OR", "Salem"),
|
|
||||||
("US-TN", "Nashville"),
|
|
||||||
("US-TN", "Memphis"),
|
|
||||||
("US-VA", "Richmond"),
|
|
||||||
("US-VA", "Virginia Beach"),
|
|
||||||
("US-MD", "Baltimore"),
|
|
||||||
("US-MD", "Frederick"),
|
|
||||||
("US-DC", "Washington"),
|
|
||||||
("US-UT", "Salt Lake City"),
|
|
||||||
("US-UT", "Provo"),
|
|
||||||
("US-LA", "New Orleans"),
|
|
||||||
("US-LA", "Baton Rouge"),
|
|
||||||
("US-KY", "Louisville"),
|
|
||||||
("US-KY", "Lexington"),
|
|
||||||
("US-IA", "Des Moines"),
|
|
||||||
("US-IA", "Cedar Rapids"),
|
|
||||||
("US-OK", "Oklahoma City"),
|
|
||||||
("US-OK", "Tulsa"),
|
|
||||||
("US-NE", "Omaha"),
|
|
||||||
("US-NE", "Lincoln"),
|
|
||||||
("US-MO", "Kansas City"),
|
|
||||||
("US-MO", "St. Louis"),
|
|
||||||
("US-NC", "Charlotte"),
|
|
||||||
("US-NC", "Raleigh"),
|
|
||||||
("US-SC", "Columbia"),
|
|
||||||
("US-SC", "Charleston"),
|
|
||||||
("US-WI", "Milwaukee"),
|
|
||||||
("US-WI", "Madison"),
|
|
||||||
("US-MN", "Duluth"),
|
|
||||||
("US-AK", "Anchorage"),
|
|
||||||
("US-HI", "Honolulu"),
|
|
||||||
("CA-ON", "Toronto"),
|
|
||||||
("CA-ON", "Ottawa"),
|
|
||||||
("CA-QC", "Montréal"),
|
|
||||||
("CA-QC", "Québec City"),
|
|
||||||
("CA-BC", "Vancouver"),
|
|
||||||
("CA-BC", "Victoria"),
|
|
||||||
("CA-AB", "Calgary"),
|
|
||||||
("CA-AB", "Edmonton"),
|
|
||||||
("CA-MB", "Winnipeg"),
|
|
||||||
("CA-NS", "Halifax"),
|
|
||||||
("CA-SK", "Saskatoon"),
|
|
||||||
("CA-SK", "Regina"),
|
|
||||||
("CA-NB", "Moncton"),
|
|
||||||
("CA-NB", "Saint John"),
|
|
||||||
("CA-PE", "Charlottetown"),
|
|
||||||
("CA-NL", "St. John's"),
|
|
||||||
("CA-ON", "Hamilton"),
|
|
||||||
("CA-ON", "London"),
|
|
||||||
("CA-QC", "Gatineau"),
|
|
||||||
("CA-QC", "Laval"),
|
|
||||||
("CA-BC", "Kelowna"),
|
|
||||||
("CA-AB", "Red Deer"),
|
|
||||||
("CA-MB", "Brandon"),
|
|
||||||
("MX-CMX", "Ciudad de México"),
|
|
||||||
("MX-JAL", "Guadalajara"),
|
|
||||||
("MX-NLE", "Monterrey"),
|
|
||||||
("MX-PUE", "Puebla"),
|
|
||||||
("MX-ROO", "Cancún"),
|
|
||||||
("MX-GUA", "Guanajuato"),
|
|
||||||
("MX-MIC", "Morelia"),
|
|
||||||
("MX-BCN", "Tijuana"),
|
|
||||||
("MX-JAL", "Zapopan"),
|
|
||||||
("MX-NLE", "San Nicolás"),
|
|
||||||
("MX-CAM", "Campeche"),
|
|
||||||
("MX-TAB", "Villahermosa"),
|
|
||||||
("MX-VER", "Veracruz"),
|
|
||||||
("MX-OAX", "Oaxaca"),
|
|
||||||
("MX-SLP", "San Luis Potosí"),
|
|
||||||
("MX-CHH", "Chihuahua"),
|
|
||||||
("MX-AGU", "Aguascalientes"),
|
|
||||||
("MX-MEX", "Toluca"),
|
|
||||||
("MX-COA", "Saltillo"),
|
|
||||||
("MX-BCS", "La Paz"),
|
|
||||||
("MX-NAY", "Tepic"),
|
|
||||||
("MX-ZAC", "Zacatecas"),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Seeds all countries, then states/provinces, then cities, in that order, so that
|
|
||||||
/// each level's parent reference already exists by the time it is created.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
|
||||||
/// <returns>A task that completes when all locations have been seeded.</returns>
|
|
||||||
public async Task SeedAsync(SqlConnection connection)
|
|
||||||
{
|
|
||||||
foreach ((string countryName, string countryCode) in Countries)
|
|
||||||
await CreateCountryAsync(connection, countryName, countryCode);
|
|
||||||
|
|
||||||
foreach ((string stateProvinceName, string stateProvinceCode, string countryCode) in States)
|
|
||||||
await CreateStateProvinceAsync(
|
|
||||||
connection,
|
|
||||||
stateProvinceName,
|
|
||||||
stateProvinceCode,
|
|
||||||
countryCode
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ((string stateProvinceCode, string cityName) in Cities)
|
|
||||||
await CreateCityAsync(connection, cityName, stateProvinceCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Creates a single country by invoking the <c>dbo.USP_CreateCountry</c> stored procedure.</summary>
|
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
|
||||||
/// <param name="countryName">The display name of the country.</param>
|
|
||||||
/// <param name="countryCode">The ISO 3166-1 code of the country.</param>
|
|
||||||
/// <returns>A task that completes when the country has been created.</returns>
|
|
||||||
private static async Task CreateCountryAsync(
|
|
||||||
SqlConnection connection,
|
|
||||||
string countryName,
|
|
||||||
string countryCode
|
|
||||||
)
|
|
||||||
{
|
|
||||||
await using SqlCommand command = new("dbo.USP_CreateCountry", connection);
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
|
||||||
command.Parameters.AddWithValue("@CountryName", countryName);
|
|
||||||
command.Parameters.AddWithValue("@ISO3166_1", countryCode);
|
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Creates a single state/province by invoking the <c>dbo.USP_CreateStateProvince</c> stored procedure.</summary>
|
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
|
||||||
/// <param name="stateProvinceName">The display name of the state/province.</param>
|
|
||||||
/// <param name="stateProvinceCode">The ISO 3166-2 code of the state/province.</param>
|
|
||||||
/// <param name="countryCode">The ISO 3166-1 code of the parent country, which must already exist.</param>
|
|
||||||
/// <returns>A task that completes when the state/province has been created.</returns>
|
|
||||||
private static async Task CreateStateProvinceAsync(
|
|
||||||
SqlConnection connection,
|
|
||||||
string stateProvinceName,
|
|
||||||
string stateProvinceCode,
|
|
||||||
string countryCode
|
|
||||||
)
|
|
||||||
{
|
|
||||||
await using SqlCommand command = new("dbo.USP_CreateStateProvince", connection);
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
|
||||||
command.Parameters.AddWithValue("@StateProvinceName", stateProvinceName);
|
|
||||||
command.Parameters.AddWithValue("@ISO3166_2", stateProvinceCode);
|
|
||||||
command.Parameters.AddWithValue("@CountryCode", countryCode);
|
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Creates a single city by invoking the <c>dbo.USP_CreateCity</c> stored procedure.</summary>
|
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
|
||||||
/// <param name="cityName">The display name of the city.</param>
|
|
||||||
/// <param name="stateProvinceCode">The ISO 3166-2 code of the parent state/province, which must already exist.</param>
|
|
||||||
/// <returns>A task that completes when the city has been created.</returns>
|
|
||||||
private static async Task CreateCityAsync(
|
|
||||||
SqlConnection connection,
|
|
||||||
string cityName,
|
|
||||||
string stateProvinceCode
|
|
||||||
)
|
|
||||||
{
|
|
||||||
await using SqlCommand command = new("dbo.USP_CreateCity", connection);
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
|
||||||
command.Parameters.AddWithValue("@CityName", cityName);
|
|
||||||
command.Parameters.AddWithValue("@StateProvinceCode", stateProvinceCode);
|
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
using Database.Seed;
|
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
|
|
||||||
// Entry point for the database seeding utility. Connects to the target database
|
|
||||||
// (retrying on transient failures), then runs each registered ISeeder in order to
|
|
||||||
// populate location and user data.
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds a SQL Server connection string from the <c>DB_SERVER</c>, <c>DB_NAME</c>,
|
|
||||||
/// <c>DB_USER</c>, <c>DB_PASSWORD</c>, and <c>DB_TRUST_SERVER_CERTIFICATE</c>
|
|
||||||
/// environment variables.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>A fully built SQL Server connection string.</returns>
|
|
||||||
/// <exception cref="InvalidOperationException">
|
|
||||||
/// Thrown when <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>, or <c>DB_PASSWORD</c>
|
|
||||||
/// is not set.
|
|
||||||
/// </exception>
|
|
||||||
string BuildConnectionString()
|
|
||||||
{
|
|
||||||
string server =
|
|
||||||
Environment.GetEnvironmentVariable("DB_SERVER")
|
|
||||||
?? throw new InvalidOperationException("DB_SERVER environment variable is not set");
|
|
||||||
|
|
||||||
string dbName =
|
|
||||||
Environment.GetEnvironmentVariable("DB_NAME")
|
|
||||||
?? throw new InvalidOperationException("DB_NAME environment variable is not set");
|
|
||||||
|
|
||||||
string user =
|
|
||||||
Environment.GetEnvironmentVariable("DB_USER")
|
|
||||||
?? throw new InvalidOperationException("DB_USER environment variable is not set");
|
|
||||||
|
|
||||||
string password =
|
|
||||||
Environment.GetEnvironmentVariable("DB_PASSWORD")
|
|
||||||
?? throw new InvalidOperationException("DB_PASSWORD environment variable is not set");
|
|
||||||
|
|
||||||
string trustServerCertificate =
|
|
||||||
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE") ?? "True";
|
|
||||||
|
|
||||||
SqlConnectionStringBuilder builder = new()
|
|
||||||
{
|
|
||||||
DataSource = server,
|
|
||||||
InitialCatalog = dbName,
|
|
||||||
UserID = user,
|
|
||||||
Password = password,
|
|
||||||
TrustServerCertificate = bool.Parse(trustServerCertificate),
|
|
||||||
Encrypt = true,
|
|
||||||
};
|
|
||||||
|
|
||||||
return builder.ConnectionString;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
string connectionString = BuildConnectionString();
|
|
||||||
|
|
||||||
Console.WriteLine("Attempting to connect to database...");
|
|
||||||
|
|
||||||
// Retry logic for database connection
|
|
||||||
SqlConnection? connection = null;
|
|
||||||
int maxRetries = 10;
|
|
||||||
int retryDelayMs = 2000;
|
|
||||||
|
|
||||||
for (int attempt = 1; attempt <= maxRetries; attempt++)
|
|
||||||
try
|
|
||||||
{
|
|
||||||
connection = new SqlConnection(connectionString);
|
|
||||||
await connection.OpenAsync();
|
|
||||||
Console.WriteLine($"Connected to database successfully on attempt {attempt}.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
catch (SqlException ex) when (attempt < maxRetries)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Connection attempt {attempt}/{maxRetries} failed: {ex.Message}");
|
|
||||||
Console.WriteLine($"Retrying in {retryDelayMs}ms...");
|
|
||||||
await Task.Delay(retryDelayMs);
|
|
||||||
connection?.Dispose();
|
|
||||||
connection = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connection == null)
|
|
||||||
throw new Exception($"Failed to connect to database after {maxRetries} attempts.");
|
|
||||||
|
|
||||||
Console.WriteLine("Starting seeding...");
|
|
||||||
|
|
||||||
using (connection)
|
|
||||||
{
|
|
||||||
ISeeder[] seeders = [new LocationSeeder(), new UserSeeder()];
|
|
||||||
|
|
||||||
foreach (ISeeder seeder in seeders)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"Seeding {seeder.GetType().Name}...");
|
|
||||||
await seeder.SeedAsync(connection);
|
|
||||||
Console.WriteLine($"{seeder.GetType().Name} seeded.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine("Seed completed successfully.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.Error.WriteLine("Seed failed:");
|
|
||||||
Console.Error.WriteLine(ex);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
using System.Data;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using idunno.Password;
|
|
||||||
using Konscious.Security.Cryptography;
|
|
||||||
using Microsoft.Data.SqlClient;
|
|
||||||
|
|
||||||
namespace Database.Seed;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Seeds user accounts, credentials, and verification records. Creates one fixed
|
|
||||||
/// "Test User" account (<c>test.user@thebiergarten.app</c> / password <c>"password"</c>)
|
|
||||||
/// for testing, followed by a randomly-generated account for each name in
|
|
||||||
/// <see cref="SeedNames" />, each with a random password and date of birth. Skips
|
|
||||||
/// adding a verification record for a user that already has one, so this seeder is
|
|
||||||
/// safe to re-run, though re-running will still attempt to register (and may fail on)
|
|
||||||
/// users that already exist.
|
|
||||||
/// </summary>
|
|
||||||
internal class UserSeeder : ISeeder
|
|
||||||
{
|
|
||||||
/// <summary>The first/last name pairs used to generate seed user accounts.</summary>
|
|
||||||
private static readonly IReadOnlyList<(string FirstName, string LastName)> SeedNames =
|
|
||||||
[
|
|
||||||
("Aarya", "Mathews"),
|
|
||||||
("Aiden", "Wells"),
|
|
||||||
("Aleena", "Gonzalez"),
|
|
||||||
("Alessandra", "Nelson"),
|
|
||||||
("Amari", "Tucker"),
|
|
||||||
("Ameer", "Huff"),
|
|
||||||
("Amirah", "Hicks"),
|
|
||||||
("Analia", "Dominguez"),
|
|
||||||
("Anne", "Jenkins"),
|
|
||||||
("Apollo", "Davis"),
|
|
||||||
("Arianna", "White"),
|
|
||||||
("Aubree", "Moore"),
|
|
||||||
("Aubrielle", "Raymond"),
|
|
||||||
("Aydin", "Odom"),
|
|
||||||
("Bowen", "Casey"),
|
|
||||||
("Brock", "Huber"),
|
|
||||||
("Caiden", "Strong"),
|
|
||||||
("Cecilia", "Rosales"),
|
|
||||||
("Celeste", "Barber"),
|
|
||||||
("Chance", "Small"),
|
|
||||||
("Clara", "Roberts"),
|
|
||||||
("Collins", "Brandt"),
|
|
||||||
("Damir", "Wallace"),
|
|
||||||
("Declan", "Crawford"),
|
|
||||||
("Dennis", "Decker"),
|
|
||||||
("Dylan", "Lang"),
|
|
||||||
("Eliza", "Kane"),
|
|
||||||
("Elle", "Poole"),
|
|
||||||
("Elliott", "Miles"),
|
|
||||||
("Emelia", "Lucas"),
|
|
||||||
("Emilia", "Simpson"),
|
|
||||||
("Emmett", "Lugo"),
|
|
||||||
("Ethan", "Stephens"),
|
|
||||||
("Etta", "Woods"),
|
|
||||||
("Gael", "Moran"),
|
|
||||||
("Grant", "Benson"),
|
|
||||||
("Gwen", "James"),
|
|
||||||
("Huxley", "Chen"),
|
|
||||||
("Isabella", "Fisher"),
|
|
||||||
("Ivan", "Mathis"),
|
|
||||||
("Jamir", "McMillan"),
|
|
||||||
("Jaxson", "Shields"),
|
|
||||||
("Jimmy", "Richmond"),
|
|
||||||
("Josiah", "Flores"),
|
|
||||||
("Kaden", "Enriquez"),
|
|
||||||
("Kai", "Lawson"),
|
|
||||||
("Karsyn", "Adkins"),
|
|
||||||
("Karsyn", "Proctor"),
|
|
||||||
("Kayden", "Henson"),
|
|
||||||
("Kaylie", "Spears"),
|
|
||||||
("Kinslee", "Jones"),
|
|
||||||
("Kora", "Guerra"),
|
|
||||||
("Lane", "Skinner"),
|
|
||||||
("Laylani", "Christian"),
|
|
||||||
("Ledger", "Carroll"),
|
|
||||||
("Leilany", "Small"),
|
|
||||||
("Leland", "McCall"),
|
|
||||||
("Leonard", "Calhoun"),
|
|
||||||
("Levi", "Ochoa"),
|
|
||||||
("Lillie", "Vang"),
|
|
||||||
("Lola", "Sheppard"),
|
|
||||||
("Luciana", "Poole"),
|
|
||||||
("Maddox", "Hughes"),
|
|
||||||
("Mara", "Blackwell"),
|
|
||||||
("Marcellus", "Bartlett"),
|
|
||||||
("Margo", "Koch"),
|
|
||||||
("Maurice", "Gibson"),
|
|
||||||
("Maxton", "Dodson"),
|
|
||||||
("Mia", "Parrish"),
|
|
||||||
("Millie", "Fuentes"),
|
|
||||||
("Nellie", "Villanueva"),
|
|
||||||
("Nicolas", "Mata"),
|
|
||||||
("Nicolas", "Miller"),
|
|
||||||
("Oakleigh", "Foster"),
|
|
||||||
("Octavia", "Pierce"),
|
|
||||||
("Paisley", "Allison"),
|
|
||||||
("Quincy", "Andersen"),
|
|
||||||
("Quincy", "Frazier"),
|
|
||||||
("Raiden", "Roberts"),
|
|
||||||
("Raquel", "Lara"),
|
|
||||||
("Rudy", "McIntosh"),
|
|
||||||
("Salvador", "Stein"),
|
|
||||||
("Samantha", "Dickson"),
|
|
||||||
("Solomon", "Richards"),
|
|
||||||
("Sylvia", "Hanna"),
|
|
||||||
("Talia", "Trujillo"),
|
|
||||||
("Thalia", "Farrell"),
|
|
||||||
("Trent", "Mayo"),
|
|
||||||
("Trinity", "Cummings"),
|
|
||||||
("Ty", "Perry"),
|
|
||||||
("Tyler", "Romero"),
|
|
||||||
("Valeria", "Pierce"),
|
|
||||||
("Vance", "Neal"),
|
|
||||||
("Whitney", "Bell"),
|
|
||||||
("Wilder", "Graves"),
|
|
||||||
("William", "Logan"),
|
|
||||||
("Zara", "Wilkinson"),
|
|
||||||
("Zaria", "Gibson"),
|
|
||||||
("Zion", "Watkins"),
|
|
||||||
("Zoie", "Armstrong"),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Registers a fixed test user account followed by one randomly-generated account
|
|
||||||
/// per entry in <see cref="SeedNames" />, adding a user verification record for each
|
|
||||||
/// newly created account that does not already have one. Progress counts are
|
|
||||||
/// written to the console on completion.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
|
||||||
/// <returns>A task that completes when all users have been seeded.</returns>
|
|
||||||
public async Task SeedAsync(SqlConnection connection)
|
|
||||||
{
|
|
||||||
PasswordGenerator generator = new();
|
|
||||||
Random rng = new();
|
|
||||||
int createdUsers = 0;
|
|
||||||
int createdCredentials = 0;
|
|
||||||
int createdVerifications = 0;
|
|
||||||
|
|
||||||
// create a known user for testing purposes
|
|
||||||
{
|
|
||||||
const string firstName = "Test";
|
|
||||||
const string lastName = "User";
|
|
||||||
const string email = "test.user@thebiergarten.app";
|
|
||||||
DateTime dob = new(1985, 03, 01);
|
|
||||||
string hash = GeneratePasswordHash("password");
|
|
||||||
|
|
||||||
await RegisterUserAsync(
|
|
||||||
connection,
|
|
||||||
$"{firstName}.{lastName}",
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
dob,
|
|
||||||
email,
|
|
||||||
hash
|
|
||||||
);
|
|
||||||
}
|
|
||||||
foreach ((string firstName, string lastName) in SeedNames)
|
|
||||||
{
|
|
||||||
// prepare user fields
|
|
||||||
string username = $"{firstName[0]}.{lastName}";
|
|
||||||
string email = $"{firstName}.{lastName}@thebiergarten.app";
|
|
||||||
DateTime dob = GenerateDateOfBirth(rng);
|
|
||||||
|
|
||||||
// generate a password and hash it
|
|
||||||
string pwd = generator.Generate(64, 10, 10);
|
|
||||||
string hash = GeneratePasswordHash(pwd);
|
|
||||||
|
|
||||||
// register the user (creates account + credential)
|
|
||||||
Guid id = await RegisterUserAsync(
|
|
||||||
connection,
|
|
||||||
username,
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
dob,
|
|
||||||
email,
|
|
||||||
hash
|
|
||||||
);
|
|
||||||
createdUsers++;
|
|
||||||
createdCredentials++;
|
|
||||||
|
|
||||||
// add user verification
|
|
||||||
if (await HasUserVerificationAsync(connection, id))
|
|
||||||
continue;
|
|
||||||
|
|
||||||
await AddUserVerificationAsync(connection, id);
|
|
||||||
createdVerifications++;
|
|
||||||
}
|
|
||||||
|
|
||||||
Console.WriteLine($"Created {createdUsers} user accounts.");
|
|
||||||
Console.WriteLine($"Added {createdCredentials} user credentials.");
|
|
||||||
Console.WriteLine($"Added {createdVerifications} user verifications.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Registers a new user account and its credential by invoking the
|
|
||||||
/// <c>dbo.USP_RegisterUser</c> stored procedure.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
|
||||||
/// <param name="username">The unique username for the account.</param>
|
|
||||||
/// <param name="firstName">The user's first name.</param>
|
|
||||||
/// <param name="lastName">The user's last name.</param>
|
|
||||||
/// <param name="dateOfBirth">The user's date of birth.</param>
|
|
||||||
/// <param name="email">The user's email address.</param>
|
|
||||||
/// <param name="hash">The salted password hash, as produced by <see cref="GeneratePasswordHash" />.</param>
|
|
||||||
/// <returns>The newly created user account's <see cref="Guid" /> identifier.</returns>
|
|
||||||
private static async Task<Guid> RegisterUserAsync(
|
|
||||||
SqlConnection connection,
|
|
||||||
string username,
|
|
||||||
string firstName,
|
|
||||||
string lastName,
|
|
||||||
DateTime dateOfBirth,
|
|
||||||
string email,
|
|
||||||
string hash
|
|
||||||
)
|
|
||||||
{
|
|
||||||
await using SqlCommand command = new("dbo.USP_RegisterUser", connection);
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
|
||||||
|
|
||||||
command.Parameters.Add("@Username", SqlDbType.VarChar, 64).Value = username;
|
|
||||||
command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 128).Value = firstName;
|
|
||||||
command.Parameters.Add("@LastName", SqlDbType.NVarChar, 128).Value = lastName;
|
|
||||||
command.Parameters.Add("@DateOfBirth", SqlDbType.DateTime).Value = dateOfBirth;
|
|
||||||
command.Parameters.Add("@Email", SqlDbType.VarChar, 128).Value = email;
|
|
||||||
command.Parameters.Add("@Hash", SqlDbType.NVarChar, -1).Value = hash;
|
|
||||||
|
|
||||||
object? result = await command.ExecuteScalarAsync();
|
|
||||||
|
|
||||||
return (Guid)result!;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hashes a plaintext password using Argon2id with a randomly generated 16-byte
|
|
||||||
/// salt, a degree of parallelism matching the available processor count, 64 MB of
|
|
||||||
/// memory, and 4 iterations.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="pwd">The plaintext password to hash.</param>
|
|
||||||
/// <returns>A string containing the base64-encoded salt and hash, separated by a colon.</returns>
|
|
||||||
private static string GeneratePasswordHash(string pwd)
|
|
||||||
{
|
|
||||||
byte[] salt = RandomNumberGenerator.GetBytes(16);
|
|
||||||
|
|
||||||
Argon2id argon2 = new(Encoding.UTF8.GetBytes(pwd))
|
|
||||||
{
|
|
||||||
Salt = salt,
|
|
||||||
DegreeOfParallelism = Math.Max(Environment.ProcessorCount, 1),
|
|
||||||
MemorySize = 65536,
|
|
||||||
Iterations = 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
byte[] hash = argon2.GetBytes(32);
|
|
||||||
return $"{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Checks whether a user verification record already exists for the given user account.</summary>
|
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
|
||||||
/// <param name="userAccountId">The user account identifier to check.</param>
|
|
||||||
/// <returns><c>true</c> if a verification record already exists; otherwise <c>false</c>.</returns>
|
|
||||||
private static async Task<bool> HasUserVerificationAsync(
|
|
||||||
SqlConnection connection,
|
|
||||||
Guid userAccountId
|
|
||||||
)
|
|
||||||
{
|
|
||||||
const string sql = """
|
|
||||||
SELECT 1
|
|
||||||
FROM dbo.UserVerification
|
|
||||||
WHERE UserAccountId = @UserAccountId;
|
|
||||||
""";
|
|
||||||
await using SqlCommand command = new(sql, connection);
|
|
||||||
command.Parameters.AddWithValue("@UserAccountId", userAccountId);
|
|
||||||
object? result = await command.ExecuteScalarAsync();
|
|
||||||
return result is not null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Creates a user verification record by invoking the <c>dbo.USP_CreateUserVerification</c> stored procedure.</summary>
|
|
||||||
/// <param name="connection">An open connection to the target database.</param>
|
|
||||||
/// <param name="userAccountId">The identifier of the user account to verify.</param>
|
|
||||||
/// <returns>A task that completes when the verification record has been created.</returns>
|
|
||||||
private static async Task AddUserVerificationAsync(SqlConnection connection, Guid userAccountId)
|
|
||||||
{
|
|
||||||
await using SqlCommand command = new("dbo.USP_CreateUserVerification", connection);
|
|
||||||
command.CommandType = CommandType.StoredProcedure;
|
|
||||||
command.Parameters.AddWithValue("@UserAccountID_", userAccountId);
|
|
||||||
|
|
||||||
await command.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a random date of birth corresponding to an age between 19 and 48
|
|
||||||
/// years (inclusive), with a random day offset within that birth year.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="random">The random number source to use.</param>
|
|
||||||
/// <returns>A randomly generated date of birth.</returns>
|
|
||||||
private static DateTime GenerateDateOfBirth(Random random)
|
|
||||||
{
|
|
||||||
int age = 19 + random.Next(0, 30);
|
|
||||||
DateTime baseDate = DateTime.UtcNow.Date.AddYears(-age);
|
|
||||||
int offsetDays = random.Next(0, 365);
|
|
||||||
return baseDate.AddDays(-offsetDays);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
18
web/backend/Database/Program.cs
Normal file
18
web/backend/Database/Program.cs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
namespace Database.Seed;
|
||||||
|
|
||||||
|
|
||||||
|
public interface IExportDatabase
|
||||||
|
{
|
||||||
|
void SeedLocations(IEnumerable<Location> locations);
|
||||||
|
void SeedUsers(IEnumerable<User> users);
|
||||||
|
|
||||||
|
void SeedBreweries(IEnumerable<Brewery> breweries);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IImportDatabase
|
||||||
|
{
|
||||||
|
IEnumerable<Location> GetLocations();
|
||||||
|
IEnumerable<User> GetUsers();
|
||||||
|
|
||||||
|
IEnumerable<Brewery> GetBreweries();
|
||||||
|
}
|
||||||
@@ -88,7 +88,7 @@ public class JwtInfrastructure : ITokenInfrastructure
|
|||||||
|
|
||||||
return new ClaimsPrincipal(result.ClaimsIdentity);
|
return new ClaimsPrincipal(result.ClaimsIdentity);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
throw new UnauthorizedException("Invalid token");
|
throw new UnauthorizedException("Invalid token");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<RootNamespace>Infrastructure.Sql</RootNamespace>
|
<RootNamespace>Infrastructure.Sql</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.4" />
|
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||||
<PackageReference Include="Microsoft.SqlServer.Types" Version="160.1000.6" />
|
<PackageReference Include="Microsoft.SqlServer.Types" Version="160.1000.6" />
|
||||||
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
|
<PackageReference Include="System.Data.SqlClient" Version="4.9.0" />
|
||||||
<PackageReference
|
<PackageReference
|
||||||
|
|||||||
Reference in New Issue
Block a user