SQL Server · Create

Turn ARX error codes into messages for the user

An "add account" screen that turns ARX errors into messages for the user, in four scenarios: constraints the database enforces, values the column can't hold, requests that don't fit the table, and the native details kept on a database error. Every error has a stable code (the same on every database), a category, and the entity, attribute or constraint involved. Messages name things, never values, so they are safe to log.

Set up for this page

The scenarios build on the sample application: SampleContext.cs (the dbContext every sample is given); SampleOutput.cs (Step and Show, which print); SampleContext.Application.cs (dbContext.Service, the application's ARX service); SampleCatalog.cs (the tables' ids, keys, lookups and display names).

Before scenario 1 the sample runs this, so the service and the rows the scenarios use are there:

Set up
var existing = new ArxEntity("account");
existing["name"] = "Litware Inc.";
existing["account_number"] = "ACC-20001";
await service.CreateAsync(existing);

1 Constraints the database enforces

Constraints the database enforces: a unique number, a required name, a CHECK on the employee count.

Already in scope: service (ArxMsSqlDbService)

C#
var numberTaken = new ArxEntity("account");
numberTaken["name"] = "Litware Europe";
numberTaken["account_number"] = "ACC-20001";

var noName = new ArxEntity("account");
noName["website"] = "https://no-name.example";

var negativeCount = new ArxEntity("account");
negativeCount["name"] = "Shrinking Co.";
negativeCount["employee_count"] = -5;

foreach (var attempt in new[] { numberTaken, noName, negativeCount })
{
    await SaveAndExplainAsync(service, attempt);
}
Used above

Defined once in this sample.

private static async Task SaveAndExplainAsync(ArxMsSqlDbService service, ArxEntity attempt)
{
    Console.WriteLine($"save {attempt["name"] ?? "(no name)"}");

    try
    {
        await service.CreateAsync(attempt);
    }
    catch (ArxException error)
    {
        // Handle by Code or Category, never by parsing the message.
        var forTheUser = error.Code switch
        {
            ArxErrorCode.UniqueViolation => "That account number is already in use.",
            ArxErrorCode.NotNullViolation => $"Please fill in {error.AttributeName}.",
            ArxErrorCode.CheckViolation => "One of the values isn't allowed.",
            ArxErrorCode.ValueTooLong => "One of the values is too long.",
            ArxErrorCode.ValueOutOfRange => "One of the numbers is too large.",
            ArxErrorCode.DataTypeMismatch or ArxErrorCode.InvalidValueFormat => "One of the values has the wrong format.",
            _ when error.IsTransient => "The database is busy. Please try again.",
            _ => "Something went wrong. The error was logged."
        };

        Console.WriteLine($"  {error.Code} ({error.Category}), constraint {error.ConstraintName ?? "-"}, attribute {error.AttributeName ?? "-"}");
        Console.WriteLine($"  logged:    {error.Message}");
        Console.WriteLine($"  user sees: {forTheUser}");
    }
}

Check it in the database

SQL

Run it in any SQL tool connected to the database, after the code has run.

SELECT name, account_number, employee_count
FROM account
WHERE account_number = 'ACC-20001' OR name IN ('Litware Europe', 'Shrinking Co.') OR website = 'https://no-name.example';

Expect: 1 row, Litware Inc.: the three attempts were refused.

2 Values the column can't hold

Values the column can't hold: too long, out of range, the wrong type.

Already in scope: service (ArxMsSqlDbService)

C#
var longWebsite = new ArxEntity("account");
longWebsite["name"] = "Long URL Ltd.";
longWebsite["website"] = "https://example.com/" + new string('a', 200);

var hugeCount = new ArxEntity("account");
hugeCount["name"] = "Huge Corp.";
hugeCount["employee_count"] = 3_000_000_000L;

var textForNumber = new ArxEntity("account");
textForNumber["name"] = "Typo Corp.";
textForNumber["employee_count"] = "forty";

foreach (var attempt in new[] { longWebsite, hugeCount, textForNumber })
{
    await SaveAndExplainAsync(service, attempt);
}
Used above

Defined once in this sample.

