Data Migrations

Migrations are usually used for changing the database schema, but in some cases, there is a need to modify the data stored in the database. For example, adding seed data, or back-filling empty columns with custom default values.

Migrations of this type are called data migrations. In this document, we will discuss how to use Ent to plan data migrations and integrate them into your regular schema migrations workflow.

Migration Types

Ent currently supports two types of migrations, versioned migration and declarative migration (also known as automatic migration). Data migrations can be executed in both types of migrations.

Versioned Migrations

When using versioned migrations, data migrations should be stored on the same migrations directory and executed the same way as regular migrations. It is recommended, however, to store data migrations and schema migrations in separate files so that they can be easily tested.

The format used for such migrations is SQL, as the file can be safely executed (and stored without changes) even if the Ent schema was modified and the generated code is not compatible with the data migration file anymore.

There are two ways to create data migrations scripts, manually and generated. By manually editing, users write all the SQL statements and can control exactly what will be executed. Alternatively, users can use Ent to generate the data migrations for them. It is recommended to verify that the generated file was correctly generated, as in some cases it may need to be manually fixed or edited.

Manual Creation

  1. If you don’t have Atlas installed, check out its getting-started guide.

  2. Create a new migration file using Atlas:

  1. atlas migrate new <migration_name> \
  2. --dir "file://my/project/migrations"
  1. Edit the migration file and add the custom data migration there. For example:

ent/migrate/migrations/20221126185750_backfill_data.sql

  1. -- Backfill NULL or null tags with a default value.
  2. UPDATE `users` SET `tags` = '["foo","bar"]' WHERE `tags` IS NULL OR JSON_CONTAINS(`tags`, 'null', '$');
  1. Update the migration directory integrity file:
  1. atlas migrate hash \
  2. --dir "file://my/project/migrations"

Check out the Testing section below if you’re unsure how to test the data migration file.

Generated Scripts

Currently, Ent provides initial support for generating data migration files. By using this option, users can simplify the process of writing complex SQL statements manually in most cases. Still, it is recommended to verify that the generated file was correctly generated, as in some edge cases it may need to be manually edited.

  1. Create your versioned-migration setup, in case it is not set.

  2. Create your first data-migration function. Below, you will find some examples that demonstrate how to write such a function:

  • Single Statement
  • Multi Statement
  • Data Seeding

ent/migrate/migratedata/migratedata.go

  1. package migratedata
  2. // BackfillUnknown back-fills all empty users' names with the default value 'Unknown'.
  3. func BackfillUnknown(dir *migrate.LocalDir) error {
  4. w := &schema.DirWriter{Dir: dir}
  5. client := ent.NewClient(ent.Driver(schema.NewWriteDriver(dialect.MySQL, w)))
  6. // Change all empty names to 'unknown'.
  7. err := client.User.
  8. Update().
  9. Where(
  10. user.NameEQ(""),
  11. ).
  12. SetName("Unknown").
  13. Exec(context.Background())
  14. if err != nil {
  15. return fmt.Errorf("failed generating statement: %w", err)
  16. }
  17. // Write the content to the migration directory.
  18. return w.FlushChange(
  19. "unknown_names",
  20. "Backfill all empty user names with default value 'unknown'.",
  21. )
  22. }

Then, using this function in ent/migrate/main.go will generate the following migration file:

migrations/20221126185750_unknown_names.sql

  1. -- Backfill all empty user names with default value 'unknown'.
  2. UPDATE `users` SET `name` = 'Unknown' WHERE `users`.`name` = '';

ent/migrate/migratedata/migratedata.go

  1. package migratedata
  2. // BackfillUserTags is used to generate the migration file '20221126185750_backfill_user_tags.sql'.
  3. func BackfillUserTags(dir *migrate.LocalDir) error {
  4. w := &schema.DirWriter{Dir: dir}
  5. client := ent.NewClient(ent.Driver(schema.NewWriteDriver(dialect.MySQL, w)))
  6. // Add defaults "foo" and "bar" tags for users without any.
  7. err := client.User.
  8. Update().
  9. Where(func(s *sql.Selector) {
  10. s.Where(
  11. sql.Or(
  12. sql.IsNull(user.FieldTags),
  13. sqljson.ValueIsNull(user.FieldTags),
  14. ),
  15. )
  16. }).
  17. SetTags([]string{"foo", "bar"}).
  18. Exec(context.Background())
  19. if err != nil {
  20. return fmt.Errorf("failed generating backfill statement: %w", err)
  21. }
  22. // Document all changes until now with a custom comment.
  23. w.Change("Backfill NULL or null tags with a default value.")
  24. // Append the "org" special tag for users with a specific prefix or suffix.
  25. err = client.User.
  26. Update().
  27. Where(
  28. user.Or(
  29. user.NameHasPrefix("org-"),
  30. user.NameHasSuffix("-org"),
  31. ),
  32. // Append to only those without this tag.
  33. func(s *sql.Selector) {
  34. s.Where(
  35. sql.Not(sqljson.ValueContains(user.FieldTags, "org")),
  36. )
  37. },
  38. ).
  39. AppendTags([]string{"org"}).
  40. Exec(context.Background())
  41. if err != nil {
  42. return fmt.Errorf("failed generating backfill statement: %w", err)
  43. }
  44. // Document all changes until now with a custom comment.
  45. w.Change("Append the 'org' tag for organization accounts in case they don't have it.")
  46. // Write the content to the migration directory.
  47. return w.Flush("backfill_user_tags")
  48. }

Then, using this function in ent/migrate/main.go will generate the following migration file:

migrations/20221126185750_backfill_user_tags.sql

  1. -- Backfill NULL or null tags with a default value.
  2. UPDATE `users` SET `tags` = '["foo","bar"]' WHERE `tags` IS NULL OR JSON_CONTAINS(`tags`, 'null', '$');
  3. -- Append the 'org' tag for organization accounts in case they don't have it.
  4. UPDATE `users` SET `tags` = CASE WHEN (JSON_TYPE(JSON_EXTRACT(`tags`, '$')) IS NULL OR JSON_TYPE(JSON_EXTRACT(`tags`, '$')) = 'NULL') THEN JSON_ARRAY('org') ELSE JSON_ARRAY_APPEND(`tags`, '$', 'org') END WHERE (`users`.`name` LIKE 'org-%' OR `users`.`name` LIKE '%-org') AND (NOT (JSON_CONTAINS(`tags`, '"org"', '$') = 1));

ent/migrate/migratedata/migratedata.go

  1. package migratedata
  2. // SeedUsers add the initial users to the database.
  3. func SeedUsers(dir *migrate.LocalDir) error {
  4. w := &schema.DirWriter{Dir: dir}
  5. client := ent.NewClient(ent.Driver(schema.NewWriteDriver(dialect.MySQL, w)))
  6. // The statement that generates the INSERT statement.
  7. err := client.User.CreateBulk(
  8. client.User.Create().SetName("a8m").SetAge(1).SetTags([]string{"foo"}),
  9. client.User.Create().SetName("nati").SetAge(1).SetTags([]string{"bar"}),
  10. ).Exec(context.Background())
  11. if err != nil {
  12. return fmt.Errorf("failed generating statement: %w", err)
  13. }
  14. // Write the content to the migration directory.
  15. return w.FlushChange(
  16. "seed_users",
  17. "Add the initial users to the database.",
  18. )
  19. }

Then, using this function in ent/migrate/main.go will generate the following migration file:

migrations/20221126212120_seed_users.sql

  1. -- Add the initial users to the database.
  2. INSERT INTO `users` (`age`, `name`, `tags`) VALUES (1, 'a8m', '["foo"]'), (1, 'nati', '["bar"]');
  1. In case the generated file was edited, the migration directory integrity file needs to be updated with the following command:
  1. atlas migrate hash \
  2. --dir "file://my/project/migrations"

Testing

After adding the migration files, it is highly recommended that you apply them on a local database to ensure they are valid and achieve the intended results. The following process can be done manually or automated by a program.

  1. Execute all migration files until the last created one, the data migration file:
  1. # Total number of files.
  2. number_of_files=$(ls ent/migrate/migrations/*.sql | wc -l)
  3. # Execute all files without the latest.
  4. atlas migrate apply $[number_of_files-1] \
  5. --dir "file://my/project/migrations" \
  6. -u "mysql://root:pass@localhost:3306/test"
  1. Ensure the last migration file is pending execution:
  1. atlas migrate status \
  2. --dir "file://my/project/migrations" \
  3. -u "mysql://root:pass@localhost:3306/test"
  4. Migration Status: PENDING
  5. -- Current Version: <VERSION_N-1>
  6. -- Next Version: <VERSION_N>
  7. -- Executed Files: <N-1>
  8. -- Pending Files: 1
  1. Fill the local database with temporary data that represents the production database before running the data migration file.

  2. Run atlas migrate apply and ensure it was executed successfully.

  1. atlas migrate apply \
  2. --dir "file://my/project/migrations" \
  3. -u "mysql://root:pass@localhost:3306/test"

Note, by using atlas schema clean you can clean the database you use for local development and repeat this process until the data migration file achieves the desired result.

Automatic Migrations

In the declarative workflow, data migrations are implemented using Diff or Apply Hooks. This is because, unlike the versioned option, migrations of this type do not hold a name or a version when they are applied. Therefore, when a data is are written using hooks, the type of the schema.Change must be checked before its execution to ensure the data migration was not applied more than once.

  1. func FillNullValues(dbdialect string) schema.ApplyHook {
  2. return func(next schema.Applier) schema.Applier {
  3. return schema.ApplyFunc(func(ctx context.Context, conn dialect.ExecQuerier, plan *migrate.Plan) error {
  4. // Search the schema.Change that triggers the data migration.
  5. hasC := func() bool {
  6. for _, c := range plan.Changes {
  7. m, ok := c.Source.(*schema.ModifyTable)
  8. if ok && m.T.Name == user.Table && schema.Changes(m.Changes).IndexModifyColumn(user.FieldName) != -1 {
  9. return true
  10. }
  11. }
  12. return false
  13. }()
  14. // Change was found, apply the data migration.
  15. if hasC {
  16. // At this stage, there are three ways to UPDATE the NULL values to "Unknown".
  17. // Append a custom migrate.Change to migrate.Plan, execute an SQL statement
  18. // directly on the dialect.ExecQuerier, or use the generated ent.Client.
  19. // Create a temporary client from the migration connection.
  20. client := ent.NewClient(
  21. ent.Driver(sql.NewDriver(dbdialect, sql.Conn{ExecQuerier: conn.(*sql.Tx)})),
  22. )
  23. if err := client.User.
  24. Update().
  25. SetName("Unknown").
  26. Where(user.NameIsNil()).
  27. Exec(ctx); err != nil {
  28. return err
  29. }
  30. }
  31. return next.Apply(ctx, conn, plan)
  32. })
  33. }
  34. }

For more examples, check out the Apply Hook examples section.