This will walk through the process of creating and testing the employee performance review capability.
In simple terms, an employee performance review can occur at any time during an employee's employment. It captures the basic information: when, the reviewer, the outcome and related notes.
A new PerformanceReview
table and related Entity Framework (EF) model will be required to support.
First step, as was previously the case, is to create the PerformanceReview
table migration script. Execute the following within the MyEf.Hr.Database
directory to create the migration script for the project.
dotnet run script create Hr PerformanceReview
Open the newly created migration script within MyEf.Hr.Database/Migrations
and replace its contents with the following.
-- Create table: [Hr].[PerformanceReview]
BEGIN TRANSACTION
CREATE TABLE [Hr].[PerformanceReview] (
[PerformanceReviewId] UNIQUEIDENTIFIER NOT NULL DEFAULT (NEWSEQUENTIALID()) PRIMARY KEY,
[EmployeeId] UNIQUEIDENTIFIER NOT NULL,
[Date] DATETIME2 NULL,
[PerformanceOutcomeCode] NVARCHAR(50) NULL,
[Reviewer] NVARCHAR(100) NULL,
[Notes] NVARCHAR(4000) NULL,
[RowVersion] TIMESTAMP NOT NULL,
[CreatedBy] NVARCHAR(250) NULL,
[CreatedDate] DATETIME2 NULL,
[UpdatedBy] NVARCHAR(250) NULL,
[UpdatedDate] DATETIME2 NULL
);
COMMIT TRANSACTION
To support the above a Hr.PerformanceOutcome
reference data table is also required. Execute the following within the MyEf.Hr.Database
directory to create the migration script. No further changes will be needed.
dotnet run script refdata Hr PerformanceOutcome
Now that the Reference Data table exists it will need to be populated. Append the following YAML to the existing MyEf.Hr.Database/Data/RefData.yaml
file.
- $PerformanceOutcome:
- DN: Does not meet expectations
- ME: Meets expectations
- EE: Exceeds expectations
For the performance review, Entity Framework will be used exclusively to support the full CRUD capabilities. Append the following after the other reference data tables within MyEf.Hr.Database/database.beef-5.yaml
.
- name: PerformanceOutcome
Add the additional relationship to the Employee
table by replacing existing table configuration.
# References the Employee and related tables to implement the EF Model and infer the underlying schema.
- name: Employee
relationships: [
# Relationships can be code-generated (basic functionality), or handcrafted in the .NET code using the standard EntityFramework capabilities.
# - One-to-many to EmergencyContacts table foreign key using EmployeeId column. Cascade the delete. Auto include collection on get and track for updates.
{ name: EmergencyContact, propertyName: EmergencyContacts, foreignKeyColumns: [ EmployeeId ], onDelete: ClientCascade, autoInclude: true },
# - One-to-many to PerformanceReview table foreign key using EmployeeId column. Cascade the delete. Do _not_ auto include collection on get and track for updates (default).
{ name: PerformanceReview, propertyName: PerformanceReviews, foreignKeyColumns: [ EmployeeId ], onDelete: ClientCascade }
]
Finally add the PerformanceReview
table to the end of the file to create the related Entity Framework model.
- name: PerformanceReview
Once the configuration has been completed then the database can be created/updated, the code-generation performed, and the corresponding reference data loaded into the corresponding tables.
At the command line execute the following command within the MyEf.Hr.Database
directory to perform these updates. The log output will describe all actions that were performed.
dotnet run all
The reference data API for the PerformanceOutcome
needs to be added. Append the following to the end of refdata.beef.yaml
within the MyEf.Hr.CodeGen
project.
- { name: PerformanceOutcome, entityFrameworkModel: EfModel.PerformanceOutcome }
Once the reference data has been configured the code-generation can be performed. Use the following command line within the MyEf.Hr.CodeGen
directory to generate.
dotnet run refdata
To implement the core PerformanceReview
operations the following will need to be performed:
- Code configuration
- Data access logic
- Validation
The PerformanceReview
entity and operations configuration is required as follows, append to the end of entity.beef-5.yaml
(MyEf.Hr.CodeGen
project). Given that we are updating a single table row within the database, all of the operations will be able to be automatically implemented (generated) limiting the amount of code that needs to be added.
# Creating a PerformanceReview entity
# - Collection and CollectionResult required by GetByEmployeeId operation.
# - WebApiRoutePrefix is explicitly specified overridding the default.
# - Behavior specifies that a Get, Update, Patch and Delete operation should be implemeted following the standard pattern.
# - EntityFrameworkModel specifies the corresponding model and will be auto-implemented (defaults from configuration hierarchy).
# - EmployeeId is made immutable within mapper by specifing DataOperationTypes as AnyExceptUpdate; i.e. the value will never map (override) on an update.
- { name: PerformanceReview, collection: true, collectionResult: true, webApiRoutePrefix: reviews, behavior: gupd, validator: PerformanceReviewValidator, entityFrameworkModel: EfModel.PerformanceReview,
properties: [
{ name: Id, type: Guid, text: '{{PerformanceReview}} identifier', primaryKey: true, dataName: PerformanceReviewId, dataAutoGenerated: true },
{ name: EmployeeId, text: '{{Employee.Id}} (value is immutable)', type: Guid, dataOperationTypes: AnyExceptUpdate },
{ name: Date, type: DateTime },
{ name: Outcome, type: ^PerformanceOutcome, dataName: PerformanceOutcomeCode },
{ name: Reviewer },
{ name: Notes },
{ name: ETag },
{ name: ChangeLog, type: ChangeLog }
],
operations: [
# Operations (in addition to the Get, Update, Patch and Delete).
# GetByEmployeeId - this requires the EmployeeId to be passed in via the URI which is filtered within the developer extension.
# Create - this requires the EmployeeId to be passed in via the URI which will override the value in the entity within the Manager layer (as defined by LayerPassing value of ToManagerSet).
# The WebApiRoute with a ! prefix informs code-gen to use as-is versus prepending with the WebApiRoutePrefix; otherwise, would incorrectly result in 'reviews/employees/{employeeId}'.
{ name: GetByEmployeeId, type: GetColl, paging: true, webApiRoute: '!employees/{employeeId}/reviews',
parameters: [
{ name: EmployeeId, text: '{{Employee}} identifier', type: Guid, isMandatory: true }
]
},
{ name: Create, type: Create, webApiRoute: '!employees/{employeeId}/reviews',
parameters: [
{ name: EmployeeId, text: '{{Employee}} identifier', type: Guid, layerPassing: ToManagerSet, isMandatory: true }
]
}
]
}
Once configured the code-generation can be performed. Use the following command line within the MyEf.Hr.CodeGen
directory to generate.
dotnet run entity
The generated PerformanceReviewData.cs
logic will need to be extended to support the filtering for the GetByEmployeeId
operation. This logic must be implemented by the developer in a non-generated partial
class. Create a new PerformanceReviewData.cs
class within MyEf.Hr.Business/Data
.
To implement the filtering, the extension delegate named _getByEmployeeIdOnQuery
is implemented. Implement the class as follows:
namespace MyEf.Hr.Business.Data;
public partial class PerformanceReviewData
{
partial void PerformanceReviewDataCtor()
{
_getByEmployeeIdOnQuery = (q, employeeId) => q.Where(x => x.EmployeeId == employeeId).OrderByDescending(x => x.Date);
}
}
Within the MyEf.Hr.Business/Validation
folder create PerformanceReviewValidator.cs
and implement as follows:
namespace MyEf.Hr.Business.Validation;
/// <summary>
/// Represents a <see cref="PerformanceReview"/> validator.
/// </summary>
public class PerformanceReviewValidator : Validator<PerformanceReview>
{
private readonly IPerformanceReviewManager _performanceReviewManager;
private readonly IEmployeeManager _employeeManager;
/// <summary>
/// Initializes a new instance of the <see cref="PerformanceReviewValidator"/> class.
/// </summary>
public PerformanceReviewValidator(IPerformanceReviewManager performanceReviewManager, IEmployeeManager employeeManager)
{
_performanceReviewManager = performanceReviewManager ?? throw new ArgumentNullException(nameof(performanceReviewManager));
_employeeManager = employeeManager ?? throw new ArgumentNullException(nameof(employeeManager));
Property(x => x.EmployeeId).Mandatory();
Property(x => x.Date).Mandatory().CompareValue(CompareOperator.LessThanEqual, _ => CoreEx.ExecutionContext.Current.Timestamp, _ => "today");
Property(x => x.Notes).String(4000);
Property(x => x.Reviewer).Mandatory().String(256);
Property(x => x.Outcome).Mandatory().IsValid();
}
/// <summary>
/// Add further validation logic.
/// </summary>
protected override async Task<Result> OnValidateAsync(ValidationContext<PerformanceReview> context, CancellationToken cancellationToken)
{
// Exit where the EmployeeId is not valid.
if (context.HasError(x => x.EmployeeId))
return Result.Success;
// Ensure that the EmployeeId, on Update, has not been changed as it is immutable.
if (ExecutionContext.OperationType == OperationType.Update)
{
var prr = await Result.GoAsync(_performanceReviewManager.GetAsync(context.Value.Id))
.When(v => v is null, _ => Result.NotFoundError()).ConfigureAwait(false);
if (prr.IsFailure)
return prr.AsResult();
if (context.Value.EmployeeId != prr.Value.EmployeeId)
return Result.Done(() => context.AddError(x => x.EmployeeId, ValidatorStrings.ImmutableFormat));
}
// Check that the referenced Employee exists, and the review data is within the bounds of their employment.
return await Result.GoAsync(_employeeManager.GetAsync(context.Value.EmployeeId)).Then(e =>
{
if (e == null)
context.AddError(x => x.EmployeeId, ValidatorStrings.ExistsFormat);
else
{
if (!context.HasError(x => x.Date))
{
if (context.Value.Date < e.StartDate)
context.AddError(x => x.Date, "{0} must not be prior to the Employee starting.");
else if (e.Termination != null && context.Value.Date > e.Termination.Date)
context.AddError(x => x.Date, "{0} must not be after the Employee has terminated.");
}
}
}).AsResult().ConfigureAwait(false);
}
}
For the purposes of this sample peform the following:
- Copy the file and contents of
PerformanceReviewTest.cs
into your test projectMyEf.Hr.Test/Apis
folder; - Copy the file and contents of
PerformanceReviewValidatorTest.cs
into your test projectMyEf.Hr.Test/Validators
folder.
For the end-to-end testing to function the performance review related data must first be populated into the database; append the following into the existing Data.yaml
(MyEf.Hr.Test/Data
).
- PerformanceReview:
- { PerformanceReviewId: 1, EmployeeId: 2, Date: 2014-03-15, PerformanceOutcomeCode: DN, Reviewer: [email protected], Notes: Work quality low. }
- { PerformanceReviewId: 2, EmployeeId: 1, Date: 2016-11-12, PerformanceOutcomeCode: EE, Reviewer: [email protected], Notes: They are awesome! }
- { PerformanceReviewId: 3, EmployeeId: 2, Date: 2014-01-15, PerformanceOutcomeCode: DN, Reviewer: [email protected], Notes: Work quality below standard. }
Review and execute the tests and ensure they all pass as expected.
At this stage we now have added and tested the performance review capabilities. All the desired functional requirements have now been implemented.
To verify, build the solution and ensure no compilation errors.
Within test explorer, run the PerformanceReviewTest
and PerformanceReviewValidatorTest
set of tests and confirm they all pass.
The following tests were newly added and should pass:
A110_Get
A110_Get_NotFound
A210_GetByEmployeeId_NotFound
A220_GetByEmployeeId
A220_GetByEmployeeId_Last
B110_Create
C110_Update_NotFound
C120_Update_Concurrency
C130_Update
E110_Delete
A110_Validate_Initial
A120_Validate_BadData
A130_Validate_BeforeStarting
A140_Validate_AfterTermination
A150_Validate_EmployeeNotFound
A160_Validate_EmployeeImmutable
A170_Validate_CreateOK
A180_Validate_UpdateOK
Next we will consider the addition of Event-driven architecture capabilities, starting by implementing the Transactional Outbox