private static async Task SaveAndExplainAsync(ArxMsSqlDbService service, ArxEntity attempt)
{
    Console.WriteLine($"save {attempt["name"] ?? "(no name)"}");

    try
    {
        await service.CreateAsync(attempt);
    }
    catch (ArxException error)
    {
        // Handle by Code or Category, never by parsing the message.
        var forTheUser = error.Code switch
        {
            ArxErrorCode.UniqueViolation => "That account number is already in use.",
            ArxErrorCode.NotNullViolation => $"Please fill in {error.AttributeName}.",
            ArxErrorCode.CheckViolation => "One of the values isn't allowed.",
            ArxErrorCode.ValueTooLong => "One of the values is too long.",
            ArxErrorCode.ValueOutOfRange => "One of the numbers is too large.",
            ArxErrorCode.DataTypeMismatch or ArxErrorCode.InvalidValueFormat => "One of the values has the wrong format.",
            _ when error.IsTransient => "The database is busy. Please try again.",
            _ => "Something went wrong. The error was logged."
        };

        Console.WriteLine($"  {error.Code} ({error.Category}), constraint {error.ConstraintName ?? "-"}, attribute {error.AttributeName ?? "-"}");
        Console.WriteLine($"  logged:    {error.Message}");
        Console.WriteLine($"  user sees: {forTheUser}");
    }
}

Check it in the database

SQL

Run it in any SQL tool connected to the database, after the code has run.

SELECT count(*)
FROM account
WHERE name IN ('Long URL Ltd.', 'Huge Corp.', 'Typo Corp.');

Expect: 0.

3 Requests that don't fit the table

Requests that don't fit the table: a column it doesn't have, a table name that isn't a plain identifier.

Already in scope: service (ArxMsSqlDbService)

C#
var unknownColumn = new ArxEntity("account");
unknownColumn["name"] = "Extra Field Corp.";
unknownColumn["fax_number"] = "555-0100";

var badTableName = new ArxEntity("account; DROP TABLE account");
badTableName["name"] = "Bad Table Name Corp.";

foreach (var attempt in new[] { unknownColumn, badTableName })
{
    await SaveAndExplainAsync(service, attempt);
}
Used above

Defined once in this sample.

private static async Task SaveAndExplainAsync(ArxMsSqlDbService service, ArxEntity attempt)
{
    Console.WriteLine($"save {attempt["name"] ?? "(no name)"}");

    try
    {
        await service.CreateAsync(attempt);
    }
    catch (ArxException error)
    {
        // Handle by Code or Category, never by parsing the message.
        var forTheUser = error.Code switch
        {
            ArxErrorCode.UniqueViolation => "That account number is already in use.",
            ArxErrorCode.NotNullViolation => $"Please fill in {error.AttributeName}.",
            ArxErrorCode.CheckViolation => "One of the values isn't allowed.",
            ArxErrorCode.ValueTooLong => "One of the values is too long.",
            ArxErrorCode.ValueOutOfRange => "One of the numbers is too large.",
            ArxErrorCode.DataTypeMismatch or ArxErrorCode.InvalidValueFormat => "One of the values has the wrong format.",
            _ when error.IsTransient => "The database is busy. Please try again.",
            _ => "Something went wrong. The error was logged."
        };

        Console.WriteLine($"  {error.Code} ({error.Category}), constraint {error.ConstraintName ?? "-"}, attribute {error.AttributeName ?? "-"}");
        Console.WriteLine($"  logged:    {error.Message}");
        Console.WriteLine($"  user sees: {forTheUser}");
    }
}

Check it in the database

SQL

Run it in any SQL tool connected to the database, after the code has run.

SELECT count(*) AS stored, CASE WHEN OBJECT_ID('dbo.account') IS NOT NULL THEN 1 ELSE 0 END AS account_table_still_there
FROM account
WHERE name IN ('Extra Field Corp.', 'Bad Table Name Corp.');

Expect: 0, 1: nothing stored, and the hostile table name changed nothing.

4 Native details on a database error

ArxValidationException is found before the database is called; ArxDatabaseException is reported by SQL Server, with the native details kept.

Already in scope: service (ArxMsSqlDbService)

