├── .gitignore
├── .rbenv-gemsets
├── .ruby-version
├── .github
├── FUNDING.yml
├── dependabot.yml
└── workflows
│ └── ci.yml
├── lib
├── sqlint.rb
└── sqlint
│ ├── version.rb
│ └── linter.rb
├── Rakefile
├── .pre-commit-hooks.yaml
├── Gemfile
├── Makefile
├── TODO
├── flake.lock
├── flake.nix
├── LICENSE.txt
├── sqlint.gemspec
├── Gemfile.lock
├── bin
└── sqlint
├── README.md
├── spec
└── linter_spec.rb
└── gemset.nix
/.gitignore:
--------------------------------------------------------------------------------
1 | /*.gem
2 |
--------------------------------------------------------------------------------
/.rbenv-gemsets:
--------------------------------------------------------------------------------
1 | sqlint
--------------------------------------------------------------------------------
/.ruby-version:
--------------------------------------------------------------------------------
1 | 2.7
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | patreon: sanityinc
2 |
--------------------------------------------------------------------------------
/lib/sqlint.rb:
--------------------------------------------------------------------------------
1 | require 'sqlint/version'
2 | require 'sqlint/linter'
3 |
--------------------------------------------------------------------------------
/lib/sqlint/version.rb:
--------------------------------------------------------------------------------
1 | module SQLint
2 | VERSION = "0.3.0"
3 | end
4 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | begin
2 | require 'rspec/core/rake_task'
3 | RSpec::Core::RakeTask.new(:spec)
4 | rescue LoadError
5 | end
6 |
7 | task default: :spec
8 |
--------------------------------------------------------------------------------
/.pre-commit-hooks.yaml:
--------------------------------------------------------------------------------
1 | ---
2 | - id: sqlint
3 | name: sqlint
4 | entry: sqlint
5 | language: ruby
6 | minimum_pre_commit_version: 0.15.0
7 | types: [sql]
8 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | ruby ">= 3.0"
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "pg_query", ">= 1.0"
6 |
7 | group :development do
8 | gem "rspec"
9 | gem "rake"
10 | end
11 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | test:
2 | bundle exec rspec
3 |
4 | gem:
5 | # Ensure perms are correct
6 | chmod +x bin/sqlint
7 | chmod -R a+r *
8 | chmod -R a+X *
9 | gem build sqlint.gemspec
10 |
11 | .PHONY: gem test
12 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: github-actions
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | open-pull-requests-limit: 10
8 | commit-message:
9 | prefix: "chore"
10 | include: "scope"
11 |
--------------------------------------------------------------------------------
/TODO:
--------------------------------------------------------------------------------
1 | - [-] More input test cases?
2 | - [-] Handle any warnings returned by PgQuery.parse
3 | - [X] Limit number of warnings
4 | - [ ] Optionally connect to a live DB to check referenced table/column names are valid
5 | - [ ] Refactor and test the CLI front end
6 | - [ ] Support for other SQL dialects
7 | - [ ] JSON output
8 | - [ ] Huge files
9 | - [X] Maybe truncate long 'at or near "blah"' strings
10 |
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "nixpkgs": {
4 | "locked": {
5 | "lastModified": 1752077645,
6 | "narHash": "sha256-HM791ZQtXV93xtCY+ZxG1REzhQenSQO020cu6rHtAPk=",
7 | "owner": "NixOS",
8 | "repo": "nixpkgs",
9 | "rev": "be9e214982e20b8310878ac2baa063a961c1bdf6",
10 | "type": "github"
11 | },
12 | "original": {
13 | "id": "nixpkgs",
14 | "ref": "nixpkgs-unstable",
15 | "type": "indirect"
16 | }
17 | },
18 | "root": {
19 | "inputs": {
20 | "nixpkgs": "nixpkgs"
21 | }
22 | }
23 | },
24 | "root": "root",
25 | "version": 7
26 | }
27 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: [push, pull_request, workflow_dispatch]
3 | jobs:
4 | test:
5 | strategy:
6 | fail-fast: false
7 | matrix:
8 | os: [ubuntu-latest, macos-latest]
9 | # Due to https://github.com/actions/runner/issues/849, we have to use quotes for '3.0'
10 | ruby: ['3.0', '3.1', '3.2', '3.3']
11 | runs-on: ${{ matrix.os }}
12 | steps:
13 | - uses: actions/checkout@v6
14 | - uses: ruby/setup-ruby@v1
15 | with:
16 | ruby-version: ${{ matrix.ruby }}
17 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically
18 | - run: bundle exec rake
19 |
--------------------------------------------------------------------------------
/flake.nix:
--------------------------------------------------------------------------------
1 | {
2 | description = "sqlint tool";
3 |
4 | inputs = {
5 | nixpkgs.url = "nixpkgs/nixpkgs-unstable";
6 | };
7 |
8 | outputs =
9 | { self, nixpkgs }@inputs:
10 | let
11 | forAllSystems = nixpkgs.lib.genAttrs nixpkgs.lib.platforms.all;
12 | in
13 | {
14 | devShell = forAllSystems (
15 | system:
16 | let
17 | pkgs = import nixpkgs { inherit system; };
18 | env = pkgs.bundlerEnv {
19 | name = "sqlint";
20 | gemdir = ./.;
21 | groups = [
22 | "default"
23 | "development"
24 | "test"
25 | ];
26 |
27 | meta = with pkgs.lib; {
28 | description = "sqlint";
29 | platforms = platforms.unix;
30 | };
31 | };
32 | in
33 | pkgs.mkShell {
34 | buildInputs = [
35 | env
36 | pkgs.bundix
37 | ];
38 | }
39 | );
40 | };
41 | }
42 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright (C) 2015 Powershop NZ Ltd.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining
4 | a copy of this software and associated documentation files (the
5 | "Software"), to deal in the Software without restriction, including
6 | without limitation the rights to use, copy, modify, merge, publish,
7 | distribute, sublicense, and/or sell copies of the Software, and to
8 | permit persons to whom the Software is furnished to do so, subject to
9 | the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be
12 | included in all copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/sqlint.gemspec:
--------------------------------------------------------------------------------
1 | # encoding: utf-8
2 |
3 | $LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
4 | require 'sqlint'
5 | require 'English'
6 |
7 | Gem::Specification.new do |s|
8 | s.name = 'sqlint'
9 | s.version = SQLint::VERSION
10 | s.platform = Gem::Platform::RUBY
11 | s.required_ruby_version = '>= 3.0'
12 | s.authors = ['Steve Purcell', 'Kieran Trezona-le Comte']
13 | s.description = <<-EOF
14 | Simple SQL linter.
15 | EOF
16 |
17 | s.email = 'steve@sanityinc.com'
18 | s.files = `git ls-files`.split($RS).reject do |file|
19 | file =~ %r{^(?:
20 | spec/.*
21 | |Gemfile
22 | |Rakefile
23 | |\.rspec
24 | |\.ruby-version
25 | |\.rbenv-gemsets
26 | |\.gitignore
27 | |\.travis.yml
28 | )$}x
29 | end
30 | s.test_files = []
31 | s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) }
32 | s.extra_rdoc_files = ['LICENSE.txt', 'README.md']
33 | s.homepage = 'https://github.com/purcell/sqlint'
34 | s.licenses = ['MIT']
35 | s.require_paths = ['lib']
36 | s.rubygems_version = '1.8.23'
37 | s.summary = 'Simple SQL linter.'
38 |
39 | s.add_runtime_dependency('pg_query', '>= 1')
40 | s.add_development_dependency('rake', '>= 12.3.3')
41 | s.add_development_dependency('rspec', '~> 3.2')
42 | s.add_development_dependency('bundler', '>= 2.2.33')
43 | end
44 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | bigdecimal (3.1.9)
5 | diff-lcs (1.6.1)
6 | google-protobuf (4.30.2)
7 | bigdecimal
8 | rake (>= 13)
9 | google-protobuf (4.30.2-aarch64-linux)
10 | bigdecimal
11 | rake (>= 13)
12 | google-protobuf (4.30.2-arm64-darwin)
13 | bigdecimal
14 | rake (>= 13)
15 | google-protobuf (4.30.2-x86-linux)
16 | bigdecimal
17 | rake (>= 13)
18 | google-protobuf (4.30.2-x86_64-darwin)
19 | bigdecimal
20 | rake (>= 13)
21 | google-protobuf (4.30.2-x86_64-linux)
22 | bigdecimal
23 | rake (>= 13)
24 | pg_query (6.1.0)
25 | google-protobuf (>= 3.25.3)
26 | rake (13.2.1)
27 | rspec (3.13.0)
28 | rspec-core (~> 3.13.0)
29 | rspec-expectations (~> 3.13.0)
30 | rspec-mocks (~> 3.13.0)
31 | rspec-core (3.13.3)
32 | rspec-support (~> 3.13.0)
33 | rspec-expectations (3.13.3)
34 | diff-lcs (>= 1.2.0, < 2.0)
35 | rspec-support (~> 3.13.0)
36 | rspec-mocks (3.13.2)
37 | diff-lcs (>= 1.2.0, < 2.0)
38 | rspec-support (~> 3.13.0)
39 | rspec-support (3.13.2)
40 |
41 | PLATFORMS
42 | aarch64-linux
43 | arm64-darwin
44 | ruby
45 | x86-linux
46 | x86_64-darwin
47 | x86_64-linux
48 |
49 | DEPENDENCIES
50 | pg_query (>= 1.0)
51 | rake
52 | rspec
53 |
54 | RUBY VERSION
55 | ruby 3.3.5p100
56 |
57 | BUNDLED WITH
58 | 2.5.16
59 |
--------------------------------------------------------------------------------
/bin/sqlint:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 |
3 | $LOAD_PATH.unshift(File.dirname(File.realpath(__FILE__)) + '/../lib')
4 | require 'pg_query'
5 | require 'sqlint'
6 | require 'optparse'
7 |
8 | options = { limit: 1000 }
9 | optparse = OptionParser.new do |opts|
10 | opts.banner = "Usage: #{File.basename($0)} [options] [file.sql ...]"
11 | opts.separator ""
12 | opts.separator "Options:"
13 | opts.on("--limit=N", Integer, "Limit checking to N errors (default: #{options[:limit]})") do |n|
14 | options[:limit] = n
15 | end
16 | opts.on_tail("-h", "--help", "Print this help") do
17 | puts opts
18 | exit 0
19 | end
20 | opts.on_tail("-v", "--version", "Display the version") do
21 | puts SQLint::VERSION
22 | exit 0
23 | end
24 | end
25 | optparse.parse!(ARGV)
26 |
27 | ERROR_TYPES = {error: "ERROR", warning: "WARNING"}
28 |
29 | class String
30 | def sanitise
31 | gsub(/[[:cntrl:]+]/) do |bad|
32 | bad.codepoints.map { |c| "\\%04d" % c }.join
33 | end
34 | end
35 | end
36 |
37 | def display_lint(lint)
38 | message_lines = lint.message.split("\n")
39 | puts [
40 | lint.filename,
41 | lint.line,
42 | lint.column,
43 | ERROR_TYPES[lint.type] + " " + message_lines.shift.sanitise
44 | ].join(":")
45 | message_lines.each do |line|
46 | puts " " + line.sanitise
47 | end
48 | end
49 |
50 | def each_input_file(&block)
51 | if ARGV.empty?
52 | yield [STDIN, "stdin"]
53 | else
54 | ARGV.each do |filename|
55 | File.open(filename, 'r') do |file|
56 | yield [file, filename]
57 | end
58 | end
59 | end
60 | end
61 |
62 | saw_errors = false
63 | each_input_file do |file, filename|
64 | results = SQLint::Linter.new(filename, file).run.first(options[:limit])
65 | results.each do |lint|
66 | display_lint(lint)
67 | end
68 | saw_errors ||= results.any? { |lint| lint.type == :error }
69 | end
70 |
71 | exit 1 if saw_errors
72 |
--------------------------------------------------------------------------------
/lib/sqlint/linter.rb:
--------------------------------------------------------------------------------
1 | require 'pg_query'
2 |
3 | module SQLint
4 | class Linter
5 | Lint = Struct.new(:filename, :line, :column, :type, :message)
6 | ParseState = Struct.new(:input, :offset)
7 | END_PARSE = ParseState.new(nil, nil)
8 |
9 | def initialize(filename, input_stream)
10 | @input = input_stream.read
11 | @filename = filename
12 | end
13 |
14 | def run
15 | Enumerator.new do |results|
16 | state = ParseState.new(@input, 0)
17 | while state != END_PARSE
18 | error, new_parse_state = parse_next_error(state)
19 | results << error if error
20 | state = new_parse_state
21 | end
22 | end
23 | end
24 |
25 | private
26 |
27 | def parse_next_error(parse_state)
28 | begin
29 | PgQuery.parse(parse_state.input)
30 | [nil, END_PARSE]
31 | rescue PgQuery::ParseError => e
32 | offset = e.location + parse_state.offset
33 | line_number, column_number = find_absolute_position(offset)
34 | lint = Lint.new(@filename, line_number, column_number, :error, clean_message(e.message))
35 |
36 | input_from_error = parse_state.input[e.location..-1]
37 | semicolon_pos = input_from_error.index(";") if input_from_error
38 | [
39 | lint,
40 | if semicolon_pos
41 | remaining_input = input_from_error[semicolon_pos+1..-1]
42 | new_offset = offset + semicolon_pos + 1
43 | ParseState.new(remaining_input, new_offset)
44 | else
45 | END_PARSE
46 | end
47 | ]
48 | end
49 | end
50 |
51 | def find_absolute_position(offset)
52 | lines_before_error = @input[0...(offset)].split("\n")
53 | line_number = lines_before_error.size
54 | column_number = lines_before_error.any? ? lines_before_error.last.size : 1
55 | [line_number, column_number]
56 | end
57 |
58 | def clean_message(message)
59 | message
60 | .gsub(/(?<=at or near ")(.*)(?=")/) { |match| match[0..49] }
61 | .gsub(/\s+\(scan\.l\:\d+\)/, '')
62 | end
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://github.com/purcell/sqlint/actions/workflows/ci.yml)
2 | [ ](https://rubygems.org/gems/sqlint)
3 | [ ](https://rubygems.org/gems/sqlint)
4 |
5 |
6 | ## SQLint - a simple SQL linter
7 |
8 | ### About
9 |
10 | SQLint is a simple command-line linter which reads your SQL files and
11 | reports any syntax errors or warnings it finds.
12 |
13 | At this stage, SQLint checks SQL against the ANSI syntax, and uses the
14 | PostgreSQL SQL parser to achieve this. SQLint does not have support
15 | for non-standard SQL variants (e.g. MySQL), but contributions are welcome.
16 |
17 | ### Installation
18 |
19 | SQLint is currently provided as a ruby gem: you can install it using the following command:
20 |
21 | ```
22 | gem install sqlint
23 | ```
24 |
25 | ### Usage
26 |
27 | To check the syntax of a file containing SQL, simply pass the filename to `sqlint` on the command line:
28 |
29 | ```
30 | sqlint filename.sql
31 | ```
32 |
33 | In the absence of a filename, `sqlint` reads from standard input.
34 |
35 | ### Editor plugins
36 |
37 | Support for `sqlint` is provided for the following editors:
38 |
39 | - Emacs, via [Flycheck](https://github.com/flycheck/flycheck)
40 | - VIM, via [Syntastic](https://github.com/scrooloose/syntastic), [Neomake](https://github.com/neomake/neomake) or [ALE](https://github.com/w0rp/ale)
41 | - SublimeText, via [SublimeLinter](https://github.com/SublimeLinter/SublimeLinter3/)
42 |
43 |
44 | ### Using with pre-commit
45 |
46 | Add this to your `.pre-commit-hooks.yaml`:
47 |
48 | ```yaml
49 | - repo: https://github.com/purcell/sqlint
50 | rev: master
51 | hooks:
52 | - id: sqlint
53 | ```
54 |
55 | ### Authors
56 |
57 | This software was written by
58 | [Steve Purcell](https://github.com/purcell) and
59 | [Kieran Trezona-le Comte](https://github.com/trezona-lecomte).
60 |
61 | ### License and copyright
62 |
63 | Copyright 2015-2018 Powershop NZ Ltd.
64 | Copyright 2018-2021 Steve Purcell.
65 | MIT license.
66 |
67 |