Migrondi Runs on Linux-x64, Linux-arm64, Windows-x64, and MacOS-x64 (intel based)
Install
For Non .NET users
Grab the binary from the releases page or build from source and put it on your PATH, that way the command is available globally e.g.
# you can put this at the end of your ~/.bashrc# $HOME/Apps/migrondi is a directory where you have downloaded your "Migrondi" binaryexport MIGRONDI_HOME="$HOME/Apps/migrondi"export PATH="$PATH:$MIGRONDI_HOME"
For .NET users
you can now install this as a global/local tool as well
It is fair as I wrote it over four years ago, So I'll give you a quick reminder:
Migrondi is a SQL Migrations tool designed to be simple and execute simple migrations. Write SQL and execute SQL against your database.
I initially wrote it as a CLI tool, but in my last attempt I tried to expose the core parts as a library, mostly to support programmatic cases as well as to be able to change the backing storage from file system to something else.
For the most part supports some of the major databases like SQL Server, MySQL, PostgreSQL, and SQLite.
Having that said... Have you ever written a CLI tool, or any kind of user facing application which stores local data? If you have, you might have faced the problem of managing a local database instance, perhaps a NoSQL embedded alternative or something painful related to text file formats.
Personally, I used to use LiteDB which is a NoSQL version which in v4 paired very nicely with F# thanks to Zaid's lovely LiteDB.FSharp library. Sadly, when v5 showed up, a lot of the F# niceties were lost given how the API was changed and v5 was not very F# friendly. You can still use it of course but you fall back to more unsafe F# code which is not ideal.
So the next natural candidate is SQLite, which is a very popular choice for local databases and also a safe bet for F# developers as there's a bunch of F# libraries that support SQL very well. My issue in general with SQL databases is having to manage the schema and migrations prior to get hacking around with your app.
For applications that have a remote database that's fine, you can use a tool like Migrondi, Flyway, Liquibase, etc. to manage your migrations and you're good to go but, for local databases how do you manage your schema and migrations?.
I'm sure there are solutions out there that I'm not aware of, but it was a big driver for me to add library support to Migrondi, so I could use it to manage local databases as well.
So let's see how we can use Migrondi to manage a local SQLite database. in your project!
For this we'll be looking at an example I already wrote for you!
This repository is meant to be source material for a future set of blog entries for more F# goodies.
Usage
Description:
A simple time tracking cli example
Usage:
Siquelin [command] [options]
Options:
--version Show version information
-?, -h, --help Show help and usage information
Commands:
log <day> Start a new work day
item <start> <finish> <label> Add a new shift item
list-items <day> List shift items for a day []
Which for the intended purposes, I'd recommend you to set github at the tag 01-exploring-a-foreign-codebase or clone it and checkout to that tag as that will be the code this post will be talking about.
If you want to learn a little bit more on "How to explore a foreign codebase" I'd recommend you to check out this post: Exploring an F# foreign codebase which contains a few tips and tricks to get you started with unknown to you F# codebases.
In our particular case we're only interested in the Types.fs, Migrations.fs, Program.fs and the Commands.Hidden in Commands.fs module.
NOTE: dotnet add package Migrondi.Core --prerelease to add the new bits to your project in case you want to try this yourself.
Let's start with the Migrations.fs file, which contains a few helper functions with Migrondi.Core to manage our migrations.
If you don't want to customize Migrondi in any way (meaning that you want to use Migrondi just like you would use the CLI tool), then you can use MigrondiFactory to create a new instance of Migrondi and then call Initialize to load the migrations from the root directory.
The MigrondiFactory function takes a MigrondiConfig item and a string which is the root directory where the migrations are located.
The run migrations function is not particularly complex, it simply calls MigrationsList() from an IMigrondi instance and then filters the pending migrations. If there are any pending migrations, it calls RunUp to apply them and logs the applied migrations.
Our CLI app focuses on being able to save "shift items" for a given "work day". and basically for any work day we can have multiple shift items.
Which should be listable when required.
NOTE: Keep in mind that this example is for a use case where the user is generating the data, but if you're generating the data yourself for configuration, or for pre/post processing, you can follow this approach as well!
Without diving into much detail, our app has a new-migration hidden command, it is callable from the CLI and is enabled only in DEBUG builds.
When we're developing our app we are able to call the following command to generate a new migration file to manage our SQLite database.
dotnet run -- new-migration initial-types
This will generate a new migration file in the migrations directory which may look like this ./migrations/initial-types_1708665113196.sql
Our file content is about creating the tables for our types.
MIGRONDI:UP which contains the SQL to create the tables.
MIGRONDI:DOWN which is empty, but it would contain the SQL to drop the tables.
The golden rule in Migrondi is to not remove anything from MIGRONDI:UP and above and not to remove the particular line where MIGRONDI:DOWN is located.
The first part is basically metadata for migrondi to know what to do with the file, the content between MIGRONDI:UP and MIGRONDI:DOWN is the SQL that Migrondi will execute when running the migration, and below MIGRONDI:DOWN is the SQL that Migrondi will execute when rolling back the migration.
Many folks don't like rolling back, so feel free to leave it empty, it is your choice.
In Program.fs theres a single line that is important for us to be able to use Migrondi.
[<EntryPoint>]letmainargv=letenv=Env.getEnv()// run this at the start of the app regardless of the commands// this will ensure that the database is up to dateRunner.runMigrations(env.logger,env.migrondi)...morecontent...
When the app starts, it will run the migrations, and then it will continue with the rest of the app.
And that's it! As long as we tell Migrondi where our migrations are and where is the database, we can use Migrondi to manage our local SQLite database.
Let's check the Env.fs file to see what are the particular values we're picking up from the environment.
letprivateloggerFactory=...letprivategetEnvLocations()=// The appData let binding, refers to where are we going to store our database// in the user's machine. We're using the LocalApplicationData folder to avoid// polluting the user's home directory.// ~/.local/share/siquelin in *nix systems// C:\Users\<USER>\AppData\Local\siquelin in WindowsletappData=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),"siquelin")// The full path to our database file including it's name.letdbPath=Path.Combine(appData,"siquelin.db")// appDir is the directory where the app is located, we use this to locate the migrations// in dev time we will be invoking these migrations from the project directory// so we just pick the current directory// in release time we will have our migrations next to our assembly// so we want to use that instead of the current directoryletappDir=#ifDEBUG// At dev time we want to be in the project directory// as they will be copied to the output directory when building for release// ~/projects/SiquelinEnvironment.CurrentDirectory#else// where is our assembly located// migrations will be in the same directory (e.g. {AppContext.BaseDirectory}/migrations/*.sql)AppContext.BaseDirectory#endif{appDirectory=appDirappDataDirectory=appDatadatabasePath=dbPath}letgetEnv():Types.Env.AppEnv=letlogger=loggerFactory.Value.CreateLogger("Siquelin")// get the locations for the app as we saw in the previous functionletlocations=getEnvLocations()// create the database directory if it doesn't exist// to avoid exceptions when trying to create the database filePath.GetDirectoryNamelocations.databasePath|>Directory.CreateDirectory|>ignoreletconfig={// use the default configuration but override the connection stringMigrondiConfig.Defaultwithconnection=$"Data Source={locations.databasePath};"}// Get an instance of migrondi, the migrondi instance will use the app directory// as the root directory for the migrations, and the connection string to connect to the databaseletmigrondi=Runner.getMigrondilocations.appDirectoryconfigletgetConnection():IDbConnection=newMicrosoft.Data.Sqlite.SqliteConnection(config.connection)letworkdays=Database.Workday.factorygetConnectionletshiftItems=Database.ShiftItem.factorygetConnection{locations=locationslogger=loggermigrondi=migrondiworkdays=workdaysshiftItems=shiftItems}
From here on you can start running the other commands
dotnet run -- item 10:00 11:00
dotnet run -- item 11:30 12:30
Before deploying this to production, we need to be sure that our migrations will be available for the app to run, so we will a couple of lines in our Siquelin.fsproj file to include the migrations in the output directory.
This will ensure that our migrations are copied to the output directory when we build the app, and live next to the assembly.
Yay! We have a local database, we're managing it with Migrondi!
However, the eagle eyed reader might have noticed that we can't identify the shift items, they are just a bunch of times, and our users might want to know which shift item is which.
The bad news is that we've already shipped this and our users are already logging their shift items... so we can't simply blow away the database and start from scratch the next time we run the app, once again Migrondi comes to the rescue!
We can add a new migration to add a new column in the existing tables, and then we can update the data to fill in the new column.
dotnet run -- new-migration add-name-column
This will create something like add-name-column_1708811304117.sql, then we can add the following migration.
-- MIGRONDI:NAME=add-name-column_1708811304117.sql-- MIGRONDI:TIMESTAMP=1708811304117-- ---------- MIGRONDI:UP ------------ We're adding new columns to the database to store the name of the item.-- and attempt to backfillaltertablework_daysaddcolumnitem_nametext;UPDATEwork_daysSETitem_name=CAST(idASTEXT)whereitem_nameisnull;altertableshift_itemsaddcolumnitem_nametext;UPDATEshift_itemsSETitem_name=CAST(idASTEXT)whereitem_nameisnull;-- ---------- MIGRONDI:DOWN ----------
After adding that migration, we can update our types
and the rest of the code updates.
The next time we run the app, the migrations will be applied and the new columns will be added to the database, and the data will be updated to fill in the new columns.
Let's move forward to the 02-siquelin-upsert-existing tag in the repository to see the updated implementation.
In regards to our migration management, nothing changed other than the migration we added, and the types we updated. the rest were application and business logic changes.
Running the new commands
dotnet run -- item 17:55 20:15 "watch netflix"
dotnet run -- item 17:00 17:30 "Bathroom"
Even if our users typed the wrong command (as it is a breaking change for them because a new required argument as added to the command line interface), the migrations will be applied and the data will be updated to fill in the new columns.
This is done transparently to the users, and we can be sure that the database is up to date.
That's how we can use Migrondi to manage a local SQLite database in our F# application. There are of course alternative approaches to this and hopefully Migrondi is up to the task for your use case. If not, I welcome you to open an issue in the repository for your potential contribution or discussions related to what you're trying to do.
Feedback and user testing is always welcome, so if you have any thoughts or questions, feel free to reach out to me on twitter or open an issue in the repository.