C#
var numberTaken = new ArxEntity("account");
numberTaken["name"] = "Litware Europe";
numberTaken["account_number"] = "ACC-20001";

try
{
    await service.CreateAsync(numberTaken);
}
catch (ArxDatabaseException error)
{
    Console.WriteLine($"NativeCode {error.NativeCode}, InnerException {error.InnerException?.GetType().Name}, IsTransient {error.IsTransient}");
}

Check it in the database

SQL

Run it in any SQL tool connected to the database, after the code has run.

SELECT count(*)
FROM account
WHERE account_number = 'ACC-20001';

Expect: 1: still only Litware Inc.

See also

Run the complete sample

The code above comes from one program, built in a fresh project and run against the database. To run it yourself, here is every file and step.

The program, its files and how to run it

Run it locally

  1. .NET SDK: .NET SDK 10.0.100 or later 10.0.x (target net10.0).
  2. Create the project:
    Terminal
    dotnet new console -n ArxSample -f net10.0
    cd ArxSample
  3. Install the packages:
    Terminal
    dotnet add package ARX.SDK.SQL --version 1.0.0-rc.1
  4. Database: a SQL Server 2022 (tested with 16.0.4225.2) database with the sample schema.
  5. Configuration: set ARX_SAMPLES_SQLSERVER to its connection string. Nothing is hard-coded.
  6. Source code: add the files of the sample application this sample uses (SampleContext.cs, SampleOutput.cs, SampleContext.Application.cs, SampleCatalog.cs), then replace Program.cs and add the other file below, with the file names shown.
  7. Run it:
    Terminal
    dotnet run
  8. Expected output: below.

The files

Program.cs

Runs the sample. Generated for this page; it reads the connection string from the environment.

using ArxSamples.MSSQL;
using ArxSamples.MSSQL.Operations.Create.RealCases;

// The connection string comes from the environment, never from the code (see "Configure the connection").
var connectionString = Environment.GetEnvironmentVariable("ARX_SAMPLES_SQLSERVER")
    ?? throw new InvalidOperationException("Set the ARX_SAMPLES_SQLSERVER environment variable to your SQL Server connection string.");

await new RealCase5_HandleErrors().RunAsync(new SampleContext(connectionString));
RealCase5_HandleErrors.cs

The sample, as it is in the repository.

using ARX.SDK;
using ARX.SDK.Services;
using static ArxSamples.MSSQL.SampleOutput;

namespace ArxSamples.MSSQL.Operations.Create.RealCases;

/// <summary>
/// An "add account" screen that turns ARX errors into messages for the user, in four
/// scenarios: constraints the database enforces, values the column can't hold, requests that don't fit the table, and
/// the native details kept on a database error. Every error has a stable code (the same on every database), a
/// category, and the entity, attribute or constraint involved. Messages name things, never values, so they are safe to log.
/// </summary>
public sealed class RealCase5_HandleErrors : ISample
{
    public string Name => "create-real-5-handle-errors";

    public string Title => "Turn ARX error codes into messages for the user";

    public async Task RunAsync(SampleContext dbContext)
    {
        // An account whose number is already taken.
        var existing = new ArxEntity("account");
        existing["name"] = "Litware Inc.";
        existing["account_number"] = "ACC-20001";
        await dbContext.Service.CreateAsync(existing);

        await Scenario1_ConstraintsTheDatabaseEnforcesAsync(dbContext);

        await Scenario2_ValuesTheColumnCantHoldAsync(dbContext);

        await Scenario3_RequestsThatDontFitTheTableAsync(dbContext);

        await Scenario4_NativeDetailsOnADatabaseErrorAsync(dbContext);
    }

