Skip to main content

Schema Linting Rules

Atlas supports the definition of custom linting rules for schemas to enforce teams' best practices, conventions, and compliance requirements. The linted schema can be defined in any supported Atlas schema format, such as HCL, SQL, ORM, a URL to a database, or a composition of multiple schema sources.

The guide below provides an overview of the language syntax for writing custom linting rules for your schemas and migrations.

The schema linting rules language is currently in beta and available for enterprise accounts and paid projects only. To start using this feature, run:

atlas login

Schema Rules Syntax

The schema rules language is an HCL-based language that lets you declaratively define rules for your schemas. It consists of three main building blocks: rule, predicate, and variable. Inspired by predicate logic, it allows defining reusable conditions and logical assertions for schema validation, similar to Prolog and SQL constraints.

Editor Support

Schema rule files use the .rule.hcl extension, and supported by the Atlas Editor Plugins.

Here's a simple example of a schema rule file:

schema.rule.hcl
# A predicate that checks if a column is not null or has a default value.
predicate "column" "not_null_or_have_default" {
or {
default {
ne = null
}
null {
eq = false
}
}
}

rule "schema" "disallow-null-columns" {
description = "require columns to be not null or have a default value"
table {
column {
assert {
predicate = predicate.column.not_null_or_have_default
message = "column ${self.name} must be not null or have a default value"
}
}
}
}

The predicate Block

The predicate block defines a reusable condition that can be used in multiple rules. It consists of a set of condition blocks that must be satisfied for the predicate to evaluate as true.

The block has two labels: type and name. The type represents the category of the predicate, and the name denotes its identifier. Once defined, it can be referenced using the following format: predicate.<type>.<name>.

A category can be one of the standard types defined in each driver language. For example, in PostgreSQL, the type can be table, column, function, trigger, policy, and so on.

The rule Block

The rule "schema" block defines a rule applied to the schema. It requires a name label and a description attribute that provides a human-readable explanation of the rule (it is used in the linting output).

A rule block can contain multiple nested blocks that traverse the analyzed schema and apply the rule to matching elements. These elements can also traverse their children and apply the rule to them.

When traversing a schema element, two blocks can be used: match and assert. The match block filters the elements the rule should apply to, and the assert block applies the predicate to the matched elements. If the predicate evaluates to false, an assertion is raised with the provided message attribute.

Injecting Variables

The schema rules language supports defining global variables, whose values can be set in atlas.hcl and then used in rules and predicates. For example:

lint {
rule "hcl" "table-policies" {
src = ["schema.rule.hcl"]
vars = { prefix = "ERROR:" }
}
}

Additionally, a predicate block can define local variables, that can be set by the rule invoking the predicate or by other predicates that use it. This allows you to define reusable predicates that work for different conditions.

The example below shows how to define a predicate that checks whether a table has created_at and updated_at timestamp columns with NOT NULL constraints:

schema.rule.hcl
predicate "column" "not_null" {
variable "name" {
type = string
}
variable "type" {
type = string
}
name {
eq = var.name
}
type {
eq = var.type
}
null {
eq = false
}
}

predicate "table" "has_timestamp_columns" {
any {
column {
predicate = predicate.column.not_null
vars = {
name = "created_at"
type = "timestamp"
}
}
}
any {
column {
predicate = predicate.column.not_null
vars = {
name = "updated_at"
type = "timestamp"
}
}
}
}

rule "schema" "table-required-columns" {
description = "All tables must include created_at and updated_at columns with NOT NULL constraints"
table {
assert {
predicate = predicate.table.has_timestamp_columns
message = "table ${self.name} must have created_at and updated_at columns"
}
}
}

Usage in Atlas

In order to run custom linting rules on your schemas, follow these steps:

  1. Create a schema rule file with your custom linting rules.
  2. Add the rule file(s) to the lint block in your atlas.hcl file.
  3. Run the migrate lint or schema lint command with the desired flags. In CI, ensure the atlas.hcl is set using the config option.

schema lint vs migrate lint

The schema linting rules language is supported by both the migrate lint and schema lint (beta) commands. The key difference between them is the context in which the rules are applied:

  • schema lint applies the rules to the entire schema and reports all results.
  • migrate lint applies the rules to the schema but only reports results for changes introduced by the analyzed migrations.

This makes migrate lint especially useful in cases like CI, where you want to lint only the parts of the schema affected by your changes.

API Reference

The API reference for the schema rules language is available in the HCL Docs section.

Examples

Below are some examples of linting rules to help you get started with creating your own:

Require columns to be not null or have a default value

predicate "column" "not_null_or_have_default" {
or {
default {
ne = null
}
null {
eq = false
}
}
}

rule "schema" "column-notnull" {
description = "require columns to be not null or have a default value"
table {
column {
assert {
predicate = predicate.column.not_null_or_have_default
message = "column ${self.name} must be not null or have a default value"
}
}
}
}

Require all PII columns to have a specific comment

predicate "column" "is_pii" {
name {
in = [
"name", "email", "phone", "address", "ssn", "dob",
"ip_address", "mac_address", "passport_number",
"driver_license_number", "biometric_data",
]
}
}

predicate "column" "comment_match" {
variable "pattern" {
type = string
}
comment {
match = var.pattern
}
}

rule "schema" "column-pii" {
description = "require all PII columns to have a specific comment"
table {
column {
match {
predicate = predicate.column.is_pii
}
assert {
predicate = predicate.column.comment_match
vars = {
pattern = ".*PII.*"
}
message = "column ${self.name} must have a comment PII"
}
}
}
}

Require foreign keys to reference primary keys of the target table and use ON DELETE CASCADE

predicate "foreign_key" "on_delete_cascade" {
on_delete {
eq = "CASCADE"
}
all {
ref_column {
predicate = predicate.column.is_pk
}
}
}

predicate "column" "is_pk" {
primary_key {
eq = true
}
}

rule "schema" "fk-action-cascade" {
description = "All foreign keys must reference primary keys of the target table and use ON DELETE CASCADE"
table {
foreign_key {
assert {
predicate = predicate.foreign_key.on_delete_cascade
message = "foreign key ${self.name} must reference primary key of target table and use ON DELETE CASCADE"
}
}
}
}

Require index on columns used by foreign keys

predicate "foreign_key" "columns_are_indexed" {
table {
predicate = predicate.table.has_columns_index
vars = {
columns = [for c in self.columns: c]
}
}
}

predicate "table" "has_columns_index" {
variable "columns" {
type = list(string)
}
any {
index {
condition = alltrue([for p in self.parts : p.expr == null]) && [for p in self.parts : p.column] == var.columns
}
}
}

rule "schema" "require-index-fk-columns" {
description = "Require index on columns used by foreign keys"
table {
foreign_key {
assert {
predicate = predicate.foreign_key.columns_are_indexed
message = "Missing index for columns used by foreign-key ${self.name}"
}
}
}
}

Require all functions to be lower-cased and prefixed with func_

predicate "function" "match_name" {
variable "pattern" {
type = string
}
name {
match = var.pattern
}
}

rule "schema" "function-naming" {
description = "Ensure all functions are lower-cased and prefixed with func_"
schema {
function {
assert {
predicate = predicate.function.match_name
vars = { pattern = "func_[a-z]+" }
message = "function ${self.name} must be lower-cased and prefixed with func_"
}
}
}
}

Ensure all procedures do not have OUT arguments

predicate "procedure" "no_out_arg" {
not {
exists {
arg {
condition = self.mode != "" && upper(self.mode) == "OUT"
}
}
}
}

rule "schema" "procedures-no-out-arg" {
description = "Ensure all procedures have no OUT arguments"
schema {
procedure {
assert {
predicate = predicate.procedure.no_out_arg
message = "procedure ${self.name} must not have OUT arguments"
}
}
}
}

All tables must have an audit sibling table

# Return true if the table is an audit table, or a regular table with an audit sibling.
predicate "table" "has_sibling_audit" {
or {
name {
match = ".+_audit$"
}
schema {
predicate = predicate.schema.has_table
vars = {
table_name = "${self.name}_audit"
}
}
}
}

predicate "schema" "has_table" {
variable "table_name" {
type = string
}
any {
table {
condition = self.name == var.table_name
}
}
}

rule "schema" "audit-tables" {
description = "All tables must have another _audit table with the same name"
table {
assert {
predicate = predicate.table.has_sibling_audit
message = "table ${self.name} must have a sibling ${self.name}_audit table"
}
}
}

All tables must have row security enabled and enforced (PostgreSQL)

predicate "table" "has_row_security_enabled" {
row_security_enabled {
eq = true
}
row_security_enforced {
eq = true
}
}

rule "schema" "ensure-row-security-enabled" {
description = "All tables must have row security enabled and enforced"
table {
assert {
predicate = predicate.table.has_row_security_enabled
}
}
}

All triggers must be deferrable and initially deferred (PostgreSQL)

predicate "trigger" "deferrable_initially_deferred" {
deferrable {
eq = true
}
initially_deferred {
eq = true
}
}

rule "schema" "triggers-deferrable-initially-deferred" {
description = "All triggers must be deferrable and initially deferred"
table {
trigger {
assert {
predicate = predicate.trigger.deferrable_initially_deferred
message = "Trigger ${self.name} must be deferrable and initially deferred"
}
}
}
}

All views must have invoker security (PostgreSQL)

predicate "view" "security_invoker_enabled" {
security_invoker {
eq = true
}
}

rule "schema" "view-security-invoker" {
description = "All views must have invoker security"
view {
assert {
predicate = predicate.view.security_invoker_enabled
message = "view \"${self.name}\" must have invoker security"
}
}
}

All functions must be deterministic (MySQL)

predicate "function" "deterministic" {
deterministic {
eq = true
}
}

rule "schema" "all-function-deterministic" {
description = "All functions must be deterministic"
schema {
function {
assert {
predicate = predicate.function.deterministic
message = "Func ${self.name} must be deterministic"
}
}
}
}