├── .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 | --------------------------------------------------------------------------------