    /// <summary>1. Constraints the database enforces: a unique number, a required name, a CHECK on the employee count.</summary>
    private static async Task Scenario1_ConstraintsTheDatabaseEnforcesAsync(SampleContext dbContext)
    {
        Step("1. Constraints the database enforces");

        var numberTaken = new ArxEntity("account");
        numberTaken["name"] = "Litware Europe";
        numberTaken["account_number"] = "ACC-20001";

        var noName = new ArxEntity("account");
        noName["website"] = "https://no-name.example";

        var negativeCount = new ArxEntity("account");
        negativeCount["name"] = "Shrinking Co.";
        negativeCount["employee_count"] = -5;

        foreach (var attempt in new[] { numberTaken, noName, negativeCount })
        {
            await SaveAndExplainAsync(dbContext, attempt);
        }

        /* ===========================================================
         * [DIRECT QUERY CHECK]
         * -----------------------------------------------------------
         * SELECT name, account_number, employee_count
         * FROM account
         * WHERE account_number = 'ACC-20001' OR name IN ('Litware Europe', 'Shrinking Co.') OR website = 'https://no-name.example';
         * -- Expect: 1 row, Litware Inc.: the three attempts were refused.
         * ===========================================================
         */
    }

    /// <summary>2. Values the column can't hold: too long, out of range, the wrong type.</summary>
    private static async Task Scenario2_ValuesTheColumnCantHoldAsync(SampleContext dbContext)
    {
        Step("2. Values the column can't hold");

        var longWebsite = new ArxEntity("account");
        longWebsite["name"] = "Long URL Ltd.";
        longWebsite["website"] = "https://example.com/" + new string('a', 200);

        var hugeCount = new ArxEntity("account");
        hugeCount["name"] = "Huge Corp.";
        hugeCount["employee_count"] = 3_000_000_000L;

        var textForNumber = new ArxEntity("account");
        textForNumber["name"] = "Typo Corp.";
        textForNumber["employee_count"] = "forty";

        foreach (var attempt in new[] { longWebsite, hugeCount, textForNumber })
        {
            await SaveAndExplainAsync(dbContext, attempt);
        }

        /* ===========================================================
         * [DIRECT QUERY CHECK]
         * -----------------------------------------------------------
         * SELECT count(*)
         * FROM account
         * WHERE name IN ('Long URL Ltd.', 'Huge Corp.', 'Typo Corp.');
         * -- Expect: 0.
         * ===========================================================
         */
    }

    /// <summary>3. Requests that don't fit the table: a column it doesn't have, a table name that isn't a plain identifier.</summary>
    private static async Task Scenario3_RequestsThatDontFitTheTableAsync(SampleContext dbContext)
    {
        Step("3. Requests that don't fit the table");

        var unknownColumn = new ArxEntity("account");
        unknownColumn["name"] = "Extra Field Corp.";
        unknownColumn["fax_number"] = "555-0100";

        var badTableName = new ArxEntity("account; DROP TABLE account");
        badTableName["name"] = "Bad Table Name Corp.";

        foreach (var attempt in new[] { unknownColumn, badTableName })
        {
            await SaveAndExplainAsync(dbContext, attempt);
        }

        /* ===========================================================
         * [DIRECT QUERY CHECK]
         * -----------------------------------------------------------
         * SELECT count(*) AS stored, CASE WHEN OBJECT_ID('dbo.account') IS NOT NULL THEN 1 ELSE 0 END AS account_table_still_there
         * FROM account
         * WHERE name IN ('Extra Field Corp.', 'Bad Table Name Corp.');
         * -- Expect: 0, 1: nothing stored, and the hostile table name changed nothing.
         * ===========================================================
         */
    }

    /// <summary>
    /// 4. ArxValidationException is found before the database is called; ArxDatabaseException is reported by SQL Server,
    /// with the native details kept.
    /// </summary>
    private static async Task Scenario4_NativeDetailsOnADatabaseErrorAsync(SampleContext dbContext)
    {
        Step("4. Native details on a database error");

        var numberTaken = new ArxEntity("account");
        numberTaken["name"] = "Litware Europe";
        numberTaken["account_number"] = "ACC-20001";

        try
        {
            await dbContext.Service.CreateAsync(numberTaken);
        }
        catch (ArxDatabaseException error)
        {
            Show($"NativeCode {error.NativeCode}, InnerException {error.InnerException?.GetType().Name}, IsTransient {error.IsTransient}");
        }

        /* ===========================================================
         * [DIRECT QUERY CHECK]
         * -----------------------------------------------------------
         * SELECT count(*)
         * FROM account
         * WHERE account_number = 'ACC-20001';
         * -- Expect: 1: still only Litware Inc.
         * ===========================================================
         */
    }

