Restructure data access layer/data layer

This commit is contained in:
Aaron Po
2026-01-11 23:36:26 -05:00
parent 8d6b903aa7
commit 372aac897a
17 changed files with 457 additions and 256 deletions

View File

@@ -9,7 +9,7 @@ namespace DALTests
{ {
public class UserAccountRepositoryTests public class UserAccountRepositoryTests
{ {
private readonly UserAccountRepository _repository; private readonly IUserAccountRepository _repository;
public UserAccountRepositoryTests() public UserAccountRepositoryTests()
{ {

View File

@@ -0,0 +1,13 @@
using System;
using System.Collections.Generic;
using DataAccessLayer.Entities;
namespace DataAccessLayer
{
public interface IUserAccountRepository : IRepository<UserAccount>
{
IEnumerable<UserAccount> GetAll();
UserAccount? GetByUsername(string username);
UserAccount? GetByEmail(string email);
}
}

View File

@@ -0,0 +1,163 @@
using System;
using System.Collections.Generic;
using DataAccessLayer.Entities;
using Microsoft.Data.SqlClient;
namespace DataAccessLayer
{
public class UserAccountRepository : IUserAccountRepository
{
private readonly string _connectionString;
public UserAccountRepository()
{
// Retrieve the connection string from environment variables
_connectionString =
Environment.GetEnvironmentVariable("DB_CONNECTION_STRING")
?? throw new InvalidOperationException(
"The connection string is not set in the environment variables."
);
}
public void Add(UserAccount userAccount)
{
using SqlConnection connection = new(_connectionString);
using SqlCommand command = new("usp_CreateUserAccount", connection);
command.CommandType = System.Data.CommandType.StoredProcedure;
AddUserAccountCreateParameters(command, userAccount);
connection.Open();
command.ExecuteNonQuery();
}
public UserAccount? GetById(Guid id)
{
using SqlConnection connection = new(_connectionString);
using SqlCommand command = new(
"usp_GetUserAccountById",
connection
);
command.CommandType = System.Data.CommandType.StoredProcedure;
command.Parameters.AddWithValue("@UserAccountId", id);
connection.Open();
using SqlDataReader reader = command.ExecuteReader();
return reader.Read() ? MapUserAccount(reader) : null;
}
public void Update(UserAccount userAccount)
{
using SqlConnection connection = new(_connectionString);
using SqlCommand command = new("usp_UpdateUserAccount", connection);
command.CommandType = System.Data.CommandType.StoredProcedure;
AddUserAccountUpdateParameters(command, userAccount);
connection.Open();
command.ExecuteNonQuery();
}
public void Delete(Guid id)
{
using SqlConnection connection = new(_connectionString);
using SqlCommand command = new(
"usp_DeleteUserAccount",
connection
);
command.CommandType = System.Data.CommandType.StoredProcedure;
command.Parameters.AddWithValue("@UserAccountId", id);
connection.Open();
command.ExecuteNonQuery();
}
public IEnumerable<UserAccount> GetAll()
{
using SqlConnection connection = new(_connectionString);
using SqlCommand command = new(
"usp_GetAllUserAccounts",
connection
);
command.CommandType = System.Data.CommandType.StoredProcedure;
connection.Open();
using SqlDataReader reader = command.ExecuteReader();
List<UserAccount> users = new();
while (reader.Read())
{
users.Add(MapUserAccount(reader));
}
return users;
}
public UserAccount? GetByUsername(string username)
{
using SqlConnection connection = new(_connectionString);
using SqlCommand command = new(
"usp_GetUserAccountByUsername",
connection
);
command.CommandType = System.Data.CommandType.StoredProcedure;
command.Parameters.AddWithValue("@Username", username);
connection.Open();
using SqlDataReader reader = command.ExecuteReader();
return reader.Read() ? MapUserAccount(reader) : null;
}
public UserAccount? GetByEmail(string email)
{
using SqlConnection connection = new(_connectionString);
using SqlCommand command = new(
"usp_GetUserAccountByEmail",
connection
);
command.CommandType = System.Data.CommandType.StoredProcedure;
command.Parameters.AddWithValue("@Email", email);
connection.Open();
using SqlDataReader reader = command.ExecuteReader();
return reader.Read() ? MapUserAccount(reader) : null;
}
private static void AddUserAccountCreateParameters(
SqlCommand command,
UserAccount userAccount
)
{
command.Parameters.AddWithValue(
"@UserAccountId",
userAccount.UserAccountID
);
command.Parameters.AddWithValue("@Username", userAccount.Username);
command.Parameters.AddWithValue("@FirstName", userAccount.FirstName);
command.Parameters.AddWithValue("@LastName", userAccount.LastName);
command.Parameters.AddWithValue("@Email", userAccount.Email);
command.Parameters.AddWithValue("@DateOfBirth", userAccount.DateOfBirth);
}
private static void AddUserAccountUpdateParameters(
SqlCommand command,
UserAccount userAccount
)
{
AddUserAccountCreateParameters(command, userAccount);
command.Parameters.AddWithValue(
"@UserAccountId",
userAccount.UserAccountID
);
}
private static UserAccount MapUserAccount(SqlDataReader reader)
{
return new UserAccount
{
UserAccountID = reader.GetGuid(0),
Username = reader.GetString(1),
FirstName = reader.GetString(2),
LastName = reader.GetString(3),
Email = reader.GetString(4),
CreatedAt = reader.GetDateTime(5),
UpdatedAt = reader.IsDBNull(6) ? null : reader.GetDateTime(6),
DateOfBirth = reader.GetDateTime(7),
Timer = reader.IsDBNull(8) ? null : (byte[])reader[8],
};
}
}
}

View File

@@ -2,7 +2,7 @@
using System.Data; using System.Data;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
namespace DataAccessLayer namespace DataAccessLayer.Sql
{ {
public class DatabaseHelper public class DatabaseHelper
{ {

View File

@@ -3,6 +3,7 @@ GO
CREATE OR ALTER PROCEDURE usp_CreateUserAccount CREATE OR ALTER PROCEDURE usp_CreateUserAccount
( (
@UserAccountId UNIQUEIDENTIFIER = NULL,
@Username VARCHAR(64), @Username VARCHAR(64),
@FirstName NVARCHAR(128), @FirstName NVARCHAR(128),
@LastName NVARCHAR(128), @LastName NVARCHAR(128),
@@ -17,6 +18,7 @@ BEGIN
INSERT INTO UserAccount INSERT INTO UserAccount
( (
UserAccountID,
Username, Username,
FirstName, FirstName,
LastName, LastName,
@@ -25,6 +27,7 @@ BEGIN
) )
VALUES VALUES
( (
COALESCE(@UserAccountId, NEWID()),
@Username, @Username,
@FirstName, @FirstName,
@LastName, @LastName,
@@ -40,7 +43,7 @@ GO
CREATE OR ALTER PROCEDURE usp_DeleteUserAccount CREATE OR ALTER PROCEDURE usp_DeleteUserAccount
( (
@UserAccountId INT @UserAccountId UNIQUEIDENTIFIER
) )
AS AS
BEGIN BEGIN
@@ -70,7 +73,7 @@ CREATE OR ALTER PROCEDURE usp_UpdateUserAccount
@LastName NVARCHAR(128), @LastName NVARCHAR(128),
@DateOfBirth DATETIME, @DateOfBirth DATETIME,
@Email VARCHAR(128), @Email VARCHAR(128),
@UserAccountId GUID @UserAccountId UNIQUEIDENTIFIER
) )
AS AS
BEGIN BEGIN
@@ -98,3 +101,87 @@ BEGIN
COMMIT TRANSACTION COMMIT TRANSACTION
END; END;
GO GO
CREATE OR ALTER PROCEDURE usp_GetUserAccountById
(
@UserAccountId UNIQUEIDENTIFIER
)
AS
BEGIN
SET NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId;
END;
GO
CREATE OR ALTER PROCEDURE usp_GetAllUserAccounts
AS
BEGIN
SET NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount;
END;
GO
CREATE OR ALTER PROCEDURE usp_GetUserAccountByUsername
(
@Username VARCHAR(64)
)
AS
BEGIN
SET NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Username = @Username;
END;
GO
CREATE OR ALTER PROCEDURE usp_GetUserAccountByEmail
(
@Email VARCHAR(128)
)
AS
BEGIN
SET NOCOUNT ON;
SELECT UserAccountID,
Username,
FirstName,
LastName,
Email,
CreatedAt,
UpdatedAt,
DateOfBirth,
Timer
FROM dbo.UserAccount
WHERE Email = @Email;
END;
GO

View File

@@ -1,51 +0,0 @@
using System;
using System.Collections.Generic;
using System.Data;
using DataAccessLayer.Entities;
using Microsoft.Data.SqlClient;
namespace DataAccessLayer
{
public class UserAccountRepository : IRepository<UserAccount>
{
private readonly string _connectionString;
public UserAccountRepository()
{
// Retrieve the connection string from environment variables
_connectionString =
Environment.GetEnvironmentVariable("DB_CONNECTION_STRING")
?? throw new InvalidOperationException(
"The connection string is not set in the environment variables."
);
}
public void Add(UserAccount userAccount)
{
}
public UserAccount? GetById(Guid id)
{
return null;
}
public void Update(UserAccount userAccount)
{
}
public void Delete(Guid id)
{
}
public IEnumerable<UserAccount> GetAll()
{
return new List<UserAccount>
{
};
}
}
}

51
README.md Normal file
View File

@@ -0,0 +1,51 @@
# Biergarten SQL Server - Architecture Overview
This solution is a monolith-oriented Web API with a layered structure. The current focus is a SQL Server-backed data layer with stored procedures and a repository-based DAL.
## High-level projects
- `WebAPI/` - ASP.NET Core API endpoints (controllers) and application entrypoint.
- `BusinessLayer/` - Intended home for domain/business logic (currently minimal).
- `DataAccessLayer/` - Repository implementations, entities (POCOs), and SQL helpers.
- `DataLayer/` - Database schema, seed scripts, and data sources.
- `WebCrawler/` - Separate crawler executable.
- `DALTests/` - Data access tests.
## Data access architecture
- **Entities (POCOs)** live in `DataAccessLayer/Entities/`.
- **Repositories** live in `DataAccessLayer/Repositories/` and implement interfaces like `IUserAccountRepository`.
- **SQL execution** lives in `DataAccessLayer/Sql/`.
- **Stored procedures** for CRUD live under `DataAccessLayer/Sql/crud/` and are invoked by repositories.
Example flow:
```
WebAPI Controller -> IUserAccountRepository -> UserAccountRepository -> stored procedure
```
The repositories are currently responsible for:
- Opening connections using `DB_CONNECTION_STRING`
- Executing stored procedures
- Mapping result sets to POCOs
## Database schema and seed
- `DataLayer/schema.sql` contains the database schema definitions.
- `DataLayer/seed/SeedDB.cs` provides seeding and stored procedure/function loading.
- Stored procedure scripts are organized under `DataAccessLayer/Sql/crud/` (UserAccount and related).
## Key conventions
- **Environment variables**: `DB_CONNECTION_STRING` is required for DAL and seed tooling.
- **Stored procedures**: CRUD operations use `usp_*` procedures.
- **Rowversion** columns are represented as `byte[]` in entities (e.g., `Timer`).
## Suggested dependency direction
```
WebAPI -> BusinessLayer -> DataAccessLayer -> SQL Server
-> DataLayer (schema/seed/scripts)
```
Keep business logic in `BusinessLayer` and avoid direct SQL or ADO code outside `DataAccessLayer`.

View File

@@ -1,75 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace WebAPI.Controllers
{
[ApiController]
[Route("api/beers")]
public class BeersController : ControllerBase
{
[HttpGet]
public IActionResult GetBeers([FromQuery] int page_num, [FromQuery] int page_size)
{
return Ok();
}
[HttpGet("search")]
public IActionResult SearchBeers([FromQuery] string search)
{
return Ok();
}
[HttpGet("styles")]
public IActionResult GetBeerStyles([FromQuery] int page_num, [FromQuery] int page_size)
{
return Ok();
}
[HttpPost("styles/create")]
public IActionResult CreateBeerStyle([FromBody] BeerStyleCreateRequest request)
{
return Ok();
}
[HttpPut("{postId}")]
public IActionResult EditBeer(string postId, [FromBody] BeerEditRequest request)
{
return Ok();
}
[HttpDelete("{postId}")]
public IActionResult DeleteBeer(string postId)
{
return Ok();
}
[HttpGet("{postId}/recommendations")]
public IActionResult GetBeerRecommendations([FromQuery] int page_num, [FromQuery] int page_size, string postId)
{
return Ok();
}
[HttpPost("{postId}/comments")]
public IActionResult AddBeerComment(string postId, [FromBody] BeerCommentRequest request)
{
return Ok();
}
[HttpGet("{postId}/comments")]
public IActionResult GetBeerComments([FromQuery] int page_num, [FromQuery] int page_size, string postId)
{
return Ok();
}
[HttpPut("{postId}/comments/{commentId}")]
public IActionResult EditBeerComment(string postId, string commentId, [FromBody] BeerCommentRequest request)
{
return Ok();
}
[HttpDelete("{postId}/comments/{commentId}")]
public IActionResult DeleteBeerComment(string postId, string commentId)
{
return Ok();
}
}
}

View File

@@ -1,57 +0,0 @@
using Microsoft.AspNetCore.Mvc;
namespace WebAPI.Controllers
{
[ApiController]
[Route("api/breweries")]
public class BreweriesController : ControllerBase
{
[HttpGet]
public IActionResult GetBreweries([FromQuery] int page_num, [FromQuery] int page_size)
{
return Ok();
}
[HttpGet("map")]
public IActionResult GetBreweriesMap([FromQuery] int page_num, [FromQuery] int page_size)
{
return Ok();
}
[HttpPut("{postId}")]
public IActionResult EditBrewery(string postId, [FromBody] BreweryEditRequest request)
{
return Ok();
}
[HttpDelete("{postId}")]
public IActionResult DeleteBrewery(string postId)
{
return Ok();
}
[HttpPost("{postId}/comments")]
public IActionResult AddBreweryComment(string postId, [FromBody] BreweryCommentRequest request)
{
return Ok();
}
[HttpGet("{postId}/comments")]
public IActionResult GetBreweryComments([FromQuery] int page_num, [FromQuery] int page_size, string postId)
{
return Ok();
}
[HttpPut("{postId}/comments/{commentId}")]
public IActionResult EditBreweryComment(string postId, string commentId, [FromBody] BreweryCommentRequest request)
{
return Ok();
}
[HttpDelete("{postId}/comments/{commentId}")]
public IActionResult DeleteBreweryComment(string postId, string commentId)
{
return Ok();
}
}
}

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using DataAccessLayer; using DataAccessLayer;
using DataAccessLayer.Entities;
using Microsoft.AspNetCore.Mvc;
namespace WebAPI.Controllers namespace WebAPI.Controllers
{ {
@@ -7,7 +8,7 @@ namespace WebAPI.Controllers
[Route("api/users")] [Route("api/users")]
public class UsersController : ControllerBase public class UsersController : ControllerBase
{ {
private readonly UserAccountRepository _userAccountRepository; private readonly IUserAccountRepository _userAccountRepository;
public UsersController() public UsersController()
{ {
@@ -15,6 +16,7 @@ namespace WebAPI.Controllers
} }
// all users // all users
[HttpGet]
[HttpGet("users")] [HttpGet("users")]
public IActionResult GetAllUsers() public IActionResult GetAllUsers()
{ {
@@ -22,5 +24,61 @@ namespace WebAPI.Controllers
return Ok(users); return Ok(users);
} }
[HttpGet("{id:guid}")]
public IActionResult GetUserById(Guid id)
{
var user = _userAccountRepository.GetById(id);
return user is null ? NotFound() : Ok(user);
}
[HttpGet("by-username/{username}")]
public IActionResult GetUserByUsername(string username)
{
var user = _userAccountRepository.GetByUsername(username);
return user is null ? NotFound() : Ok(user);
}
[HttpGet("by-email/{email}")]
public IActionResult GetUserByEmail(string email)
{
var user = _userAccountRepository.GetByEmail(email);
return user is null ? NotFound() : Ok(user);
}
[HttpPost]
public IActionResult CreateUser([FromBody] UserAccount userAccount)
{
if (userAccount.UserAccountID == Guid.Empty)
{
userAccount.UserAccountID = Guid.NewGuid();
}
_userAccountRepository.Add(userAccount);
return CreatedAtAction(
nameof(GetUserById),
new { id = userAccount.UserAccountID },
userAccount
);
}
[HttpPut("{id:guid}")]
public IActionResult UpdateUser(Guid id, [FromBody] UserAccount userAccount)
{
if (userAccount.UserAccountID != Guid.Empty && userAccount.UserAccountID != id)
{
return BadRequest("UserAccountID does not match route id.");
}
userAccount.UserAccountID = id;
_userAccountRepository.Update(userAccount);
return NoContent();
}
[HttpDelete("{id:guid}")]
public IActionResult DeleteUser(Guid id)
{
_userAccountRepository.Delete(id);
return NoContent();
}
} }
} }

View File

@@ -30,17 +30,22 @@ catch
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add services to the container. // Add services to the container.
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
builder.Services.AddOpenApi(); builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddOpenApi();
var app = builder.Build(); var app = builder.Build();
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); app.UseSwagger();
} app.UseSwaggerUI();
app.MapOpenApi();
}
app.UseHttpsRedirection(); app.UseHttpsRedirection();
@@ -58,24 +63,6 @@ var summaries = new[]
"Scorching", "Scorching",
}; };
app.MapGet(
"/weatherforecast",
() =>
{
var forecast = Enumerable
.Range(1, 5)
.Select(index => new WeatherForecast(
DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
Random.Shared.Next(-20, 55),
summaries[Random.Shared.Next(summaries.Length)]
))
.ToArray();
return forecast;
}
)
.WithName("GetWeatherForecast");
// Register controllers
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();

View File

@@ -5,9 +5,10 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
</ItemGroup> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../DataAccessLayer/DataAccessLayer.csproj" /> <ProjectReference Include="../DataAccessLayer/DataAccessLayer.csproj" />

View File

@@ -1,6 +1,64 @@
@WebAPI_HostAddress = http://localhost:5069 @WebAPI_HostAddress = http://localhost:5069
GET {{WebAPI_HostAddress}}/weatherforecast/ GET {{WebAPI_HostAddress}}/weatherforecast/
Accept: application/json Accept: application/json
### ###
GET {{WebAPI_HostAddress}}/api/users
Accept: application/json
###
GET {{WebAPI_HostAddress}}/api/users/{{userId}}
Accept: application/json
###
GET {{WebAPI_HostAddress}}/api/users/by-username/{{username}}
Accept: application/json
###
GET {{WebAPI_HostAddress}}/api/users/by-email/{{email}}
Accept: application/json
###
POST {{WebAPI_HostAddress}}/api/users
Content-Type: application/json
Accept: application/json
{
"userAccountID": "00000000-0000-0000-0000-000000000000",
"username": "testuser",
"firstName": "Test",
"lastName": "User",
"email": "testuser@example.com",
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": null,
"dateOfBirth": "1990-01-01T00:00:00Z",
"timer": null
}
###
PUT {{WebAPI_HostAddress}}/api/users/{{userId}}
Content-Type: application/json
Accept: application/json
{
"userAccountID": "{{userId}}",
"username": "testuser",
"firstName": "Updated",
"lastName": "User",
"email": "testuser@example.com",
"createdAt": "2025-01-01T00:00:00Z",
"updatedAt": "2025-02-01T00:00:00Z",
"dateOfBirth": "1990-01-01T00:00:00Z",
"timer": null
}
###
DELETE {{WebAPI_HostAddress}}/api/users/{{userId}}

View File

@@ -17,40 +17,6 @@ services:
retries: 12 retries: 12
networks: networks:
- devnet - devnet
redis:
image: redis:7
container_name: redis
env_file:
- .env
networks:
- devnet
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
dotnet:
image: mcr.microsoft.com/dotnet/sdk:10.0
container_name: dotnet-sdk
tty: true
stdin_open: true
volumes:
- ./:/home/dev/projects
- nuget-cache:/home/dev/.nuget/packages
- ~/.gitconfig:/home/dev/.gitconfig:ro
working_dir: /home/dev/projects
environment:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
HOME: /home/dev
USER: dev
DB_CONNECTION_STRING: "Server=sqlserver,1433;User Id=sa;Password=${SA_PASSWORD};Encrypt=True;TrustServerCertificate=True;Connection Timeout=30;Database=${DB_NAME};"
REDIS_URL: "${REDIS_URL}"
user: root
networks:
- devnet
volumes: volumes:
sqlserverdata: sqlserverdata:
nuget-cache: nuget-cache: