Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ test_snapshots_path
test/test.db
test/test.db-shm
test/test.db-wal
test/dev_test.db
test/dev_test.db-shm
test/dev_test.db-wal
9 changes: 8 additions & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,15 @@ if Mix.env() == :test do
pool: Ecto.Adapters.SQL.Sandbox,
migration_primary_key: [name: :id, type: :binary_id]

config :ash_sqlite, AshSqlite.DevTestRepo,
database: Path.join(__DIR__, "../test/dev_test.db"),
pool_size: 1,
migration_lock: false,
pool: Ecto.Adapters.SQL.Sandbox,
migration_primary_key: [name: :id, type: :binary_id]

config :ash_sqlite,
ecto_repos: [AshSqlite.TestRepo],
ecto_repos: [AshSqlite.TestRepo, AshSqlite.DevTestRepo],
ash_domains: [
AshSqlite.Test.Domain
]
Expand Down
27 changes: 23 additions & 4 deletions documentation/topics/development/migrations-and-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,30 @@ Ash comes with its own tasks, and AshSqlite exposes lower level tasks that you c

## Basic Workflow

- Make resource changes
- Run `mix ash.codegen --name add_a_combobulator` to generate migrations and resource snapshots
- Run `mix ash.migrate` to run those migrations
### Development Workflow (Recommended)

For more information on generating migrations, run `mix help ash_sqlite.generate_migrations` (the underlying task that is called by `mix ash.migrate`)
For development iterations, use the dev workflow to avoid naming migrations prematurely:

1. Make resource changes
2. Run `mix ash.codegen --dev` to generate dev migrations
3. Review the migrations and run `mix ash.migrate` to run them
4. Continue making changes and running `mix ash.codegen --dev` as needed
5. When your feature is complete, run `mix ash.codegen add_feature_name` to generate final named migrations (this will remove dev migrations and squash them)
6. Review the migrations and run `mix ash.migrate` to run them

### Traditional Migration Generation

For single-step changes or when you know the final feature name:

1. Make resource changes
2. Run `mix ash.codegen --name add_a_combobulator` to generate migrations and resource snapshots
3. Run `mix ash.migrate` to run those migrations

> **Tip**: The dev workflow (`--dev` flag) is preferred during development as it allows you to iterate without thinking of migration names and provides better development ergonomics.

> **Warning**: Always review migrations before applying them to ensure they are correct and safe.

For more information on generating migrations, run `mix help ash_sqlite.generate_migrations` (the underlying task that is called by `mix ash.codegen`)

### Regenerating Migrations

Expand Down
2 changes: 2 additions & 0 deletions documentation/tutorials/getting-started-with-ash-sqlite.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ Then we will generate database migrations. This is one of the many ways that Ash
mix ash_sqlite.generate_migrations --name add_tickets_and_representatives
```

> **Development Tip**: For iterative development, you can use `mix ash_sqlite.generate_migrations --dev` to create dev migrations without needing to name them immediately. When you're ready to finalize your changes, run the command with a proper name to consolidate all dev migrations into a single, well-named migration.

If you are unfamiliar with database migrations, it is a good idea to get a rough idea of what they are and how they work. See the links at the bottom of this guide for more. A rough overview of how migrations work is that each time you need to make changes to your database, they are saved as small, reproducible scripts that can be applied in order. This is necessary both for clean deploys as well as working with multiple developers making changes to the structure of a single database.

Typically, you need to write these by hand. AshSqlite, however, will store snapshots each time you run the command to generate migrations and will figure out what migrations need to be created.
Expand Down
85 changes: 80 additions & 5 deletions lib/migration_generator/migration_generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ defmodule AshSqlite.MigrationGenerator do
format: true,
dry_run: false,
check: false,
dev: false,
auto_name: false,
drop_columns: false

def generate(domains, opts \\ []) do
Expand Down Expand Up @@ -213,7 +215,7 @@ defmodule AshSqlite.MigrationGenerator do
migration_file =
opts
|> migration_path(repo)
|> Path.join(migration_name <> ".exs")
|> Path.join(migration_name <> "#{if opts.dev, do: "_dev"}.exs")

sanitized_module =
module
Expand Down Expand Up @@ -332,6 +334,22 @@ defmodule AshSqlite.MigrationGenerator do
:ok

operations ->
dev_migrations = get_dev_migrations(opts, repo)

if !opts.dev and dev_migrations != [] do
if opts.check do
Mix.shell().error("""
Generated migrations are from dev mode.

Generate migrations without `--dev` flag.
""")

exit({:shutdown, 1})
else
remove_dev_migrations_and_snapshots(dev_migrations, repo, opts, snapshots)
end
end

if opts.check do
IO.puts("""
Migrations would have been generated, but the --check flag was provided.
Expand All @@ -353,6 +371,46 @@ defmodule AshSqlite.MigrationGenerator do
end)
end

defp get_dev_migrations(opts, repo) do
opts
|> migration_path(repo)
|> File.ls()
|> case do
{:error, _error} -> []
{:ok, migrations} -> Enum.filter(migrations, &String.contains?(&1, "_dev.exs"))
end
end

defp remove_dev_migrations_and_snapshots(dev_migrations, repo, opts, snapshots) do
# Remove dev migration files
Enum.each(dev_migrations, fn migration_name ->
opts
|> migration_path(repo)
|> Path.join(migration_name)
|> File.rm!()
end)

# Remove dev snapshots
Enum.each(snapshots, fn snapshot ->
snapshot_folder =
opts
|> snapshot_path(snapshot.repo)
|> Path.join(repo_name(snapshot.repo))
|> Path.join(snapshot.table)

if File.exists?(snapshot_folder) do
snapshot_folder
|> File.ls!()
|> Enum.filter(&String.contains?(&1, "_dev.json"))
|> Enum.each(fn snapshot_name ->
snapshot_folder
|> Path.join(snapshot_name)
|> File.rm!()
end)
end
end)
end

defp add_order_to_operations({snapshot, operations}) do
operations_with_order = Enum.map(operations, &add_order_to_operation(&1, snapshot.attributes))

Expand Down Expand Up @@ -712,6 +770,8 @@ defmodule AshSqlite.MigrationGenerator do
defp write_migration!({up, down}, repo, opts) do
migration_path = migration_path(opts, repo)

require_name!(opts)

{migration_name, last_part} =
if opts.name do
{"#{timestamp(true)}_#{opts.name}", "#{opts.name}"}
Expand Down Expand Up @@ -742,7 +802,7 @@ defmodule AshSqlite.MigrationGenerator do

migration_file =
migration_path
|> Path.join(migration_name <> ".exs")
|> Path.join(migration_name <> "#{if opts.dev, do: "_dev"}.exs")

module_name =
Module.concat([repo, Migrations, Macro.camelize(last_part)])
Expand Down Expand Up @@ -815,6 +875,20 @@ defmodule AshSqlite.MigrationGenerator do
end
end

defp require_name!(opts) do
if !opts.name && !opts.dry_run && !opts.check && !opts.dev && !opts.auto_name do
raise """
Name must be provided when generating migrations, unless `--dry-run` or `--check` or `--dev` is also provided.

Please provide a name. for example:

mix ash_sqlite.generate_migrations <name> ...args
"""
end

:ok
end

defp add_line_numbers(contents) do
lines = String.split(contents, "\n")

Expand All @@ -837,15 +911,16 @@ defmodule AshSqlite.MigrationGenerator do
|> snapshot_path(snapshot.repo)
|> Path.join(repo_name)

snapshot_file = Path.join(snapshot_folder, "#{snapshot.table}/#{timestamp()}.json")
dev = if opts.dev, do: "_dev"
snapshot_file = Path.join(snapshot_folder, "#{snapshot.table}/#{timestamp()}#{dev}.json")

File.mkdir_p(Path.dirname(snapshot_file))
File.write!(snapshot_file, snapshot_binary, [])

old_snapshot_folder = Path.join(snapshot_folder, "#{snapshot.table}.json")
old_snapshot_folder = Path.join(snapshot_folder, "#{snapshot.table}#{dev}.json")

if File.exists?(old_snapshot_folder) do
new_snapshot_folder = Path.join(snapshot_folder, "#{snapshot.table}/initial.json")
new_snapshot_folder = Path.join(snapshot_folder, "#{snapshot.table}/initial#{dev}.json")
File.rename(old_snapshot_folder, new_snapshot_folder)
end
end)
Expand Down
22 changes: 22 additions & 0 deletions lib/mix/tasks/ash_sqlite.generate_migrations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ defmodule Mix.Tasks.AshSqlite.GenerateMigrations do
* `no-format` - files that are created will not be formatted with the code formatter
* `dry-run` - no files are created, instead the new migration is printed
* `check` - no files are created, returns an exit(1) code if the current snapshots and resources don't fit
* `dev` - dev files are created (see Development Workflow section below)

#### Snapshots

Expand Down Expand Up @@ -58,6 +59,25 @@ defmodule Mix.Tasks.AshSqlite.GenerateMigrations do
Non-function default values will be dumped to their native type and inspected. This may not work for some types,
and may require manual intervention/patches to the migration generator code.

#### Development Workflow

The `--dev` flag enables a development-focused migration workflow that allows you to iterate
on resource changes without committing to migration names prematurely:

1. Make resource changes
2. Run `mix ash_sqlite.generate_migrations --dev` to generate dev migrations
- Creates migration files with `_dev.exs` suffix
- Creates snapshot files with `_dev.json` suffix
- No migration name required
3. Continue making changes and running `--dev` as needed
4. When ready, run `mix ash_sqlite.generate_migrations my_feature_name` to:
- Remove all dev migrations and snapshots
- Generate final named migrations that consolidate all changes
- Create clean snapshots

This workflow prevents migration history pollution during development while maintaining
the ability to generate clean, well-named migrations for production.

#### Identities

Identities will cause the migration generator to generate unique constraints. If multiple
Expand All @@ -79,6 +99,8 @@ defmodule Mix.Tasks.AshSqlite.GenerateMigrations do
no_format: :boolean,
dry_run: :boolean,
check: :boolean,
dev: :boolean,
auto_name: :boolean,
drop_columns: :boolean
]
)
Expand Down
Empty file.
Loading