    /// <summary>The screen's save: on an ARX error, logs it and shows the user a message chosen by code, never by parsing the text.</summary>
    private static async Task SaveAndExplainAsync(SampleContext dbContext, ArxEntity attempt)
    {
        Show($"save {attempt["name"] ?? "(no name)"}");

        try
        {
            await dbContext.Service.CreateAsync(attempt);
        }
        catch (ArxException error)
        {
            // Handle by Code or Category, never by parsing the message.
            var forTheUser = error.Code switch
            {
                ArxErrorCode.UniqueViolation => "That account number is already in use.",
                ArxErrorCode.NotNullViolation => $"Please fill in {error.AttributeName}.",
                ArxErrorCode.CheckViolation => "One of the values isn't allowed.",
                ArxErrorCode.ValueTooLong => "One of the values is too long.",
                ArxErrorCode.ValueOutOfRange => "One of the numbers is too large.",
                ArxErrorCode.DataTypeMismatch or ArxErrorCode.InvalidValueFormat => "One of the values has the wrong format.",
                _ when error.IsTransient => "The database is busy. Please try again.",
                _ => "Something went wrong. The error was logged."
            };

            Show($"  {error.Code} ({error.Category}), constraint {error.ConstraintName ?? "-"}, attribute {error.AttributeName ?? "-"}");
            Show($"  logged:    {error.Message}");
            Show($"  user sees: {forTheUser}");
        }
    }
}

Expected output

What dotnet run printed when the sample was verified, on a freshly created sample schema. Ids differ when the tables already hold rows.

Output
  > 1. Constraints the database enforces
      save Litware Europe
        UniqueViolation (Constraint), constraint account_account_number_key, attribute -
        logged:    ARX3001 UniqueViolation: Create on 'account' violates unique constraint 'account_account_number_key'.
        user sees: That account number is already in use.
      save (no name)
        NotNullViolation (Constraint), constraint -, attribute name
        logged:    ARX3003 NotNullViolation: Create on 'account' has no value for required attribute 'name'.
        user sees: Please fill in name.
      save Shrinking Co.
        CheckViolation (Constraint), constraint account_employee_count_check, attribute employee_count
        logged:    ARX3004 CheckViolation: Create on 'account' violates check constraint 'account_employee_count_check'.
        user sees: One of the values isn't allowed.
  > 2. Values the column can't hold
      save Long URL Ltd.
        ValueTooLong (Data), constraint -, attribute website
        logged:    ARX4002 ValueTooLong: Create on 'account' has a value longer than column 'website' allows.
        user sees: One of the values is too long.
      save Huge Corp.
        ValueOutOfRange (Data), constraint -, attribute -
        logged:    ARX4001 ValueOutOfRange: Create on 'account' has a value outside its column's range.
        user sees: One of the numbers is too large.
      save Typo Corp.
        InvalidValueFormat (Data), constraint -, attribute -
        logged:    ARX4003 InvalidValueFormat: Create on 'account' has a value in a format its column can't accept.
        user sees: One of the values has the wrong format.
  > 3. Requests that don't fit the table
      save Extra Field Corp.
        UndefinedAttribute (Schema), constraint -, attribute fax_number
        logged:    ARX5002 UndefinedAttribute: Create on 'account' refers to a column that doesn't exist or can't be written: Invalid column name 'fax_number'. If it's a column the schema catalog declares (the id, a key, a lookup), check its name there: ValidateSchemaAsync() lists every declared column the table doesn't have.
        user sees: Something went wrong. The error was logged.
      save Bad Table Name Corp.
        SuspectedInjection (Validation), constraint -, attribute -
        logged:    ARX1017 SuspectedInjection: Entity name 'account; DROP TABLE account' looks like an SQL injection attempt (contains ';'). ARX refused it before sending anything to the database. Names must come from your code or a fixed list, never from user input.
        user sees: Something went wrong. The error was logged.
  > 4. Native details on a database error
      NativeCode 2601, InnerException SqlException, IsTransient False