| | | 1 | | using System.Data.Common; |
| | | 2 | | using Microsoft.EntityFrameworkCore; |
| | | 3 | | |
| | | 4 | | namespace ArturRios.Data.Relational.Core.Repositories; |
| | | 5 | | |
| | | 6 | | /// <summary> |
| | | 7 | | /// Provider-independent classification of relational failures, and the caller-safe |
| | | 8 | | /// messages that replace provider text. Provider messages name constraints, indexes, |
| | | 9 | | /// columns, SQL fragments and conflicting values, so they are used for classification |
| | | 10 | | /// only and are never returned to the caller. The full exception is still logged by |
| | | 11 | | /// EF Core's own diagnostics, where operators can read it. |
| | | 12 | | /// </summary> |
| | | 13 | | public static class RelationalErrors |
| | | 14 | | { |
| | | 15 | | /// <summary>Message returned when an optimistic-concurrency conflict is detected.</summary> |
| | | 16 | | public const string ConcurrencyMessage = |
| | | 17 | | "Concurrency conflict: the record was modified or removed by another process."; |
| | | 18 | | |
| | | 19 | | /// <summary>Message returned when a write violates a unique constraint.</summary> |
| | | 20 | | public const string UniqueViolationMessage = "Conflict: a record with the same unique value already exists."; |
| | | 21 | | |
| | | 22 | | /// <summary>Message returned when a write violates a non-unique integrity rule.</summary> |
| | | 23 | | public const string IntegrityViolationMessage = "Conflict: the operation violates a data-integrity rule."; |
| | | 24 | | |
| | | 25 | | /// <summary>Message returned when the failure is transient and the operation may be retried.</summary> |
| | | 26 | | public const string TransientMessage = "The data store is temporarily unavailable. Please retry."; |
| | | 27 | | |
| | | 28 | | /// <summary>Message returned when the failure has no caller-actionable classification.</summary> |
| | | 29 | | public const string GenericMessage = "A data-access error occurred."; |
| | | 30 | | |
| | | 31 | | // Markers for a unique/duplicate-key violation, for providers that report no SQLSTATE |
| | | 32 | | // or a class-level one (MySQL reports 23000 for every integrity violation). |
| | 2 | 33 | | private static readonly string[] UniqueViolationMarkers = |
| | 2 | 34 | | [ |
| | 2 | 35 | | "unique constraint", // PostgreSQL, Oracle, SQLite ("UNIQUE constraint failed: ...") |
| | 2 | 36 | | "unique index", // SQL Server, SQLite |
| | 2 | 37 | | "unique key", // SQL Server ("Violation of UNIQUE KEY constraint ...") |
| | 2 | 38 | | "duplicate key", // SQL Server |
| | 2 | 39 | | "duplicate entry" // MySQL / MariaDB |
| | 2 | 40 | | ]; |
| | | 41 | | |
| | | 42 | | // Markers for the remaining integrity violations: foreign key, not null, check. |
| | 2 | 43 | | private static readonly string[] IntegrityViolationMarkers = |
| | 2 | 44 | | [ |
| | 2 | 45 | | "constraint failed", // SQLite ("FOREIGN KEY constraint failed", "NOT NULL ...") |
| | 2 | 46 | | "foreign key", |
| | 2 | 47 | | "check constraint", |
| | 2 | 48 | | "not-null", |
| | 2 | 49 | | "cannot be null", |
| | 2 | 50 | | "null value in column" |
| | 2 | 51 | | ]; |
| | | 52 | | |
| | | 53 | | /// <summary> |
| | | 54 | | /// Detects a unique-constraint (duplicate-key) violation anywhere in the exception chain. |
| | | 55 | | /// Recognised by SQLSTATE where the provider reports one, and by provider-neutral |
| | | 56 | | /// duplicate-key wording otherwise. |
| | | 57 | | /// </summary> |
| | | 58 | | /// <param name="ex">The exception caught by a repository or query guard.</param> |
| | | 59 | | /// <returns><c>true</c> when the failure is a unique-constraint violation.</returns> |
| | | 60 | | public static bool IsUniqueViolation(Exception ex) |
| | | 61 | | { |
| | 0 | 62 | | for (var current = ex; current is not null; current = current.InnerException) |
| | | 63 | | { |
| | 0 | 64 | | if (current is DbException db && IsUniqueViolation(db)) |
| | | 65 | | { |
| | 0 | 66 | | return true; |
| | | 67 | | } |
| | | 68 | | } |
| | | 69 | | |
| | 0 | 70 | | return false; |
| | | 71 | | } |
| | | 72 | | |
| | | 73 | | /// <summary> |
| | | 74 | | /// Classifies a failure and returns the caller-safe message for it. Never includes |
| | | 75 | | /// provider text. |
| | | 76 | | /// </summary> |
| | | 77 | | /// <param name="ex">The exception caught by a repository or query guard.</param> |
| | | 78 | | /// <returns>One of the caller-safe messages exposed by this class.</returns> |
| | | 79 | | public static string Describe(Exception ex) |
| | | 80 | | { |
| | 38 | 81 | | if (ex is DbUpdateConcurrencyException) |
| | | 82 | | { |
| | 4 | 83 | | return ConcurrencyMessage; |
| | | 84 | | } |
| | | 85 | | |
| | 128 | 86 | | for (var current = ex; current is not null; current = current.InnerException) |
| | | 87 | | { |
| | 40 | 88 | | if (current is TimeoutException) |
| | | 89 | | { |
| | 0 | 90 | | return TransientMessage; |
| | | 91 | | } |
| | | 92 | | |
| | 40 | 93 | | if (current is not DbException db) |
| | | 94 | | { |
| | | 95 | | continue; |
| | | 96 | | } |
| | | 97 | | |
| | 20 | 98 | | if (IsUniqueViolation(db)) |
| | | 99 | | { |
| | 8 | 100 | | return UniqueViolationMessage; |
| | | 101 | | } |
| | | 102 | | |
| | 12 | 103 | | if (IsIntegrityViolation(db)) |
| | | 104 | | { |
| | 2 | 105 | | return IntegrityViolationMessage; |
| | | 106 | | } |
| | | 107 | | |
| | 10 | 108 | | if (db.IsTransient) |
| | | 109 | | { |
| | 0 | 110 | | return TransientMessage; |
| | | 111 | | } |
| | | 112 | | } |
| | | 113 | | |
| | 24 | 114 | | return GenericMessage; |
| | | 115 | | } |
| | | 116 | | |
| | | 117 | | private static bool IsUniqueViolation(DbException db) => |
| | 20 | 118 | | db.SqlState == "23505" || // PostgreSQL unique_violation |
| | 20 | 119 | | Matches(db.Message, UniqueViolationMarkers); |
| | | 120 | | |
| | | 121 | | private static bool IsIntegrityViolation(DbException db) => |
| | 12 | 122 | | db.SqlState?.StartsWith("23", StringComparison.Ordinal) == true || // SQLSTATE integrity class |
| | 12 | 123 | | Matches(db.Message, IntegrityViolationMarkers); |
| | | 124 | | |
| | | 125 | | private static bool Matches(string message, string[] markers) => |
| | 170 | 126 | | markers.Any(marker => message.Contains(marker, StringComparison.OrdinalIgnoreCase)); |
| | | 127 | | } |