Skip to content

Commit

Permalink
[ODS-6358] Review and testing of Problem Details responses related to…
Browse files Browse the repository at this point in the history
… conflict exceptions (#1037)
  • Loading branch information
gmcelhanon authored May 6, 2024
1 parent 5791680 commit 1e75a6e
Show file tree
Hide file tree
Showing 29 changed files with 912 additions and 423 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,17 @@ namespace EdFi.Ods.Api.ExceptionHandling.Translators
{
public class NonUniqueObjectExceptionTranslator : IProblemDetailsExceptionTranslator
{
private const string ExpectedExceptionPattern =
@"^a different object with the same identifier value was already associated with the session: (?<subject>\w*): (?<subjectId>\d*), (?<entitySimple>\w*): (?<property>\w*): (?<entityPropertyId>\d*), of entity: (?<entityFullName>\w*)";
private static readonly Regex _expectedExceptionRegex = new(
@"^a different object with the same identifier value was already associated with the session: (?<subject>\w*): (?<subjectId>\d*), (?<entitySimple>\w*): (?<property>\w*): (?<entityPropertyId>\d*), of entity: (?<entityFullName>\w*)",
RegexOptions.Compiled);

private readonly ILog _logger = LogManager.GetLogger(typeof(NonUniqueObjectExceptionTranslator));

public bool TryTranslate(Exception ex, out IEdFiProblemDetails problemDetails)
{
if (ex is NonUniqueObjectException)
{
var match = Regex.Match(ex.Message, ExpectedExceptionPattern);
var match = _expectedExceptionRegex.Match(ex.Message);

if (match.Success)
{
Expand All @@ -34,9 +35,10 @@ public bool TryTranslate(Exception ex, out IEdFiProblemDetails problemDetails)
match.Groups["subject"].Value,
match.Groups["entitySimple"].Value,
match.Groups["property"].Value,
match.Groups["entityPropertyId"].Value));
match.Groups["entityPropertyId"].Value),
ex);

problemDetails = new NonUniqueConflictException("A problem occurred while processing the request.",
problemDetails = new NonUniqueValuesException("A problem occurred while processing the request.",
new[] { $"Two {match.Groups["subject"]} entities with the same identifier were associated with the session. See log for additional details." });

return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
using System.Text.RegularExpressions;
using EdFi.Ods.Common.Context;
using EdFi.Ods.Common.Exceptions;
using EdFi.Ods.Common.Models;
using EdFi.Ods.Common.Models.Domain;
using EdFi.Ods.Common.Security.Claims;
using NHibernate.Exceptions;
using Npgsql;
Expand All @@ -17,21 +19,29 @@ namespace EdFi.Ods.Api.ExceptionHandling.Translators.Postgres
public class PostgresDuplicateKeyExceptionTranslator : IProblemDetailsExceptionTranslator
{
private readonly IContextProvider<DataManagementResourceContext> _dataManagementResourceContextProvider;
private readonly IDomainModelProvider _domainModelProvider;

private static readonly Regex _expression = new(@"(?<ErrorCode>\d*): duplicate key value violates unique constraint ""(?<ConstraintName>.*?)""");
private static readonly Regex _detailExpression = new(@"Key \((?<KeyColumns>.*?)\)=\((?<KeyValues>.*?)\) (?<ConstraintType>already exists).");
private static readonly Regex _detailExpression = new(
@"Key \((?<KeyColumns>.*?)\)=\((?<KeyValues>.*?)\) (?<ConstraintType>already exists).",
RegexOptions.Compiled);

private const string GenericMessage = "The value(s) supplied for the resource are not unique.";

private const string SimpleKeyMessageFormat = "The value supplied for property '{0}' of entity '{1}' is not unique.";
private const string CompositeKeyMessageFormat = "The values supplied for properties '{0}' of entity '{1}' are not unique.";
private const string DuplicatePrimaryKeyTableOnlyErrorFormat =
"A primary key conflict occurred when attempting to create or update a record in the '{0}' table.";

private const string DuplicatePrimaryKeyErrorFormat =
"A primary key conflict occurred when attempting to create or update a record in the '{0}' table. The duplicate key is ({1}) = ({2}).";

private const string SimpleKeyErrorMessageFormat = "The value supplied for property '{0}' of entity '{1}' is not unique.";
private const string CompositeKeyErrorMessageFormat = "The values supplied for properties '{0}' of entity '{1}' are not unique.";

private const string PrimaryKeyNameSuffix = "_pk";

public PostgresDuplicateKeyExceptionTranslator(
IContextProvider<DataManagementResourceContext> dataManagementResourceContextProvider)
IContextProvider<DataManagementResourceContext> dataManagementResourceContextProvider,
IDomainModelProvider domainModelProvider)
{
_dataManagementResourceContextProvider = dataManagementResourceContextProvider;
_domainModelProvider = domainModelProvider;
}

public bool TryTranslate(Exception ex, out IEdFiProblemDetails problemDetails)
Expand All @@ -42,66 +52,110 @@ public bool TryTranslate(Exception ex, out IEdFiProblemDetails problemDetails)

if (exceptionToEvaluate is PostgresException postgresException)
{
var match = _expression.Match(postgresException.Message);

if (match.Success)
if (postgresException.SqlState == PostgresErrorCodes.UniqueViolation)
{
var constraintName = match.Groups["ConstraintName"].ValueSpan;

string message = GetMessageUsingRequestContext(constraintName)
?? GetMessageUsingPostgresException();

problemDetails = new NonUniqueConflictException(message);
// Examples:
// ----------------------------------------
// INSERT duplicate AK values
// [23505] ERROR: duplicate key value violates unique constraint "student_ui_studentuniqueid" Detail: Key (studentuniqueid)=(ABC) already exists.

// INSERT duplicate PK values
// [23505] ERROR: duplicate key value violates unique constraint "educationorganization_pk" Detail: Key (educationorganizationid)=(123) already exists.

// UPDATE PK to duplicate PK values
// [23505] ERROR: duplicate key value violates unique constraint "educationorganization_pk" Detail: Key (educationorganizationid)=(123) already exists.
// ----------------------------------------

problemDetails =
GetNonUniqueIdentityException()
?? GetNonUniqueValuesException()
?? new NonUniqueValuesException(NonUniqueValuesException.DefaultDetail);

return true;
}

string GetMessageUsingRequestContext(ReadOnlySpan<char> constraintName)
{
// Rely on PK suffix naming convention to identify PK constraint violation (which covers almost all scenarios for this violation)
if (constraintName.EndsWith(PrimaryKeyNameSuffix, StringComparison.OrdinalIgnoreCase))
EdFiProblemDetailsExceptionBase GetNonUniqueIdentityException()
{
var tableName = constraintName.Slice(0, constraintName.Length - PrimaryKeyNameSuffix.Length).ToString();

// Look for matching class in the request's targeted resource
if (_dataManagementResourceContextProvider.Get()?.Resource?
.ContainedItemTypeByName.TryGetValue(tableName, out var resourceClass) ?? false)
// Rely on PK suffix naming convention to identify PK constraint violation (which covers almost all scenarios for this violation)
if (postgresException.ConstraintName!.EndsWith(PrimaryKeyNameSuffix, StringComparison.OrdinalIgnoreCase))
{
var pkPropertyNames = resourceClass.IdentifyingProperties.Select(p => p.PropertyName).ToArray();

return string.Format(
(pkPropertyNames.Length > 1)
? CompositeKeyMessageFormat
: SimpleKeyMessageFormat,
string.Join("', '", pkPropertyNames),
resourceClass.Name);
var tableName = postgresException.TableName;

// If detail is available, use it.
if (!string.IsNullOrEmpty(postgresException.Detail) && !postgresException.Detail.StartsWith("Detail redacted"))
{
var exceptionInfo = new PostgresExceptionInfo(postgresException, _detailExpression);

if (!string.IsNullOrEmpty(exceptionInfo.ColumnNames) && !string.IsNullOrEmpty(exceptionInfo.Values))
{
string errorMessage = string.Format(
DuplicatePrimaryKeyErrorFormat,
exceptionInfo.TableName,
exceptionInfo.ColumnNames,
exceptionInfo.Values);

return new NonUniqueIdentityException(NonUniqueIdentityException.DefaultDetail, [errorMessage]);
}
}

// Look for matching class in the request's targeted resource
if (_dataManagementResourceContextProvider.Get()?.Resource?
.ContainedItemTypeByName.TryGetValue(tableName, out var resourceClass) ?? false)
{
var pkPropertyNames = resourceClass.IdentifyingProperties.Select(p => p.PropertyName).ToArray();

string errorMessage = string.Format(
(pkPropertyNames.Length > 1)
? CompositeKeyErrorMessageFormat
: SimpleKeyErrorMessageFormat,
string.Join("', '", pkPropertyNames),
resourceClass.Name);

return new NonUniqueIdentityException(NonUniqueIdentityException.DefaultDetail, [errorMessage]);
}

// Look for matching entity from schema information (to provide normalized name in exception)
if (_domainModelProvider.GetDomainModel()
.EntityByFullName.TryGetValue(
new FullName(postgresException.SchemaName, postgresException.TableName),
out var entity))
{
return new NonUniqueIdentityException(
NonUniqueIdentityException.DefaultDetail,
[
string.Format(DuplicatePrimaryKeyTableOnlyErrorFormat, entity.Name)
]);
}

// Use the literal Postgres table name provided on the exception
return new NonUniqueIdentityException(
NonUniqueIdentityException.DefaultDetail,
[
string.Format(DuplicatePrimaryKeyTableOnlyErrorFormat, tableName)
]);
}

return null;
}

return null;
}
EdFiProblemDetailsExceptionBase GetNonUniqueValuesException()
{
var exceptionInfo = new PostgresExceptionInfo(postgresException, _detailExpression);

string GetMessageUsingPostgresException()
{
string message;
var exceptionInfo = new PostgresExceptionInfo(postgresException, _detailExpression);
// Column names will only be available from Postgres if the "Include Error Detail=true" value is added to the connection string
if (exceptionInfo.ColumnNames.Length > 0 && exceptionInfo.ColumnNames != PostgresExceptionInfo.UnknownValue)
{
string detail = string.Format(
exceptionInfo.IsComposedKeyConstraint
? CompositeKeyErrorMessageFormat
: SimpleKeyErrorMessageFormat,
exceptionInfo.ColumnNames,
exceptionInfo.TableName);

return new NonUniqueValuesException(detail);
}

// Column names will only be available form Postgres if a special argument is added to the connection string
if (exceptionInfo.ColumnNames.Length > 0 && exceptionInfo.ColumnNames != PostgresExceptionInfo.UnknownValue)
{
message = string.Format(
exceptionInfo.IsComposedKeyConstraint
? CompositeKeyMessageFormat
: SimpleKeyMessageFormat,
exceptionInfo.ColumnNames,
exceptionInfo.TableName);
}
else
{
message = GenericMessage;
return null;
}

return message;
}
}

Expand Down
Loading

0 comments on commit 1e75a6e

Please sign in to comment.