├── .gitignore
├── .pre-commit-hooks.yaml
├── .travis.yml
├── CHANGELOG.rst
├── LICENSE
├── README.rst
├── docs
├── Makefile
├── _static
│ └── logo.png
├── api
│ ├── index.rst
│ ├── squabble.cli.rst
│ ├── squabble.config.rst
│ ├── squabble.lint.rst
│ ├── squabble.message.rst
│ ├── squabble.reporter.rst
│ ├── squabble.rst
│ ├── squabble.rule.rst
│ ├── squabble.rules.rst
│ └── squabble.util.rst
├── conf.py
├── editors.rst
├── index.rst
├── plugins.rst
└── rules.rst
├── setup.cfg
├── setup.py
├── squabble
├── __init__.py
├── __main__.py
├── cli.py
├── config.py
├── lint.py
├── message.py
├── reporter.py
├── rule.py
├── rules
│ ├── __init__.py
│ ├── add_column_disallow_constraints.py
│ ├── disallow_change_column_type.py
│ ├── disallow_float_types.py
│ ├── disallow_foreign_key.py
│ ├── disallow_not_in.py
│ ├── disallow_padded_char_type.py
│ ├── disallow_rename_enum_value.py
│ ├── disallow_timestamp_precision.py
│ ├── disallow_timetz_type.py
│ ├── require_columns.py
│ ├── require_concurrent_index.py
│ ├── require_foreign_key.py
│ └── require_primary_key.py
└── util.py
└── tests
├── __init__.py
├── sql
├── add_column_disallow_constraints.sql
├── disallow_change_column_type.sql
├── disallow_float_types.sql
├── disallow_foreign_key.sql
├── disallow_not_in.sql
├── disallow_padded_char_type.sql
├── disallow_rename_enum_value.sql
├── disallow_timestamp_precision.sql
├── disallow_timetz_types.sql
├── require_columns.sql
├── require_concurrent_index.sql
├── require_foreign_key.sql
├── require_primary_key.sql
└── syntax_error.sql
├── test_config.py
├── test_rules.py
└── test_snapshots.py
/.gitignore:
--------------------------------------------------------------------------------
1 | ve/
2 | *.pyc
3 | *~
4 | __pycache__/
5 | *.egg-info/
6 | .squabblerc
7 | .coverage
8 | dist/
9 | _build
10 | build/
11 |
--------------------------------------------------------------------------------
/.pre-commit-hooks.yaml:
--------------------------------------------------------------------------------
1 | - id: squabble
2 | name: squabble
3 | description: "squabble: An extensible linter for SQL queries and migrations."
4 | entry: squabble
5 | language: python
6 | language_version: python3
7 | types: [sql]
8 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | python:
3 | - 3.6
4 | - 3.7
5 | - 3.8
6 | install:
7 | - python setup.py develop
8 | - pip install flake8
9 | script:
10 | - python setup.py test
11 | - python -m flake8 squabble
12 |
--------------------------------------------------------------------------------
/CHANGELOG.rst:
--------------------------------------------------------------------------------
1 | Changelog
2 | =========
3 |
4 | v1.4.0 (2020-02-18)
5 | -------------------
6 |
7 | New
8 | ~~~
9 |
10 | - Added `precommit `__ hook definition (Thanks
11 | @PhilipTrauner!)
12 | - Added ``squabble-disable`` configuration to disable all lint checks for a
13 | file.
14 |
15 | Changes
16 | ~~~~~~~
17 |
18 | - Per-file configuration is now done using ``squabble-enable:rule`` and
19 | ``squabble-disable:rule``. The original ``enable:rule`` format will continue
20 | to be supported, but is deprecated.
21 |
22 |
23 | v1.3.3 (2019-08-27)
24 | -------------------
25 |
26 | Fixes
27 | ~~~~~
28 |
29 | - Fix crash when handling SQL files containing only comments.
30 |
31 | v1.3.2 (2019-07-30)
32 | -------------------
33 |
34 | Fixes
35 | ~~~~~
36 |
37 | - Prevent crash when running ``RequireForeignKey`` rule against a
38 | ``CREATE TABLE`` statement with no columns defined.
39 | - Fix Emacs Flycheck integration documentation.
40 |
41 | v1.3.1 (2019-05-15)
42 | -------------------
43 |
44 | Changes
45 | ~~~~~~~
46 |
47 | - Upgrade ``pglast`` to version ``1.4``.
48 |
49 | Fixes
50 | ~~~~~
51 |
52 | - Fixed standard input reader to not print a stack trace on interrupt.
53 |
54 |
55 | v1.3.0 (2019-05-10)
56 | -------------------
57 |
58 | New
59 | ~~~
60 |
61 | - Added ``-x, --expanded`` to show explanations of message codes after linting.
62 | - Added ``DisallowNotIn`` rule.
63 | - Added ``DisallowPaddedCharType`` rule.
64 | - Added ``DisallowTimetzType`` rule.
65 | - Added ``DisallowTimestampPrecision`` rule.
66 |
67 | Changes
68 | ~~~~~~~
69 |
70 | - Modified ``-p, --preset`` to allow multiple presets to be used at once.
71 | - Update packaging to run tests through ``python setup.py test``.
72 |
73 | Fixes
74 | ~~~~~
75 |
76 | - Small documentation fixes for rules.
77 |
78 | v1.2.0 (2019-01-14)
79 | -------------------
80 |
81 | New
82 | ~~~
83 |
84 | - Added ``DisallowForeignKey`` rule.
85 |
86 | Changes
87 | ~~~~~~~
88 |
89 | - Allow rule descriptions to span multiple lines.
90 | - Add ``DisallowForeignKey``, ``RequireForeignKey`` to ``"full"`` preset.
91 |
92 | Fixes
93 | ~~~~~
94 |
95 | - Fix ``RequireColumns`` to work with irregular casing.
96 |
97 | v1.1.0 (2019-01-11)
98 | -------------------
99 |
100 | New
101 | ~~~
102 |
103 | - Added ``RequireForeignKey`` rule.
104 |
105 | Changes
106 | ~~~~~~~
107 |
108 | - Split ``"postgres"`` preset into ``"postgres"`` and
109 | ``"postgres-zero-downtime"``.
110 | - Strip RST code directives from message explanations and rules.
111 | - Sort API documentation based on file order rather than
112 | alphabetically.
113 |
114 | Fixes
115 | ~~~~~
116 |
117 | - Support DOS ``\r\n`` line-endings for reporting issue location.
118 | - Fixed calls to logger to correctly report module.
119 |
120 | v1.0.0 (2019-01-05)
121 | -------------------
122 |
123 | New
124 | ~~~
125 | - Added ``-e, --explain`` to print out detailed explanation of why a
126 | message was raised.
127 | - Added ``-r, --reporter`` to override reporter on command line.
128 | - Added support for reading from stdin.
129 | - Added ``full`` preset.
130 | - Added ``Severity`` enum for LintIssues.
131 | - Added ``sqlint`` reporter for compatibility with tooling targeting
132 | sqlint.
133 | - Added ``DisallowFloatTypes`` rule.
134 | - Added user facing documentation for https://squabble.readthedocs.org
135 |
136 | Changes
137 | ~~~~~~~
138 | - Improved existing API documentation with type signatures and
139 | examples.
140 | - Removed ``BaseRule.MESSAGES`` in favor of
141 | ``squabble.message.Message``.
142 |
143 | v0.1.0 (2018-12-27)
144 | -------------------
145 |
146 | - Initial release.
147 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | squabble
2 | ========
3 |
4 | |build-status| |docs| |pypi|
5 |
6 | Catch unsafe SQL migrations.
7 |
8 | .. code:: console
9 |
10 | $ squabble sql/migration.sql
11 | sql/migration.sql:4:46 ERROR: column "uh_oh" has a disallowed constraint [1004]
12 | ALTER TABLE big_table ADD COLUMN uh_oh integer DEFAULT 0;
13 | ^
14 | # Use --explain to get more information on a lint violation
15 | $ squabble --explain 1004
16 | ConstraintNotAllowed
17 | When adding a column to an existing table, certain constraints can have
18 | unintentional side effects, like locking the table or introducing
19 | performance issues.
20 | ...
21 |
22 | Squabble can also be `integrated with your editor
23 | `__ to catch errors in
24 | SQL files.
25 |
26 | .. code:: console
27 |
28 | $ echo 'SELECT * FROM WHERE x = y;' | squabble --reporter=plain
29 | stdin:1:15 CRITICAL: syntax error at or near "WHERE"
30 |
31 | Currently, most of the rules have been focused on Postgres and its
32 | quirks. However, squabble can parse any ANSI SQL and new rules that are
33 | specific to other databases are appreciated!
34 |
35 | Installation
36 | ------------
37 |
38 | .. code-block:: console
39 |
40 | $ pip3 install squabble
41 | $ squabble --help
42 |
43 | .. note::
44 |
45 | Squabble is only supported on Python 3.5+
46 |
47 | If you’d like to install from source:
48 |
49 | .. code-block:: console
50 |
51 | $ git clone https://github.com/erik/squabble.git && cd squabble
52 | $ python3 -m venv ve && source ve/bin/activate
53 | $ python setup.py install
54 | $ squabble --help
55 |
56 | Configuration
57 | -------------
58 |
59 | To see a list of rules, try
60 |
61 | .. code-block:: console
62 |
63 | $ squabble --list-rules
64 |
65 | Then, to show more verbose information about a rule (such as rationale
66 | and configuration options)
67 |
68 | .. code-block:: console
69 |
70 | $ squabble --show-rule AddColumnDisallowConstraints
71 |
72 | Once a configuration file is in place, it can be passed explicitly on
73 | the command line, or automatically looked up.
74 |
75 | .. code-block:: console
76 |
77 | $ squabble -c path/to/config ...
78 |
79 | If not explicitly given on the command line, squabble will look for a
80 | file named ``.squabblerc`` in the following places (in order):
81 |
82 | - ``./.squabblerc``
83 | - ``(git_repo_root)/.squabblerc``
84 | - ``~/.squabblerc``
85 |
86 | Per-File Configuration
87 | ~~~~~~~~~~~~~~~~~~~~~~
88 |
89 | Configuration can also be applied at the file level by using SQL line comments
90 | in the form ``-- squabble-enable:RuleName`` or ``-- squabble-disable:RuleName``.
91 |
92 | For example, to disable ``RuleA`` and enable ``RuleB`` just for one file,
93 | this could be done:
94 |
95 | .. code-block:: sql
96 |
97 | -- squabble-disable:RuleA
98 | -- squabble-enable:RuleB config=value array=1,2,3
99 | SELECT email FROM users WHERE ...;
100 |
101 | To prevent squabble from running on a file, use ``-- squabble-disable``. Note
102 | that this will also disable syntax checking. Note that this flag will take
103 | precedence over any other configuration set either on the command line or in
104 | the rest of the file.
105 |
106 |
107 | Example Configuration
108 | ~~~~~~~~~~~~~~~~~~~~~
109 |
110 | .. code-block:: json
111 |
112 | {
113 | "reporter": "color",
114 |
115 | "plugins": [
116 | "/some/directory/with/custom/rules"
117 | ],
118 |
119 | "rules": {
120 | "AddColumnsDisallowConstraints": {
121 | "disallowed": ["DEFAULT", "FOREIGN", "NOT NULL"]
122 | }
123 | }
124 | }
125 |
126 | Prior Art
127 | ---------
128 |
129 | ``squabble`` is of course not the first tool in this space. If it
130 | doesn't fit your needs, consider one of these tools:
131 |
132 | - `sqlcheck `__ - regular
133 | expression based (rather than parsing), focuses more on ``SELECT``
134 | statements than migrations.
135 | - `sqlint `__ - checks that the
136 | syntax of a file is valid. Uses the same parsing library as
137 | squabble.
138 | - `sqlfluff `__ -
139 | focused more on style and formatting, seems to still be a work in
140 | progress.
141 |
142 |
143 | Acknowledgments
144 | ---------------
145 |
146 | This project would not be possible without:
147 |
148 | - `libpg_query `__ - Postgres
149 | query parser
150 | - `pglast `__ - Python bindings to
151 | libpg_query
152 | - Postgres - …obviously
153 |
154 | The `logo image `__ used
155 | in the documentation is created by Gianni - Dolce Merda from the Noun
156 | Project.
157 |
158 | .. |build-status| image:: https://img.shields.io/travis/erik/squabble.svg?style=flat
159 | :alt: build status
160 | :target: https://travis-ci.org/erik/squabble
161 |
162 | .. |docs| image:: https://readthedocs.org/projects/squabble/badge/?version=stable
163 | :alt: Documentation Status
164 | :target: https://squabble.readthedocs.io/en/stable/?badge=stable
165 |
166 | .. |pypi| image:: https://img.shields.io/pypi/v/squabble.svg
167 | :alt: PyPI version
168 | :target: https://pypi.org/project/squabble
169 |
--------------------------------------------------------------------------------
/docs/Makefile:
--------------------------------------------------------------------------------
1 | # Minimal makefile for Sphinx documentation
2 | #
3 |
4 | # You can set these variables from the command line.
5 | SPHINXOPTS =
6 | SPHINXBUILD = sphinx-build
7 | SOURCEDIR = .
8 | BUILDDIR = _build
9 |
10 | # Put it first so that "make" without argument is like "make help".
11 | help:
12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
13 |
14 | .PHONY: help Makefile apidoc
15 |
16 | apidoc:
17 | sphinx-apidoc --module-first --separate --tocfile index -o api/ ../squabble
18 |
19 | # Catch-all target: route all unknown targets to Sphinx using the new
20 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
21 | %: Makefile
22 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
23 |
--------------------------------------------------------------------------------
/docs/_static/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/erik/squabble/0f5b2dbb2088389a6b2d4d68f55e4e55f4da5e28/docs/_static/logo.png
--------------------------------------------------------------------------------
/docs/api/index.rst:
--------------------------------------------------------------------------------
1 | API
2 | ===
3 |
4 | .. toctree::
5 | :maxdepth: 3
6 |
7 | squabble
8 |
--------------------------------------------------------------------------------
/docs/api/squabble.cli.rst:
--------------------------------------------------------------------------------
1 | squabble.cli module
2 | ===================
3 |
4 | .. automodule:: squabble.cli
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
8 |
--------------------------------------------------------------------------------
/docs/api/squabble.config.rst:
--------------------------------------------------------------------------------
1 | squabble.config module
2 | ======================
3 |
4 | .. automodule:: squabble.config
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
8 |
--------------------------------------------------------------------------------
/docs/api/squabble.lint.rst:
--------------------------------------------------------------------------------
1 | squabble.lint module
2 | ====================
3 |
4 | .. automodule:: squabble.lint
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
8 |
--------------------------------------------------------------------------------
/docs/api/squabble.message.rst:
--------------------------------------------------------------------------------
1 | squabble.message module
2 | =======================
3 |
4 | .. automodule:: squabble.message
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
8 |
--------------------------------------------------------------------------------
/docs/api/squabble.reporter.rst:
--------------------------------------------------------------------------------
1 | squabble.reporter module
2 | ========================
3 |
4 | .. automodule:: squabble.reporter
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
8 |
--------------------------------------------------------------------------------
/docs/api/squabble.rst:
--------------------------------------------------------------------------------
1 | squabble package
2 | ================
3 |
4 | .. automodule:: squabble
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
8 |
9 | .. toctree::
10 |
11 | squabble.cli
12 | squabble.config
13 | squabble.lint
14 | squabble.message
15 | squabble.reporter
16 | squabble.rule
17 | squabble.rules
18 | squabble.util
19 |
--------------------------------------------------------------------------------
/docs/api/squabble.rule.rst:
--------------------------------------------------------------------------------
1 | squabble.rule module
2 | ====================
3 |
4 | .. automodule:: squabble.rule
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
8 |
--------------------------------------------------------------------------------
/docs/api/squabble.rules.rst:
--------------------------------------------------------------------------------
1 | squabble.rules package
2 | ======================
3 |
4 | .. automodule:: squabble.rules
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
8 |
--------------------------------------------------------------------------------
/docs/api/squabble.util.rst:
--------------------------------------------------------------------------------
1 | squabble.util module
2 | ====================
3 |
4 | .. automodule:: squabble.util
5 | :members:
6 | :undoc-members:
7 | :show-inheritance:
8 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | #
3 | # Configuration file for the Sphinx documentation builder.
4 | #
5 | # This file does only contain a selection of the most common options. For a
6 | # full list see the documentation:
7 | # http://www.sphinx-doc.org/en/master/config
8 |
9 | # -- Path setup --------------------------------------------------------------
10 |
11 | # If extensions (or modules to document with autodoc) are in another directory,
12 | # add these directories to sys.path here. If the directory is relative to the
13 | # documentation root, use os.path.abspath to make it absolute, like shown here.
14 | #
15 | import os
16 | import sys
17 |
18 | sys.path.insert(0, os.path.abspath('..'))
19 |
20 |
21 | # -- Project information -----------------------------------------------------
22 |
23 | project = 'squabble'
24 | copyright = '2018, Erik Price'
25 | author = 'Erik Price'
26 |
27 | # The short X.Y version
28 | version = ''
29 | # The full version, including alpha/beta/rc tags
30 | release = ''
31 |
32 |
33 | # -- General configuration ---------------------------------------------------
34 |
35 | # If your documentation needs a minimal Sphinx version, state it here.
36 | #
37 | # needs_sphinx = '1.0'
38 |
39 | # Add any Sphinx extension module names here, as strings. They can be
40 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
41 | # ones.
42 | extensions = [
43 | 'sphinx.ext.autodoc',
44 | 'sphinx.ext.doctest',
45 | 'sphinx.ext.todo',
46 | 'sphinx.ext.coverage',
47 | 'sphinx.ext.viewcode',
48 | ]
49 |
50 | # Add any paths that contain templates here, relative to this directory.
51 | templates_path = ['_templates']
52 |
53 | # The suffix(es) of source filenames.
54 | # You can specify multiple suffix as a list of string:
55 | #
56 | # source_suffix = ['.rst', '.md']
57 | source_suffix = '.rst'
58 |
59 | # The master toctree document.
60 | master_doc = 'index'
61 |
62 | # The language for content autogenerated by Sphinx. Refer to documentation
63 | # for a list of supported languages.
64 | #
65 | # This is also used if you do content translation via gettext catalogs.
66 | # Usually you set "language" from the command line for these cases.
67 | language = None
68 |
69 | # List of patterns, relative to source directory, that match files and
70 | # directories to ignore when looking for source files.
71 | # This pattern also affects html_static_path and html_extra_path.
72 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
73 |
74 | # The name of the Pygments (syntax highlighting) style to use.
75 | pygments_style = None
76 |
77 |
78 | # -- Options for HTML output -------------------------------------------------
79 |
80 | # The theme to use for HTML and HTML Help pages. See the documentation for
81 | # a list of builtin themes.
82 | #
83 | html_theme = 'alabaster'
84 |
85 | # Theme options are theme-specific and customize the look and feel of a theme
86 | # further. For a list of options available for each theme, see the
87 | # documentation.
88 | #
89 | html_theme_options = {
90 | 'description': 'An extensible linter for SQL.',
91 | 'logo': 'logo.png',
92 | 'logo_name': True,
93 |
94 | 'github_user': 'erik',
95 | 'github_repo': 'squabble',
96 | 'github_banner': True,
97 | 'github_button': False,
98 | }
99 |
100 | # Add any paths that contain custom static files (such as style sheets) here,
101 | # relative to this directory. They are copied after the builtin static files,
102 | # so a file named "default.css" will overwrite the builtin "default.css".
103 | html_static_path = ['_static/']
104 |
105 | # Custom sidebar templates, must be a dictionary that maps document names
106 | # to template names.
107 | #
108 | # The default sidebars (for documents that don't match any pattern) are
109 | # defined by theme itself. Builtin themes are using these templates by
110 | # default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
111 | # 'searchbox.html']``.
112 | #
113 | # html_sidebars = {}
114 |
115 |
116 | # -- Options for HTMLHelp output ---------------------------------------------
117 |
118 | # Output file base name for HTML help builder.
119 | htmlhelp_basename = 'squabbledoc'
120 |
121 |
122 | # -- Options for LaTeX output ------------------------------------------------
123 |
124 | latex_elements = {
125 | # The paper size ('letterpaper' or 'a4paper').
126 | #
127 | # 'papersize': 'letterpaper',
128 |
129 | # The font size ('10pt', '11pt' or '12pt').
130 | #
131 | # 'pointsize': '10pt',
132 |
133 | # Additional stuff for the LaTeX preamble.
134 | #
135 | # 'preamble': '',
136 |
137 | # Latex figure (float) alignment
138 | #
139 | # 'figure_align': 'htbp',
140 | }
141 |
142 | # Grouping the document tree into LaTeX files. List of tuples
143 | # (source start file, target name, title,
144 | # author, documentclass [howto, manual, or own class]).
145 | latex_documents = [
146 | (master_doc, 'squabble.tex', 'squabble Documentation',
147 | 'Erik Price', 'manual'),
148 | ]
149 |
150 |
151 | # -- Options for manual page output ------------------------------------------
152 |
153 | # One entry per manual page. List of tuples
154 | # (source start file, name, description, authors, manual section).
155 | man_pages = [
156 | (master_doc, 'squabble', 'squabble Documentation',
157 | [author], 1)
158 | ]
159 |
160 |
161 | # -- Options for Texinfo output ----------------------------------------------
162 |
163 | # Grouping the document tree into Texinfo files. List of tuples
164 | # (source start file, target name, title, author,
165 | # dir menu entry, description, category)
166 | texinfo_documents = [
167 | (master_doc, 'squabble', 'squabble Documentation',
168 | author, 'squabble', 'One line description of project.',
169 | 'Miscellaneous'),
170 | ]
171 |
172 |
173 | # -- Options for Epub output -------------------------------------------------
174 |
175 | # Bibliographic Dublin Core info.
176 | epub_title = project
177 |
178 | # The unique identifier of the text. This can be a ISBN number
179 | # or the project homepage.
180 | #
181 | # epub_identifier = ''
182 |
183 | # A unique identification for the text.
184 | #
185 | # epub_uid = ''
186 |
187 | # A list of files that should not be packed into the epub file.
188 | epub_exclude_files = ['search.html']
189 |
190 |
191 | # -- Extension configuration -------------------------------------------------
192 |
193 | # Don't reorder code alphabetically.
194 | autodoc_member_order = 'bysource'
195 |
196 | # -- Options for todo extension ----------------------------------------------
197 |
198 | # If true, `todo` and `todoList` produce output, else they produce nothing.
199 | todo_include_todos = True
200 |
--------------------------------------------------------------------------------
/docs/editors.rst:
--------------------------------------------------------------------------------
1 | Editor Integration
2 | ==================
3 |
4 | Several editor syntax checkers already natively support the output
5 | format for a similar tool, `sqlint `__,
6 | which we can piggy-back off of by using the ``"sqlint"`` reporter.
7 |
8 | Specific editors are mentioned below, but generally, if your editor
9 | has support for ``sqlint`` and can customize the executable, try running
10 | ``squabble --reporter=sqlint`` instead!
11 |
12 | emacs (via flycheck)
13 | --------------------
14 |
15 | The best way to configure squabble through flycheck would be to create
16 | a new checker definition, which should Just Work when you open a SQL
17 | file with flycheck turned on.
18 |
19 | .. code-block:: elisp
20 |
21 | (flycheck-define-checker sql-squabble
22 | "A SQL syntax checker using the squabble tool.
23 | See URL `https://github.com/erik/squabble'."
24 | :command ("squabble" "--reporter=sqlint")
25 | :standard-input t
26 | :error-patterns
27 | ((warning line-start "stdin:" line ":" column ":WARNING "
28 | (message (one-or-more not-newline)
29 | (zero-or-more "\n"))
30 | line-end)
31 | (error line-start "stdin:" line ":" column ":ERROR "
32 | (message (one-or-more not-newline)
33 | (zero-or-more "\n"))
34 | line-end))
35 | :modes (sql-mode))
36 |
37 | (add-to-list 'flycheck-checkers 'sql-squabble)
38 |
39 |
40 | Flycheck ships with support for `sqlint
41 | `__, so if for whatever reason you
42 | don't want to define a new checker, you should just be able to point
43 | flycheck at the ``squabble`` executable.
44 |
45 | .. code-block:: elisp
46 |
47 | (setq 'flycheck-sql-sqlint-executable "squabble")
48 |
49 | Unfortunately flycheck does not allow user customization of the
50 | command line arguments passed to the program, so you'll need to make
51 | sure to have configuration file with ``{"reporter": "sqlint"}``.
52 |
53 | vim (via syntastic)
54 | -------------------
55 |
56 | .. code-block:: vim
57 |
58 | let g:syntastic_sql_sqlint_exec = "squabble"
59 | let g:syntastic_sql_sqlint_args = "--reporter=sqlint"
60 |
--------------------------------------------------------------------------------
/docs/index.rst:
--------------------------------------------------------------------------------
1 | .. toctree::
2 | :maxdepth: 3
3 | :hidden:
4 |
5 | editors
6 | rules
7 | plugins
8 | api/index
9 |
10 | .. include:: ../README.rst
11 |
12 | Indices and tables
13 | ==================
14 |
15 | * :ref:`genindex`
16 | * :ref:`modindex`
17 | * :ref:`search`
18 |
--------------------------------------------------------------------------------
/docs/plugins.rst:
--------------------------------------------------------------------------------
1 | Writing Plugins
2 | ===============
3 |
4 | Squabble supports loading rule definitions from directories specified in the
5 | ``.squabblerc`` configuration file.
6 |
7 | Every Python file in the list of directories will be loaded and any classes
8 | that inherit from :class:`squabble.rules.BaseRule` will be registered and
9 | available for use.
10 |
11 |
12 | Configuration
13 | -------------
14 |
15 | ::
16 |
17 | {
18 | "plugins": [
19 | "/path/to/plugins/",
20 | ...
21 | ]
22 | }
23 |
24 | Concepts
25 | --------
26 |
27 | Rules
28 | ~~~~~
29 |
30 | Rules are classes which inherit from :class:`squabble.rules.BaseRule` and
31 | are responsible for checking the abstract syntax tree of a SQL file.
32 |
33 | At a minimum, each rule will define ``def enable(self, root_context, config)``,
34 | which is responsible for doing any initialization when the rule is enabled.
35 |
36 | Rules register callback functions to trigger when certain nodes of the abstract
37 | syntax tree are hit. Rules will report messages_ to indicate any issues
38 | discovered.
39 |
40 | For example ::
41 |
42 | class MyRule(squabble.rules.BaseRule):
43 | def enable(self, context, config):
44 | ...
45 |
46 | Could be configured with this ``.squabblerc`` ::
47 |
48 | {"rules": {"MyRule": {"foo": "bar"}}}
49 |
50 | ``enable()`` would be passed ``config={"foo": "bar"}``.
51 |
52 | .. _messages:
53 |
54 | Messages
55 | ~~~~~~~~
56 |
57 | Messages inherit from :class:`squabble.message.Message`, and are used to define
58 | specific kinds of lint exceptions a rule can uncover.
59 |
60 | At a bare minimum, each message class needs a ``TEMPLATE`` class variable,
61 | which is used when formatting the message to be printed on the command line.
62 |
63 | For example ::
64 |
65 | class BadlyNamedColumn(squabble.message.Message):
66 | """
67 | Here are some more details about ``BadlyNamedColumn``.
68 |
69 | This is where you would explain why this message is relevant,
70 | how to resolve it, etc.
71 | """
72 |
73 | TEMPLATE = 'tried to {foo} when you should have done {bar}!'
74 |
75 | >>> msg = MyMessage(foo='abc', bar='xyz')
76 | >>> msg.format()
77 | 'tried to abc when you should have done xyz'
78 | >>> msg.explain()
79 | 'Here are some more details ...
80 |
81 | Messages may also define a ``CODE`` class variable, which is an integer which
82 | uniquely identifies the class. If not explicitly specified, one will be
83 | assigned, starting at ``9000``. These can be used by the ``--explain`` command
84 | line flag ::
85 |
86 | $ squabble --explain 9001
87 | BadlyNamedColumn
88 | Here are some more details about ``BadlyNamedColumn``.
89 |
90 | ...
91 |
92 | Context
93 | ~~~~~~~
94 |
95 | Each instance of :class:`squabble.lint.Context` holds the callback
96 | functions that have been registered at or below a particular node in the
97 | abstract syntax tree, as well as being responsible for reporting any messages
98 | that get raised.
99 |
100 | When the ``enable()`` function for a class inheriting from
101 | :class:`squabble.rules.BaseRule` is called, it will be passed a context
102 | pointing to the root node of the syntax tree. Every callback function will be
103 | passed a context scoped to the node that triggered the callback.
104 |
105 | ::
106 |
107 | def enable(root_context, _config):
108 | root_context.register('CreateStmt', create_table_callback)
109 |
110 | def create_table_callback(child_context, node):
111 | # register a callback that is only scoped to this ``node``
112 | child_context.register('ColumnDef', column_def_callback):
113 |
114 | def column_def_callback(child_context, node):
115 | ...
116 |
117 | Details
118 | -------
119 |
120 | - Parsing is done using
121 | `libpg_query `__, a Postgres
122 | query parser.
123 |
124 | - *theoretically* it will work with other SQL dialects
125 |
126 | - Rules are implemented by registering callbacks while traversing the
127 | Abstract Syntax Tree of the query.
128 |
129 | - e.g. entering a ``CREATE TABLE`` node registers a callback for a
130 | column definition node, which checks that the column type is
131 | correct.
132 |
133 | As a somewhat unfortunate consequence of our reliance on libpg_query,
134 | the abstract syntax tree is very, very specific to Postgres. While
135 | developing new rules, it will be necessary to reference the `Postgres
136 | AST
137 | Node `__
138 | source listing, or, more readably, the `Python
139 | bindings `__.
140 |
141 | Example Rule
142 | ------------
143 |
144 | .. code-block:: python
145 |
146 | import squabble.rule
147 | from squabble.lint import Severity
148 | from squabble.message import Message
149 | from squabble.rules import BaseRule
150 |
151 | class AllTablesMustBeLoud(BaseRule):
152 | """
153 | A custom rule which makes sure that all table names are
154 | in CAPSLOCK NOTATION.
155 | """
156 |
157 | class TableNotLoudEnough(Message):
158 | """Add more details about the message here"""
159 | CODE = 9876
160 | TEMPLATE = 'table "{name}" not LOUD ENOUGH'
161 |
162 | def enable(self, root_ctx, config):
163 | """
164 | Called before the root AST node is traversed. Here's where
165 | most callbacks should be registered for different AST
166 | nodes.
167 |
168 | Each linter is initialized once per file that it is being
169 | run against. `config` will contain the merged base
170 | configuration with the file-specific configuration options
171 | for this linter.
172 | """
173 |
174 | # Register that any time we see a `CreateStmt`
175 | # (`CREATE TABLE`), call self._check()
176 | root_ctx.register('CreateStmt', self._check_create())
177 |
178 | # When we exit the root `ctx`, call `self._on_finish()`
179 | root_ctx.register_exit(lambda ctx: self._on_finish(ctx))
180 |
181 | # node_visitor will pass in `ctx, node` for you so there's no
182 | # need to use a lambda
183 | @squabble.rule.node_visitor
184 | def _check_create(self, ctx, node):
185 | """
186 | Called when we enter a 'CreateStmt' node. Here we can
187 | register more callbacks if we need to, or do some checking
188 | based on the `node` which will be the AST representation of
189 | a `CREATE TABLE`.
190 | """
191 |
192 | table_name = node.relation.relname.value
193 | if table_name != table_name.upper():
194 | # Report an error if this table was not SCREAMING_CASE
195 | ctx.report(
196 | self.TableNotLoudEnough(name=table_name),
197 | node=node.relation,
198 | severity=Severity.HIGH)
199 |
200 | def _on_finish(self, ctx):
201 | pass
202 |
--------------------------------------------------------------------------------
/docs/rules.rst:
--------------------------------------------------------------------------------
1 | Built-in Rules
2 | ==============
3 |
4 | Squabble ships with several rules that are focused mostly on
5 | preventing unsafe schema migrations. To enable these rules,
6 | reference them in your ``.squabblerc`` configuration file.
7 |
8 | For example:
9 |
10 | .. code-block:: json
11 |
12 | {
13 | "rules": {
14 | "AddColumnDisallowConstraints": {
15 | "disallowed": ["DEFAULT"]
16 | },
17 | "RequirePrimaryKey": {}
18 | }
19 | }
20 |
21 | .. contents:: Available Rules
22 | :local:
23 |
24 | AddColumnDisallowConstraints
25 | ----------------------------
26 | .. autoclass:: squabble.rules.add_column_disallow_constraints.AddColumnDisallowConstraints
27 | :members:
28 | :show-inheritance:
29 | :exclude-members: enable
30 |
31 | DisallowChangeColumnType
32 | ------------------------
33 | .. autoclass:: squabble.rules.disallow_change_column_type.DisallowChangeColumnType
34 | :members:
35 | :show-inheritance:
36 | :exclude-members: enable
37 |
38 | DisallowForeignKey
39 | ------------------
40 | .. autoclass:: squabble.rules.disallow_foreign_key.DisallowForeignKey
41 | :members:
42 | :show-inheritance:
43 | :exclude-members: enable
44 |
45 | DisallowFloatTypes
46 | ------------------
47 | .. autoclass:: squabble.rules.disallow_float_types.DisallowFloatTypes
48 | :members:
49 | :show-inheritance:
50 | :exclude-members: enable
51 |
52 | DisallowNotIn
53 | -------------
54 | .. autoclass:: squabble.rules.disallow_not_in.DisallowNotIn
55 | :members:
56 | :show-inheritance:
57 | :exclude-members: enable
58 |
59 | DisallowPaddedCharType
60 | ----------------------
61 | .. autoclass:: squabble.rules.disallow_padded_char_type.DisallowPaddedCharType
62 | :members:
63 | :show-inheritance:
64 | :exclude-members: enable
65 |
66 | DisallowRenameEnumValue
67 | -----------------------
68 | .. autoclass:: squabble.rules.disallow_rename_enum_value.DisallowRenameEnumValue
69 | :members:
70 | :show-inheritance:
71 | :exclude-members: enable
72 |
73 | DisallowTimestampPrecision
74 | --------------------------
75 | .. autoclass:: squabble.rules.disallow_timestamp_precision.DisallowTimestampPrecision
76 | :members:
77 | :show-inheritance:
78 | :exclude-members: enable
79 |
80 |
81 | DisallowTimetzType
82 | ------------------
83 | .. autoclass:: squabble.rules.disallow_timetz_type.DisallowTimetzType
84 | :members:
85 | :show-inheritance:
86 | :exclude-members: enable
87 |
88 | RequireColumns
89 | --------------
90 | .. autoclass:: squabble.rules.require_columns.RequireColumns
91 | :members:
92 | :show-inheritance:
93 | :exclude-members: enable
94 |
95 | RequireConcurrentIndex
96 | ----------------------
97 | .. autoclass:: squabble.rules.require_concurrent_index.RequireConcurrentIndex
98 | :members:
99 | :show-inheritance:
100 | :exclude-members: enable
101 |
102 | RequireForeignKey
103 | -----------------
104 | .. autoclass:: squabble.rules.require_foreign_key.RequireForeignKey
105 | :members:
106 | :show-inheritance:
107 | :exclude-members: enable
108 |
109 | RequirePrimaryKey
110 | -----------------
111 | .. autoclass:: squabble.rules.require_primary_key.RequirePrimaryKey
112 | :members:
113 | :show-inheritance:
114 | :exclude-members: enable
115 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 80
3 | doctests = True
4 | exclude = .git, __pycache__, ve/
5 |
6 | [aliases]
7 | test=pytest
8 |
9 | [tool:pytest]
10 | addopts = --verbose --doctest-modules --disable-warnings -cov=squabble tests/ squabble/
11 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os.path
2 |
3 | from setuptools import setup, find_packages
4 |
5 | __version__ = '1.4.0'
6 |
7 |
8 | readme_path = os.path.join(os.path.dirname(__file__), 'README.rst')
9 | with open(readme_path) as fp:
10 | long_description = fp.read()
11 |
12 | setup(
13 | name='squabble',
14 | version=__version__,
15 | description='An extensible linter for SQL',
16 | long_description=long_description,
17 | author='Erik Price',
18 | url='https://github.com/erik/squabble',
19 | packages=find_packages(),
20 | entry_points={
21 | 'console_scripts': [
22 | 'squabble = squabble.__main__:main',
23 | ],
24 | },
25 | python_requires='>=3.5',
26 | license='GPLv3+',
27 | setup_requires=[
28 | 'pytest-runner',
29 | ],
30 | install_requires=[
31 | 'pglast==1.4',
32 | 'docopt==0.6.2',
33 | 'colorama==0.4.1'
34 | ],
35 | tests_require=[
36 | 'pytest',
37 | 'pytest-runner',
38 | 'pytest-cov',
39 | ],
40 | classifiers=[
41 | 'Development Status :: 5 - Production/Stable',
42 | 'Programming Language :: Python :: 3 :: Only',
43 | 'Programming Language :: SQL',
44 | 'Topic :: Utilities',
45 | ]
46 | )
47 |
--------------------------------------------------------------------------------
/squabble/__init__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import types
3 | import logging
4 |
5 |
6 | logger = logging.getLogger(__name__)
7 |
8 |
9 | class SquabbleException(Exception):
10 | """Base exception type for all things in this package"""
11 |
12 |
13 | class RuleConfigurationException(SquabbleException):
14 | def __init__(self, rule, msg):
15 | self.rule = rule
16 | self.msg = msg
17 |
18 |
19 | class UnknownRuleException(SquabbleException):
20 | def __init__(self, name):
21 | super().__init__('unknown rule: "%s"' % name)
22 |
23 |
24 | # The following implementation is half of the code in the package
25 | # pep487 available at https://github.com/zaehlwerk/pep487
26 | #
27 | # This is used to support Python 3.5.
28 |
29 |
30 | HAS_PY36 = sys.version_info >= (3, 6)
31 | HAS_PEP487 = HAS_PY36
32 |
33 | if HAS_PEP487:
34 | PEP487Meta = type # pragma: no cover
35 | PEP487Base = object # pragma: no cover
36 | PEP487Object = object # pragma: no cover
37 | else:
38 | class PEP487Meta(type):
39 | def __new__(mcls, name, bases, ns, **kwargs):
40 | init = ns.get('__init_subclass__')
41 | if isinstance(init, types.FunctionType):
42 | ns['__init_subclass__'] = classmethod(init)
43 | cls = super().__new__(mcls, name, bases, ns)
44 | for key, value in cls.__dict__.items():
45 | func = getattr(value, '__set_name__', None)
46 | if func is not None:
47 | func(cls, key)
48 | super(cls, cls).__init_subclass__(**kwargs)
49 | return cls
50 |
51 | def __init__(cls, name, bases, ns, **kwargs):
52 | super().__init__(name, bases, ns)
53 |
54 | class PEP487Base:
55 | @classmethod
56 | def __init_subclass__(cls, **kwargs):
57 | pass
58 |
59 | class PEP487Object(PEP487Base, metaclass=PEP487Meta):
60 | pass
61 |
--------------------------------------------------------------------------------
/squabble/__main__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import sys
3 |
4 | import colorama
5 |
6 | from squabble import cli
7 |
8 |
9 | def main():
10 | logging.basicConfig()
11 | colorama.init()
12 | status = cli.main()
13 |
14 | if status:
15 | sys.exit(status)
16 |
17 |
18 | if __name__ == '__main__':
19 | main()
20 |
--------------------------------------------------------------------------------
/squabble/cli.py:
--------------------------------------------------------------------------------
1 | """
2 | Usage:
3 | squabble [options] [PATHS...]
4 | squabble (-h | --help)
5 |
6 | Arguments:
7 | PATHS Paths to check. If given a directory, will recursively traverse the
8 | path and lint all files ending in `.sql` [default: -].
9 |
10 | Options:
11 | -h --help Show this screen.
12 | -V --verbose Turn on debug level logging.
13 | -v --version Show version information.
14 |
15 | -x --expanded Show explantions for every raised message.
16 |
17 | -c --config=PATH Path to configuration file.
18 | -p --preset=PRESETS Comma-separated list of presets to use as a base.
19 | -r --reporter=REPORTER Use REPORTER for output rather than one in config.
20 |
21 | -e --explain=CODE Show detailed explanation of a message code.
22 | --list-presets List available preset configurations.
23 | --list-rules List available rules.
24 | --show-rule=RULE Show detailed information about RULE.
25 | """
26 |
27 | import glob
28 | import json
29 | import os.path
30 | import sys
31 |
32 | import docopt
33 | from colorama import Style
34 | from pkg_resources import get_distribution
35 |
36 | import squabble
37 | import squabble.message
38 | from squabble import config, lint, reporter, rule
39 | from squabble.util import strip_rst_directives
40 |
41 |
42 | def main():
43 | version = get_distribution('squabble').version
44 | args = docopt.docopt(__doc__, version=version)
45 | return dispatch_args(args)
46 |
47 |
48 | def dispatch_args(args):
49 | """
50 | Handle the command line arguments as parsed by ``docopt``. Calls the
51 | subroutine implied by the combination of command line flags and returns the
52 | exit status (or ``None``, if successful) of the program.
53 |
54 | Note that some exceptional conditions will terminate the program directly.
55 | """
56 | if args['--verbose']:
57 | squabble.logger.setLevel('DEBUG')
58 |
59 | if args['--list-presets']:
60 | return list_presets()
61 |
62 | config_file = args['--config'] or config.discover_config_location()
63 | if config_file and not os.path.exists(config_file):
64 | sys.exit('%s: no such file or directory' % config_file)
65 |
66 | presets = args['--preset'].split(',') if args['--preset'] else []
67 |
68 | base_config = config.load_config(
69 | config_file,
70 | preset_names=presets,
71 | reporter_name=args['--reporter'])
72 |
73 | # Load all of the rule classes into memory (need to do this now to
74 | # be able to list all rules / show rule details)
75 | rule.load_rules(plugin_paths=base_config.plugins)
76 |
77 | if args['--list-rules']:
78 | return list_rules()
79 |
80 | if args['--show-rule']:
81 | return show_rule(name=args['--show-rule'])
82 |
83 | if args['--explain']:
84 | return explain_message(code=args['--explain'])
85 |
86 | return run_linter(base_config, args['PATHS'], args['--expanded'])
87 |
88 |
89 | def run_linter(base_config, paths, expanded):
90 | """
91 | Run linter against all SQL files contained in ``paths``.
92 |
93 | ``paths`` may contain both files and directories.
94 |
95 | If ``paths`` is empty or only contains ``"-"``, squabble will read
96 | from stdin instead.
97 |
98 | If ``expanded`` is ``True``, print the detailed explanation of each message
99 | after the lint has finished.
100 | """
101 | if not paths:
102 | paths = ['-']
103 |
104 | files = collect_files(paths)
105 |
106 | issues = []
107 | for file_name, contents in files:
108 | file_config = config.apply_file_config(base_config, contents)
109 | if file_config is None:
110 | continue
111 | issues += lint.check_file(file_config, file_name, contents)
112 |
113 | reporter.report(base_config.reporter, issues, dict(files))
114 |
115 | if expanded:
116 | codes = {
117 | i.message.CODE for i in issues
118 | if i.message
119 | }
120 |
121 | for c in codes:
122 | print('\n')
123 | explain_message(c)
124 |
125 | # Make sure we have an error status if something went wrong.
126 | return 1 if issues else 0
127 |
128 |
129 | def _slurp_file(file_name):
130 | """Read entire contents of ``file_name`` as text."""
131 | with open(file_name, 'r') as fp:
132 | return fp.read()
133 |
134 |
135 | def _slurp_stdin():
136 | """
137 | Read entirety of stdin and return as string, or ``None`` if a ``^c``
138 | interrupt is triggered.
139 | """
140 | try:
141 | return sys.stdin.read()
142 | except KeyboardInterrupt:
143 | return None
144 |
145 |
146 | def collect_files(paths):
147 | """
148 | Given a list of files or directories, find all named files as well as
149 | any files ending in `.sql` in the directories.
150 |
151 | The return format is a list of tuples containing the file name and
152 | file contents.
153 |
154 | The value ``'-'`` is treated specially as stdin.
155 | """
156 | files = []
157 |
158 | for path in map(os.path.expanduser, paths):
159 | if path == '-':
160 | stdin = _slurp_stdin()
161 | if stdin is not None and stdin.strip() != '':
162 | files.append(('stdin', stdin))
163 |
164 | elif not os.path.exists(path):
165 | sys.exit('%s: no such file or directory' % path)
166 |
167 | elif os.path.isdir(path):
168 | sql_glob = os.path.join(path, '**/*.sql')
169 | sql_files = glob.iglob(sql_glob, recursive=True)
170 |
171 | files.extend(collect_files(sql_files))
172 |
173 | else:
174 | files.append((path, _slurp_file(path)))
175 |
176 | return files
177 |
178 |
179 | def show_rule(name):
180 | """Print information about rule named ``name``."""
181 | color = {
182 | 'bold': Style.BRIGHT,
183 | 'reset': Style.RESET_ALL,
184 | }
185 |
186 | try:
187 | meta = rule.Registry.get_meta(name)
188 | except squabble.UnknownRuleException:
189 | sys.exit('{bold}Unknown rule:{reset} {name}'.format(**{
190 | 'name': name,
191 | **color
192 | }))
193 |
194 | print('{bold}{name}{reset} - {description}\n\n{help}'.format(**{
195 | **meta,
196 | **color
197 | }))
198 |
199 |
200 | def list_rules():
201 | """Print out all registered rules and brief description of what they do."""
202 | color = {
203 | 'bold': Style.BRIGHT,
204 | 'reset': Style.RESET_ALL,
205 | }
206 |
207 | all_rules = sorted(rule.Registry.all(), key=lambda r: r['name'])
208 |
209 | for meta in all_rules:
210 | desc = strip_rst_directives(meta['description'])
211 |
212 | print('{bold}{name: <32}{reset} {description}'.format(**{
213 | **color,
214 | **meta,
215 | 'desc': desc,
216 | }))
217 |
218 |
219 | def explain_message(code):
220 | """Print out the more detailed explanation of the given message code."""
221 | try:
222 | code = int(code)
223 | cls = squabble.message.Registry.by_code(code)
224 | except (ValueError, KeyError):
225 | sys.exit('{bold}Unknown message code:{reset} {code}'.format(
226 | bold=Style.BRIGHT,
227 | reset=Style.RESET_ALL,
228 | code=code
229 | ))
230 |
231 | print('{bold}{name}{reset} [{code}]\n'.format(
232 | bold=Style.BRIGHT,
233 | reset=Style.RESET_ALL,
234 | code=cls.CODE,
235 | name=cls.__name__
236 | ))
237 |
238 | explanation = cls.explain() or 'No additional info.'
239 | print(strip_rst_directives(explanation))
240 |
241 |
242 | def list_presets():
243 | """Print out all the preset configurations."""
244 | for name, preset in config.PRESETS.items():
245 | print('{bold}{name}{reset} - {description}'.format(
246 | name=name,
247 | description=preset.get('description', ''),
248 | bold=Style.BRIGHT,
249 | reset=Style.RESET_ALL
250 | ))
251 |
252 | # npm here i come
253 | left_pad = ' '
254 | cfg = json.dumps(preset['config'], indent=4)\
255 | .replace('\n', '\n' + left_pad)
256 | print(left_pad + cfg)
257 |
--------------------------------------------------------------------------------
/squabble/config.py:
--------------------------------------------------------------------------------
1 | import collections
2 | import copy
3 | import json
4 | import logging
5 | import os.path
6 | import re
7 | import subprocess
8 |
9 | from squabble import SquabbleException
10 |
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 |
15 | Config = collections.namedtuple('Config', [
16 | 'reporter',
17 | 'plugins',
18 | 'rules'
19 | ])
20 |
21 |
22 | # TODO: Move these out somewhere else, feels gross to have them hardcoded.
23 | DEFAULT_CONFIG = dict(
24 | reporter='plain',
25 | plugins=[],
26 | rules={}
27 | )
28 |
29 | PRESETS = {
30 | 'postgres': {
31 | 'description': (
32 | 'A sane set of defaults that checks for obviously '
33 | 'dangerous Postgres migrations and query antipatterns.'
34 | ),
35 | 'config': {
36 | 'rules': {
37 | 'DisallowRenameEnumValue': {},
38 | 'DisallowChangeColumnType': {},
39 | 'DisallowNotIn': {},
40 | 'DisallowTimetzType': {},
41 | 'DisallowPaddedCharType': {},
42 | 'DisallowTimestampPrecision': {},
43 | }
44 | }
45 | },
46 |
47 | 'postgres-zero-downtime': {
48 | 'description': (
49 | 'A set of rules focused on preventing downtime during schema '
50 | 'migrations on busy Postgres databases.'
51 | ),
52 | 'config': {
53 | 'rules': {
54 | 'AddColumnDisallowConstraints': {
55 | 'disallowed': ['DEFAULT', 'NOT NULL', 'UNIQUE']
56 | },
57 | 'DisallowRenameEnumValue': {},
58 | 'DisallowChangeColumnType': {},
59 | 'RequireConcurrentIndex': {},
60 | }
61 | }
62 | },
63 |
64 | 'full': {
65 | 'description': ('Every rule that ships with squabble. The output will '
66 | 'be noisy (and nonsensical), but it\'s probably a good '
67 | 'starting point to figure out which rules are useful.'),
68 | 'config': {
69 | 'rules': {
70 | 'AddColumnDisallowConstraints': {
71 | 'disallowed': ['DEFAULT', 'NOT NULL', 'UNIQUE', 'FOREIGN']
72 | },
73 | 'RequireConcurrentIndex': {},
74 | 'DisallowRenameEnumValue': {},
75 | 'DisallowChangeColumnType': {},
76 | 'DisallowFloatTypes': {},
77 | 'DisallowNotIn': {},
78 | 'DisallowTimetzType': {},
79 | 'DisallowPaddedCharType': {},
80 | 'DisallowTimestampPrecision': {},
81 | 'RequirePrimaryKey': {},
82 | # Yes, these are incompatible.
83 | 'DisallowForeignKey': {},
84 | 'RequireForeignKey': {},
85 | }
86 | }
87 | }
88 | }
89 |
90 |
91 | class UnknownPresetException(SquabbleException):
92 | """Raised when user tries to apply a preset that isn't defined."""
93 | def __init__(self, preset):
94 | super().__init__('unknown preset: "%s"' % preset)
95 |
96 |
97 | def discover_config_location():
98 | """
99 | Try to locate a config file in some likely locations.
100 |
101 | Used when no config path is specified explicitly. In order, this
102 | will check for a file named ``.squabblerc``:
103 |
104 | - in the current directory.
105 | - in the root of the repository (if working in a git repo).
106 | - in the user's home directory.
107 | """
108 | possible_dirs = [
109 | '.',
110 | _get_vcs_root(),
111 | os.path.expanduser('~')
112 | ]
113 |
114 | for d in possible_dirs:
115 | if d is None:
116 | continue
117 |
118 | logger.debug('checking %s for a config file', d)
119 |
120 | file_name = os.path.join(d, '.squabblerc')
121 | if os.path.exists(file_name):
122 | logger.debug('using "%s" for configuration', file_name)
123 | return file_name
124 |
125 | logger.debug('no config file found')
126 | return None
127 |
128 |
129 | def _get_vcs_root():
130 | """
131 | Return the path to the root of the Git repository for the current
132 | directory, or empty string if not in a repository.
133 | """
134 | return subprocess.getoutput(
135 | 'git rev-parse --show-toplevel 2>/dev/null || echo ""')
136 |
137 |
138 | def get_base_config(preset_names=None):
139 | """
140 | Return a basic config value that can be overridden by user configuration
141 | files.
142 |
143 | :param preset_names: The named presets to use (applied in order), or None
144 | """
145 | if not preset_names:
146 | return Config(**DEFAULT_CONFIG)
147 |
148 | preset_settings = {}
149 | for name in preset_names:
150 | if name not in PRESETS:
151 | raise UnknownPresetException(name)
152 |
153 | preset_settings = _merge_dicts(preset_settings, PRESETS[name])
154 |
155 | return Config(**{
156 | **DEFAULT_CONFIG,
157 | **preset_settings['config']
158 | })
159 |
160 |
161 | def _parse_config_file(config_file):
162 | if not config_file:
163 | return {}
164 |
165 | with open(config_file, 'r') as fp:
166 | return json.load(fp)
167 |
168 |
169 | def load_config(config_file, preset_names=None, reporter_name=None):
170 | """
171 | Load configuration from a file, optionally applying a predefined
172 | set of rules.
173 |
174 | :param config_file: Path to JSON file containing user configuration.
175 | :type config_file: str
176 | :param preset_name: Preset to use as a base before applying user
177 | configuration.
178 | :type preset_name: str
179 | :param reporter_name: Override the reporter named in configuration.
180 | :type reporter_name: str
181 | """
182 | base = get_base_config(preset_names)
183 | config = _parse_config_file(config_file)
184 |
185 | rules = copy.deepcopy(base.rules)
186 | for name, rule in config.get('rules', {}).items():
187 | rules[name] = rule
188 |
189 | return Config(
190 | reporter=reporter_name or config.get('reporter', base.reporter),
191 | plugins=config.get('plugins', base.plugins),
192 | rules=rules
193 | )
194 |
195 |
196 | def apply_file_config(base, contents):
197 | """
198 | Given a base configuration object and the contents of a file,
199 | return a new config that applies any file-specific rule
200 | additions/deletions.
201 |
202 | Returns ``None`` if the file should be skipped.
203 | """
204 | # Operate on a copy so we don't mutate the base config
205 | file_rules = copy.deepcopy(base.rules)
206 |
207 | rules = _extract_file_rules(contents)
208 |
209 | if rules['skip_file']:
210 | return None
211 |
212 | for rule, opts in rules['enable'].items():
213 | file_rules[rule] = opts
214 |
215 | for rule in rules['disable']:
216 | del file_rules[rule]
217 |
218 | return base._replace(rules=file_rules)
219 |
220 |
221 | def _extract_file_rules(text):
222 | """
223 | Try to extract any file-level rule additions/suppressions.
224 |
225 | Valid lines are SQL line comments that enable or disable specific rules.
226 |
227 | >>> r = _extract_file_rules('-- squabble-enable:rule1 arr=a,b,c')
228 | >>> r['disable']
229 | []
230 | >>> r['enable']
231 | {'rule1': {'arr': ['a', 'b', 'c']}}
232 | >>> r['skip_file']
233 | False
234 | >>> r = _extract_file_rules('-- squabble-disable')
235 | >>> r['skip_file']
236 | True
237 | """
238 | rules = {
239 | 'enable': {},
240 | 'disable': [],
241 | 'skip_file': False
242 | }
243 |
244 | comment_re = re.compile(
245 | r'--\s*'
246 | r'(?:squabble-)?(enable|disable)'
247 | r'(?::\s*(\w+)(.*?))?'
248 | r'$', re.I)
249 |
250 | for line in text.splitlines():
251 | line = line.strip()
252 |
253 | m = re.match(comment_re, line)
254 | if m is None:
255 | continue
256 |
257 | action, rule, opts = m.groups()
258 |
259 | if action == 'disable' and not rule:
260 | rules['skip_file'] = True
261 |
262 | elif action == 'disable':
263 | rules['disable'].append(rule)
264 |
265 | elif action == 'enable':
266 | rules['enable'][rule] = _parse_options(opts)
267 |
268 | return rules
269 |
270 |
271 | def _parse_options(opts):
272 | """
273 | Given a string of space-separated `key=value` pairs, return a dictionary of
274 | `{"key": "value"}`. Note the value will always be returned as a string, and
275 | no further parsing will be attempted.
276 |
277 | >>> opts = _parse_options('k=v abc=1,2,3')
278 | >>> opts == {'k': 'v', 'abc': ['1', '2', '3']}
279 | True
280 | >>> _parse_options('k="1,2","3,4"')
281 | {'k': ['1,2', '3,4']}
282 | """
283 | options = {}
284 |
285 | # Either a simple quoted string or a bare value
286 | value = r'(?:(?:"([^"]+)")|([^,\s]+))'
287 |
288 | # Value followed by zero or more values
289 | value_list = r'{0}(?:,{0})*'.format(value)
290 |
291 | value_regex = re.compile(value)
292 | kv_regex = re.compile(r'(\w+)=({0})'.format(value_list))
293 |
294 | # 'k=1,2' => ('k', '1,2')
295 | for match in re.finditer(kv_regex, opts):
296 | key, val = match.group(1, 2)
297 |
298 | # value_regex will return ('string', '') or ('', 'value')
299 | values = [a or b for a, b in re.findall(value_regex, val)]
300 |
301 | # Collapse single len lists into scalars
302 | options[key] = values[0] if len(values) == 1 else values
303 |
304 | return options
305 |
306 |
307 | def _merge_dicts(a, b):
308 | """
309 | Combine the values of two (possibly nested) dictionaries.
310 |
311 | Values in ``b`` will take precedence over those in ``a``. This function
312 | will return a new dictionary rather than mutating its arguments.
313 |
314 | >>> a = {'foo': {'a': 1, 'b': 2}}
315 | >>> b = {'foo': {'b': 3, 'c': 4}, 'bar': 5}
316 | >>> m = _merge_dicts(a, b)
317 | >>> m == {'foo': {'a': 1, 'b': 3, 'c': 4}, 'bar': 5}
318 | True
319 | """
320 | def _inner(x, y, out):
321 | """Recursive helper function which mutates its arguments."""
322 | for k in x.keys() | y.keys():
323 | xv, yv = x.get(k, {}), y.get(k, {})
324 |
325 | if isinstance(xv, dict) and isinstance(yv, dict):
326 | out[k] = _inner(xv, yv, {})
327 | else:
328 | out[k] = yv if k in y else xv
329 | return out
330 |
331 | return _inner(a, b, {})
332 |
--------------------------------------------------------------------------------
/squabble/lint.py:
--------------------------------------------------------------------------------
1 | """ linting engine """
2 |
3 | import collections
4 | import enum
5 |
6 | import pglast
7 |
8 | from squabble.rule import Registry
9 |
10 | _LintIssue = collections.namedtuple('_LintIssue', [
11 | 'message',
12 | 'message_text',
13 | 'node',
14 | 'file',
15 | 'severity',
16 | 'location'
17 | ])
18 |
19 | # Make all the fields nullable
20 | _LintIssue.__new__.__defaults__ = (None,) * len(_LintIssue._fields)
21 |
22 |
23 | class LintIssue(_LintIssue):
24 | pass
25 |
26 |
27 | class Severity(enum.Enum):
28 | """
29 | Enumeration describing the relative severity of a :class:`~LintIssue`.
30 |
31 | By themselves, these values don't mean much, but are meant to
32 | convey the likely hood that a detected issue is truly
33 | problematic. For example, a syntax error in a migration would be
34 | ``CRITICAL``, but perhaps a naming inconsistency would be ``LOW``.
35 | """
36 | LOW = 'LOW'
37 | MEDIUM = 'MEDIUM'
38 | HIGH = 'HIGH'
39 | CRITICAL = 'CRITICAL'
40 |
41 |
42 | def _parse_string(text):
43 | """
44 | Use ``pglast`` to turn ``text`` into a SQL AST node.
45 |
46 | Returns ``pglast.node.Scalar(None)`` when no AST nodes could be
47 | parsed. This is a hack, but prevents convoluting the downstream
48 | logic too much, as ``Context.traverse`` will simply ignore scalar
49 | values.
50 |
51 | >>> _parse_string('SELECT 1')
52 | [1*{RawStmt}]
53 | >>> _parse_string('-- just a comment')
54 |
55 | """
56 | ast = pglast.parse_sql(text)
57 | return pglast.Node(ast) if ast else pglast.node.Scalar(None)
58 |
59 |
60 | def _configure_rules(rule_config):
61 | rules = []
62 |
63 | for name, config in rule_config.items():
64 | cls = Registry.get_class(name)
65 | rules.append((cls(), config))
66 |
67 | return rules
68 |
69 |
70 | def check_file(config, name, contents):
71 | """
72 | Return a list of lint issues from using ``config`` to lint
73 | ``name``.
74 | """
75 | rules = _configure_rules(config.rules)
76 | s = Session(rules, contents, file_name=name)
77 | return s.lint()
78 |
79 |
80 | class Session:
81 | """
82 | A run of the linter using a given set of rules over a single file. This
83 | class exists mainly to hold the list of issues returned by the enabled
84 | rules.
85 | """
86 | def __init__(self, rules, sql_text, file_name):
87 | self._rules = rules
88 | self._sql = sql_text
89 | self._issues = []
90 | self._file_name = file_name
91 |
92 | def report_issue(self, issue):
93 | i = issue._replace(file=self._file_name)
94 | self._issues.append(i)
95 |
96 | def lint(self):
97 | """
98 | Run the linter on SQL given in constructor, returning a list of
99 | :class:`~LintIssue` discovered.
100 | """
101 | root_ctx = Context(self)
102 |
103 | for rule, config in self._rules:
104 | rule.enable(root_ctx, config)
105 |
106 | try:
107 | ast = _parse_string(self._sql)
108 | root_ctx.traverse(ast)
109 |
110 | except pglast.parser.ParseError as exc:
111 | root_ctx.report_issue(LintIssue(
112 | severity=Severity.CRITICAL,
113 | message_text=exc.args[0],
114 | location=exc.location
115 | ))
116 |
117 | return self._issues
118 |
119 |
120 | class Context:
121 | """
122 | Contains the node tag callback hooks enabled at or below the `parent_node`
123 | passed to the call to `traverse`.
124 |
125 | >>> import pglast
126 | >>> ast = pglast.Node(pglast.parse_sql('''
127 | ... CREATE TABLE foo (id INTEGER PRIMARY KEY);
128 | ... '''))
129 | >>> ctx = Context(session=...)
130 | >>>
131 | >>> def create_stmt(child_ctx, node):
132 | ... print('create stmt')
133 | ... child_ctx.register('ColumnDef', lambda _c, _n: print('from child'))
134 | ...
135 | >>> ctx.register('CreateStmt', create_stmt)
136 | >>> ctx.register('ColumnDef', lambda _c, _n: print('from root'))
137 | >>> ctx.traverse(ast)
138 | create stmt
139 | from child
140 | from root
141 | """
142 | def __init__(self, session):
143 | self._hooks = {}
144 | self._exit_hooks = []
145 | self._session = session
146 |
147 | def traverse(self, parent_node):
148 | """
149 | Recursively walk down the AST starting at `parent_node`.
150 |
151 | For every node, call any callback functions registered for that
152 | particular node tag.
153 | """
154 | for node in parent_node.traverse():
155 | # Ignore scalar values
156 | if not isinstance(node, pglast.node.Node):
157 | continue
158 |
159 | tag = node.node_tag
160 |
161 | if tag not in self._hooks:
162 | continue
163 |
164 | child_ctx = Context(self._session)
165 | for hook in self._hooks[tag]:
166 | hook(child_ctx, node)
167 |
168 | # children can set up their own hooks, so recurse
169 | child_ctx.traverse(node)
170 |
171 | for exit_fn in self._exit_hooks:
172 | exit_fn(self)
173 |
174 | def register_exit(self, fn):
175 | """
176 | Register `fn` to be called when the current node is finished being
177 | traversed.
178 | """
179 | self._exit_hooks.append(fn)
180 |
181 | def register(self, node_tag, fn):
182 | """
183 | Register `fn` to be called whenever `node_tag` node is visited.
184 |
185 | >>> session = ...
186 | >>> ctx = Context(session)
187 | >>> ctx.register('CreateStmt', lambda ctx, node: ...)
188 | """
189 | if node_tag not in self._hooks:
190 | self._hooks[node_tag] = []
191 |
192 | self._hooks[node_tag].append(fn)
193 |
194 | def report_issue(self, issue):
195 | self._session.report_issue(issue)
196 |
197 | def report(self, message, node=None, severity=None):
198 | """Convenience wrapper to create and report a lint issue."""
199 | self.report_issue(LintIssue(
200 | message=message,
201 | node=node,
202 | severity=severity or Severity.MEDIUM,
203 | # This is filled in later
204 | file=None,
205 | ))
206 |
--------------------------------------------------------------------------------
/squabble/message.py:
--------------------------------------------------------------------------------
1 | import inspect
2 | import logging
3 |
4 | from squabble import SquabbleException, PEP487Object
5 |
6 |
7 | logger = logging.getLogger(__name__)
8 |
9 |
10 | class DuplicateMessageCodeException(SquabbleException):
11 | def __init__(self, dupe):
12 | original = Registry.by_code(dupe.CODE)
13 | message = 'Message %s has the same code as %s' % (dupe, original)
14 |
15 | super().__init__(message)
16 |
17 |
18 | class Registry(PEP487Object):
19 | """
20 | Singleton which maps message code values to classes.
21 |
22 | >>> class MyMessage(Message):
23 | ... '''My example message.'''
24 | ... TEMPLATE = '...'
25 | ... CODE = 5678
26 | >>> cls = Registry.by_code(5678)
27 | >>> cls.explain()
28 | 'My example message.'
29 | >>> cls is MyMessage
30 | True
31 |
32 | Duplicate codes are not allowed, and will throw an exception.
33 |
34 | >>> class MyDuplicateMessage(Message):
35 | ... CODE = 5678
36 | Traceback (most recent call last):
37 | ...
38 | squabble.message.DuplicateMessageCodeException: ...
39 | """
40 |
41 | _MAP = {}
42 | _CODE_COUNTER = 9000
43 |
44 | @classmethod
45 | def register(cls, msg):
46 | """
47 | Add ``msg`` to the registry, and assign a ``CODE`` value if not
48 | explicitly specified.
49 | """
50 | if msg.CODE is None:
51 | setattr(msg, 'CODE', cls._next_code())
52 | logger.info('assigning code %s to %s', msg.CODE, msg)
53 |
54 | # Don't allow duplicates
55 | if msg.CODE in cls._MAP:
56 | raise DuplicateMessageCodeException(msg)
57 |
58 | cls._MAP[msg.CODE] = msg
59 |
60 | @classmethod
61 | def by_code(cls, code):
62 | """
63 | Return the :class:`squabble.message.Message` class identified by
64 | ``code``, raising a :class:`KeyError` if it doesn't exist.
65 | """
66 | return cls._MAP[code]
67 |
68 | @classmethod
69 | def _next_code(cls):
70 | cls._CODE_COUNTER += 1
71 | return cls._CODE_COUNTER
72 |
73 |
74 | class Message(PEP487Object):
75 | """
76 | Messages represent specific issues identified by a lint rule.
77 |
78 | Each class that inherits from ``Message`` should have a docstring
79 | which explains the reasoning and context of the message, as well
80 | as a class member variable named ``TEMPLATE``, which is used to
81 | display a brief explanation on the command line.
82 |
83 | Messages may also have a ``CODE`` class member, which is used to
84 | identify the message. The actual value doesn't matter much, as
85 | long as it is unique among all the loaded ``Message`` s. If no
86 | ``CODE`` is defined, one will be assigned.
87 |
88 | >>> class TooManyColumns(Message):
89 | ... '''
90 | ... This may indicate poor design, consider multiple tables instead.
91 | ... '''
92 | ... TEMPLATE = 'table "{table}" has > {limit} columns'
93 | ... CODE = 1234
94 | >>> message = TooManyColumns(table='foo', limit=30)
95 | >>> message.format()
96 | 'table "foo" has > 30 columns'
97 | >>> message.explain()
98 | 'This may indicate poor design, consider multiple tables instead.'
99 | """
100 | TEMPLATE = None
101 | CODE = None
102 |
103 | def __init__(self, **kwargs):
104 | self.kwargs = kwargs
105 |
106 | def __init_subclass__(cls, **kwargs):
107 | """Assign unique (locally, not globally) message codes."""
108 | super().__init_subclass__(**kwargs)
109 | Registry.register(cls)
110 |
111 | def format(self):
112 | return self.TEMPLATE.format(**self.kwargs)
113 |
114 | @classmethod
115 | def explain(cls):
116 | """
117 | Provide more context around this message.
118 |
119 | The purpose of this function is to explain to users _why_ the
120 | message was raised, and what they can do to resolve the issue.
121 |
122 | The base implementation will simply return the docstring for
123 | the class, but this can be overridden if more specialized
124 | behavior is necessary.
125 |
126 | >>> class NoDocString(Message): pass
127 | >>> NoDocString().explain() is None
128 | True
129 | """
130 | if not cls.__doc__:
131 | return None
132 |
133 | # Remove the leading indentation on the docstring
134 | return inspect.cleandoc(cls.__doc__)
135 |
136 | def asdict(self):
137 | """
138 | Return dictionary representation of message, for formatting.
139 |
140 | >>> class SummaryMessage(Message):
141 | ... CODE = 90291
142 | ... TEMPLATE = 'everything is {status}'
143 | ...
144 | >>> msg = SummaryMessage(status='wrong')
145 | >>> msg.asdict() == {
146 | ... 'message_id': 'SummaryMessage',
147 | ... 'message_text': 'everything is wrong',
148 | ... 'message_template': SummaryMessage.TEMPLATE,
149 | ... 'message_params': {'status': 'wrong'},
150 | ... 'message_code': 90291
151 | ... }
152 | True
153 | """
154 | return {
155 | 'message_id': self.__class__.__name__,
156 | 'message_text': self.format(),
157 | 'message_template': self.TEMPLATE,
158 | 'message_params': self.kwargs,
159 | 'message_code': self.CODE
160 | }
161 |
--------------------------------------------------------------------------------
/squabble/reporter.py:
--------------------------------------------------------------------------------
1 | # coding: utf-8
2 |
3 | import functools
4 | import json
5 | import sys
6 |
7 | import pglast
8 | from colorama import Fore, Style
9 |
10 | import squabble
11 | from squabble.lint import Severity
12 |
13 | _REPORTERS = {}
14 |
15 |
16 | class UnknownReporterException(squabble.SquabbleException):
17 | """Raised when a configuration references a reporter that doesn't exist."""
18 | def __init__(self, name):
19 | super().__init__('unknown reporter: "%s"' % name)
20 |
21 |
22 | def reporter(name):
23 | """
24 | Decorator to register function as a callback when the config sets the
25 | ``"reporter"`` config value to ``name``.
26 |
27 | The wrapped function will be called with each
28 | :class:`squabble.lint.LintIssue` and the contents of the file
29 | being linted. Each reporter should return a list of lines of
30 | output which will be printed to stderr.
31 |
32 | >>> from squabble.lint import LintIssue
33 | >>> @reporter('no_info')
34 | ... def no_info(issue, file_contents):
35 | ... return ['something happened']
36 | ...
37 | >>> no_info(LintIssue(), file_contents='')
38 | ['something happened']
39 | """
40 | def wrapper(fn):
41 | _REPORTERS[name] = fn
42 |
43 | @functools.wraps(fn)
44 | def wrapped(*args, **kwargs):
45 | return fn(*args, **kwargs)
46 | return wrapped
47 |
48 | return wrapper
49 |
50 |
51 | def report(reporter_name, issues, files):
52 | """
53 | Call the named reporter function for every issue in the list of
54 | issues. All lines of output returned will be printed to stderr.
55 |
56 | :param reporter_name: Issue reporter format to use.
57 | :type reporter_name: str
58 | :param issues: List of generated :class:`squabble.lint.LintIssue`.
59 | :type issues: list
60 | :param files: Map of file name to contents of file.
61 | :type files: dict
62 |
63 | >>> import sys; sys.stderr = sys.stdout # for doctest.
64 | >>> from squabble.lint import LintIssue
65 | >>> @reporter('message_and_severity')
66 | ... def message_and_severity_reporter(issue, contents):
67 | ... return ['%s:%s' % (issue.severity.name, issue.message_text)]
68 | ...
69 | >>> issue = LintIssue(severity=Severity.CRITICAL,
70 | ... message_text='bad things!')
71 | >>> report('message_and_severity', [issue], files={})
72 | CRITICAL:bad things!
73 | """
74 | if reporter_name not in _REPORTERS:
75 | raise UnknownReporterException(reporter_name)
76 |
77 | reporter_fn = _REPORTERS[reporter_name]
78 |
79 | for i in issues:
80 | file_contents = files.get(i.file, '')
81 | for line in reporter_fn(i, file_contents):
82 | _print_err(line)
83 |
84 |
85 | def _location_for_issue(issue):
86 | """
87 | Return the offset into the file for this issue, or None if it
88 | cannot be determined.
89 | """
90 | if issue.node and issue.node.location != pglast.Missing:
91 | return issue.node.location.value
92 |
93 | return issue.location
94 |
95 |
96 | def _issue_to_file_location(issue, contents):
97 | """
98 | Given an issue (which may or may not have a :class:`pglast.Node` with a
99 | ``location`` field) and the contents of the file containing that
100 | node, return the ``(line_str, line, column)`` that node is located at,
101 | or ``('', 1, 0)``.
102 |
103 | :param issue:
104 | :type issue: :class:`squabble.lint.LintIssue`
105 | :param contents: Full contents of the file being linted, as a string.
106 | :type contents: str
107 |
108 | >>> from squabble.lint import LintIssue
109 |
110 | >>> issue = LintIssue(location=8, file='foo')
111 | >>> sql = '1234\\n678\\nABCD'
112 | >>> _issue_to_file_location(issue, sql)
113 | ('678', 2, 3)
114 |
115 | >>> issue = LintIssue(location=7, file='foo')
116 | >>> sql = '1\\r\\n\\r\\n678\\r\\nBCD'
117 | >>> _issue_to_file_location(issue, sql)
118 | ('678', 3, 2)
119 | """
120 | loc = _location_for_issue(issue)
121 |
122 | if loc is None or loc >= len(contents):
123 | return ('', 1, 0)
124 |
125 | # line number is number of newlines in the file before this
126 | # location, 1 indexed.
127 | line_num = contents[:loc].count('\n') + 1
128 |
129 | # Search forwards/backwards for the first newline before and first
130 | # newline after this point.
131 | line_start = contents.rfind('\n', 0, loc) + 1
132 | line_end = contents.find('\n', loc)
133 |
134 | # Strip out \r so we can treat \r\n and \n the same way
135 | line = contents[line_start:line_end].replace('\r', '')
136 | column = loc - line_start
137 |
138 | return(line, line_num, column)
139 |
140 |
141 | def _print_err(msg):
142 | print(msg, file=sys.stderr)
143 |
144 |
145 | def _format_message(issue):
146 | if issue.message_text:
147 | return issue.message_text
148 |
149 | return issue.message.format()
150 |
151 |
152 | def _issue_info(issue, file_contents):
153 | """Return a dictionary of metadata for an issue."""
154 | line, line_num, column = _issue_to_file_location(issue, file_contents)
155 | formatted = _format_message(issue)
156 |
157 | return {
158 | **issue._asdict(),
159 | **(issue.message.asdict() if issue.message else {}),
160 | 'line_text': line,
161 | 'line': line_num,
162 | 'column': column,
163 | 'message_formatted': formatted,
164 | 'severity': issue.severity.name,
165 | }
166 |
167 |
168 | _SIMPLE_FORMAT = '{file}:{line}:{column} {severity}: {message_formatted}'
169 |
170 | # Partially pre-format the message since the color codes will be static.
171 | _COLOR_FORMAT = '{bold}{{file}}:{reset}{{line}}:{{column}}{reset} '\
172 | '{{severity}} {{message_formatted}}'\
173 | .format(bold=Style.BRIGHT, reset=Style.RESET_ALL)
174 |
175 |
176 | @reporter("plain")
177 | def plain_text_reporter(issue, file_contents):
178 | """Simple single-line output format that is easily parsed by editors."""
179 | info = _issue_info(issue, file_contents)
180 | return [
181 | _SIMPLE_FORMAT.format(**info)
182 | ]
183 |
184 |
185 | _SEVERITY_COLOR = {
186 | Severity.CRITICAL: Fore.RED,
187 | Severity.HIGH: Fore.RED,
188 | Severity.MEDIUM: Fore.YELLOW,
189 | Severity.LOW: Fore.BLUE,
190 | }
191 |
192 |
193 | @reporter('color')
194 | def color_reporter(issue, file_contents):
195 | """
196 | Extension of :func:`squabble.reporter.plain_text_reporter`, uses
197 | ANSI color and shows error location.
198 | """
199 | info = _issue_info(issue, file_contents)
200 | info['severity'] = '{color}{severity}{reset}'.format(
201 | color=_SEVERITY_COLOR[issue.severity],
202 | severity=issue.severity.name,
203 | reset=Style.RESET_ALL
204 | )
205 |
206 | output = [_COLOR_FORMAT.format(**info)]
207 |
208 | if 'message_code' in info:
209 | output[0] += ' [{message_code}]'.format(**info)
210 |
211 | if info['line_text'] != '':
212 | arrow = ' ' * info['column'] + '^'
213 | output.append(info['line_text'])
214 | output.append(Style.BRIGHT + arrow + Style.RESET_ALL)
215 |
216 | return output
217 |
218 |
219 | @reporter('json')
220 | def json_reporter(issue, _file_contents):
221 | """Dump each issue as a JSON dictionary"""
222 |
223 | # Swap out all of the non-JSON serializable elements:
224 | issue = issue._replace(severity=issue.severity.name)
225 |
226 | if issue.node:
227 | issue = issue._replace(node=issue.node.parse_tree)
228 |
229 | if issue.message:
230 | issue = issue._replace(message=issue.message.asdict())
231 |
232 | obj = {
233 | k: v for k, v in issue._asdict().items()
234 | if v is not None
235 | }
236 |
237 | return [
238 | json.dumps(obj)
239 | ]
240 |
241 |
242 | _SQLINT_FORMAT = '{file}:{line}:{column}:{severity} {message_formatted}'
243 |
244 |
245 | @reporter('sqlint')
246 | def sqlint_reporter(issue, file_contents):
247 | """
248 | Format compatible with ``sqlint``, which is already integrated into
249 | Flycheck and other editor linting frameworks.
250 |
251 | Main difference is really just that there are only two severity
252 | levels: ``ERROR`` and ``WARNING``.
253 | """
254 |
255 | error_level = {Severity.HIGH, Severity.CRITICAL}
256 |
257 | info = _issue_info(issue, file_contents)
258 | info['severity'] = 'ERROR' if issue.severity in error_level else 'WARNING'
259 |
260 | return [
261 | _SQLINT_FORMAT.format(**info)
262 | ]
263 |
--------------------------------------------------------------------------------
/squabble/rule.py:
--------------------------------------------------------------------------------
1 | import functools
2 | import glob
3 | import importlib
4 | import importlib.util as import_util
5 | import logging
6 | import os.path
7 |
8 | from squabble import UnknownRuleException, PEP487Object
9 |
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 |
14 | def _load_plugin(path):
15 | """
16 | Given an arbitrary directory, try to load all Python files in order
17 | to register the custom rule definitions.
18 |
19 | Nothing is done with the Python files once they're loaded, it is
20 | assumed that simply importing the module will be enough to have the
21 | side effect of registering the modules correctly.
22 | """
23 | logger.debug('trying to load "%s" as a plugin directory', path)
24 |
25 | if not os.path.isdir(path):
26 | raise NotADirectoryError('cannot load "%s": not a directory' % path)
27 |
28 | files = os.path.join(path, '*.py')
29 | pkg_name = os.path.basename(os.path.dirname(path))
30 |
31 | for file_name in glob.glob(files):
32 | logger.debug('loading file "%s" to pkg "%s"', file_name, pkg_name)
33 | spec = import_util.spec_from_file_location(pkg_name, file_name)
34 |
35 | # Parse and execute the file
36 | mod = import_util.module_from_spec(spec)
37 | spec.loader.exec_module(mod)
38 |
39 |
40 | def _load_builtin_rules():
41 | """Load the rules that ship with squabble (squabble/rules/*.py)"""
42 | modules = glob.glob(os.path.dirname(__file__) + '/rules/*.py')
43 |
44 | # Sort the modules to guarantee stable ordering
45 | for mod in sorted(modules):
46 | mod_name = os.path.basename(mod)[:-3]
47 |
48 | if not os.path.isfile(mod) or mod_name.startswith('__'):
49 | continue
50 |
51 | importlib.import_module('squabble.rules.' + mod_name)
52 |
53 |
54 | def load_rules(plugin_paths=None):
55 | """
56 | Load built in rules as well as any custom rules contained in the
57 | directories in `plugin_paths`.
58 | """
59 | _load_builtin_rules()
60 |
61 | # Import plugins last so their naming takes precedence
62 | plugin_paths = plugin_paths or []
63 | for path in plugin_paths:
64 | _load_plugin(path)
65 |
66 |
67 | def node_visitor(fn):
68 | """
69 | Helper decorator to make it easier to register callbacks for AST
70 | nodes. Effectively creates the partial function automatically so
71 | there's no need for a lambda.
72 |
73 | Wraps ``fn`` to pass in ``self``, ``context``, and ``node``
74 | when the callback is called.
75 |
76 | >>> from squabble.rules import BaseRule
77 | >>> class SomeRule(BaseRule):
78 | ... def enable(self, ctx, config):
79 | ... # These are equivalent
80 | ... ctx.register('foo', self.check_foo(x=1))
81 | ... ctx.register('bar', lambda c, n: self.check_bar(c, n, x=1))
82 | ...
83 | ... @node_visitor
84 | ... def check_foo(self, context, node, x):
85 | ... pass
86 | ...
87 | ... def check_bar(self, context, node, x):
88 | ... pass
89 | """
90 | def wrapper(self, *args, **kwargs):
91 | @functools.wraps(fn)
92 | def inner(context, node):
93 | return fn(self, context, node, *args, **kwargs)
94 | return inner
95 |
96 | return wrapper
97 |
98 |
99 | class Registry(PEP487Object):
100 | """
101 | Singleton instance used to keep track of all rules.
102 |
103 | Any class that inherits from :class:`squabble.rules.BaseRule` will
104 | automatically be registered to the registry.
105 | """
106 | _REGISTRY = {}
107 |
108 | @staticmethod
109 | def register(rule):
110 | meta = rule.meta()
111 | name = meta['name']
112 |
113 | logger.debug('registering rule "%s"', name)
114 |
115 | Registry._REGISTRY[name] = {'class': rule, 'meta': meta}
116 |
117 | @staticmethod
118 | def get_meta(name):
119 | """
120 | Return metadata about a given rule in the registry.
121 |
122 | If no rule exists in the registry named ``name``,
123 | :class:`UnknownRuleException` will be thrown.
124 |
125 | The returned dictionary will look something like this:
126 |
127 | .. code-block:: python
128 |
129 | {
130 | 'name': 'RuleClass',
131 | 'help': 'Some rule...',
132 | # ...
133 | }
134 | """
135 | if name not in Registry._REGISTRY:
136 | raise UnknownRuleException(name)
137 |
138 | return Registry._REGISTRY[name]['meta']
139 |
140 | @staticmethod
141 | def get_class(name):
142 | """
143 | Return class for given rule name in the registry.
144 |
145 | If no rule exists in the registry named ``name``,
146 | :class:`UnknownRuleException` will be thrown.
147 | """
148 | if name not in Registry._REGISTRY:
149 | raise UnknownRuleException(name)
150 |
151 | return Registry._REGISTRY[name]['class']
152 |
153 | @staticmethod
154 | def all():
155 | """
156 | Return an iterator over all known rule metadata. Equivalent to calling
157 | :func:`~Registry.get_meta()` for all registered rules.
158 | """
159 | for r in Registry._REGISTRY.values():
160 | yield r['meta']
161 |
--------------------------------------------------------------------------------
/squabble/rules/__init__.py:
--------------------------------------------------------------------------------
1 | import inspect
2 |
3 | from squabble.rule import Registry
4 | from squabble import PEP487Object
5 |
6 |
7 | class BaseRule(PEP487Object):
8 | """
9 | Base implementation of a linter rule.
10 |
11 | Rules work by adding hooks into the abstract syntax tree for a SQL
12 | file, and then performing their lint actions inside the callback
13 | functions.
14 |
15 | Rules represent lint issues using pre-defined message classes, which are
16 | defined by creating a child class inheriting from
17 | :class:`squabble.message.Message`.
18 |
19 | For example:
20 |
21 | >>> import squabble.message
22 | >>> class MyRule(BaseRule):
23 | ... class BadColumnName(squabble.message.Message):
24 | ... TEMPLATE = 'column {name} is not allowed'
25 | ...
26 | >>> MyRule.BadColumnName(name='foo').format()
27 | 'column foo is not allowed'
28 | """
29 |
30 | def __init_subclass__(cls, **kwargs):
31 | """Keep track of all classes that inherit from ``BaseRule``."""
32 | super().__init_subclass__(**kwargs)
33 | Registry.register(cls)
34 |
35 | @classmethod
36 | def meta(cls):
37 | """
38 | Return metadata about the Rule. Base implementation should be sane
39 | enough for most purposes.
40 |
41 | >>> class MyRule(BaseRule):
42 | ... '''
43 | ... Brief description of rule. This can
44 | ... wrap to the next line.
45 | ...
46 | ... More details about this rule.
47 | ... '''
48 | >>> m = MyRule.meta()
49 | >>> m['name']
50 | 'MyRule'
51 | >>> m['description']
52 | 'Brief description of rule. This can wrap to the next line.'
53 | >>> m['help']
54 | 'More details about this rule.'
55 | """
56 | split_doc = inspect.cleandoc(cls.__doc__ or '').split('\n\n', 1)
57 |
58 | help = None
59 | if len(split_doc) == 2:
60 | help = split_doc[1]
61 |
62 | description = split_doc[0].replace('\n', ' ')
63 |
64 | return {
65 | 'name': cls.__name__,
66 | 'description': description,
67 | 'help': help
68 | }
69 |
70 | def enable(self, ctx, config):
71 | """
72 | Called before the root AST node is traversed. Here's where most
73 | callbacks should be registered for different AST nodes.
74 |
75 | Each linter is initialized once per file that it is being run
76 | against. ``config`` will contain the merged base configuration
77 | with the file-specific configuration options for this linter.
78 | """
79 | raise NotImplementedError('must be overridden by subclass')
80 |
--------------------------------------------------------------------------------
/squabble/rules/add_column_disallow_constraints.py:
--------------------------------------------------------------------------------
1 | import pglast
2 | from pglast.enums import AlterTableType, ConstrType
3 |
4 | import squabble.rule
5 | from squabble import RuleConfigurationException
6 | from squabble.message import Message
7 | from squabble.rules import BaseRule
8 |
9 |
10 | class AddColumnDisallowConstraints(BaseRule):
11 | """
12 | Prevent adding a column with certain constraints to an existing table.
13 |
14 | Configuration: ::
15 |
16 | {
17 | "AddColumnDisallowConstraints": {
18 | "disallowed": ["DEFAULT", "FOREIGN"]
19 | }
20 | }
21 |
22 | Valid constraint types:
23 | - DEFAULT
24 | - NULL
25 | - NOT NULL
26 | - FOREIGN
27 | - UNIQUE
28 | """
29 |
30 | _CONSTRAINT_MAP = {
31 | 'DEFAULT': ConstrType.CONSTR_DEFAULT,
32 | 'NULL': ConstrType.CONSTR_NULL,
33 | 'NOT NULL': ConstrType.CONSTR_NOTNULL,
34 | 'FOREIGN': ConstrType.CONSTR_FOREIGN,
35 | 'UNIQUE': ConstrType.CONSTR_UNIQUE,
36 | }
37 |
38 | class ConstraintNotAllowed(Message):
39 | """
40 | When adding a column to an existing table, certain constraints can have
41 | unintentional side effects, like locking the table or introducing
42 | performance issues.
43 |
44 | For example, adding a ``DEFAULT`` constraint may hold a lock on the
45 | table while all existing rows are modified to fill in the default
46 | value.
47 |
48 | A ``UNIQUE`` constraint will require scanning the table to confirm
49 | there are no duplicates.
50 |
51 | On a particularly hot table, a ``FOREIGN`` constraint will introduce
52 | possibly dangerous overhead to confirm the referential integrity of
53 | each row.
54 | """
55 | CODE = 1004
56 | TEMPLATE = 'column "{col}" has a disallowed constraint'
57 |
58 | def enable(self, ctx, config):
59 | disallowed = config.get('disallowed', [])
60 | if disallowed == []:
61 | raise RuleConfigurationException(
62 | self, 'must specify `disallowed` constraints')
63 |
64 | constraints = set()
65 |
66 | for c in disallowed:
67 | ty = self._CONSTRAINT_MAP.get(c.upper())
68 | if ty is None:
69 | raise RuleConfigurationException(
70 | self, 'unknown constraint: `%s`' % c)
71 |
72 | constraints.add(ty)
73 |
74 | ctx.register('AlterTableCmd', self._check(constraints))
75 |
76 | @squabble.rule.node_visitor
77 | def _check(self, ctx, node, disallowed_constraints):
78 | """
79 | Node is an `AlterTableCmd`:
80 |
81 | ::
82 | {
83 | 'AlterTableCmd': {
84 | 'def': {
85 | 'ColumnDef': {
86 | 'colname': 'bar',
87 | 'constraints': [{'Constraint': {'contype': 2}}]
88 | }
89 | }
90 | }
91 | }
92 | """
93 |
94 | # We only care about adding a column
95 | if node.subtype != AlterTableType.AT_AddColumn:
96 | return
97 |
98 | constraints = node['def'].constraints
99 |
100 | # No constraints imposed, nothing to do.
101 | if constraints == pglast.Missing:
102 | return
103 |
104 | for constraint in constraints:
105 | if constraint.contype.value in disallowed_constraints:
106 | col = node['def'].colname.value
107 |
108 | ctx.report(
109 | self.ConstraintNotAllowed(col=col),
110 | node=constraint)
111 |
--------------------------------------------------------------------------------
/squabble/rules/disallow_change_column_type.py:
--------------------------------------------------------------------------------
1 | from pglast.enums import AlterTableType
2 |
3 | from squabble.message import Message
4 | from squabble.rules import BaseRule
5 |
6 |
7 | class DisallowChangeColumnType(BaseRule):
8 | """
9 | Prevent changing the type of an existing column.
10 |
11 | Configuration: ::
12 |
13 | { "DisallowChangeColumnType": {} }
14 | """
15 |
16 | class ChangeTypeNotAllowed(Message):
17 | """
18 | Trying to change the type of an existing column may hold a
19 | full table lock while all of the rows are modified.
20 |
21 | Additionally, changing the type of a column may not be
22 | backwards compatible with code that has already been deployed.
23 |
24 | Instead, try adding a new column with the updated type, and
25 | then migrate over.
26 |
27 | For example, to migrate a column from ``type_a`` to ``type_b``.
28 |
29 | .. code-block:: sql
30 |
31 | ALTER TABLE foo ADD COLUMN bar_new type_b;
32 | UPDATE foo SET bar_new = cast(bar_old, type_b);
33 | -- Deploy server code to point to new column
34 | ALTER TABLE foo DROP COLUMN bar_old;
35 | """
36 | CODE = 1003
37 | TEMPLATE = 'cannot change type of existing column "{col}"'
38 |
39 | def enable(self, ctx, _config):
40 | ctx.register('AlterTableCmd', lambda c, n: self._check(c, n))
41 |
42 | def _check(self, ctx, node):
43 | """
44 | Node is an `AlterTableCmd`:
45 |
46 | {
47 | 'AlterTableCmd': {
48 | 'def': {
49 | 'ColumnDef': {
50 | 'colname': 'bar',
51 | 'constraints': [{'Constraint': {'contype': 2, 'location': 35}}]
52 | }
53 | }
54 | }
55 | }
56 | """
57 |
58 | # We only care about changing the type of a column
59 | if node.subtype != AlterTableType.AT_AlterColumnType:
60 | return
61 |
62 | ty = node['def'].typeName
63 |
64 | issue = self.ChangeTypeNotAllowed(col=node.name.value)
65 | ctx.report(issue, node=ty)
66 |
--------------------------------------------------------------------------------
/squabble/rules/disallow_float_types.py:
--------------------------------------------------------------------------------
1 | import pglast
2 |
3 | import squabble.rule
4 | from squabble.lint import Severity
5 | from squabble.message import Message
6 | from squabble.rules import BaseRule
7 | from squabble.util import format_type_name
8 |
9 |
10 | def _parse_column_type(typ):
11 | """
12 | Feed column type name through pglast.
13 |
14 | >>> _parse_column_type('real')
15 | 'pg_catalog.float4'
16 |
17 | >>> _parse_column_type('double precision')
18 | 'pg_catalog.float8'
19 | """
20 | sql = 'CREATE TABLE _(_ {0});'.format(typ)
21 |
22 | create_table = pglast.Node(pglast.parse_sql(sql))[0].stmt
23 | type_name = create_table.tableElts[0].typeName
24 |
25 | return format_type_name(type_name)
26 |
27 |
28 | class DisallowFloatTypes(BaseRule):
29 | """
30 | Prevent using approximate float point number data types.
31 |
32 | In SQL, the types ``FLOAT``, ``REAL``, and ``DOUBLE PRECISION`` are
33 | implemented as IEEE 754 floating point numbers, which will not be
34 | able to perfectly represent all numbers within their ranges.
35 |
36 | Often, they'll be "good enough", but when doing aggregates over a
37 | large table, or trying to store very large (or very small)
38 | numbers, errors can be exaggerated.
39 |
40 | Most of the time, you'll probably want to used a fixed-point
41 | number, such as ``NUMERIC(3, 4)``.
42 |
43 | Configuration ::
44 |
45 | { "DisallowFloatTypes": {} }
46 | """
47 |
48 | _INEXACT_TYPES = set(
49 | _parse_column_type(ty)
50 | for ty in ['real', 'float', 'double', 'double precision']
51 | )
52 |
53 | class LossyFloatType(Message):
54 | """
55 | The types ``FLOAT``, ``REAL``, and ``DOUBLE PRECISION`` are
56 | implemented as IEEE 754 floating point numbers, which by
57 | definition will not be able to perfectly represent all numbers
58 | within their ranges.
59 |
60 | This is an issue when performing aggregates over large numbers of
61 | rows, as errors can accumulate.
62 |
63 | Instead, using the fixed-precision numeric data types (``NUMERIC``,
64 | ``DECIMAL``) are likely the right choice for most cases.
65 | """
66 | TEMPLATE = 'tried to use a lossy float type instead of fixed precision'
67 | CODE = 1007
68 |
69 | def enable(self, root_ctx, _config):
70 | root_ctx.register('ColumnDef', self._check_column_def())
71 |
72 | @squabble.rule.node_visitor
73 | def _check_column_def(self, ctx, node):
74 | col_type = format_type_name(node.typeName)
75 |
76 | if col_type in self._INEXACT_TYPES:
77 | ctx.report(self.LossyFloatType(), node=node, severity=Severity.LOW)
78 |
--------------------------------------------------------------------------------
/squabble/rules/disallow_foreign_key.py:
--------------------------------------------------------------------------------
1 | from pglast.enums import ConstrType
2 |
3 | import squabble
4 | from squabble.message import Message
5 | from squabble.rules import BaseRule
6 |
7 |
8 | class DisallowForeignKey(BaseRule):
9 | """
10 | Prevent creation of new ``FOREIGN KEY`` constraints.
11 |
12 | Optionally, can be configured with a list of table names that ARE
13 | allowed to create foreign key references.
14 |
15 | This rule will check ``CREATE TABLE`` and ``ALTER TABLE``
16 | statements for foreign keys.
17 |
18 | Configuration ::
19 |
20 | {
21 | "DisallowForeignKey": {
22 | "excluded": ["table1", "table2"]
23 | }
24 | }
25 | """
26 |
27 | class DisallowedForeignKeyConstraint(Message):
28 | """
29 | Sometimes, foreign keys are not possible, or may cause more
30 | overhead than acceptable.
31 |
32 | If you're working with multiple services, each of which with
33 | their own database, it's not possible to create a foreign key
34 | reference to a table that exists on another database. In this
35 | case, you'll likely need to rely on your business logic being
36 | correct to guarantee referential integrity.
37 |
38 | A foreign key constraint requires the database to query the
39 | referenced table to ensure that the value exists. On
40 | high-traffic, write heavy production instances, this may cause
41 | unacceptable overhead on writes.
42 | """
43 | TEMPLATE = '"{table}" has disallowed foreign key constraint'
44 | CODE = 1009
45 |
46 | def enable(self, root_ctx, config):
47 | allowed_tables = set(
48 | name.lower()
49 | for name in config.get('excluded', [])
50 | )
51 |
52 | # We want to check both columns that are part of CREATE TABLE
53 | # as well as ALTER TABLE ... ADD COLUMN
54 | root_ctx.register(
55 | 'CreateStmt', self._check_for_foreign_key(allowed_tables))
56 |
57 | root_ctx.register(
58 | 'AlterTableStmt', self._check_for_foreign_key(allowed_tables))
59 |
60 | @squabble.rule.node_visitor
61 | def _check_for_foreign_key(self, ctx, node, allowed_tables):
62 | """
63 | Make sure ``node`` doesn't have a FOREIGN KEY reference.
64 |
65 | Coincidentally, both ``AlterTableStmt`` and ``CreateStmt``
66 | have a similar enough structure that we can use the same
67 | function for both.
68 | """
69 | table_name = node.relation.relname.value.lower()
70 |
71 | # No need to check further after this
72 | if table_name in allowed_tables:
73 | return
74 |
75 | def _check_constraint(child_ctx, constraint):
76 | if constraint.contype == ConstrType.CONSTR_FOREIGN:
77 | child_ctx.report(
78 | self.DisallowedForeignKeyConstraint(table=table_name),
79 | node=constraint
80 | )
81 |
82 | ctx.register('Constraint', _check_constraint)
83 |
--------------------------------------------------------------------------------
/squabble/rules/disallow_not_in.py:
--------------------------------------------------------------------------------
1 | from pglast.enums import A_Expr_Kind, BoolExprType, SubLinkType
2 |
3 | import squabble.rule
4 | from squabble.lint import Severity
5 | from squabble.message import Message
6 | from squabble.rules import BaseRule
7 |
8 |
9 | class DisallowNotIn(BaseRule):
10 | """
11 | Prevent ``NOT IN`` as part of queries, due to the unexpected behavior
12 | around ``NULL`` values.
13 |
14 | Configuration: ::
15 |
16 | { "DisallowNotIn": {} }
17 | """
18 |
19 | class NotInNotAllowed(Message):
20 | """
21 | ``NOT IN`` (along with any expression containing ``NOT ... IN``) should
22 | generally not be used as it behaves in unexpected ways if there is a
23 | null present.
24 |
25 | .. code-block:: sql
26 |
27 | -- Always returns 0 rows
28 | SELECT * FROM foo WHERE col NOT IN (1, null);
29 |
30 | -- Returns 0 rows if any value of bar.x is null
31 | SELECT * FROM foo WHERE col NOT IN (SELECT x FROM bar);
32 |
33 | ``col IN (1, null)`` returns TRUE if ``col=1``, and NULL
34 | otherwise (i.e. it can never return FALSE).
35 |
36 | Since ``NOT (TRUE) = FALSE``, but ``NOT (NULL) = NULL``, it is not
37 | possible for this expression to return ``TRUE``.
38 |
39 | If you can guarantee that there will never be a null in the list of
40 | values, ``NOT IN`` is safe to use, but will not be optimized nicely.
41 | """
42 | CODE = 1010
43 | TEMPLATE = 'using `NOT IN` has nonintuitive behavior with null values'
44 |
45 | def enable(self, ctx, _config):
46 | ctx.register('A_Expr', self._check_not_in_list())
47 |
48 | def check_bool_expr(child_ctx, child_node):
49 | if child_node.boolop == BoolExprType.NOT_EXPR:
50 | child_ctx.register('SubLink', self._check_not_in_subquery())
51 |
52 | ctx.register('BoolExpr', check_bool_expr)
53 |
54 | @squabble.rule.node_visitor
55 | def _check_not_in_list(self, ctx, node):
56 | """Handles cases like ``WHERE NOT IN (1, 2, 3)``."""
57 | # We're only interested in `IN` expressions
58 | if node.kind != A_Expr_Kind.AEXPR_IN:
59 | return
60 |
61 | # Specifically only ``NOT IN``
62 | if node.name.string_value != "<>":
63 | return
64 |
65 | ctx.report(
66 | self.NotInNotAllowed(),
67 | node=node.rexpr[0],
68 | severity=Severity.LOW)
69 |
70 | @squabble.rule.node_visitor
71 | def _check_not_in_subquery(self, ctx, node):
72 | """Handles cases like ``WHERE NOT IN (SELECT * FROM foo)``."""
73 |
74 | if node.subLinkType != SubLinkType.ANY_SUBLINK:
75 | return
76 |
77 | ctx.report(
78 | self.NotInNotAllowed(),
79 | node=node,
80 | severity=Severity.LOW)
81 |
--------------------------------------------------------------------------------
/squabble/rules/disallow_padded_char_type.py:
--------------------------------------------------------------------------------
1 | import squabble.rule
2 | from squabble.lint import Severity
3 | from squabble.message import Message
4 | from squabble.rules import BaseRule
5 | from squabble.util import format_type_name
6 |
7 |
8 | class DisallowPaddedCharType(BaseRule):
9 | """
10 | Prevent using ``CHAR(n)`` data type.
11 |
12 | Postgres recommends never using ``CHAR(n)``, as any value stored in this
13 | type will be padded with spaces to the declared width. This padding wastes
14 | space, but doesn't make operations on any faster; in fact the reverse,
15 | thanks to the need to strip spaces in many contexts.
16 |
17 | In most cases, the variable length types ``TEXT`` or ``VARCHAR`` will be
18 | more appropriate.
19 |
20 | Configuration ::
21 |
22 | { "DisallowPaddedCharType": {} }
23 | """
24 |
25 | _DISALLOWED_TYPES = {
26 | # note: ``bpchar`` for "bounded, padded char"
27 | 'pg_catalog.bpchar'
28 | }
29 |
30 | class WastefulCharType(Message):
31 | """
32 | Any value stored in this type will be padded with spaces to the
33 | declared width. This padding wastes space, but doesn't make operations
34 | on any faster; in fact the reverse, thanks to the need to strip spaces
35 | in many contexts.
36 |
37 | From a storage point of view, ``CHAR(n)`` is not a fixed-width type.
38 | The actual number of bytes varies since characters (e.g. unicode) may
39 | take more than one byte, and the stored values are therefore treated as
40 | variable-length anyway (even though the space padding is included in
41 | the storage).
42 |
43 | If a maximum length must be enforced in the database, use
44 | ``VARCHAR(n)``, otherwise, consider using ``TEXT`` as a replacement.
45 | """
46 | TEMPLATE = '`CHAR(n)` has unnecessary space and time overhead,' + \
47 | ' consider using `TEXT` or `VARCHAR`'
48 | CODE = 1013
49 |
50 | def enable(self, root_ctx, _config):
51 | root_ctx.register('ColumnDef', self._check_column_def())
52 |
53 | @squabble.rule.node_visitor
54 | def _check_column_def(self, ctx, node):
55 | col_type = format_type_name(node.typeName)
56 |
57 | if col_type in self._DISALLOWED_TYPES:
58 | ctx.report(
59 | self.WastefulCharType(),
60 | node=node,
61 | severity=Severity.LOW)
62 |
--------------------------------------------------------------------------------
/squabble/rules/disallow_rename_enum_value.py:
--------------------------------------------------------------------------------
1 | import pglast
2 |
3 | import squabble.rule
4 | from squabble.message import Message
5 | from squabble.rules import BaseRule
6 |
7 |
8 | class DisallowRenameEnumValue(BaseRule):
9 | """
10 | Prevent renaming existing enum value.
11 |
12 | Configuration:
13 |
14 | ::
15 |
16 | { "DisallowChangeEnumValue": {} }
17 | """
18 |
19 | class RenameNotAllowed(Message):
20 | """
21 | Renaming an existing enum value may not be backwards compatible
22 | with code that is live in production, and may cause errors
23 | (either from the database or application) if the old enum
24 | value is read or written.
25 | """
26 | CODE = 1000
27 | TEMPLATE = 'cannot rename existing enum value "{value}"'
28 |
29 | def enable(self, ctx, _config):
30 | ctx.register('AlterEnumStmt', self._check_enum())
31 |
32 | @squabble.rule.node_visitor
33 | def _check_enum(self, ctx, node):
34 | """
35 | Node is an 'AlterEnumStmt' value
36 |
37 | {
38 | 'AlterEnumStmt': {
39 | 'newVal': 'bar',
40 | 'oldVal': 'foo', # present if we're renaming
41 | }
42 | }
43 | """
44 |
45 | # Nothing to do if this isn't a rename
46 | if node.oldVal == pglast.Missing:
47 | return
48 |
49 | renamed = node.oldVal.value
50 |
51 | ctx.report(self.RenameNotAllowed(value=renamed))
52 |
--------------------------------------------------------------------------------
/squabble/rules/disallow_timestamp_precision.py:
--------------------------------------------------------------------------------
1 | import pglast
2 |
3 | import squabble.rule
4 | from squabble.lint import Severity
5 | from squabble.message import Message
6 | from squabble.rules import BaseRule
7 | from squabble.util import format_type_name
8 |
9 |
10 | class DisallowTimestampPrecision(BaseRule):
11 | """
12 | Prevent using ``TIMESTAMP(p)`` due to rounding behavior.
13 |
14 | For both ``TIMESTAMP(p)`` and ``TIMESTAMP WITH TIME ZONE(p)``, (as well as
15 | the corresponding ``TIME`` types) the optional precision parameter ``p``
16 | rounds the value instead of truncating.
17 |
18 | This means that it is possible to store values that are half a second in
19 | the future for ``p == 0``.
20 |
21 | To only enforce this rule for certain values of ``p``, set the
22 | configuration option ``allow_precision_greater_than``.
23 |
24 | Configuration ::
25 |
26 | { "DisallowTimestampPrecision": {
27 | "allow_precision_greater_than": 0
28 | }
29 | }
30 | """
31 |
32 | _CHECKED_TYPES = {
33 | 'pg_catalog.time',
34 | 'pg_catalog.timetz',
35 | 'pg_catalog.timestamp',
36 | 'pg_catalog.timestamptz',
37 | }
38 |
39 | _DEFAULT_MIN_PRECISION = 9999
40 |
41 | class NoTimestampPrecision(Message):
42 | """
43 | Specifying a fixed precision for ``TIMESTAMP`` and ``TIME`` types will
44 | cause the database to round inserted values (instead of truncating, as
45 | one would expect).
46 |
47 | This rounding behavior means that some values that get inserted may be
48 | in the future, up to half a second with a precision of ``0``.
49 |
50 | Instead, explicitly using ``date_trunc('granularity', time)`` may be a
51 | better option.
52 | """
53 | TEMPLATE = "use `date_trunc` instead of fixed precision timestamps"
54 | CODE = 1014
55 |
56 | def enable(self, root_ctx, config):
57 | min_precision = int(
58 | config.get(
59 | 'allow_precision_greater_than',
60 | self._DEFAULT_MIN_PRECISION))
61 |
62 | root_ctx.register('ColumnDef', self._check_column_def(min_precision))
63 |
64 | @squabble.rule.node_visitor
65 | def _check_column_def(self, ctx, node, min_precision):
66 | col_type = format_type_name(node.typeName)
67 |
68 | if col_type not in self._CHECKED_TYPES:
69 | return
70 |
71 | modifiers = node.typeName.typmods
72 | if modifiers == pglast.Missing or \
73 | len(modifiers) != 1 or \
74 | modifiers[0].val.node_tag != 'Integer':
75 | return
76 |
77 | if modifiers[0].val.ival.value <= min_precision:
78 | ctx.report(
79 | self.NoTimestampPrecision(),
80 | node=node.typeName,
81 | severity=Severity.LOW)
82 |
--------------------------------------------------------------------------------
/squabble/rules/disallow_timetz_type.py:
--------------------------------------------------------------------------------
1 | from pglast.enums import SQLValueFunctionOp
2 |
3 | import squabble.rule
4 | from squabble.lint import Severity
5 | from squabble.message import Message
6 | from squabble.rules import BaseRule
7 | from squabble.util import format_type_name
8 |
9 |
10 | class DisallowTimetzType(BaseRule):
11 | """
12 | Prevent using ``time with time zone``, along with ``CURRENT_TIME``.
13 |
14 | Postgres recommends never using this type, citing that it's only
15 | implemented for ANSI SQL compliance, and that ``timestamptz`` /
16 | ``timestamp with time zone`` is almost always a better solution.
17 |
18 | Configuration ::
19 |
20 | { "DisallowTimetzType": {} }
21 | """
22 |
23 | _DISALLOWED_TYPES = {
24 | 'pg_catalog.timetz',
25 | 'timetz',
26 | }
27 |
28 | class NoTimetzType(Message):
29 | """
30 | The type ``time with time zone`` is defined by the SQL standard, but
31 | the definition exhibits properties which lead to questionable
32 | usefulness.
33 |
34 | In most cases, a combination of ``date``, ``time``,
35 | ``timestamp without time zone``, and ``timestamp with time zone``
36 | should provide a complete range of date/time functionality required by
37 | any application.
38 | """
39 |
40 | TEMPLATE = 'use `timestamptz` instead of `timetz` in most cases'
41 | CODE = 1011
42 |
43 | class NoCurrentTime(Message):
44 | """
45 | ``CURRENT_TIME`` returns a ``time with time zone`` type, which is
46 | likely not what you want.
47 |
48 | In most cases, ``CURRENT_TIMESTAMP`` is the correct replacement.
49 |
50 | Some other options:
51 |
52 | - ``CURRENT_TIMESTAMP, now()`` - timestamp with time zone
53 | - ``LOCALTIMESTAMP`` - timestamp without time zone
54 | - ``CURRENT_DATE`` - date
55 | - ``LOCALTIME`` - time
56 | """
57 |
58 | TEMPLATE = 'use `CURRENT_TIMESTAMP` instead of `CURRENT_TIME`'
59 | CODE = 1012
60 |
61 | def enable(self, root_ctx, _config):
62 | root_ctx.register('ColumnDef', self._check_column_def())
63 | root_ctx.register('SQLValueFunction', self._check_function_call())
64 |
65 | @squabble.rule.node_visitor
66 | def _check_column_def(self, ctx, node):
67 | col_type = format_type_name(node.typeName)
68 |
69 | if col_type in self._DISALLOWED_TYPES:
70 | ctx.report(
71 | self.NoTimetzType(),
72 | node=node.typeName,
73 | severity=Severity.LOW)
74 |
75 | @squabble.rule.node_visitor
76 | def _check_function_call(self, ctx, node):
77 | if node.op == SQLValueFunctionOp.SVFOP_CURRENT_TIME:
78 | ctx.report(self.NoCurrentTime(), node=node, severity=Severity.LOW)
79 |
--------------------------------------------------------------------------------
/squabble/rules/require_columns.py:
--------------------------------------------------------------------------------
1 | import pglast
2 | import pglast.printers
3 |
4 | import squabble.rule
5 | from squabble import RuleConfigurationException
6 | from squabble.message import Message
7 | from squabble.rules import BaseRule
8 |
9 |
10 | def split_required_col(req):
11 | """
12 | Split columns as given as strings in the configuration.
13 |
14 | >>> split_required_col('col_a')
15 | ('col_a', None)
16 | >>> split_required_col('col_B,integer')
17 | ('col_b', 'integer')
18 | >>> split_required_col('col_c,some(parametric,type)')
19 | ('col_c', 'some(parametric,type)')
20 | """
21 | split = req.lower().split(',', 1)
22 |
23 | # no type, just a value
24 | if len(split) == 1:
25 | return (split[0], None)
26 |
27 | return (split[0], split[1])
28 |
29 |
30 | def _normalize_columns(table_elts):
31 | """
32 | Return a list of (column name, column type) after pretty printing
33 | them using pglast.
34 |
35 | >>> import pglast.printer
36 | >>> create_table = pglast.parse_sql(
37 | ... 'CREATE TABLE _(COL1 foo.bar(baz,123), Col2 integer);')
38 | >>> table_elts = pglast.Node(create_table)[0].stmt.tableElts
39 | >>> _normalize_columns(table_elts)
40 | [('col1', 'foo.bar(baz, 123)'), ('col2', 'integer')]
41 | """
42 | # This is a pretty hacky implementation
43 | printer = pglast.printer.RawStream()
44 | columns = printer(table_elts).split(';')
45 |
46 | res = []
47 |
48 | for col in columns:
49 | name, typ = col.strip().split(' ', 1)
50 | res.append((name, typ))
51 |
52 | return res
53 |
54 |
55 | def parse_column_type(typ):
56 | """
57 | Feed the column type through pglast to normalize naming
58 |
59 | e.g. `timestamp with time zone => timestamptz`.
60 |
61 | >>> parse_column_type('integer')
62 | 'integer'
63 | >>> parse_column_type('custom(type)')
64 | 'custom(type)'
65 | """
66 | sql = 'CREATE TABLE _(_ {0});'.format(typ)
67 |
68 | try:
69 | create_table = pglast.Node(pglast.parse_sql(sql))[0].stmt
70 | _, typ = _normalize_columns(create_table.tableElts)[0]
71 | return typ
72 | except pglast.parser.ParseError:
73 | raise RuleConfigurationException(
74 | RequireColumns, 'unable to parse column type "%s' % typ)
75 |
76 |
77 | def get_required_columns(config):
78 | """
79 | Extracts the column name and normalizes types out of the config
80 | value for `RequireColumns`.
81 |
82 | >>> get_required_columns(['foo,int', 'Bar'])
83 | {'foo': 'integer', 'bar': None}
84 | """
85 | if not config:
86 | raise RuleConfigurationException(
87 | RequireColumns, 'must provide `required` columns')
88 |
89 | required = {}
90 | for req in config:
91 | column, type_str = split_required_col(req)
92 | typ = parse_column_type(type_str) if type_str else None
93 |
94 | required[column] = typ
95 |
96 | return required
97 |
98 |
99 | class RequireColumns(BaseRule):
100 | """
101 | Require that newly created tables have specified columns.
102 |
103 | Configuration ::
104 |
105 | {
106 | "RequireColumns": {
107 | "required": ["column_foo,column_type", "column_bar"]
108 | }
109 | }
110 |
111 | If a column type is specified (like ``column_foo`` in the example
112 | configuration), the linter will make sure that the types match.
113 |
114 | Otherwise, only the presence of the column will be checked.
115 | """
116 |
117 | class MissingRequiredColumn(Message):
118 | CODE = 1005
119 | TEMPLATE = '"{tbl}" missing required column "{col}"'
120 |
121 | class ColumnWrongType(Message):
122 | CODE = 1006
123 | TEMPLATE = '"{tbl}.{col}" has type "{actual}" expected "{required}"'
124 |
125 | def enable(self, ctx, config):
126 | config = config.get('required', [])
127 | cols = get_required_columns(config)
128 |
129 | ctx.register('CreateStmt', self._check_create_table(cols))
130 |
131 | @squabble.rule.node_visitor
132 | def _check_create_table(self, ctx, node, required):
133 | table = node.relation
134 | columns = {}
135 |
136 | for col, typ in _normalize_columns(node.tableElts):
137 | columns[col] = {'type': typ, 'node': None}
138 |
139 | def _attach_column_node(_ctx, col):
140 | name = col.colname.value.lower()
141 | columns[name]['node'] = col
142 |
143 | ctx.register('ColumnDef', _attach_column_node)
144 | ctx.register_exit(
145 | lambda _ctx: self._check_required(_ctx, table, columns, required))
146 |
147 | def _check_required(self, ctx, table, columns, required):
148 | table_name = table.relname.value
149 |
150 | for col, typ in required.items():
151 | if col not in columns:
152 | ctx.report(
153 | self.MissingRequiredColumn(tbl=table_name, col=col),
154 | node=table)
155 |
156 | continue
157 |
158 | actual = columns[col]['type']
159 | if typ is not None and actual != typ:
160 | node = columns[col]['node']
161 | ctx.report(
162 | self.ColumnWrongType(
163 | tbl=table_name, col=col, required=typ, actual=actual),
164 | node=node)
165 |
--------------------------------------------------------------------------------
/squabble/rules/require_concurrent_index.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | import pglast
4 |
5 | import squabble.rule
6 | from squabble.message import Message
7 | from squabble.rules import BaseRule
8 |
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 |
13 | class RequireConcurrentIndex(BaseRule):
14 | """
15 | Require all new indexes to be created with ``CONCURRENTLY`` so they won't
16 | block.
17 |
18 | By default, tables created in the same file as the index are exempted,
19 | since they are known to be empty. This can be changed with the option
20 | ``"include_new_tables": true``.
21 |
22 | Configuration: ::
23 |
24 | {
25 | "RequireConcurrentIndex": {
26 | "include_new_tables": false
27 | }
28 | }
29 | """
30 |
31 | class IndexNotConcurrent(Message):
32 | """
33 | Adding a new index to an existing table may hold a full table lock
34 | while the index is being built. On large tables, this may take a long
35 | time, so the preferred approach is to create the index concurrently
36 | instead.
37 |
38 | .. code-block:: sql
39 |
40 | -- Don't do this
41 | CREATE INDEX users_by_name ON users(name);
42 |
43 | -- Try this instead
44 | CREATE INDEX CONCURRENTLY users_by_name ON users(name);
45 | """
46 | CODE = 1001
47 | TEMPLATE = 'index "{name}" not created `CONCURRENTLY`'
48 |
49 | def enable(self, ctx, config):
50 | include_new = config.get('include_new_tables', False)
51 | tables = set()
52 |
53 | # Keep track of CREATE TABLE statements if we're not including
54 | # them in our check.
55 | if not include_new:
56 | ctx.register('CreateStmt', self._create_table(tables))
57 |
58 | ctx.register('IndexStmt', self._create_index(tables))
59 |
60 | @squabble.rule.node_visitor
61 | def _create_table(self, ctx, node, tables):
62 | table = node.relation.relname.value.lower()
63 | logger.debug('found a new table: %s', table)
64 |
65 | tables.add(table)
66 |
67 | @squabble.rule.node_visitor
68 | def _create_index(self, ctx, node, tables):
69 | index_name = 'unnamed'
70 | if node.idxname != pglast.Missing:
71 | index_name = node.idxname.value
72 |
73 | concurrent = node.concurrent
74 |
75 | # Index was created concurrently, nothing to do here
76 | if concurrent != pglast.Missing and concurrent.value is True:
77 | return
78 |
79 | table = node.relation.relname.value.lower()
80 |
81 | # This is a new table, don't alert on it
82 | if table in tables:
83 | return
84 |
85 | ctx.report(
86 | self.IndexNotConcurrent(name=index_name),
87 | node=node.relation)
88 |
--------------------------------------------------------------------------------
/squabble/rules/require_foreign_key.py:
--------------------------------------------------------------------------------
1 | import re
2 |
3 | import pglast
4 | from pglast.enums import AlterTableType, ConstrType
5 |
6 | from squabble.message import Message
7 | from squabble.rules import BaseRule
8 |
9 |
10 | class RequireForeignKey(BaseRule):
11 | """
12 | New columns that look like references must have a foreign key constraint.
13 |
14 | By default, "looks like" means that the name of the column matches
15 | the regex ``.*_id$``, but this is configurable.
16 |
17 | .. code-block:: sql
18 |
19 | CREATE TABLE comments (
20 | post_id INT, -- warning here, this looks like a foreign key,
21 | -- but no constraint was given
22 |
23 | -- No warning here
24 | user_id INT REFERENCES users(id)
25 | )
26 |
27 | ALTER TABLE books
28 | ADD COLUMN author_id INT; -- warning here
29 |
30 | Configuration ::
31 |
32 | {
33 | "RequireForeignKey": {
34 | "column_regex": ".*_id$"
35 | }
36 | }
37 | """
38 |
39 | class MissingForeignKeyConstraint(Message):
40 | """
41 | Foreign keys are a good way to guarantee that your database
42 | retains referential integrity.
43 |
44 | When adding a new column that points to another table, make sure to add
45 | a constraint so that the database can check that it points to a valid
46 | record.
47 |
48 | Foreign keys can either be added when creating a table, or
49 | after the fact in the case of adding a new column.
50 |
51 | .. code-block:: sql
52 |
53 | CREATE TABLE admins (user_id INTEGER REFERENCES users(id));
54 |
55 | CREATE TABLE admins (
56 | user_id INTEGER,
57 | FOREIGN KEY (user_id) REFERENCES users(id)
58 | );
59 |
60 | ALTER TABLE admins ADD COLUMN user_id INTEGER REFERENCES users(id);
61 |
62 | ALTER TABLE admins ADD FOREIGN KEY user_id REFERENCES users(id);
63 | """
64 | TEMPLATE = '"{col}" may need a foreign key constraint'
65 | CODE = 1008
66 |
67 | _DEFAULT_REGEX = '.*_id$'
68 |
69 | def enable(self, root_ctx, config):
70 | fk_regex = re.compile(config.get('column_regex', self._DEFAULT_REGEX))
71 |
72 | # Keep track of column_name -> column_def node so we can
73 | # report a sane location for the warning when a new column
74 | # doesn't have a foreign key.
75 | missing_fk = {}
76 |
77 | # We want to check both columns that are part of CREATE TABLE
78 | # as well as ALTER TABLE ... ADD COLUMN
79 | root_ctx.register(
80 | 'CreateStmt',
81 | lambda _, node: _create_table_stmt(node, fk_regex, missing_fk))
82 |
83 | root_ctx.register(
84 | 'AlterTableStmt',
85 | lambda _, node: _alter_table_stmt(node, fk_regex, missing_fk))
86 |
87 | def _report_missing(ctx):
88 | """
89 | When we exit the root context, any elements remaining in
90 | ``missing_fk`` are known not to have a FOREIGN KEY
91 | constraint, so report them as errors.
92 | """
93 | for column, node in missing_fk.items():
94 | ctx.report(
95 | self.MissingForeignKeyConstraint(col=column),
96 | node=node)
97 |
98 | root_ctx.register_exit(_report_missing)
99 |
100 |
101 | def _create_table_stmt(table_node, fk_regex, missing_fk):
102 | table_name = table_node.relation.relname.value
103 | if table_node.tableElts == pglast.Missing:
104 | return
105 |
106 | for e in table_node.tableElts:
107 | # Defining a column, may include an inline constraint.
108 | if e.node_tag == 'ColumnDef':
109 | if _column_needs_foreign_key(fk_regex, e):
110 | key = '{}.{}'.format(table_name, e.colname.value)
111 | missing_fk[key] = e
112 |
113 | # FOREIGN KEY (...) REFERENCES ...
114 | elif e.node_tag == 'Constraint':
115 | _remove_satisfied_foreign_keys(e, table_name, missing_fk)
116 |
117 |
118 | def _alter_table_stmt(node, fk_regex, missing_fk):
119 | table_name = node.relation.relname.value
120 |
121 | for cmd in node.cmds:
122 | if cmd.subtype == AlterTableType.AT_AddColumn:
123 | if _column_needs_foreign_key(fk_regex, cmd['def']):
124 | key = '{}.{}'.format(table_name, cmd['def'].colname.value)
125 | missing_fk[key] = cmd['def']
126 |
127 | elif cmd.subtype in (AlterTableType.AT_AddConstraint,
128 | AlterTableType.AT_AddConstraintRecurse):
129 | constraint = cmd['def']
130 | _remove_satisfied_foreign_keys(constraint, table_name, missing_fk)
131 |
132 |
133 | def _remove_satisfied_foreign_keys(constraint, table_name, missing_fk):
134 | # Nothing to do if this isn't a foreign key constraint
135 | if constraint.contype != ConstrType.CONSTR_FOREIGN:
136 | return
137 |
138 | # Clear out any columns that we earlier identified as
139 | # needing a foreign key.
140 | for col_name in constraint.fk_attrs:
141 | key = '{}.{}'.format(table_name, col_name.string_value)
142 | missing_fk.pop(key, '')
143 |
144 |
145 | def _column_needs_foreign_key(fk_regex, column_def):
146 | """
147 | Return True if the ``ColumnDef`` defines a column with a name that
148 | matches the foreign key regex but does not specify an inline
149 | constraint.
150 |
151 | >>> import re
152 | >>> import pglast
153 |
154 | >>> fk_regex = re.compile('.*_id$')
155 | >>> cols = {
156 | ... # name doesn't match regex
157 | ... 'email': {'ColumnDef': {'colname': 'email'}},
158 | ...
159 | ... # name matches regex, but no foreign key
160 | ... 'users_id': {'ColumnDef': {'colname': 'users_id'}},
161 | ...
162 | ... # name matches regex, but has foreign key (contype == 8)
163 | ... 'post_id': {'ColumnDef': {
164 | ... 'colname': 'post_id',
165 | ... 'constraints': [{'Constraint': {'contype': 8}}]
166 | ... }}
167 | ... }
168 | >>> _column_needs_foreign_key(fk_regex, pglast.Node(cols['email']))
169 | False
170 | >>> _column_needs_foreign_key(fk_regex, pglast.Node(cols['users_id']))
171 | True
172 | >>> _column_needs_foreign_key(fk_regex, pglast.Node(cols['post_id']))
173 | False
174 | """
175 | name = column_def.colname.value
176 | if not fk_regex.match(name):
177 | return False
178 |
179 | if column_def.constraints == pglast.Missing:
180 | return True
181 |
182 | return not any(
183 | e.contype == ConstrType.CONSTR_FOREIGN
184 | for e in column_def.constraints
185 | )
186 |
--------------------------------------------------------------------------------
/squabble/rules/require_primary_key.py:
--------------------------------------------------------------------------------
1 | import pglast
2 | from pglast.enums import ConstrType
3 |
4 | import squabble.rule
5 | from squabble.message import Message
6 | from squabble.rules import BaseRule
7 |
8 |
9 | class RequirePrimaryKey(BaseRule):
10 | """
11 | Require that all new tables specify a ``PRIMARY KEY`` constraint.
12 |
13 | Configuration: ::
14 |
15 | { "RequirePrimaryKey": {} }
16 | """
17 |
18 | class MissingPrimaryKey(Message):
19 | """
20 | When creating a new table, it's usually a good idea to define a primary
21 | key, as it can guarantee a unique, fast lookup into the table.
22 |
23 | If no single column will uniquely identify a row, creating a composite
24 | primary key is also possible.
25 |
26 | .. code-block:: sql
27 |
28 | CREATE TABLE users (email VARCHAR(128) PRIMARY KEY);
29 |
30 | -- Also valid
31 | CREATE TABLE users (
32 | email VARCHAR(128),
33 | -- ...
34 | PRIMARY KEY(email)
35 | );
36 | """
37 | CODE = 1002
38 | TEMPLATE = 'table "{tbl}" does not name a primary key'
39 |
40 | def enable(self, ctx, _config):
41 | ctx.register('CreateStmt', self._create_table())
42 |
43 | @squabble.rule.node_visitor
44 | def _create_table(self, ctx, node):
45 | seen_pk = False
46 |
47 | def _check_constraint(_ctx, constraint):
48 | nonlocal seen_pk
49 |
50 | if constraint.contype == ConstrType.CONSTR_PRIMARY:
51 | seen_pk = True
52 |
53 | def _check_column(_ctx, col):
54 | if col.constraints == pglast.Missing:
55 | return
56 |
57 | for c in col.constraints:
58 | _check_constraint(_ctx, c)
59 |
60 | ctx.register('ColumnDef', _check_column)
61 | ctx.register('Constraint', _check_constraint)
62 |
63 | ctx.register_exit(lambda c: self._check_pk(c, seen_pk, node))
64 |
65 | def _check_pk(self, ctx, seen_pk, table_node):
66 | """
67 | Make sure we've seen a primary key constraint by the time the
68 | `CREATE TABLE` statement is finished.
69 | """
70 | if seen_pk:
71 | return
72 |
73 | # Use the table's name as the reported error's location
74 | node = table_node.relation
75 |
76 | ctx.report(self.MissingPrimaryKey(tbl=node.relname.value),
77 | node=node)
78 |
--------------------------------------------------------------------------------
/squabble/util.py:
--------------------------------------------------------------------------------
1 | """
2 | Odds and ends pieces that don't fit elsewhere, but aren't important
3 | enough to have their own modules.
4 | """
5 |
6 | import re
7 |
8 |
9 | _RST_DIRECTIVE = re.compile(r'^\.\. [\w-]+:: \w+$\n', flags=re.MULTILINE)
10 |
11 |
12 | def strip_rst_directives(string):
13 | """
14 | Strip reStructuredText directives out of a block of text.
15 |
16 | Lines containing a directive will be stripped out entirely
17 |
18 | >>> strip_rst_directives('hello\\n.. code-block:: foo\\nworld')
19 | 'hello\\nworld'
20 | """
21 |
22 | return re.sub(_RST_DIRECTIVE, '', string)
23 |
24 |
25 | def format_type_name(type_name):
26 | """
27 | Return a simple stringified version of a ``pglast.node.TypeName`` node.
28 |
29 | Note that this won't be suitable for printing, and ignores type
30 | modifiers (e.g. ``NUMERIC(3,4)`` => ``NUMERIC``).
31 |
32 | >>> import pglast
33 | >>> sql = 'CREATE TABLE _ (y time with time zone);'
34 | >>> node = pglast.Node(pglast.parse_sql(sql))
35 | >>> col_def = node[0]['stmt']['tableElts'][0]
36 | >>> format_type_name(col_def.typeName)
37 | 'pg_catalog.timetz'
38 | """
39 | return '.'.join([p.string_value for p in type_name.names])
40 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/erik/squabble/0f5b2dbb2088389a6b2d4d68f55e4e55f4da5e28/tests/__init__.py
--------------------------------------------------------------------------------
/tests/sql/add_column_disallow_constraints.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:AddColumnDisallowConstraints disallowed=DEFAULT,FOREIGN
2 | -- >>> {"line": 4, "column": 46, "message_id": "ConstraintNotAllowed"}
3 |
4 | ALTER TABLE foobar ADD COLUMN colname coltype DEFAULT baz;
5 |
6 | ALTER TABLE foobar ADD COLUMN colname coltype UNIQUE;
7 |
8 | ALTER TABLE foobar ADD COLUMN colname coltype;
9 |
10 | ALTER TABLE foobar ADD CONSTRAINT new_constraint UNIQUE (foo);
11 |
--------------------------------------------------------------------------------
/tests/sql/disallow_change_column_type.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:DisallowChangeColumnType
2 | -- >>> {"line": 4, "column": 47, "message_id": "ChangeTypeNotAllowed"}
3 |
4 | ALTER TABLE foo ALTER COLUMN bar SET DATA TYPE baz;
5 |
6 | ALTER TABLE foo ALTER COLUMN bar DROP DEFAULT;
7 |
--------------------------------------------------------------------------------
/tests/sql/disallow_float_types.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:DisallowFloatTypes
2 | -- >>> {"line": 8, "column": 2, "message_id": "LossyFloatType"}
3 | -- >>> {"line": 9, "column": 2, "message_id": "LossyFloatType"}
4 | -- >>> {"line": 15, "column": 2, "message_id": "LossyFloatType"}
5 |
6 | CREATE TABLE foo (
7 | -- should not pass
8 | bar REAL,
9 | baz DOUBLE PRECISION,
10 | -- should pass
11 | quux INT
12 | );
13 |
14 | ALTER TABLE foo ADD COLUMN
15 | bar FLOAT;
16 |
--------------------------------------------------------------------------------
/tests/sql/disallow_foreign_key.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:DisallowForeignKey excluded=fk_allowed,new_table
2 | -- >>> {"line": 7, "column": 47, "message_id": "DisallowedForeignKeyConstraint", "message_params": {"table": "foreign_key_reference"}}
3 | -- >>> {"line": 8, "column": 53, "message_id": "DisallowedForeignKeyConstraint", "message_params": {"table": "inline_foreign_key_reference"}}
4 | -- >>> {"line": 9, "column": 42, "message_id": "DisallowedForeignKeyConstraint", "message_params": {"table": "alter_table_with_fk_later"}}
5 |
6 | -- Disallowed foreign keys
7 | CREATE TABLE foreign_key_reference(foo_id int, FOREIGN KEY (foo_id) REFERENCES foo(pk));
8 | CREATE TABLE inline_foreign_key_reference(foo_id int REFERENCES foo);
9 | ALTER TABLE alter_table_with_fk_later ADD FOREIGN KEY (foo_id) REFERENCES foo(pk);
10 |
11 | -- Allowed here
12 | ALTER TABLE fk_allowed ADD COLUMN foo_id int REFERENCES foo(pk);
13 | CREATE TABLE new_table(foo_id int REFERENCES foo(id));
14 |
15 | -- No issue
16 | ALTER TABLE alter_table_without_fk ADD COLUMN foo_id int;
17 | ALTER TABLE alter_table_without_fk ADD UNIQUE (foo_id);
18 |
--------------------------------------------------------------------------------
/tests/sql/disallow_not_in.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:DisallowNotIn
2 | -- >>> {"line": 8, "column": 17, "message_id": "NotInNotAllowed"}
3 | -- >>> {"line": 12, "column": 9, "message_id": "NotInNotAllowed"}
4 |
5 | SELECT abc
6 | FROM xyz
7 | WHERE 1=1
8 | AND id NOT IN (1, null);
9 |
10 | SELECT *
11 | FROM users
12 | WHERE id NOT IN (
13 | SELECT user_id
14 | FROM posts
15 | );
16 |
--------------------------------------------------------------------------------
/tests/sql/disallow_padded_char_type.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:DisallowPaddedCharType
2 | -- >>> {"line": 13, "column": 2, "message_id": "WastefulCharType"}
3 | -- >>> {"line": 14, "column": 2, "message_id": "WastefulCharType"}
4 | -- >>> {"line": 22, "column": 13, "message_id": "WastefulCharType"}
5 | -- >>> {"line": 23, "column": 13, "message_id": "WastefulCharType"}
6 |
7 |
8 | CREATE TABLE foo (
9 | good text,
10 | good varchar,
11 | good varchar(3),
12 |
13 | bad char,
14 | bad char(3)
15 | );
16 |
17 | ALTER TABLE foo
18 | ADD COLUMN good text,
19 | ADD COLUMN good varchar,
20 | ADD COLUMN good varchar(3),
21 |
22 | ADD COLUMN bad char,
23 | ADD COLUMN bad char(3);
24 |
--------------------------------------------------------------------------------
/tests/sql/disallow_rename_enum_value.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:DisallowRenameEnumValue
2 | -- >>> {"message_id": "RenameNotAllowed"}
3 |
4 | ALTER TYPE this_is_fine ADD VALUE '!!!';
5 | ALTER TYPE this_is_not_fine RENAME VALUE '!!!' TO 'something else';
6 |
--------------------------------------------------------------------------------
/tests/sql/disallow_timestamp_precision.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:DisallowTimestampPrecision allow_precision_greater_than=5
2 | -- >>> {"line": 24, "column": 6, "message_id": "NoTimestampPrecision"}
3 | -- >>> {"line": 25, "column": 6, "message_id": "NoTimestampPrecision"}
4 | -- >>> {"line": 26, "column": 6, "message_id": "NoTimestampPrecision"}
5 | -- >>> {"line": 28, "column": 6, "message_id": "NoTimestampPrecision"}
6 | -- >>> {"line": 29, "column": 6, "message_id": "NoTimestampPrecision"}
7 | -- >>> {"line": 30, "column": 6, "message_id": "NoTimestampPrecision"}
8 |
9 | CREATE TABLE foo (
10 | good timestamp,
11 | good timestamp with time zone,
12 |
13 | good timestamp(10),
14 | good timestamp(10) with time zone,
15 | good timestamp(10) without time zone,
16 |
17 | good time,
18 | good time with time zone,
19 |
20 | good time(10),
21 | good time(10) with time zone,
22 | good time(10) without time zone,
23 |
24 | bad timestamp(0),
25 | bad timestamp(0) with time zone,
26 | bad timestamp(0) without time zone,
27 |
28 | bad time(0),
29 | bad time(0) with time zone,
30 | bad time(0) without time zone
31 | );
32 |
--------------------------------------------------------------------------------
/tests/sql/disallow_timetz_types.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:DisallowTimetzType
2 | -- >>> {"line": 15, "column": 6, "message_id": "NoTimetzType"}
3 | -- >>> {"line": 16, "column": 6, "message_id": "NoTimetzType"}
4 | -- >>> {"line": 24, "column": 2, "message_id": "NoCurrentTime"}
5 |
6 |
7 | CREATE TABLE foo (
8 | good timestamp,
9 | good timestamp without time zone,
10 | good timestamptz,
11 | good timestamp with time zone,
12 | good time,
13 | good time without time zone,
14 |
15 | bad timetz,
16 | bad time with time zone
17 | );
18 |
19 | SELECT
20 | CURRENT_TIMESTAMP as good,
21 | LOCALTIMESTAMP as good,
22 | CURRENT_DATE as good,
23 | now() as good,
24 | CURRENT_TIME as bad
25 | FROM xyz;
26 |
--------------------------------------------------------------------------------
/tests/sql/require_columns.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:RequireColumns required="created_at,timestamp with time zone","updated_at"
2 | -- >>> {"line": 10, "column": 13, "message_id": "MissingRequiredColumn", "message_params": {"tbl": "missing_columns", "col": "updated_at"}}
3 | -- >>> {"line": 15, "column": 2, "message_id": "ColumnWrongType", "message_params": {"tbl": "type_mismatch", "col": "created_at", "actual": "integer", "required": "timestamp with time zone"}}
4 |
5 | CREATE TABLE has_all_columns(
6 | created_at timestamp with time zone,
7 | updated_at timestamp with time zone
8 | );
9 |
10 | CREATE TABLE missing_columns(
11 | created_at timestamp with time zone
12 | );
13 |
14 | CREATE TABLE type_mismatch(
15 | created_at integer,
16 | updated_at timestamp with time zone
17 | );
18 |
--------------------------------------------------------------------------------
/tests/sql/require_concurrent_index.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:RequireConcurrentIndex
2 | -- >>> {"line": 10, "column": 24, "message_id": "IndexNotConcurrent"}
3 |
4 | CREATE TABLE foo(id uuid);
5 |
6 | -- Ok, this is on a new table
7 | CREATE INDEX on foo(id);
8 |
9 | -- Not okay, this is not a new table
10 | CREATE INDEX bad_idx on bar(id);
11 |
12 | -- Okay, created with CONCURRENTLY
13 | CREATE INDEX CONCURRENTLY on bar(id);
14 |
--------------------------------------------------------------------------------
/tests/sql/require_foreign_key.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:RequireForeignKey
2 | -- >>> {"line": 12, "column": 24, "message_id": "MissingForeignKeyConstraint"}
3 | -- >>> {"line": 19, "column": 46, "message_id": "MissingForeignKeyConstraint"}
4 | CREATE TABLE empty ();
5 |
6 | CREATE TABLE doesnt_match_column_regex(foo_id_not_a_references int);
7 |
8 | CREATE TABLE inline_fk(foo_id int REFERENCES foo(id));
9 |
10 | CREATE TABLE inline_fk(foo_id int, FOREIGN KEY (foo_id) REFERENCES foo(pk));
11 |
12 | CREATE TABLE missing_fk(foo_id int);
13 |
14 | ALTER TABLE alter_table_with_fk ADD COLUMN foo_id int REFERENCES foo(pk);
15 |
16 | ALTER TABLE alter_table_with_fk_later ADD COLUMN foo_id int;
17 | ALTER TABLE alter_table_with_fk_later ADD FOREIGN KEY (foo_id) REFERENCES foo(pk);
18 |
19 | ALTER TABLE alter_table_without_fk ADD COLUMN foo_id int;
20 | ALTER TABLE alter_table_without_fk ADD UNIQUE (foo_id);
21 |
--------------------------------------------------------------------------------
/tests/sql/require_primary_key.sql:
--------------------------------------------------------------------------------
1 | -- squabble-enable:RequirePrimaryKey
2 | -- >>> {"line": 14, "column": 13, "message_id": "MissingPrimaryKey"}
3 |
4 | CREATE TABLE inline_pk(
5 | pk int PRIMARY KEY
6 | );
7 |
8 | CREATE TABLE inline_pk(
9 | pk int,
10 |
11 | PRIMARY KEY(pk)
12 | );
13 |
14 | CREATE TABLE missing_pk(
15 | not_pk int
16 | );
17 |
--------------------------------------------------------------------------------
/tests/sql/syntax_error.sql:
--------------------------------------------------------------------------------
1 | -- >>> {"line": 6, "column": 17}
2 |
3 | SELECT *
4 | FROM foo
5 | JOIN bar ON (foo.a = bar.b)
6 | WHERE something idk;
7 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | import copy
2 | from unittest.mock import patch
3 |
4 | import pytest
5 |
6 | from squabble import config
7 |
8 |
9 | def test_extract_file_rules():
10 | text = '''
11 | foo
12 | -- enable:foo k1=v1 k2=v2
13 | bar
14 | -- disable:abc enable:xyz
15 | '''
16 |
17 | rules = config._extract_file_rules(text)
18 |
19 | expected = {
20 | 'enable': {'foo': {'k1': 'v1', 'k2': 'v2'}},
21 | 'disable': ['abc'],
22 | 'skip_file': False
23 | }
24 |
25 | assert expected == rules
26 |
27 |
28 | def test_extract_file_rules_with_prefix():
29 | text = '''
30 | foo
31 | -- squabble-enable:foo k1=v1 k2=v2
32 | bar
33 | -- squabble-disable:abc enable:xyz
34 | -- squabble-disable
35 | '''
36 |
37 | rules = config._extract_file_rules(text)
38 |
39 | expected = {
40 | 'enable': {'foo': {'k1': 'v1', 'k2': 'v2'}},
41 | 'disable': ['abc'],
42 | 'skip_file': True
43 | }
44 |
45 | assert expected == rules
46 |
47 |
48 | def test_get_base_config_without_preset():
49 | cfg = config.get_base_config()
50 | assert cfg == config.Config(**config.DEFAULT_CONFIG)
51 |
52 |
53 | def test_get_base_config_with_preset():
54 | cfg = config.get_base_config(['postgres'])
55 | assert cfg.rules == config.PRESETS['postgres']['config']['rules']
56 |
57 |
58 | def test_unknown_preset():
59 | with pytest.raises(config.UnknownPresetException):
60 | config.get_base_config(preset_names=['asdf'])
61 |
62 |
63 | def test_merging_presets():
64 | cfg = config.get_base_config(preset_names=['postgres', 'full'])
65 | merged = config._merge_dicts(
66 | config.PRESETS['postgres']['config']['rules'],
67 | config.PRESETS['full']['config']['rules']
68 | )
69 | assert cfg.rules == merged
70 |
71 |
72 | @patch('squabble.config._extract_file_rules')
73 | def test_apply_file_config(mock_extract):
74 | mock_extract.return_value = {
75 | 'enable': {'baz': {'a': 1}},
76 | 'disable': ['bar'],
77 | 'skip_file': False
78 | }
79 |
80 | orig = config.Config(reporter='', plugins=[], rules={'foo': {}, 'bar': {}})
81 | base = copy.deepcopy(orig)
82 |
83 | modified = config.apply_file_config(base, 'file_name')
84 |
85 | assert modified.rules == {'foo': {}, 'baz': {'a': 1}}
86 |
87 | # Make sure nothing has been mutated
88 | assert base == orig
89 |
90 |
91 | @patch('squabble.config._extract_file_rules')
92 | def test_apply_file_config_with_skip_file(mock_extract):
93 | mock_extract.return_value = {
94 | 'enable': {},
95 | 'disable': [],
96 | 'skip_file': True
97 | }
98 |
99 | orig = config.Config(reporter='', plugins=[], rules={})
100 | base = copy.deepcopy(orig)
101 |
102 | modified = config.apply_file_config(base, 'file_name')
103 |
104 | assert modified is None
105 |
106 |
107 | @patch('squabble.config._get_vcs_root')
108 | @patch('os.path.expanduser')
109 | @patch('os.path.exists')
110 | def test_discover_config_location(mock_exists, mock_expand, mock_vcs):
111 | mock_exists.return_value = False
112 | mock_expand.return_value = 'user'
113 | mock_vcs.return_value = 'gitrepo'
114 |
115 | config.discover_config_location()
116 |
117 | mock_exists.assert_any_call('./.squabblerc')
118 | mock_exists.assert_any_call('gitrepo/.squabblerc')
119 | mock_exists.assert_any_call('user/.squabblerc')
120 |
--------------------------------------------------------------------------------
/tests/test_rules.py:
--------------------------------------------------------------------------------
1 | """ Odds and ends tests to hit corner cases etc in rules logic. """
2 |
3 | import unittest
4 |
5 | import pytest
6 |
7 | import squabble.rule
8 | from squabble import RuleConfigurationException, UnknownRuleException
9 | from squabble.rules.add_column_disallow_constraints import \
10 | AddColumnDisallowConstraints
11 |
12 |
13 | class TestDisallowConstraints(unittest.TestCase):
14 |
15 | def test_missing_constraints(self):
16 | with pytest.raises(RuleConfigurationException):
17 | AddColumnDisallowConstraints().enable(ctx={}, config={})
18 |
19 | def test_unknown_constraints(self):
20 | with pytest.raises(RuleConfigurationException):
21 | AddColumnDisallowConstraints().enable(ctx={}, config={
22 | 'disallowed': ['UNIQUE', 'foo']
23 | })
24 |
25 |
26 | class TestRuleRegistry(unittest.TestCase):
27 | def test_get_meta_unknown_name(self):
28 | with pytest.raises(UnknownRuleException):
29 | squabble.rule.Registry.get_meta('asdfg')
30 |
31 | def test_get_class_unknown_name(self):
32 | with pytest.raises(UnknownRuleException):
33 | squabble.rule.Registry.get_class('asdfg')
34 |
--------------------------------------------------------------------------------
/tests/test_snapshots.py:
--------------------------------------------------------------------------------
1 | """Integration style tests against the files in `./sql`"""
2 |
3 | import glob
4 | import json
5 |
6 | import pytest
7 |
8 | import squabble.cli
9 | from squabble import config, lint, reporter, rule
10 |
11 | SQL_FILES = glob.glob('tests/sql/*.sql')
12 | OUTPUT_MARKER = '-- >>> '
13 |
14 |
15 | def setup_module(_mod):
16 | rule.load_rules(plugin_paths=[])
17 |
18 |
19 | def expected_output(file_name):
20 | with open(file_name, 'r') as fp:
21 | lines = fp.readlines()
22 |
23 | expected = []
24 | for line in lines:
25 | if not line.startswith(OUTPUT_MARKER):
26 | continue
27 |
28 | _, out = line.split(OUTPUT_MARKER, 1)
29 | expected.append(json.loads(out))
30 |
31 | return expected
32 |
33 |
34 | @pytest.mark.parametrize('file_name', SQL_FILES)
35 | def test_snapshot(file_name):
36 | with open(file_name, 'r') as fp:
37 | contents = fp.read()
38 |
39 | expected = expected_output(file_name)
40 |
41 | if not expected:
42 | pytest.skip('no output configured')
43 |
44 | base_cfg = config.get_base_config()
45 | cfg = config.apply_file_config(base_cfg, contents)
46 |
47 | issues = lint.check_file(cfg, file_name, contents)
48 |
49 | assert len(issues) == len(expected)
50 |
51 | for i, e in zip(issues, expected):
52 | info = reporter._issue_info(i, contents)
53 | actual = {
54 | k: info.get(k)
55 | for k in e.keys()
56 | }
57 |
58 | assert actual == e
59 |
60 |
61 | @pytest.mark.parametrize('reporter_name', reporter._REPORTERS.keys())
62 | def test_reporter_sanity(reporter_name):
63 | """
64 | Make sure all the reporters can at least format all of the
65 | issues generated without errors.
66 | """
67 | base_cfg = config.get_base_config()
68 |
69 | issues = []
70 | files = {}
71 |
72 | for file_name in SQL_FILES:
73 | with open(file_name, 'r') as fp:
74 | contents = fp.read()
75 | files[file_name] = contents
76 |
77 | cfg = config.apply_file_config(base_cfg, contents)
78 | issues.extend(lint.check_file(cfg, file_name, contents))
79 |
80 | reporter.report(reporter_name, issues, files)
81 |
82 |
83 | def test_cli_linter():
84 | """Dumb test to make sure things are wired correctly in CLI."""
85 | base_cfg = config.get_base_config()
86 | exit_status = squabble.cli.run_linter(base_cfg, SQL_FILES, expanded=True)
87 |
88 | # Exit status 1 means that lint issues occurred, not that the process
89 | # itself failed.
90 | assert exit_status == 1
91 |
--------------------------------------------------------------------------------