MySQL · CreateMultiple
Import 5,000 contacts in batches, in one transaction
A 5,000-contact import in batches of 500, in one transaction that ARX manages (batch scopes), in three scenarios: automatic commit with RunBatchAsync; manual commit with BeginBatchAsync when you want to decide after a failed batch; and savepoints off when any failure must stop the whole import.
Set up for this page
Besides the sample itself, it uses 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); SampleContext.Direct.cs (dbContext.Direct); MySqlDirectHelper.cs (the checks behind dbContext.Direct), in the files below.
dbContext.Direct is the samples' own checking code on the plain driver, not part of ARX: leave it out of your application.
Before scenario 1 the sample runs this, so the service and the rows the scenarios use are there:
var account = new ArxEntity("account");
account["name"] = "Wide World Importers";
var accountId = await service.CreateAsync(account);1 Automatic commit: 10 batches, one commit at the end
RunBatchAsync commits when your code returns, and rolls back if anything in it throws.
Already in scope: service (ArxMySqlDbService), accountId (ArxRecordId)
await service.RunBatchAsync(async scope =>
{
foreach (var batch in ReadContactFile(accountId, "first"))
{
await scope.CreateMultipleAsync(batch);
}
});Defined once in this sample.
private static IEnumerable<List<ArxEntity>> ReadContactFile(ArxRecordId accountId, string file, int? badRowInBatch = null)
{
for (var batchNumber = 1; batchNumber <= 10; batchNumber++)
{
var batch = new List<ArxEntity>();
for (var row = 1; row <= 500; row++)
{
var contact = new ArxEntity("contact");
contact["account_id"] = new ArxEntityReference("account", accountId);
contact["first_name"] = $"Customer {batchNumber}-{row}";
contact["last_name"] = batchNumber == badRowInBatch && row == 250 ? null : "Imported";
contact["email"] = $"{file}.{batchNumber}.{row}@wideworld.example";
batch.Add(contact);
}
yield return batch;
}
}Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT count(*)
FROM contact
WHERE email LIKE 'first.%';Expect: 5000.
2 Manual commit: batch 8 has a contact without a last name
Each call has its own savepoint (asked for with Savepoints = true), so a failed batch is undone alone and the scope carries on. The error says which batch failed; you decide what to do.
Already in scope: service (ArxMySqlDbService), accountId (ArxRecordId)
await using (var scope = await service.BeginBatchAsync(new ArxBatchOptions { Savepoints = true }))
{
foreach (var batch in ReadContactFile(accountId, "second", badRowInBatch: 8))
{
try
{
await scope.CreateMultipleAsync(batch);
}
catch (ArxDatabaseException error) when (error.Code == ArxErrorCode.NotNullViolation)
{
Console.WriteLine($"batch {error.BatchNumber} skipped: {error.Message}");
}
}
// Your rule for this import: one bad batch is acceptable, more is not.
if (scope.FailedBatches.Count <= 1)
{
await scope.CommitAsync();
}
// Leaving the block without CommitAsync() rolls back, even when an exception is passing through.
}Defined once in this sample.
private static IEnumerable<List<ArxEntity>> ReadContactFile(ArxRecordId accountId, string file, int? badRowInBatch = null)
{
for (var batchNumber = 1; batchNumber <= 10; batchNumber++)
{
var batch = new List<ArxEntity>();
for (var row = 1; row <= 500; row++)
{
var contact = new ArxEntity("contact");
contact["account_id"] = new ArxEntityReference("account", accountId);
contact["first_name"] = $"Customer {batchNumber}-{row}";
contact["last_name"] = batchNumber == badRowInBatch && row == 250 ? null : "Imported";
contact["email"] = $"{file}.{batchNumber}.{row}@wideworld.example";
batch.Add(contact);
}
yield return batch;
}
}Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT count(*) AS contacts, SUM(email LIKE 'second.8.%') AS from_batch_8
FROM contact
WHERE email LIKE 'second.%';Expect: 4500, 0: batch 8 was undone alone, the other nine committed.
3 Savepoints off: one failed batch rolls back the whole import
With savepoints off, one failed batch rolls back the whole import.
Already in scope: service (ArxMySqlDbService), accountId (ArxRecordId)
await using (var scope = await service.BeginBatchAsync(new ArxBatchOptions { Savepoints = false }))
{
try
{
foreach (var batch in ReadContactFile(accountId, "third", badRowInBatch: 2))
{
await scope.CreateMultipleAsync(batch);
}
await scope.CommitAsync();
}
catch (ArxDatabaseException error)
{
Console.WriteLine($"batch {error.BatchNumber} failed; the scope is {scope.State}");
}
}Defined once in this sample.
private static IEnumerable<List<ArxEntity>> ReadContactFile(ArxRecordId accountId, string file, int? badRowInBatch = null)
{
for (var batchNumber = 1; batchNumber <= 10; batchNumber++)
{
var batch = new List<ArxEntity>();
for (var row = 1; row <= 500; row++)
{
var contact = new ArxEntity("contact");
contact["account_id"] = new ArxEntityReference("account", accountId);
contact["first_name"] = $"Customer {batchNumber}-{row}";
contact["last_name"] = batchNumber == badRowInBatch && row == 250 ? null : "Imported";
contact["email"] = $"{file}.{batchNumber}.{row}@wideworld.example";
batch.Add(contact);
}
yield return batch;
}
}Check it in the database
Run it in any SQL tool connected to the database, after the code has run.
SELECT count(*)
FROM contact
WHERE email LIKE 'third.%';Expect: 0: with savepoints off, one failed batch rolled back the whole import.
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
- .NET SDK: .NET SDK 10.0.100 or later 10.0.x (target
net10.0). - Create the project:
Terminal dotnet new console -n ArxSample -f net10.0 cd ArxSample - Install the packages:
Terminal dotnet add package ARX.SDK.MySQL --version 1.0.0-rc.1 - Database: a MySQL 8.4 (tested with 8.4.13) database with the sample schema.
- Configuration: set
ARX_SAMPLES_MYSQLto its connection string. Nothing is hard-coded. - Source code: replace
Program.csand add the 7 other files below, with the file names shown. - Run it:
Terminal dotnet run - Expected output: below.
The files
Runs the sample. Generated for this page; it reads the connection string from the environment.
using ArxSamples.MySQL;
using ArxSamples.MySQL.Operations.CreateMultiple.AdvancedCases;
// The connection string comes from the environment, never from the code (see "Configure the connection").
var connectionString = Environment.GetEnvironmentVariable("ARX_SAMPLES_MYSQL")
?? throw new InvalidOperationException("Set the ARX_SAMPLES_MYSQL environment variable to your MySQL connection string.");
await new Advanced1_ImportInBatches().RunAsync(new SampleContext(connectionString));
The sample, as it is in the repository.
using ARX.SDK;
using ARX.SDK.Services;
using static ArxSamples.MySQL.SampleOutput;
namespace ArxSamples.MySQL.Operations.CreateMultiple.AdvancedCases;
/// <summary>
/// A 5,000-contact import in batches of 500, in one transaction that ARX manages (batch scopes), in three
/// scenarios: automatic commit with RunBatchAsync; manual commit with BeginBatchAsync when you want to decide after a
/// failed batch; and savepoints off when any failure must stop the whole import.
/// </summary>
public sealed class Advanced1_ImportInBatches : ISample
{
public string Name => "create-multiple-advanced-1-import-in-batches";
public string Title => "Import 5,000 contacts in batches, in one transaction";
public async Task RunAsync(SampleContext dbContext)
{
// The customer the imported contacts belong to.
var account = new ArxEntity("account");
account["name"] = "Wide World Importers";
var accountId = await dbContext.Service.CreateAsync(account);
await Scenario1_AutomaticCommitAsync(dbContext, accountId);
await Scenario2_ManualCommit_DecideAfterAFailedBatchAsync(dbContext, accountId);
await Scenario3_SavepointsOff_OneFailureStopsTheImportAsync(dbContext, accountId);
}
/// <summary>1. RunBatchAsync commits when your code returns, and rolls back if anything in it throws.</summary>
private static async Task Scenario1_AutomaticCommitAsync(SampleContext dbContext, ArxRecordId accountId)
{
Step("1. Automatic commit: 10 batches, one commit at the end");
await dbContext.Service.RunBatchAsync(async scope =>
{
foreach (var batch in ReadContactFile(accountId, "first"))
{
await scope.CreateMultipleAsync(batch);
}
Show($"{scope.RowsWritten:N0} rows written; visible to other connections before the commit: {await dbContext.Direct.CountAsync("contact", "email LIKE 'first.%'")}");
});
Show($"after the commit: {await dbContext.Direct.CountAsync("contact", "email LIKE 'first.%'"):N0}");
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT count(*)
* FROM contact
* WHERE email LIKE 'first.%';
* -- Expect: 5000.
* ===========================================================
*/
}
/// <summary>
/// 2. Each call has its own savepoint (asked for with Savepoints = true), so a failed batch is undone alone and the scope carries on. The
/// error says which batch failed; you decide what to do.
/// </summary>
private static async Task Scenario2_ManualCommit_DecideAfterAFailedBatchAsync(SampleContext dbContext, ArxRecordId accountId)
{
Step("2. Manual commit: batch 8 has a contact without a last name");
await using (var scope = await dbContext.Service.BeginBatchAsync(new ArxBatchOptions { Savepoints = true }))
{
foreach (var batch in ReadContactFile(accountId, "second", badRowInBatch: 8))
{
try
{
await scope.CreateMultipleAsync(batch);
}
catch (ArxDatabaseException error) when (error.Code == ArxErrorCode.NotNullViolation)
{
Show($"batch {error.BatchNumber} skipped: {error.Message}");
}
}
// Your rule for this import: one bad batch is acceptable, more is not.
if (scope.FailedBatches.Count <= 1)
{
await scope.CommitAsync();
}
// Leaving the block without CommitAsync() rolls back, even when an exception is passing through.
}
Show($"committed: {await dbContext.Direct.CountAsync("contact", "email LIKE 'second.%'"):N0} contacts");
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT count(*) AS contacts, SUM(email LIKE 'second.8.%') AS from_batch_8
* FROM contact
* WHERE email LIKE 'second.%';
* -- Expect: 4500, 0: batch 8 was undone alone, the other nine committed.
* ===========================================================
*/
}
/// <summary>3. With savepoints off, one failed batch rolls back the whole import.</summary>
private static async Task Scenario3_SavepointsOff_OneFailureStopsTheImportAsync(SampleContext dbContext, ArxRecordId accountId)
{
Step("3. Savepoints off: one failed batch rolls back the whole import");
await using (var scope = await dbContext.Service.BeginBatchAsync(new ArxBatchOptions { Savepoints = false }))
{
try
{
foreach (var batch in ReadContactFile(accountId, "third", badRowInBatch: 2))
{
await scope.CreateMultipleAsync(batch);
}
await scope.CommitAsync();
}
catch (ArxDatabaseException error)
{
Show($"batch {error.BatchNumber} failed; the scope is {scope.State}");
}
}
Show($"kept: {await dbContext.Direct.CountAsync("contact", "email LIKE 'third.%'")} contacts");
/* ===========================================================
* [DIRECT QUERY CHECK]
* -----------------------------------------------------------
* SELECT count(*)
* FROM contact
* WHERE email LIKE 'third.%';
* -- Expect: 0: with savepoints off, one failed batch rolled back the whole import.
* ===========================================================
*/
}
/// <summary>
/// Stands in for reading a contact file 500 rows at a time: the whole file is never in memory. One row of batch
/// <paramref name="badRowInBatch"/> has no last name, which the table refuses.
/// </summary>
private static IEnumerable<List<ArxEntity>> ReadContactFile(ArxRecordId accountId, string file, int? badRowInBatch = null)
{
for (var batchNumber = 1; batchNumber <= 10; batchNumber++)
{
var batch = new List<ArxEntity>();
for (var row = 1; row <= 500; row++)
{
var contact = new ArxEntity("contact");
contact["account_id"] = new ArxEntityReference("account", accountId);
contact["first_name"] = $"Customer {batchNumber}-{row}";
contact["last_name"] = batchNumber == badRowInBatch && row == 250 ? null : "Imported";
contact["email"] = $"{file}.{batchNumber}.{row}@wideworld.example";
batch.Add(contact);
}
yield return batch;
}
}
}
What every sample is given: the connection string (and the ISample interface the samples implement).
namespace ArxSamples.MySQL;
/// <summary>One documentation sample: a scenario a real application has, shown end to end.</summary>
public interface ISample
{
/// <summary>The name to run it alone: <c>dotnet run -- <name></c>.</summary>
string Name { get; }
/// <summary>One line on what it shows.</summary>
string Title { get; }
Task RunAsync(SampleContext dbContext);
}
/// <summary>
/// What a sample is given. Every sample gets the connection string; the application's service and catalog
/// (SampleContext.Application.cs) and the plain MySqlConnector read-back helper (SampleContext.Direct.cs) live in their own
/// files, so a sample needs only the files it uses.
/// </summary>
public sealed partial class SampleContext
{
public SampleContext(string connectionString)
{
ConnectionString = connectionString;
}
public string ConnectionString { get; }
}
How the samples print: Step for each scenario, Show for what it did, ShowError for an error caught on purpose.
using ARX.SDK;
namespace ArxSamples.MySQL;
/// <summary>How the samples print: a numbered step for each scenario, then what it did.</summary>
public static class SampleOutput
{
/// <summary>The start of a scenario: <c>Step("1. By the id Create returned")</c>.</summary>
public static void Step(string text) => Console.WriteLine($" > {text}");
/// <summary>What the scenario did.</summary>
public static void Show(string text) => Console.WriteLine($" {text}");
/// <summary>An ARX error the scenario caught on purpose.</summary>
public static void ShowError(ArxException exception)
=> Console.WriteLine($" caught {exception.GetType().Name}: {exception.Message}");
}
The application's ARX service and its schema catalog, created once and shared by the samples.
using ARX.SDK.MySQL.Services;
using ARX.SDK.Services;
namespace ArxSamples.MySQL;
/// <summary>The application's ARX service and its schema catalog, shared by the real and advanced samples.</summary>
public sealed partial class SampleContext
{
private ArxSchemaCatalog? _catalog;
private ArxMySqlDbService? _service;
/// <summary>The application's schema catalog: table ids, keys and selection sets (SampleCatalog.cs).</summary>
public ArxSchemaCatalog Catalog => _catalog ??= SampleCatalog.Build();
/// <summary>
/// One service for the whole application. It holds no connection: every call opens one from the MySqlConnector pool and
/// closes it, unless you pass your own connection or transaction.
/// </summary>
public ArxMySqlDbService Service => _service ??= new ArxMySqlDbService(ConnectionString, Catalog);
}
The application's schema catalog: every table's id column and keys, and its selection sets.
using ARX.SDK.Services;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace ArxSamples.MySQL;
/// <summary>An account's industry. Stored in account.industry (SMALLINT) as the enum's number.</summary>
public enum Industry : short
{
Retail = 1,
Manufacturing = 2,
Healthcare = 3,
Finance = 4,
Technology = 5
}
/// <summary>
/// The application's schema catalog: every table's id column and keys, and every picklist (selection set),
/// defined once.
/// </summary>
/// <remarks>
/// <para>
/// <b>Ids.</b> Every table the application writes is declared with its one id column: its name, its .NET type, and
/// whether the database generates it (<c>ArxIdColumn.Generated</c>: identity, sequence, default) or the caller supplies
/// it (<c>ArxIdColumn.Supplied</c>: a natural id, a client-generated Guid). A table with no id column, such as a log or a
/// table identified by several columns together, is declared <c>ArxIdColumn.NoIdColumn</c>. ARX never reads ids from
/// the database: the developer knows the schema, and an undeclared table is refused (ARX1016).
/// </para>
/// <para>
/// <b>Keys.</b> Optional business keys: one or more columns whose values identify a record, such as an account number,
/// or a region and a SKU together. Create doesn't use them (the database's unique constraints do the enforcing); Update
/// and Delete will be able to find rows by them. <c>ValidateSchemaAsync</c> checks ids and keys against the database
/// when you ask it to.
/// </para>
/// <para>
/// <b>Selection sets.</b> /// A selection set (picklist, option set) is a column that only accepts a fixed list of codes, each with a display
/// text: an account's status (ACTIVE, ON_HOLD, CLOSED), a contact's preferred channel (E, P, S). The catalog tells ARX,
/// for each set:
/// </para>
/// <list type="bullet">
/// <item><b>Name</b>: what an <see cref="ArxSelectionSetValue"/> refers to, for example <c>new ArxSelectionSetValue("AccountStatus", "ON_HOLD")</c>.</item>
/// <item><b>Kind</b>: what the codes are, matching the column: Integer, String, Character, Boolean, Decimal or Guid.
/// (A fixed list of text values is a String set, whether the column is an ENUM or a VARCHAR that a CHECK guards.)</item>
/// <item><b>Multiplicity</b>: Single (one code), or several codes stored as bit flags (Flags), a delimited string
/// (Delimited) or a JSON array (Json). MySQL has no array columns.</item>
/// <item><b>Options</b>: each code with its display text and order. A retired option (<c>IsActive: false</c>) stays
/// readable in old rows, but can't be written any more.</item>
/// </list>
/// <para>
/// Build the catalog once at startup and give it to the service: <c>new ArxMySqlDbService(connectionString, SampleCatalog.Build())</c>.
/// ARX checks every <see cref="ArxSelectionSetValue"/> against it in memory, before any SQL is sent, and stores the code
/// itself in the column: no lookup table, no extra query. A plain value (a string or number that isn't an
/// ArxSelectionSetValue) isn't checked.
/// </para>
/// <para>
/// The catalog is plain code, so the options can come from wherever you keep them: written out here, taken from a .NET
/// enum (<c>AddEnum</c>), loaded from JSON (<see cref="SetsJson"/> and <see cref="FromJson"/> below), or loaded at
/// startup from your own option table and passed to <c>new ArxSelectionSetDefinition(name, kind, options)</c>. ARX
/// never reads option tables or files itself.
/// </para>
/// </remarks>
public static class SampleCatalog
{
/// <summary>
/// Selection sets kept as JSON, as an application might keep them in a file, a settings store or a database column.
/// Here the JSON is in the class itself; in an application, read it from wherever you keep it
/// (<c>File.ReadAllText("selection-sets.json")</c>, configuration, an HTTP call) and pass it to <see cref="FromJson"/>.
/// "multiplicity" and "active" are optional (Single and true).
/// </summary>
public const string SetsJson = """
[
{
"name": "LeadSource",
"kind": "String",
"options": [
{ "value": "WEB", "text": "Website", "order": 0 },
{ "value": "REFERRAL", "text": "Referral", "order": 1 },
{ "value": "EVENT", "text": "Trade show", "order": 2 },
{ "value": "COLD_CALL", "text": "Cold call", "order": 3, "active": false }
]
},
{
"name": "ContactRating",
"kind": "Integer",
"multiplicity": "Single",
"options": [
{ "value": 1, "text": "Poor" },
{ "value": 2, "text": "Fair" },
{ "value": 3, "text": "Good" },
{ "value": 4, "text": "Very good" },
{ "value": 5, "text": "Excellent" }
]
}
]
""";
public static ArxSchemaCatalog Build()
{
var builder = ArxSchemaCatalog.Create()
// ---------- tables: id column and keys ----------
// Generated BIGINT ids (AUTO_INCREMENT): the database fills them in. The keys are the unique columns that also
// identify a record.
.Table("account", ArxIdColumn.Generated<long>("id")).Key("account", "account_number")
.Table("contact", ArxIdColumn.Generated<long>("id")).Key("contact", "email")
// Retrieve: a contact's account_id reads back as a reference to its account, named by the account's
// name; a contact is named by its first and last names.
.Lookup("contact", "account_id", "account")
.DisplayName("account", "name")
.DisplayName("contact", new[] { "first_name", "last_name" }, " ")
.Table("product_category", ArxIdColumn.Generated<long>("id")).Key("product_category", "name")
.Table("product", ArxIdColumn.Generated<long>("id")).Key("product", "sku")
.Table("sales_order", ArxIdColumn.Generated<long>("id")).Key("sales_order", "order_number")
.Table("order_line", ArxIdColumn.Generated<long>("id"))
.Table("data_type_showcase", ArxIdColumn.Generated<long>("id"))
// A natural id: the caller gives the ISO country code.
.Table("country", ArxIdColumn.Supplied<string>("code"))
// A Guid id the application gives (CHAR(36)): MySQL can only hand back generated integer ids.
.Table("device", ArxIdColumn.Supplied<Guid>("id")).Key("device", "serial_number")
// No id column: a price is identified by its region and SKU together, a compound key.
.Table("price_list", ArxIdColumn.NoIdColumn).Key("price_list", "region", "sku")
// No primary key at all: the same action can be logged twice.
.Table("activity_log", ArxIdColumn.NoIdColumn)
// A table without an id: a visit is a fact, never updated (Retrieve Advanced8).
.Table("web_visit", ArxIdColumn.NoIdColumn)
// ---------- selection sets ----------
// From a .NET enum: the set is named after the enum ("Industry"), the codes are its numbers (Retail = 1, …),
// the display texts are its member names. Column: account.industry SMALLINT.
.AddEnum<Industry>()
// Written out: String codes with display texts, single choice. Column: account.status VARCHAR(10).
.Add("AccountStatus", ArxSelectionSetKind.String, ("ACTIVE", "Active"), ("ON_HOLD", "On hold"), ("CLOSED", "Closed"))
// Multiple choice, stored as delimited text ("ENTERPRISE,PUBLIC"). RESELLER is retired: rows that have it still
// read back, but new writes can't use it. Column: account.segments VARCHAR(200).
.Add(new ArxSelectionSetDefinition("CustomerSegment", ArxSelectionSetKind.String, new[]
{
new ArxSelectionSetOption("SMB", "Small business", 0),
new ArxSelectionSetOption("ENTERPRISE", "Enterprise", 1),
new ArxSelectionSetOption("PUBLIC", "Public sector", 2),
new ArxSelectionSetOption("RESELLER", "Reseller", 3, IsActive: false)
}, ArxSelectionSetMultiplicity.Delimited))
// Single-character codes. Column: contact.preferred_channel CHAR(1).
.Add("ContactChannel", ArxSelectionSetKind.Character, ('E', "Email"), ('P', "Phone"), ('S', "Text message"))
// A fixed list of text values, in a MySQL ENUM column, which allows only its labels.
// Column: data_type_showcase.mood ENUM('happy', 'neutral', 'sad').
.Add(new ArxSelectionSetDefinition("Mood", ArxSelectionSetKind.String, new[]
{
new ArxSelectionSetOption("happy", "Happy", 0),
new ArxSelectionSetOption("neutral", "Neutral", 1),
new ArxSelectionSetOption("sad", "Sad", 2)
}));
// Loaded from JSON: LeadSource (contact.lead_source VARCHAR(50)) and ContactRating (contact.rating SMALLINT).
foreach (var set in FromJson(SetsJson))
{
builder.Add(set);
}
return builder.Build();
}
/// <summary>Reads selection set definitions from JSON (the shape of <see cref="SetsJson"/>).</summary>
public static IEnumerable<ArxSelectionSetDefinition> FromJson(string json)
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() } // "String", "Integer", "Array", … as names
};
var sets = JsonSerializer.Deserialize<List<JsonSet>>(json, options)
?? throw new InvalidOperationException("The selection set JSON is empty.");
foreach (var set in sets)
{
yield return new ArxSelectionSetDefinition(
set.Name,
set.Kind,
set.Options.Select(x => new ArxSelectionSetOption(Code(x.Value, set.Kind), x.Text, x.Order, x.Active)),
set.Multiplicity,
set.DatabaseTypeName);
}
}
/// <summary>A JSON value as a code of the set's kind: JSON has no char, Guid or 64-bit integer of its own.</summary>
private static object Code(JsonElement value, ArxSelectionSetKind kind) => kind switch
{
ArxSelectionSetKind.Integer => value.GetInt64(),
ArxSelectionSetKind.Decimal => value.GetDecimal(),
ArxSelectionSetKind.Boolean => value.GetBoolean(),
ArxSelectionSetKind.Guid => value.GetGuid(),
ArxSelectionSetKind.Character => value.GetString()![0],
_ => value.GetString()! // String and DatabaseEnum
};
private sealed record JsonSet(
string Name,
ArxSelectionSetKind Kind,
List<JsonOption> Options,
ArxSelectionSetMultiplicity Multiplicity = ArxSelectionSetMultiplicity.Single,
string? DatabaseTypeName = null);
private sealed record JsonOption(JsonElement Value, string Text, int Order = 0, bool Active = true);
}
The plain MySqlConnector read-back helper, for the samples that show what MySQL stored.
namespace ArxSamples.MySQL;
/// <summary>plain MySqlConnector outside the SDK, for the samples that show what MySQL stored.</summary>
public sealed partial class SampleContext
{
private MySqlDirectHelper? _direct;
/// <summary>plain MySqlConnector outside the SDK, used only to reset the tables and to show what was stored.</summary>
public MySqlDirectHelper Direct => _direct ??= new MySqlDirectHelper(ConnectionString);
}
Plain MySqlConnector, not part of ARX: reads rows back so a sample can show what MySQL stored.
using MySqlConnector;
namespace ArxSamples.MySQL;
/// <summary>
/// Plain MySqlConnector, outside ARX.SDK: resets the sample tables, reads rows back so a sample can show what MySQL
/// actually stored (printing is SampleOutput.cs). It isn't part of the SDK and isn't how an application uses ARX; it only
/// lets the samples verify and show their results.
/// </summary>
public sealed class MySqlDirectHelper
{
private readonly string _connectionString;
public MySqlDirectHelper(string connectionString)
{
_connectionString = connectionString;
}
/// <summary>The same helper pointed at another database on the same server.</summary>
public MySqlDirectHelper ForDatabase(string database) => new MySqlDirectHelper(ConnectionStringFor(database));
/// <summary>The connection string of <see cref="ForDatabase"/>'s database, for a second ARX service.</summary>
public string ConnectionStringFor(string database)
=> new MySqlConnectionStringBuilder(_connectionString) { Database = database }.ConnectionString;
/// <summary>Creates <paramref name="database"/> on the server if it doesn't exist yet (connecting without a database).</summary>
public async Task EnsureDatabaseExistsAsync(string database)
{
await using var connection = new MySqlConnection(ConnectionStringFor(string.Empty));
await connection.OpenAsync();
// CREATE DATABASE takes no parameters; the name is the sample's own constant, quoted.
await using var create = new MySqlCommand($"CREATE DATABASE IF NOT EXISTS `{database.Replace("`", "``")}`", connection);
await create.ExecuteNonQueryAsync();
}
/// <summary>
/// Drops and recreates the sample tables (Schema/sample-schema.sql), statement by statement, as the mysql client runs
/// it (procedure bodies between DELIMITER lines). The optional reporting database is skipped for a user who can't create it.
/// </summary>
public async Task ResetSchemaAsync()
{
await EnsureDatabaseExistsAsync(new MySqlConnectionStringBuilder(_connectionString).Database);
var script = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "Schema", "sample-schema.sql"));
var skippedReporting = false;
foreach (var statement in ScriptStatements(script))
{
if (skippedReporting && statement.Contains(REPORTING_DATABASE, StringComparison.Ordinal))
{
continue;
}
try
{
await ExecuteAsync(statement);
}
catch (MySqlException e) when (statement.Contains(REPORTING_DATABASE, StringComparison.Ordinal)
&& e.Number is 1044 or 1142 or 1227) // access denied to a database, a table, or a privilege
{
// The script's optional part: a user who can't create a database goes on without it.
skippedReporting = true;
Console.WriteLine($"Skipped the optional {REPORTING_DATABASE} database ({e.Message}); only RetrieveMultiple's Advanced 7 reads it.");
}
}
}
private const string REPORTING_DATABASE = "arx_samples_reporting";
/// <summary>
/// The statements of a mysql client script: each ends with the current delimiter (<c>;</c>, or what a
/// <c>DELIMITER</c> line sets, so a procedure's body keeps its own semicolons). Comment lines never end a statement.
/// </summary>
public static IEnumerable<string> ScriptStatements(string script)
{
var delimiter = ";";
var current = new System.Text.StringBuilder();
foreach (var raw in script.Split('\n'))
{
var line = raw.TrimEnd('\r');
var trimmed = line.Trim();
if (trimmed.StartsWith("DELIMITER ", StringComparison.OrdinalIgnoreCase))
{
delimiter = trimmed.Substring("DELIMITER ".Length).Trim();
continue;
}
current.AppendLine(line);
if (!trimmed.StartsWith("--", StringComparison.Ordinal) && trimmed.EndsWith(delimiter, StringComparison.Ordinal))
{
var text = current.ToString().TrimEnd();
current.Clear();
yield return text.Substring(0, text.Length - delimiter.Length);
}
}
if (current.ToString().Split('\n').Any(x => x.Trim().Length > 0 && !x.Trim().StartsWith("--", StringComparison.Ordinal)))
{
yield return current.ToString();
}
}
public async Task ExecuteAsync(string sql)
{
await using var connection = await OpenConnectionAsync();
await using var command = new MySqlCommand(sql, connection);
await command.ExecuteNonQueryAsync();
}
public async Task<MySqlConnection> OpenConnectionAsync()
{
var connection = new MySqlConnection(_connectionString);
await connection.OpenAsync();
return connection;
}
/// <summary>
/// Counts committed rows, optionally with a WHERE clause using @p0, @p1, … for <paramref name="parameters"/>. A plain
/// SELECT is InnoDB's consistent read: it shows what is committed without waiting for another transaction's locks.
/// </summary>
public async Task<long> CountAsync(string table, string where = "1 = 1", params object[] parameters)
{
await using var connection = await OpenConnectionAsync();
await using var command = new MySqlCommand($"SELECT COUNT(*) FROM {table} WHERE {where}", connection);
for (var i = 0; i < parameters.Length; i++)
{
command.Parameters.AddWithValue("@p" + i, parameters[i] ?? DBNull.Value);
}
return Convert.ToInt64(await command.ExecuteScalarAsync());
}
/// <summary>Reads one row by id as column → value (null for SQL NULL).</summary>
public async Task<Dictionary<string, object?>> RowAsync(string table, long id)
{
await using var connection = await OpenConnectionAsync();
await using var command = new MySqlCommand($"SELECT * FROM {table} WHERE id = @p0", connection);
command.Parameters.AddWithValue("@p0", id);
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
{
throw new InvalidOperationException($"No {table} row with id {id}.");
}
var row = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader.IsDBNull(i) ? null : reader.GetValue(i);
}
return row;
}
/// <summary>
/// Reads columns of one row as MySQL prints them as text, in a UTC session (<c>CAST(column AS CHAR)</c>; binary values
/// as hex), which shows the stored value exactly, whatever its type: a TIMESTAMP as its UTC time.
/// </summary>
public async Task<Dictionary<string, string?>> TextRowAsync(string table, long id, params string[] columns)
{
await using var connection = await OpenConnectionAsync();
var binary = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
await using (var types = new MySqlCommand(
"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @p0 AND DATA_TYPE IN ('binary', 'varbinary', 'tinyblob', 'blob', 'mediumblob', 'longblob')", connection))
{
types.Parameters.AddWithValue("@p0", table);
await using var names = await types.ExecuteReaderAsync();
while (await names.ReadAsync())
{
binary.Add(names.GetString(0));
}
}
var select = string.Join(", ", columns.Select(x => binary.Contains(x) ? $"CONCAT('0x', HEX(`{x}`))" : $"CAST(`{x}` AS CHAR)"));
await using var command = new MySqlCommand($"SET time_zone = '+00:00'; SELECT {select} FROM {table} WHERE id = @p0", connection);
command.Parameters.AddWithValue("@p0", id);
await using var reader = await command.ExecuteReaderAsync();
if (!await reader.ReadAsync())
{
throw new InvalidOperationException($"No {table} row with id {id}.");
}
var row = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < columns.Length; i++)
{
row[columns[i]] = reader.IsDBNull(i) ? null : reader.GetString(i);
}
return row;
}
}
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.
> 1. Automatic commit: 10 batches, one commit at the end
5,000 rows written; visible to other connections before the commit: 0
after the commit: 5,000
> 2. Manual commit: batch 8 has a contact without a last name
batch 8 skipped: ARX3003 NotNullViolation: CreateMultiple on 'contact' has no value for required attribute 'last_name'.
committed: 4,500 contacts
> 3. Savepoints off: one failed batch rolls back the whole import
batch 2 failed; the scope is RolledBack
kept: 0 contacts