Writing Plugins¶
Squabble supports loading rule definitions from directories specified in the
.squabblerc
configuration file.
Every Python file in the list of directories will be loaded and any classes
that inherit from squabble.rules.BaseRule
will be registered and
available for use.
Configuration¶
{
"plugins": [
"/path/to/plugins/",
...
]
}
Concepts¶
Rules¶
Rules are classes which inherit from squabble.rules.BaseRule
and
are responsible for checking the abstract syntax tree of a SQL file.
At a minimum, each rule will define def enable(self, root_context, config)
,
which is responsible for doing any initialization when the rule is enabled.
Rules register callback functions to trigger when certain nodes of the abstract syntax tree are hit. Rules will report messages to indicate any issues discovered.
For example
class MyRule(squabble.rules.BaseRule):
def enable(self, context, config):
...
Could be configured with this .squabblerc
{"rules": {"MyRule": {"foo": "bar"}}}
enable()
would be passed config={"foo": "bar"}
.
Messages¶
Messages inherit from squabble.message.Message
, and are used to define
specific kinds of lint exceptions a rule can uncover.
At a bare minimum, each message class needs a TEMPLATE
class variable,
which is used when formatting the message to be printed on the command line.
For example
class BadlyNamedColumn(squabble.message.Message):
"""
Here are some more details about ``BadlyNamedColumn``.
This is where you would explain why this message is relevant,
how to resolve it, etc.
"""
TEMPLATE = 'tried to {foo} when you should have done {bar}!'
>>> msg = MyMessage(foo='abc', bar='xyz')
>>> msg.format()
'tried to abc when you should have done xyz'
>>> msg.explain()
'Here are some more details ...
Messages may also define a CODE
class variable, which is an integer which
uniquely identifies the class. If not explicitly specified, one will be
assigned, starting at 9000
. These can be used by the --explain
command
line flag
$ squabble --explain 9001
BadlyNamedColumn
Here are some more details about ``BadlyNamedColumn``.
...
Context¶
Each instance of squabble.lint.Context
holds the callback
functions that have been registered at or below a particular node in the
abstract syntax tree, as well as being responsible for reporting any messages
that get raised.
When the enable()
function for a class inheriting from
squabble.rules.BaseRule
is called, it will be passed a context
pointing to the root node of the syntax tree. Every callback function will be
passed a context scoped to the node that triggered the callback.
def enable(root_context, _config):
root_context.register('CreateStmt', create_table_callback)
def create_table_callback(child_context, node):
# register a callback that is only scoped to this ``node``
child_context.register('ColumnDef', column_def_callback):
def column_def_callback(child_context, node):
...
Details¶
- Parsing is done using
libpg_query, a Postgres
query parser.
- theoretically it will work with other SQL dialects
- Rules are implemented by registering callbacks while traversing the
Abstract Syntax Tree of the query.
- e.g. entering a
CREATE TABLE
node registers a callback for a column definition node, which checks that the column type is correct.
- e.g. entering a
As a somewhat unfortunate consequence of our reliance on libpg_query, the abstract syntax tree is very, very specific to Postgres. While developing new rules, it will be necessary to reference the Postgres AST Node source listing, or, more readably, the Python bindings.
Example Rule¶
import squabble.rule
from squabble.lint import Severity
from squabble.message import Message
from squabble.rules import BaseRule
class AllTablesMustBeLoud(BaseRule):
"""
A custom rule which makes sure that all table names are
in CAPSLOCK NOTATION.
"""
class TableNotLoudEnough(Message):
"""Add more details about the message here"""
CODE = 9876
TEMPLATE = 'table "{name}" not LOUD ENOUGH'
def enable(self, root_ctx, config):
"""
Called before the root AST node is traversed. Here's where
most callbacks should be registered for different AST
nodes.
Each linter is initialized once per file that it is being
run against. `config` will contain the merged base
configuration with the file-specific configuration options
for this linter.
"""
# Register that any time we see a `CreateStmt`
# (`CREATE TABLE`), call self._check()
root_ctx.register('CreateStmt', self._check_create())
# When we exit the root `ctx`, call `self._on_finish()`
root_ctx.register_exit(lambda ctx: self._on_finish(ctx))
# node_visitor will pass in `ctx, node` for you so there's no
# need to use a lambda
@squabble.rule.node_visitor
def _check_create(self, ctx, node):
"""
Called when we enter a 'CreateStmt' node. Here we can
register more callbacks if we need to, or do some checking
based on the `node` which will be the AST representation of
a `CREATE TABLE`.
"""
table_name = node.relation.relname.value
if table_name != table_name.upper():
# Report an error if this table was not SCREAMING_CASE
ctx.report(
self.TableNotLoudEnough(name=table_name),
node=node.relation,
severity=Severity.HIGH)
def _on_finish(self, ctx):
pass