Database β
This page explains how we use PostgreSQL database with jOOQ for code generation and Liquibase for managing migrations in this project. This approach ensures reliable deployments, safe migrations, and a smooth integration between the database and the application code.
π Liquibase β
Liquibase is an essential tool for tracking, managing, and applying database schema changes in a platform-independent way. It enables versioned, reproducible, and team-friendly migrations.
π Structure of Liquibase Scripts β
The changelogs live in deployment/updater/src/main/resources/db/changelog/base/, and db.changelog-master.yml picks up every file of that folder with includeAll. There is one file per schema, and each file holds a single changeset that is re-executed whenever its content changes. A schema change therefore edits the file of its schema in place: it never adds a new changeset or a new file of its own.
Each SQL script must follow a specific format to be correctly interpreted by Liquibase.
Liquibase Header: The script must begin with the following line:
sql--liquibase formatted sqlChangeset Description: Immediately after, you must define the single changeset of the file, named after the file. The
logicalFilePathmust be fixed to prevent errors if the file is ever moved.sql--changeset mboisnard:01-initial-schema logicalFilePath:fixed splitStatements:false runInTransaction:false runOnChange:true
π§° Useful Changeset Options β
Several options can be added to the --changeset line to control its behavior:
logicalFilePath:fixed: Mandatory. Prevents Liquibase from complaining if you move the changeset file.runInTransaction:false: Necessary for operations that cannot run inside a transaction, such as creating an index with theCONCURRENTLYoption.splitStatements:false: Essential when usingDOblocks with custom delimiters (e.g.,$do$).runOnChange:true: Set on every changeset of this project. The whole file is re-executed when its content changes, which is what allows editing it in place.--validCheckSum: ANY: Added on a separate line, it prevents Liquibase from raising an error if the file's checksum has changed. Useful in specific cases, like updating views via custom mechanisms.labels:local: Allows you to run a script only in a specific environment (e.g.,localfor development).
πSecurity: Liquibase Outside the Application β
To enhance security, it is recommended to run Liquibase via an external process (CI/CD, dedicated script) with elevated privileges, while the application connects to the database using a role with restricted permissions (read/write on its tables, but no administrative rights like CREATE, ALTER, DROP).
β Database Best Practices β
Use a Dedicated Schema: To avoid conflicts and keep your database well-organized, it is crucial not to use the default
publicschema. Create a specific schema for your application, such asdrinkit_application. This isolates your database objects and simplifies permissions management.Don't ignore timezones when manipulating dates and times: don't use
TIMESTAMPalone useTIMESTAMP WITH TIME ZONEunless you actually want to store a logical date such as a birth date, in which case you should useDATE.Don't use hyphens (
-) to name your PostgreSQL objects: It's just painful to have to protect every such name with quotes
π‘οΈWriting Safe Migrations β
The Golden Rule: Idempotency π β
The fundamental rule is that all creation and addition operations must be idempotent. An idempotent operation can be executed multiple times without changing the result beyond its initial application.
To achieve this, use SQL constructs that check for existence before creating or modifying:
CREATE TABLE IF NOT EXISTS ...ALTER TABLE ... ADD COLUMN IF NOT EXISTS ...CREATE INDEX IF NOT EXISTS ...
Idempotency is required for all changes to ensure that migrations can be re-applied without causing errors: every change to a file re-executes all of its statements.
A new database and an existing one must end up with the same schema once the file has run:
- To change an existing table, add a statement after its
CREATE TABLE, such asALTER TABLE ... ADD COLUMN IF NOT EXISTS .... Editing theCREATE TABLEalone only reaches a new database. - To remove an object, add a
DROP ... IF EXISTS ...statement. Deleting a statement from the file leaves existing databases unchanged. - When a statement has no
IF NOT EXISTSform (ADD CONSTRAINTfor instance), wrap it in aDOblock that checks the catalog first.
Handling Complex Schema Changes (Without Locking) β οΈ β
Certain database operations can lock tables for a long time (ACCESS EXCLUSIVE lock), which is unacceptable in production. Here is how to handle the most common cases.
Creating/Dropping Indexes π β
A standard index creation or deletion rewrites the table and locks it.
- Creation: Use
CREATE INDEX CONCURRENTLY IF NOT EXISTSon a table that already holds data. This operation is slower but does not block writes to the table. It cannot run inside a transaction block, and a changeset withsplitStatements:falsesends its whole file as one statement, so it needs a changeset of its own: decide where with the maintainer. On a new table,CREATE INDEX IF NOT EXISTSis enough. - Deletion: Use
DROP INDEX CONCURRENTLY IF EXISTS, which has the same constraint as the creation: a changeset of its own, decided with the maintainer. - Tip: Before dropping a column (
DROP COLUMN), it is better to first drop any associated indexes usingDROP INDEX CONCURRENTLYto minimize the duration of theACCESS EXCLUSIVElock on the table.
NOT NULL Constraint π« β
Adding a NOT NULL constraint on a large table locks it while it scans all rows. To avoid this, proceed in three steps (in separate changes of the file, each applied before the next):
- Add a non-validated
CHECKconstraint: It is added instantly because the database does not check existing data. TheDOblock keeps it idempotent.sqlDO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'my_column_not_null' AND conrelid = 'my_table'::regclass) THEN ALTER TABLE my_table ADD CONSTRAINT my_column_not_null CHECK (my_column IS NOT NULL) NOT VALID; END IF; END $$; - Manually verify that there are no longer any
NULLvalues in the column. - Validate the constraint: This operation only requires a light lock to update metadata.sql
ALTER TABLE my_table VALIDATE CONSTRAINT my_column_not_null;
Changing a Column Type βοΈ β
In most cases, changing a column's type rewrites the entire table. A safer approach is as follows:
- Create a new column with the desired type.
- Use a trigger to copy and synchronize data from the old column to the new one during
INSERTandUPDATEoperations. - Update the application code to use the new column.
- Once the code is deployed and stable, drop the old column and the trigger in a later change of the file.
TIP
More best practices here
β¨ jOOQ Code Generation β
jOOQ generates Java/Kotlin classes from your database schema. This provides a type-safe DSL (Domain Specific Language) for writing elegant and safe SQL queries directly in your code.
Why Commit Generated Code? π β
We commit the jOOQ-generated files to our code repository. The main reason is to simplify the Continuous Integration (CI) pipeline. Without this, the CI would have to:
- Start a PostgreSQL container using TestContainer.
- Run all Liquibase migrations.
- Generate the jOOQ code from this newly created database.
- Compile and test the application.
Committing the code avoids this overhead and complexity in the CI process. After a changelog change, apply it locally and regenerate the classes as the "Database and jOOQ" section of AGENTS.md describes.
π§ͺ Integration Testing β
To ensure our queries work as expected, we use integration tests that connect to a real database.
- TestContainers π³: This tool starts a PostgreSQL Docker container for each test suite. The database connection is then configured to point to this container.
- Using jOOQ Schemas: Instead of using annotations like
@Sqlto load test data from SQL files, we can directly use the jOOQ-generated schemas to insert the necessary data for the test.
Here is a configuration example with a custom annotation in Kotlin:
// Usage in a test class
@JooqIntegrationTest(schemas = [DrinkitApplication::class])
class MyJooqRepositoryTest {
// ... tests ...
}