Customizing the migration process
Choosing the right customization approach
Before implementing a migration customization, use the table below to select the interface that fits your use case:
| Use case | Approach | Interfaces | When it runs |
|---|---|---|---|
| Wrap the migration call to execute code before and/or after the entire migration (notifications, external system updates, etc.) | Migration decorator | IMigrator |
Before and/or after the migration |
| Insert custom commands at a specific point within the migration execution pipeline | Migration interceptor | IMigrationInterceptor + ICommand |
During migration command building, on every migration |
| Insert a custom command that should only execute once at a specific point in the migration pipeline | Migration interceptor (run once) | IMigrationInterceptor + IRunOnceCommand |
During migration command building; once, then skipped |
| Execute business logic per tenant after a successful migration (repositories, services available) | Tenant migration interceptor | ITenantDatabaseMigrationInterceptor |
After each successful migration, once per tenant |
| One-shot data correction per tenant — skipped on subsequent migrations | Tenant migration interceptor (run once) | IRunOnceTenantDatabaseMigrationInterceptor |
After migration; once per tenant, then skipped |
| Insert data with explicit auto-incremented ID values | Identity scope | IMigrationInterceptor + ICommand (using IInsertIdentityScope) |
Inside an ICommand.Execute method |
flowchart TD
Start([Start]) --> Q1{Where to run the code?}
Q1 -->|Before/after the migration| A[Migration decorator]
Q1 -->|Inside the migration pipeline| Q2{Run only once?}
Q1 -->|After migration, per tenant| Q3{Run only once per tenant?}
Q2 -->|No| B[Migration interceptor]
Q2 -->|Yes| C[Migration interceptor - run once]
Q3 -->|No| D[Tenant migration interceptor]
Q3 -->|Yes| E[Tenant migration interceptor - run once]
Note
ITenantDatabaseMigrationInterceptor and IRunOnceTenantDatabaseMigrationInterceptor also run in single-tenant mode. In that mode, the interceptors execute once per migration for the single virtual tenant.
Executing operations before and after the migration
It is possible to perform operations before and / or after a migration using the C# decorator pattern with the IMigrator interface.
Each module of a cluster can define the operations it needs to execute.
Note
The decorator pattern is a design pattern that allows behavior to be added to the database migration, dynamically, without affecting its behavior. The decorator pattern is often useful by allowing the functionality of the migration to be extended without being modified.
Warning
In a multi-tenant context, you will not be able to use entity nor entity view repositories to perform operations on all tenants at the same time.
Before starting you must decide which module will contain the decorator.
Then open the Application layer business assembly C# project of the module.
Make sure that the project contains a reference to the Scrutor package.
Then you can create your decorator class :
public class MyMigratorDecorator : IMigrator
{
private readonly IMigrator _baseMigrator;
public MyMigratorDecorator(IMigrator baseMigrator)
{
_baseMigrator = baseMigrator;
}
/// <inheritdoc />
public async Task<Result<int>> ExecuteAsync(MigrationOptions? options = null)
{
// Place operations to launch before the migration here
Result<int> result = await _baseMigrator.ExecuteAsync(options);
if (result.IsFailed)
{
return result;
}
// Place operations to launch after the migration here
return result;
}
}
Warning
Always check the result of the call to the base implementation before the post-migration code.
Next, you need to create or update the Startup class at the root of the project and register your decorator in the ConfigureServices method for IMigrator using the Decorate method :
public static class Startup
{
public static void ConfigureServices(IServiceCollection services)
{
services.Decorate<IMigrator, MyMigratorDecorator>();
}
}
Customizing the migration
The IMigrationInterceptor interface exposes methods that can be used to add code or commands that will be executed at different stages of the migration.
This approach is more versatile that using a migration decorator that can only execute operations before and after a migration.
Each module of a cluster can define one or several migration interceptors that will all be used during a migration.
Interceptors and commands are created in the Application layer business assembly C# project of a module.
Note
This is the preferred method for customized database migration. It's simple to implement and well integrated into the migration process. What's more, in the case of a command implementation, you benefit from an instance of the FluentMigrator.Migration class, with all the features provided by FluentMigrator.
Creating commands to execute during the migration
An interceptor allows the customization of the commands Neos will execute during the migration.
Custom commands can be created by implementing the ICommand interface. Another interface IRunOnceCommand allows you to implement commands that will be executed only once.
For example, when an application is deployed in production, if we mark as required an entity property that previously was not, we will need to updated existing database records to remove null values for this property during the migration.
In order to do that, we will create a new command class :
private class ReplaceNullOrderNotesCommand : ICommand
{
public void Execute(FluentMigrator.Migration migration, ICommandExecutionArgs args)
{
if (args.DatabaseType == DatabaseType.SqlServer)
{
// Setting a value using raw SQL
// Useful when access to other columns of the row is needed
// Here we set the Notes column value with a concatenation of a text value and the ID column value
migration.Execute.Sql("UPDATE Order SET Notes = 'Default notes ' + CONVERT(NVARCHAR(100), ID) WHERE Notes IS NULL");
}
else if (args.DatabaseType == DatabaseType.PostgreSQL)
{
// Setting a value using the Fluent Migrator syntax
// Here we set the Notes column value with a text value
migration.Update.Table("Order").Set(new { Notes = "Default notes" }).Where(new { Notes = DBNull.Value });
}
}
}
The Execute method contains the code that will be executed by the migration. Here we set a default value in the Notes column of the Order table since the column can no longer contain null values.
The migration parameter is a Fluent Migrator object that allows you to interact with the database. You can check the syntax to use to manipulate data here.
Warning
You should only need to use the Insert, Update and Delete methods to handle data in tables.
Database structure changes should always be handled in Neos Studio.
The args parameters contains information on the context of migration, notably the type of the database being updated.
This other example lets you create database objects not yet managed by Neos Studio. The command below will create the GetProductName function, which will be used in Neos Studio via a ProgrammableObject.
public class CreateGetProductNameFunction : IRunOnceCommand
{
public string CommandName => "CreateGetProductNameFunction";
public void Execute(FluentMigrator.Migration migration, ICommandExecutionArgs args)
{
switch (args.DatabaseType)
{
case DatabaseType.Oracle:
migration.Execute
.Sql(@"CREATE FUNCTION GetProductName(IdOfProduct IN NUMBER) RETURN NVARCHAR2
IS
product_name NVARCHAR2(100);
BEGIN
SELECT ""ProductName""
INTO product_name
FROM ""Product""
WHERE ""ProductId"" = IdOfProduct;
RETURN(product_name);
END;");
break;
case DatabaseType.PostgreSQL:
migration.Execute
.Sql(@"CREATE FUNCTION ""GetProductName"" (""IdOfProduct"" integer)
RETURNS character varying(100)
AS $$
DECLARE
""NameOfProduct"" character varying(100);
BEGIN
SELECT ""ProductName"" INTO ""NameOfProduct""
FROM ""Product""
WHERE ""ProductId"" = ""IdOfProduct"";
RETURN ""NameOfProduct"";
END;
$$ LANGUAGE plpgsql;");
break;
case DatabaseType.SqlServer:
migration.Execute
.Sql(@"CREATE FUNCTION [dbo].[GetProductName] (@IdOfProduct int)
RETURNS VARCHAR(100)
AS
BEGIN
DECLARE @NameOfProduct VARCHAR(100)
SELECT @NameOfProduct = ProductName
FROM Product
WHERE ProductId = @IdOfProduct
RETURN @NameOfProduct
END");
break;
default:
throw new NotSupportedException();
}
}
}
As this command implements the IRunOnceCommand interface, it will only be executed once.
Note
Compared with the ICommand interface, IRunOnceCommand requires implementation of the CommandName property. This type of command is stored in the $NeosObject system table, after successful execution. Before execution, the presence of the command in the table is tested, thus guaranteeing a unique execution.
Verifying and resetting the run-once marker
Storage format for manual maintenance:
- Table:
$NeosObject - Key columns:
ObjectType,ObjectName - Stored value for
ObjectType:Command - Stored value for
ObjectName: the exact value ofCommandName
Example:
| ObjectType | ObjectName |
|---|---|
Command |
CreateGetProductNameFunction |
Maintenance operations:
- To force the command to run again on the next migration, delete the corresponding row from
$NeosObject. - To prevent the command from running, insert the row in
$NeosObjectin advance with the exactCommandNamevalue.
Example queries:
SELECT *
FROM "$NeosObject"
WHERE "ObjectType" = 'Command'
AND "ObjectName" = 'CreateGetProductNameFunction';
DELETE
FROM "$NeosObject"
WHERE "ObjectType" = 'Command'
AND "ObjectName" = 'CreateGetProductNameFunction';
Warning
Use the exact persisted value of CommandName. If you change CommandName in code, Neos will consider it a different run-once command and will create a new marker.
Creating a migration interceptor class
Interceptors are classes that implement the IMigrationInterceptor interface.
This interface exposes methods that allow you to execute code or commands at different stages of the migration.
OnCommandsCreatedAsync
Executed after the existing and expected database schemas are compared and the standard migration commands to execute have been determined.
Allows to add custom commands to the list of commands or totally replace it.
The data parameter contains information on the context of the migration.
The commandList parameter contains the command list. You can add custom commands to the list. See here for more information.
Here is an example adding the custom command that we created earlier that replaces null values in the Notes column of the Order table before the commands that (re)activate constraints :
public class MyMigrationInterceptor : IMigrationInterceptor
{
public Task<ICommandList> OnCommandsCreatedAsync(CommandsCreatedEventData data, ICommandList commandList)
{
if (data.ExistingSchema.Columns.Exists(c => c.TableName == "Order" && c.Name == "Notes" && c.Nullable)
&& data.ExpectedSchema.Columns.Exists(c => c.TableName == "Order" && c.Name == "Notes" && !c.Nullable))
{
commandList.AddBefore<ISetNotNullConstraint>(new ReplaceNullOrderNotesCommand());
}
return Task.FromResult(commandList);
}
}
Registering a migration interceptor
After a migration interceptor has been created, you need to create or update the Startup class at the root of the project and register it in the ConfigureServices method using the AddMigrationInterceptor method :
public static class Startup
{
public static void ConfigureServices(IServiceCollection services)
{
services.AddMigrationInterceptor<MyMigrationInterceptor>();
}
}
Command execution order during the migration
The migration process runs in two phases:
- Detection phase: The system compares the current database schema with the expected schema (defined by your entities and data tables). It generates a list of commands to execute (create tables, columns, indexes, etc.).
- Execution phase: The commands are executed in a precise, predefined order.
Standard migration commands determined by Neos to migrate from the current database structure to the one configured in the metadata are always executed in the same order. The ICommandList interface allows you to add custom commands between them.
| Phase | Command | Description |
|---|---|---|
| ↑ | AddFirst(ICommand) |
Custom commands inserted here |
| Exclusions | ExcludeTable | Excludes a table from the migration |
| ExcludePrimaryKey | Excludes a primary key from the migration | |
| ExcludeForeignKey | Excludes a foreign key from the migration | |
| ExcludeIndex | Excludes an index from the migration | |
| Drop operations | DropView | Drops a view |
| DropCheckConstraint | Drops a check constraint | |
| DropForeignKey | Drops a foreign key | |
| DropPrimaryKey | Drops a primary key | |
| DropIndex | Drops an index | |
| Remove constraints | DeleteNotNullConstraint | Deletes a not null constraint |
| DeleteDefaultValueConstraint | Deletes a default value constraint | |
| Column modifications | RenameColumn | Renames a column |
| Table & column creation | CreateTable | Creates a table |
| CreateColumn | Creates a column | |
| Column alterations | ChangeColumnTypeToLocalizableString | Changes a column type to manage localizable strings (JSON) |
| AlterColumn | Alters a column | |
| Cleanup | DropTable | Drops a table |
| DropDependentComputedColumns | Drops dependent computed columns | |
| DropComputedColumn | Drops a computed column | |
| DropColumn | Drops a column | |
| Constraints, views & indexes | SetNotNullConstraint | Sets a not null constraint |
| CreateDependentComputedColumns | Creates dependent computed columns | |
| CreateView | Creates a view | |
| CreatePrimaryKey | Creates a primary key | |
| CreateCheckConstraint | Creates a check constraint | |
| RenameIndex | Renames an index | |
| CreateIndex | Creates an index | |
| CreateForeignKey | Creates a foreign key | |
| Finalization | UpdateSchemaVersion | Updates the schema version |
| ↓ | AddLast(ICommand) |
Custom commands inserted here |
Note
During the Drop operations and Remove constraints phases, constraints are removed from the database before structural updates are applied. This allows schema modifications to proceed without causing constraint violations. Constraints are then recreated during the Constraints, views & indexes phase at the end of the migration.
Therefore, if you need to add a custom command that manipulates data, it is recommended to add it just before the ISetNotNullConstraint command.
Caution
When using AddFirst(), your custom command will execute before any table creation. On a new database, tables do not exist yet at this stage. If your command attempts to read or modify data in tables, it will fail.
To safely manipulate data after tables are created, use AddAfter<ICreateTable>(...) or AddLast(...) instead.
You can use the ICommandList.AddBefore<TCommand>(ICommand) / ICommandList.AddAfter<TCommand>(ICommand) methods to insert custom commands at any stage of the migration. If the specified command is not found, the custom command will be inserted at the next step, respecting the order of precedence.
The parameter type TCommand of the generic methods ICommandList.AddBefore<TCommand>(ICommand) / ICommandList.AddAfter<TCommand>(ICommand) must be an interface corresponding to a standard command (e.g., IDropColumn, ICreateTable).
The following examples illustrate how custom commands are inserted into the command list.
Inserting before an existing command
When the specified command exists in the list, the custom command is inserted directly before it.
Initial command list: CreateTable → CreateColumn → AlterColumn → DropColumn → CreatePrimaryKey → CreateIndex
commandList.AddBefore<IDropColumn>(new CustomCommand());
Result: CreateTable → CreateColumn → AlterColumn → CustomCommand → DropColumn → CreatePrimaryKey → CreateIndex
Inserting before a non-existing command (fallback behavior)
When the specified command does not exist in the list, the custom command is inserted at the position where that command would have been, based on the standard execution order.
Initial command list (CreateTable is missing): AlterColumn → DropColumn → CreatePrimaryKey → CreateIndex
commandList.AddBefore<ICreateTable>(new CustomCommand());
Result (CustomCommand is inserted at the start, where CreateTable would have been): CustomCommand → AlterColumn → DropColumn → CreatePrimaryKey → CreateIndex
Inserting after a non-existing command
Similarly, when using AddAfter with a command that does not exist, the custom command is inserted just before the next existing command in the standard order.
Initial command list (CreatePrimaryKey is missing): CreateTable → CreateColumn → AlterColumn → DropColumn → CreateIndex
commandList.AddAfter<ICreatePrimaryKey>(new CustomCommand());
Result (CustomCommand is inserted before CreateIndex): CreateTable → CreateColumn → AlterColumn → DropColumn → CustomCommand → CreateIndex
Inserting at the end when no following command exists
When the specified command and all subsequent commands do not exist, the custom command is appended at the end of the list.
Initial command list (CreateIndex is missing): CreateTable → CreateColumn → AlterColumn → DropColumn → CreatePrimaryKey
commandList.AddBefore<ICreateIndex>(new CustomCommand());
Result (CustomCommand is appended at the end): CreateTable → CreateColumn → AlterColumn → DropColumn → CreatePrimaryKey → CustomCommand
Warning
When you specify a custom command type instead of a standard command interface (e.g., your own ICustomCommand), an InvalidOperationException will be thrown. Only the standard command interfaces listed above are allowed.
Executing operations after tenant database migration
It is possible to perform operations after the migration for all the tenants associated with the database thanks to the OnTenantDatabaseMigratedAsync method of the ITenantDatabaseMigrationInterceptor interceptor.
For example, when a shared database has 3 associated tenants, the interceptor will be executed 3 times.
Creating a tenant database migration interceptor
When an interceptor is executed, its OnTenantDatabaseMigratedAsync method is called. It is in this method that you can set your logic.
ITenants.GetCurrentTenant, INeosTenantInfoAccessor.NeosTenantInfo, repositories... are set for the current tenant.
Example :
internal class MyTenantDatabaseMigrationInterceptor : ITenantDatabaseMigrationInterceptor
{
private readonly IProductRepository _productRepository;
private readonly IUnitOfWork _unitOfWork;
public MyTenantDatabaseMigrationInterceptor(IProductRepository productRepository, IUnitOfWork unitOfWork)
{
_productRepository = productRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result> OnTenantDatabaseMigratedAsync(TenantDatabaseMigratedEventData tenantDatabaseMigratedEventData)
{
if (!_productRepository.GetQuery().Any(c => c.Name == "MyProduct"))
{
_productRepository.Add(new()
{
Name = "MyProduct",
});
}
return await _unitOfWork.SaveAsync();
}
}
The TenantDatabaseMigratedEventData provides data to the interceptor:
| Property | Type | Required | Description |
|---|---|---|---|
| Tenant | NeosTenantInfo | Yes | Informations on the tenant |
| ClientAdministratorLogin | String | No | Client administrator login |
| PublisherAdministratorLogin | String | No | Publisher administrator login |
| MigrationOptions | MigrationOptions | No | Options of the migration |
| MigrationResult | MigrationResult | No | Object indicating whether the migration has succeeded with additional informations (migration identifier, previous and current database schemas) |
Note
You have the SchemaComparison helper class that allows comparing schema between two databases. The SchemaComparisonResult class represents the result of the comparison and provides information about the differences between a source and target database models. A collection of SchemaDifference is available that contains details of schema comparison.
Example :
internal class SampleTenantDatabaseMigrationInterceptor : ITenantDatabaseMigrationInterceptor
{
private readonly IMigrationSettings _migrationSettings;
private readonly IDatabase _database;
public SampleTenantDatabaseMigrationInterceptor(IMigrationSettings migrationSettings, IDatabase database)
{
_migrationSettings = migrationSettings;
_database = database;
}
public async Task<Result> OnTenantDatabaseMigratedAsync(TenantDatabaseMigratedEventData eventData)
{
SchemaComparisonResult result = SchemaComparison.Compare(
eventData.MigrationResult.PreviousSchema,
eventData.MigrationResult.CurrentSchema,
_database.DatabaseType,
_migrationSettings.QuotedIdentifiers);
if (result.IsEqual)
{
// No database change
return Result.Ok();
}
if (result.IsAdded(ObjectType.Table, "Product"))
{
// New table "Product"
...
}
if (result.IsAdded(ObjectType.Column, "Product.QuantityInStock"))
{
// New column "QuantityInStock" of the Product "Table"
...
}
if (result.HasChanged(ObjectType.Column, "Product.CategoryId"))
{
// Modifying the "CategoryId" column in the "Product" table
...
}
if (result.IsDeleted(ObjectType.ForeignKey, "FK_OrderDetail_Order"))
{
// Deleting the "FK_OrderDetail_Order" foreign key
...
}
SchemaDifference? diff =
result.Differences.FirstOrDefault(
d =>
d.UpdateAction == SchemaUpdateAction.Change &&
d.ObjectType == ObjectType.Column &&
string.Equals(d.Name, "Product.Price", System.StringComparison.OrdinalIgnoreCase));
if (diff != null)
{
// Gets the old and new version of the "Price" column of the "Product" table
INeosColumn oldColumn = (INeosColumn)diff.SourceObject;
INeosColumn newColumn = (INeosColumn)diff.TargetObject;
...
}
...
return Result.Ok();
}
}
Registering a tenant database migration interceptor
After a tenant migration interceptor has been created, you need to create or update the Startup class at the root of the project and register it in the ConfigureServices method using the AddTenantDatabaseMigrationInterceptor method :
public static class Startup
{
public static void ConfigureServices(IServiceCollection services)
{
services.AddTenantDatabaseMigrationInterceptor<MyTenantDatabaseMigrationInterceptor>();
}
}
Executing a tenant database migration interceptor only once per tenant
Some tenant migration interceptors are not meant to run after every successful migration. This is typically the case when an interceptor performs a one-shot data correction or seeds data that should not be revisited once it has been applied successfully.
In that case, implement IRunOnceTenantDatabaseMigrationInterceptor instead of ITenantDatabaseMigrationInterceptor:
internal class MyTenantDatabaseMigrationInterceptor : IRunOnceTenantDatabaseMigrationInterceptor
{
public string InterceptorName => "MyTenantDatabaseMigrationInterceptor_V1";
public Task<Result> OnTenantDatabaseMigratedAsync(TenantDatabaseMigratedEventData tenantDatabaseMigratedEventData)
{
// One-shot migration logic for the current tenant.
return Task.FromResult(Result.Ok());
}
}
IRunOnceTenantDatabaseMigrationInterceptor uses the same registration method as any other tenant migration interceptor:
services.AddTenantDatabaseMigrationInterceptor<MyTenantDatabaseMigrationInterceptor>();
Behavior details:
- Neos stores a marker only after a successful execution.
- If the interceptor fails, no marker is stored and the interceptor is retried on the next migration.
- The marker is scoped per tenant, including when several tenants share the same physical database.
- The marker key is based on
TenantIdandInterceptorName, soInterceptorNamemust remain stable once deployed. - Avoid using
nameof(...)forInterceptorName, because renaming the class would change the persisted key and re-execute the interceptor for every tenant. - In migration history, a run-once interceptor that does not execute again is reported with the
Skippedexecution status.
Verifying and resetting the run-once marker
Storage format for manual maintenance:
- Table:
$NeosObject - Key columns:
ObjectType,ObjectName - Stored value for
ObjectType:TenantMigrationInterceptor - Stored value for
ObjectName:tenant-id:<TenantId>|interceptor:<InterceptorName> - In single-tenant mode, if the effective tenant identifier is empty, the stored value becomes
tenant-id:|interceptor:<InterceptorName>
Example:
ObjectType:TenantMigrationInterceptorObjectName:tenant-id:42|interceptor:MyTenantDatabaseMigrationInterceptor_V1
Maintenance operations:
- To force a re-execution for one tenant, delete the corresponding row from
$NeosObject. - To prevent execution for one tenant, insert the row in
$NeosObjectin advance with the exact tenant id andInterceptorName. - To re-execute the interceptor for all tenants of a shared database, delete all rows matching the same
InterceptorName.
Example queries:
SELECT *
FROM "$NeosObject"
WHERE "ObjectType" = 'TenantMigrationInterceptor'
AND "ObjectName" = 'tenant-id:42|interceptor:MyTenantDatabaseMigrationInterceptor_V1';
DELETE
FROM "$NeosObject"
WHERE "ObjectType" = 'TenantMigrationInterceptor'
AND "ObjectName" = 'tenant-id:42|interceptor:MyTenantDatabaseMigrationInterceptor_V1';
DELETE
FROM "$NeosObject"
WHERE "ObjectType" = 'TenantMigrationInterceptor'
AND "ObjectName" LIKE 'tenant-id:%|interceptor:MyTenantDatabaseMigrationInterceptor_V1';
Warning
IRunOnceTenantDatabaseMigrationInterceptor does not make the business logic and the persisted run-once marker atomic. The marker is written only after OnTenantDatabaseMigratedAsync(...) returns success. If the interceptor has already committed business changes and the marker persistence fails afterwards (database error, transient network issue, concurrency problem, process interruption, ...), the migration is reported as failed and the interceptor will be retried on the next migration.
As a consequence, run-once tenant migration interceptors must still be designed to be idempotent, even when the business intention is to execute them only once. Prefer logic that can safely run several times, or that explicitly checks whether the targeted correction or seed has already been applied before writing again.
Warning
Use the exact persisted value of InterceptorName. If you rename the interceptor or change InterceptorName, the previously stored marker will no longer match and Neos will execute the interceptor again.
Use this mode only for logic that is truly one-shot. If an interceptor is expected to resynchronize data or configuration on every migration, keep using ITenantDatabaseMigrationInterceptor.
When is the tenant database migration interceptor executed ?
In multi-tenant mode
When the application is executed using the neos run -mt command or when the application is deployed and configured with multiTenancy=true option, the interceptor is executed in several cases :
After the database migration process
In a shared database, the interceptor is called sequentially for each tenant. There is no parallelization to avoid database access problems and facilitate debugging.
In a separate database, the interceptor is called only once per database on the single tenant it is associated with.Example with a migration request on a shared database containing 3 tenants :
sequenceDiagram
autonumber
participant A as Requester
participant B as Business Cluster - Backend
A->>B: Request migration
B->>B: Migration successful
B->>B: OnTenantDatabaseMigratedAsync on tenant 1
B->>B: OnTenantDatabaseMigratedAsync on tenant 2
B->>B: OnTenantDatabaseMigratedAsync on tenant 3
B->>A: Migration result
- During the tenant initialization after a successful migration
sequenceDiagram
autonumber
participant A as Requester
participant B as Business Cluster - Backend
A->>B: Publish "TenantInit" to create Tenant 'MyTenant'
B->>B: Tenant initialization
B->>B: Migration successful
B->>B: OnTenantDatabaseMigratedAsync on tenant 'MyTenant'
B->>A: TenantInit result
Single tenant mode (automatic migration)
When a application is in single tenant mode, the backend runs database migrations automatically.
Warning
Even if the application is not in multi-tenant mode, OnTenantDatabaseMigratedAsync will be called as we consider that there is one tenant in the database.
By default, in development mode, when you execute the neos run command, the backend runs migrations and when successful, the interceptor is executed.
graph
A(Business Cluster - Backend)-->B(Automatic migration : true)
B-->C{Run migration}
C-->|Successful| D[Run OnTenantDatabaseMigratedAsync]
C-->|Failed| E[OnTenantDatabaseMigratedAsync not executed]
Inserting data and forcing the value of auto-incremented properties
When you need to import data during the migration, you may be inserting data that contain auto-incremented values, like an "ID" property for example. By default, this value is automatically set by the database and you cannot specify a the value you want.
When you create the ICommand class that will import data, you can inject and use an IInsertIdentityScope to force an auto-incremented property to use a specified value. This value will automatically be incremented for each subsequent insertions.
Here is an example. You can find a complete example in the technical demos cluster in the TechnicalDemos.slnx > Business > Core.Application > ImportDataMigration folder :
internal class ImportDataCommand : ICommand
{
private readonly IInsertIdentityScopeService _insertIdentityScopeService;
private readonly DatabaseContext _databaseContext;
public ImportDataCommand(IInsertIdentityScopeService insertIdentityScopeService, DatabaseContext databaseContext)
{
_insertIdentityScopeService = insertIdentityScopeService;
// Need to add the Persistence project to references.
_databaseContext = databaseContext;
}
public void Execute(Migration migration, ICommandExecutionArgs args)
{
// Example using connection with SQL
migration.Execute.WithConnection((connection, transaction) =>
{
using IDbCommand hasCompanyCommand = connection.CreateCommand();
hasCompanyCommand.CommandText = "SELECT EXISTS(SELECT 1 FROM \"Company\")";
bool hasCompany = (bool)hasCompanyCommand.ExecuteScalar()!;
if (!hasCompany)
{
using (IInsertIdentityScope insertIdentityScope = _insertIdentityScopeService.Start(connection, "Company", "Id", transaction))
{
// Insert the first company with the ID 5.
using IDbCommand insertCommand = connection.CreateCommand();
insertCommand.Transaction = transaction;
insertCommand.CommandText = "INSERT INTO \"Company\" (\"Id\", \"Name\") VALUES (5, 'AKANEA')";
insertCommand.ExecuteNonQuery();
// If not explicitly specified, then dispose will stop the scope automatically.
insertIdentityScope.StopScope();
}
// The second company will have the ID 6.
using IDbCommand insert2Command = connection.CreateCommand();
insert2Command.Transaction = transaction;
insert2Command.CommandText = "INSERT INTO \"Company\" (\"Name\") VALUES ('IRIUM')";
insert2Command.ExecuteNonQuery();
}
});
}
}