├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── rubocop.yml ├── .gitignore ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── err_now.png ├── err_was.png ├── lib ├── generators │ ├── niceql │ │ └── install_generator.rb │ └── templates │ │ └── niceql_initializer.rb ├── niceql.rb └── niceql │ └── version.rb ├── niceql.gemspec ├── test ├── niceql │ ├── declarative.rb │ └── niceql_test.rb └── test_helper.rb └── to_niceql.png /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | ruby: ["2.5", "2.6", "2.7", "3.0", "3.1"] 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Set up Ruby 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | bundler-cache: true # 'bundle install' and cache gems 21 | ruby-version: ${{ matrix.ruby }} 22 | - name: Run tests 23 | run: bundle exec rake test 24 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Set up Ruby 12 | uses: ruby/setup-ruby@v1 13 | with: 14 | ruby-version: 2.7 15 | bundler-cache: true # 'bundle install' and cache 16 | - name: Run RuboCop 17 | run: bundle exec rubocop --parallel 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /inspectionProfiles/ 11 | .idea -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-shopify: rubocop.yml 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 0.6.1 2 | * github CI added ( PR: https://github.com/alekseyl/niceql/pull/22 many thnx to @petergoldstein ) 3 | * issue fixed https://github.com/alekseyl/niceql/issues/23 4 | * dropped support for 2.4 ( transform_keys is missing, and I'm too lazy to backward reimplement it you can do PR if needed ) 5 | 6 | # 0.6.0 7 | * Huge core-logic refactoring and simplification 8 | * dollar signed literals/strings added 9 | * strings literals issue https://github.com/alekseyl/niceql/issues/16 fixed 10 | * strings literals colorized properly 11 | * comments are bold and greyed now. 12 | * code now is rubocoped with shopify rules 13 | 14 | # 0.5.1 15 | * No features just some code refactoring 16 | 17 | # 0.5.0 18 | * BREAKING CHANGE! ActiveRecord compatibility extracted to the rails_sql_prettifier gem! 19 | If you need niceql funcitonality with rails / active_record plz include rails_sql_prettifier has a 20 | a versioning aligned to the active_record versions and has same DSL for ActiveRecord the niceql was providing prior. 21 | 22 | # 0.4.1 23 | * description update 24 | 25 | # 0.4.0 26 | * merged PR https://github.com/alekseyl/niceql/pull/19, now Arel is also extended with niceql methods!! 27 | * test and better niceql comparisons assertion 28 | * tests were trialed against rails 4.2 and some additional conditions were added for later cases 29 | 30 | # 0.3.0 31 | * ruby forced to >= 2.4 32 | * String match extension no longer needed 33 | * fixed issue with missing HINT and DETAIL string ( https://github.com/alekseyl/niceql/issues/18 ) 34 | * both new and old activerecord StatementInvalid formats supported 35 | * major prettify_pg_err refactoring ( much cleaner code now ) 36 | 37 | # 0.2.0 38 | * Fix to issue https://github.com/alekseyl/niceql/pull/17#issuecomment-924278172. ActiveRecord base config is no longer a hash, 39 | so it does not have dig method, hence it's breaking the ar_using_pg_adapter? method. 40 | * active_record added as development dependency :( for proper testing cover. 41 | 42 | # 0.1.30 43 | * ActiveRecord pg check for config now will try both connection_db_config and connection_config for adapter verification 44 | * prettify_pg_errors will not be set to true if ActiveRecord adapter is not using pg, i.e. ar_using_pg_adapter? is false. 45 | * rake dev dependency bumped according to security issues 46 | 47 | # 0.1.24/25 48 | 49 | * No features, just strict ruby dependency for >= 2.3, 50 | * travis fix for ruby 2.3 and 2.6 added 51 | 52 | # 0.1.23 53 | * +LATERAL verb 54 | * removed hidden rails dependencies PR(https://github.com/alekseyl/niceql/pull/9) 55 | 56 | # 0.1.22 57 | * multi query formatting 58 | 59 | # 0.1.21 60 | * fix bug for SQL started with comment 61 | 62 | # 0.1.20 63 | * Add respect for SQL comments single lined, multi lined, and inline 64 | 65 | # 0.1.19 66 | * add prettify_pg_errors to config - now pg errors prettified output is configurable, 67 | default is true if ActiveRecord::Base defined and db adapter is pg 68 | 69 | * tests for error prettifying 70 | 71 | # 0.1.18 72 | * add color to logger output 73 | 74 | # 0.1.17 75 | * add test 76 | * fix issue 1 for real 77 | 78 | # 0.1.16 79 | * Add prettify_active_record_log_output to rails config generator 80 | 81 | # 0.1.15 82 | * JOIN verb refactored, INNER|OUTER will be also colored properly 83 | * prettify_active_record_log_output added to config, now you can set it to true 84 | and sql will log prettified -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # Specify your gem's dependencies in niceql.gemspec 8 | gemspec 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 alekseyl 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Niceql 2 | 3 | **ATTENTION: After ver 0.5.0 the ActiveRecord integration is provided via standalone gem: [rails_sql_prettifier](https://github.com/alekseyl/rails_sql_prettifier)!** 4 | 5 | This is a small, nice, simple and zero dependency solution for SQL prettifying for Ruby. 6 | It can be used in an irb console without any dependencies ( run bin/console and look for examples ). 7 | 8 | Any reasonable suggestions are welcome. 9 | 10 | ## Before/After 11 | ### SQL prettifier: 12 | ![alt text](https://github.com/alekseyl/niceql/raw/master/to_niceql.png "To_niceql") 13 | 14 | ### PG errors prettifier 15 | 16 | before: 17 | ![alt text](https://github.com/alekseyl/niceql/raw/master/err_was.png "To_niceql") 18 | 19 | after: 20 | ![alt text](https://github.com/alekseyl/niceql/raw/master/err_now.png "To_niceql") 21 | 22 | 23 | ## Installation 24 | 25 | Add this line to your application's Gemfile: 26 | 27 | ```ruby 28 | gem 'niceql' 29 | ``` 30 | 31 | And then execute: 32 | 33 | $ bundle 34 | # if you are using rails, you may want to install niceql config: 35 | rails g niceql:install 36 | 37 | Or install it yourself as: 38 | 39 | $ gem install niceql 40 | 41 | ## Configuration 42 | 43 | ```ruby 44 | Niceql.configure do |c| 45 | # Setting pg_adapter_with_nicesql to true will force formatting SQL queries 46 | # before execution. Formatted SQL will lead to much better SQL-query debugging and much more clearer error messages 47 | # if you are using Postgresql as a data source. 48 | # 49 | # Adjusting pg_adapter in production is strongly discouraged! 50 | # 51 | # If you need to debug SQL queries in production use exec_niceql 52 | # default: false 53 | # uncomment next string to enable in development 54 | # c.pg_adapter_with_nicesql = Rails.env.development? 55 | 56 | # uncomment next string if you want to log prettified SQL inside ActiveRecord logging. 57 | # default: false 58 | # c.prettify_active_record_log_output = true 59 | 60 | # Error prettifying is also configurable 61 | # default: defined? ::ActiveRecord::Base && ActiveRecord::Base.configurations[Rails.env]['adapter'] == 'postgresql' 62 | # c.prettify_pg_errors = defined? ::ActiveRecord::Base && ActiveRecord::Base.configurations[Rails.env]['adapter'] == 'postgresql' 63 | 64 | # spaces count for one indentation 65 | c.indentation_base = 2 66 | 67 | # setting open_bracket_is_newliner to true will start opening brackets '(' with nested subqueries from new line 68 | # i.e. SELECT * FROM ( SELECT * FROM tags ) tags; will transform to: 69 | # SELECT * 70 | # FROM 71 | # ( 72 | # SELECT * FROM tags 73 | # ) tags; 74 | # when open_bracket_is_newliner is false: 75 | # SELECT * 76 | # FROM ( 77 | # SELECT * FROM tags 78 | # ) tags; 79 | # default: false 80 | c.open_bracket_is_newliner = false 81 | end 82 | ``` 83 | 84 | ## Usage 85 | 86 | ### With ActiveRecord ( you need rails_sql_prettifier for that! ) 87 | 88 | ```ruby 89 | # puts colorized and formatted corresponding SQL query 90 | Model.scope.niceql 91 | 92 | # only formatting without colorization, you can run output of to_niceql as a SQL query in connection.execute 93 | Model.scope.to_niceql 94 | 95 | # prettify PG errors if scope runs with any 96 | Model.scope_with_err.exec_niceql 97 | ``` 98 | 99 | ### Without ActiveRecord 100 | 101 | ```ruby 102 | 103 | puts Niceql::Prettifier.prettify_sql("SELECT * FROM ( VALUES(1), (2) ) AS tmp") 104 | #=> SELECT * 105 | #=> FROM ( VALUES(1), (2) ) AS tmp 106 | 107 | puts Niceql::Prettifier.prettify_multiple("SELECT * FROM ( VALUES(1), (2) ) AS tmp; SELECT * FROM table") 108 | 109 | #=> SELECT * 110 | #=> FROM ( VALUES(1), (2) ) AS tmp; 111 | #=> 112 | #=> SELECT * 113 | #=> FROM table 114 | 115 | 116 | puts Niceql::Prettifier.prettify_pg_err( pg_err_output, sql_query ) 117 | 118 | # to get real nice result you should execute prettified version (i.e. execute( prettified_sql ) !) of query on your DB! 119 | # otherwise you will not get such a nice output 120 | raw_sql = <<~SQL 121 | SELECT err 122 | FROM ( VALUES(1), (2) ) 123 | ORDER BY 1 124 | SQL 125 | 126 | puts Niceql::Prettifier.prettify_pg_err(<<~ERR, raw_sql ) 127 | ERROR: VALUES in FROM must have an alias 128 | LINE 2: FROM ( VALUES(1), (2) ) 129 | ^ 130 | HINT: For example, FROM (VALUES ...) [AS] foo. 131 | ERR 132 | 133 | 134 | # ERROR: VALUES in FROM must have an alias 135 | # LINE 2: FROM ( VALUES(1), (2) ) 136 | # ^ 137 | # HINT: For example, FROM (VALUES ...) [AS] foo. 138 | # SELECT err 139 | # FROM ( VALUES(1), (2) ) 140 | # ^ 141 | # ORDER BY 1 142 | 143 | ``` 144 | 145 | ## Customizing colors 146 | If your console support more colors or different schemes, or if you prefer different colorization, then you can override ColorizeString methods. Current colors are selected with dark and white console themes in mind, so a niceql colorization works good for dark, and good enough for white. 147 | 148 | ## Limitations 149 | 150 | Right now gem detects only uppercased form of verbs with simple indentation and parsing options. 151 | 152 | ## 153 | 154 | ## Contributing 155 | 156 | Bug reports and pull requests are welcome on GitHub at https://github.com/alekseyl/niceql. 157 | 158 | ## License 159 | 160 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 161 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList["test/**/*_test.rb"] 10 | end 11 | 12 | task default: :test 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "niceql" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /err_now.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alekseyl/niceql/c48dc4feaa1400299b16d97191a07a6fb557bd7f/err_now.png -------------------------------------------------------------------------------- /err_was.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alekseyl/niceql/c48dc4feaa1400299b16d97191a07a6fb557bd7f/err_was.png -------------------------------------------------------------------------------- /lib/generators/niceql/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Niceql 4 | module Generators 5 | class InstallGenerator < Rails::Generators::Base 6 | source_root File.expand_path("../../templates", __FILE__) 7 | desc "Creates Niceql initializer for your application" 8 | 9 | def copy_initializer 10 | template("niceql_initializer.rb", "config/initializers/niceql.rb") 11 | 12 | puts "Install complete!" 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/generators/templates/niceql_initializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Niceql.configure do |c| 4 | # You can adjust pg_adapter in prooduction at your own risk! 5 | # If you need it in production use exec_niceql 6 | # default: false 7 | # c.pg_adapter_with_nicesql = Rails.env.development? 8 | 9 | # this are default settings, change it to your project needs 10 | # c.indentation_base = 2 11 | # c.open_bracket_is_newliner = false 12 | # c.prettify_active_record_log_output = false 13 | end 14 | -------------------------------------------------------------------------------- /lib/niceql.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "niceql/version" 4 | require "securerandom" 5 | require "forwardable" 6 | 7 | module Niceql 8 | module StringColorize 9 | class << self 10 | def colorize_keyword(str) 11 | # yellow ANSI color 12 | "\e[0;33;49m#{str}\e[0m" 13 | end 14 | 15 | def colorize_str(str) 16 | # cyan ANSI color 17 | "\e[0;36;49m#{str}\e[0m" 18 | end 19 | 20 | def colorize_err(err) 21 | # red ANSI color 22 | "\e[0;31;49m#{err}\e[0m" 23 | end 24 | 25 | def colorize_comment(comment) 26 | # bright black bold ANSI color 27 | "\e[0;90;1;49m#{comment}\e[0m" 28 | end 29 | end 30 | end 31 | 32 | module Prettifier 33 | # ?= -- should be present but without being added to MatchData 34 | AFTER_KEYWORD_SPACE = '(?=\s{1})' 35 | JOIN_KEYWORDS = '(RIGHT\s+|LEFT\s+){0,1}(INNER\s+|OUTER\s+){0,1}JOIN(\s+LATERAL){0,1}' 36 | INLINE_KEYWORDS = "WITH|ASC|COALESCE|AS|WHEN|THEN|ELSE|END|AND|UNION|ALL|ON|DISTINCT|"\ 37 | "INTERSECT|EXCEPT|EXISTS|NOT|COUNT|ROUND|CAST|IN" 38 | NEW_LINE_KEYWORDS = "SELECT|FROM|WHERE|CASE|ORDER BY|LIMIT|GROUP BY|HAVING|OFFSET|UPDATE|SET|#{JOIN_KEYWORDS}" 39 | 40 | POSSIBLE_INLINER = /(ORDER BY|CASE)/ 41 | KEYWORDS = "(#{NEW_LINE_KEYWORDS}|#{INLINE_KEYWORDS})#{AFTER_KEYWORD_SPACE}" 42 | # ?: -- will not match partial enclosed by (..) 43 | MULTILINE_INDENTABLE_LITERAL = /(?:'[^']+'\s*\n+\s*)+(?:'[^']+')+/ 44 | # STRINGS matched both kind of strings the multiline solid 45 | # and single quoted multiline strings with \s*\n+\s* separation 46 | STRINGS = /("[^"]+")|((?:'[^']+'\s*\n+\s*)*(?:'[^']+')+)/ 47 | BRACKETS = '[\(\)]' 48 | # will match all /* single line and multiline comments */ and -- based comments 49 | # the last will be matched as single block whenever comment lines followed each other. 50 | # For instance: 51 | # SELECT * -- comment 1 52 | # -- comment 2 53 | # all comments will be matched as a single block 54 | SQL_COMMENTS = %r{(\s*?--[^\n]+\n*)+|(\s*?/\*[^/\*]*\*/\s*)}m 55 | COMMENT_CONTENT = /[\S]+[\s\S]*[\S]+/ 56 | NAMED_DOLLAR_QUOTED_STRINGS_REGEX = /[^\$](\$[^\$]+\$)[^\$]/ 57 | DOLLAR_QUOTED_STRINGS = /(\$\$.*\$\$)/ 58 | 59 | class << self 60 | def config 61 | Niceql.config 62 | end 63 | 64 | def prettify_err(err, original_sql_query = nil) 65 | prettify_pg_err(err.to_s, original_sql_query) 66 | end 67 | 68 | # Postgres error output: 69 | # ERROR: VALUES in FROM must have an alias 70 | # LINE 2: FROM ( VALUES(1), (2) ); 71 | # ^ 72 | # HINT: For example, FROM (VALUES ...) [AS] foo. 73 | 74 | # May go without HINT or DETAIL: 75 | # ERROR: column "usr" does not exist 76 | # LINE 1: SELECT usr FROM users ORDER BY 1 77 | # ^ 78 | 79 | # ActiveRecord::StatementInvalid will add original SQL query to the bottom like this: 80 | # ActiveRecord::StatementInvalid: PG::UndefinedColumn: ERROR: column "usr" does not exist 81 | # LINE 1: SELECT usr FROM users ORDER BY 1 82 | # ^ 83 | # : SELECT usr FROM users ORDER BY 1 84 | 85 | # prettify_pg_err parses ActiveRecord::StatementInvalid string, 86 | # but you may use it without ActiveRecord either way: 87 | # prettify_pg_err( err + "\n" + sql ) OR prettify_pg_err( err, sql ) 88 | # don't mess with original sql query, or prettify_pg_err will deliver incorrect results 89 | def prettify_pg_err(err, original_sql_query = nil) 90 | return err if err[/LINE \d+/].nil? 91 | 92 | # LINE 2: ... -> err_line_num = 2 93 | err_line_num = err.match(/LINE (\d+):/)[1].to_i 94 | # LINE 1: SELECT usr FROM users ORDER BY 1 95 | err_address_line = err.lines[1] 96 | 97 | sql_start_line_num = 3 if err.lines.length <= 3 98 | # error not always contains HINT 99 | sql_start_line_num ||= err.lines[3][/(HINT|DETAIL)/] ? 4 : 3 100 | sql_body_lines = if sql_start_line_num < err.lines.length 101 | err.lines[sql_start_line_num..-1] 102 | else 103 | original_sql_query&.lines 104 | end 105 | 106 | # this means original query is missing so it's nothing to prettify 107 | return err unless sql_body_lines 108 | 109 | # this is an SQL line with an error. 110 | # we need err_line to properly align the caret in the caret line 111 | # and to apply a full red colorizing schema on an SQL line with error 112 | err_line = sql_body_lines[err_line_num - 1] 113 | 114 | # colorizing keywords, strings and error line 115 | err_body = sql_body_lines.map do |ln| 116 | ln == err_line ? StringColorize.colorize_err(ln) : colorize_err_line(ln) 117 | end 118 | 119 | err_caret_line = extract_err_caret_line(err_address_line, err_line, sql_body_lines, err) 120 | err_body.insert(err_line_num, StringColorize.colorize_err(err_caret_line)) 121 | 122 | err.lines[0..sql_start_line_num - 1].join + err_body.join 123 | end 124 | 125 | def prettify_sql(sql, colorize = true) 126 | QueryNormalizer.new(sql, colorize).prettified_sql 127 | end 128 | 129 | def prettify_multiple(sql_multi, colorize = true) 130 | sql_multi.split(/(?>#{SQL_COMMENTS})|(\;)/).each_with_object([""]) do |pattern, queries| 131 | queries[-1] += pattern 132 | queries << "" if pattern == ";" 133 | end.map! do |sql| 134 | # we were splitting by comments and ';', so if next sql start with comment we've got a misplaced \n\n 135 | sql.match?(/\A\s+\z/) ? nil : prettify_sql(sql, colorize) 136 | end.compact.join("\n") 137 | end 138 | 139 | private 140 | 141 | def colorize_err_line(line) 142 | line.gsub(/#{KEYWORDS}/) { |keyword| StringColorize.colorize_keyword(keyword) } 143 | .gsub(STRINGS) { |str| StringColorize.colorize_str(str) } 144 | end 145 | 146 | def extract_err_caret_line(err_address_line, err_line, sql_body, err) 147 | # LINE could be quoted ( both sides and sometimes only from one ): 148 | # "LINE 1: ...t_id\" = $13 AND \"products\".\"carrier_id\" = $14 AND \"product_t...\n", 149 | err_quote = (err_address_line.match(/\.\.\.(.+)\.\.\./) || err_address_line.match(/\.\.\.(.+)/))&.send(:[], 1) 150 | 151 | # line[2] is original err caret line i.e.: ' ^' 152 | # err_address_line[/LINE \d+:/].length+1..-1 - is a position from error quote begin 153 | err_caret_line = err.lines[2][err_address_line[/LINE \d+:/].length + 1..-1] 154 | 155 | # when err line is too long postgres quotes it in double '...' 156 | # so we need to reposition caret against original line 157 | if err_quote 158 | err_quote_caret_offset = err_caret_line.length - err_address_line.index("...").to_i + 3 159 | err_caret_line = " " * (err_line.index(err_quote) + err_quote_caret_offset) + "^\n" 160 | end 161 | 162 | # older versions of ActiveRecord were adding ': ' before an original query :( 163 | err_caret_line.prepend(" ") if sql_body[0].start_with?(": ") 164 | # if mistake is on last string than err_line.last != \n then we need to prepend \n to caret line 165 | err_caret_line.prepend("\n") unless err_line[-1] == "\n" 166 | err_caret_line 167 | end 168 | end 169 | 170 | # The normalizing and formatting logic: 171 | # 1. Split the original query onto the query part + literals + comments 172 | # a. find all potential dollar-signed separators 173 | # b. prepare full literal extractor regex 174 | # 2. Find and separate all literals and comments into mutable/format-able types 175 | # and immutable ( see the typing and formatting rules below ) 176 | # 3. Replace all literals and comments with uniq ids on the original query to get the parametrized query 177 | # 4. Format parametrized query alongside with mutable/format-able comments and literals 178 | # a. clear space characters: replace all \s+ to \s, remove all "\n" e.t.c 179 | # b. split in lines -> indent -> colorize 180 | # 5. Restore literals and comments with their values 181 | class QueryNormalizer 182 | extend Forwardable 183 | def_delegator :Niceql, :config 184 | 185 | # Literals content should not be indented, only string parts separated by new lines can be indented 186 | # indentable_string: 187 | # UPDATE docs SET body = 'First line' 188 | # 'Second line' 189 | # 'Third line', ... 190 | # 191 | # SQL standard allow such multiline separation. 192 | 193 | # newline_end_comments: 194 | # SELECT * -- get all column 195 | # SELECT * /* get all column */ 196 | # 197 | # SELECT * -- get all column 198 | # -- we need all columns for this request 199 | # SELECT * /* get all column 200 | # we need all columns for this request */ 201 | # 202 | # rare case newline_start_comments: 203 | # SELECT * 204 | # /* get all column 205 | # we need all columns for this request */ FROM table 206 | # 207 | # newline_wrapped_comments: 208 | # SELECT * 209 | # /* get all column 210 | # we need all columns for this request */ 211 | # FROM table 212 | # 213 | # SELECT * 214 | # -- get all column 215 | # -- we need all columns for this request 216 | # FROM ... 217 | # Potentially we could prettify different type of comments and strings a little bit differently, 218 | # but right now there is no difference between the 219 | # newline_wrapped_comment, newline_start_comment, newline_end_comment, they all will be wrapped in newlines 220 | COMMENT_AND_LITERAL_TYPES = [:immutable_string, :indentable_string, :inline_comment, :newline_wrapped_comment, 221 | :newline_start_comment, :newline_end_comment] 222 | 223 | attr_reader :parametrized_sql, :initial_sql, :string_regex, :literals_and_comments_types, :colorize 224 | 225 | def initialize(sql, colorize) 226 | @initial_sql = sql 227 | @colorize = colorize 228 | @parametrized_sql = "" 229 | @guids_to_content = {} 230 | @literals_and_comments_types = {} 231 | @counter = Hash.new(0) 232 | 233 | init_strings_regex 234 | prepare_parametrized_sql 235 | prettify_parametrized_sql 236 | end 237 | 238 | def prettified_sql 239 | @parametrized_sql % @guids_to_content.transform_keys(&:to_sym) 240 | end 241 | 242 | private 243 | 244 | def prettify_parametrized_sql 245 | indent = 0 246 | brackets = [] 247 | first_keyword = true 248 | 249 | parametrized_sql.gsub!(query_split_regex) do |matched_part| 250 | if inline_piece?(matched_part) 251 | first_keyword = false 252 | next matched_part 253 | end 254 | post_match_str = Regexp.last_match.post_match 255 | 256 | if ["SELECT", "UPDATE", "INSERT"].include?(matched_part) 257 | indent_block = !config.open_bracket_is_newliner || brackets.last.nil? || brackets.last[:nested] 258 | indent += config.indentation_base if indent_block 259 | brackets.last[:nested] = true if brackets.last 260 | add_new_line = !first_keyword 261 | elsif matched_part == "(" 262 | next_closing_bracket = post_match_str.index(")") 263 | # check if brackets contains SELECT statement 264 | add_new_line = !!post_match_str[0..next_closing_bracket][/SELECT/] && config.open_bracket_is_newliner 265 | brackets << { nested: add_new_line } 266 | elsif matched_part == ")" 267 | # this also covers case when right bracket is used without corresponding left one 268 | add_new_line = brackets.last.nil? || brackets.last[:nested] 269 | indent -= (brackets.last.nil? && 2 || brackets.last[:nested] && 1 || 0) * config.indentation_base 270 | indent = 0 if indent < 0 271 | brackets.pop 272 | elsif matched_part[POSSIBLE_INLINER] 273 | # in postgres ORDER BY can be used in aggregation function this will keep it 274 | # inline with its agg function 275 | add_new_line = brackets.last.nil? || brackets.last[:nested] 276 | else 277 | # since we are matching KEYWORD without space on the end 278 | # IN will be present in JOIN, DISTINCT e.t.c, so we need to exclude it explicitly 279 | add_new_line = matched_part.match?(/(#{NEW_LINE_KEYWORDS})/) 280 | end 281 | 282 | # do not indent first keyword in query, and indent everytime we started new line 283 | add_indent_to_keyword = !first_keyword && add_new_line 284 | 285 | if literals_and_comments_types[matched_part] 286 | # this is a case when comment followed by ordinary SQL part not by any keyword 287 | # this means that it will not be gsubed and no indent will be added before this part, while needed 288 | last_comment_followed_by_keyword = post_match_str.match?(/\A\}\s{0,1}(?:#{KEYWORDS})/) 289 | indent_parametrized_part(matched_part, indent, !last_comment_followed_by_keyword, !first_keyword) 290 | matched_part 291 | else 292 | first_keyword = false 293 | indented_sql = (add_indent_to_keyword ? indent_multiline(matched_part, indent) : matched_part) 294 | add_new_line ? "\n" + indented_sql : indented_sql 295 | end 296 | end 297 | 298 | parametrized_sql.gsub!(" \n", "\n") # moved keywords could keep space before it, we can crop it anyway 299 | 300 | clear_extra_newline_after_comments 301 | 302 | colorize_query if colorize 303 | end 304 | 305 | def add_string_or_comment(string_or_comment) 306 | # when we splitting original SQL, it could and could not end with literal/comment 307 | # hence we could try to add nil... 308 | return if string_or_comment.nil? 309 | 310 | type = get_placeholder_type(string_or_comment) 311 | # will be formatted to comment_1_guid 312 | typed_id = new_placeholder_name(type) 313 | @guids_to_content[typed_id] = string_or_comment 314 | @counter[type] += 1 315 | @literals_and_comments_types[typed_id] = type 316 | "%{#{typed_id}}" 317 | end 318 | 319 | def literal_and_comments_placeholders_regex 320 | /(#{@literals_and_comments_types.keys.join("|")})/ 321 | end 322 | 323 | def inline_piece?(comment_or_string) 324 | [:immutable_string, :inline_comment].include?(literals_and_comments_types[comment_or_string]) 325 | end 326 | 327 | def prepare_parametrized_sql 328 | @parametrized_sql = @initial_sql.split(/#{SQL_COMMENTS}|#{string_regex}/) 329 | .each_slice(2).map do |sql_part, comment_or_string| 330 | # remove additional formatting for sql_parts and replace comment and strings with a guids 331 | [sql_part.gsub(/[\s]+/, " "), add_string_or_comment(comment_or_string)] 332 | end.flatten.compact.join("") 333 | end 334 | 335 | def query_split_regex(with_brackets = true) 336 | if with_brackets 337 | /(#{KEYWORDS}|#{BRACKETS}|#{literal_and_comments_placeholders_regex})/ 338 | else 339 | /(#{KEYWORDS}|#{literal_and_comments_placeholders_regex})/ 340 | end 341 | end 342 | 343 | # when comment ending with newline followed by a keyword we should remove double newlines 344 | def clear_extra_newline_after_comments 345 | newlined_comments = @literals_and_comments_types.select { |k,| new_line_ending_comment?(k) } 346 | return if newlined_comments.empty? 347 | 348 | parametrized_sql.gsub!(/(#{newlined_comments.keys.join("}\n|")}}\n)/, &:chop) 349 | end 350 | 351 | def colorize_query 352 | parametrized_sql.gsub!(query_split_regex(false)) do |matched_part| 353 | if literals_and_comments_types[matched_part] 354 | colorize_comment_or_literal(matched_part) 355 | matched_part 356 | else 357 | StringColorize.colorize_keyword(matched_part) 358 | end 359 | end 360 | end 361 | 362 | def indent_parametrized_part(matched_typed_id, indent, indent_after_comment, start_with_newline = true) 363 | case @literals_and_comments_types[matched_typed_id] 364 | # technically we will not get here, since this types of literals/comments are not indentable 365 | when :inline_comment, :immutable_string 366 | when :indentable_string 367 | lines = @guids_to_content[matched_typed_id].lines 368 | @guids_to_content[matched_typed_id] = lines[0] + 369 | lines[1..-1].map! { |ln| indent_multiline(ln[/'[^']+'/], indent) }.join("\n") 370 | else 371 | content = @guids_to_content[matched_typed_id][COMMENT_CONTENT] 372 | @guids_to_content[matched_typed_id] = (start_with_newline ? "\n" : "") + 373 | "#{indent_multiline(content, indent)}\n" + 374 | (indent_after_comment ? indent_multiline("", indent) : "") 375 | end 376 | end 377 | 378 | def colorize_comment_or_literal(matched_typed_id) 379 | @guids_to_content[matched_typed_id] = if comment?(@literals_and_comments_types[matched_typed_id]) 380 | StringColorize.colorize_comment(@guids_to_content[matched_typed_id]) 381 | else 382 | StringColorize.colorize_str(@guids_to_content[matched_typed_id]) 383 | end 384 | end 385 | 386 | def get_placeholder_type(comment_or_string) 387 | if SQL_COMMENTS.match?(comment_or_string) 388 | get_comment_type(comment_or_string) 389 | else 390 | get_string_type(comment_or_string) 391 | end 392 | end 393 | 394 | def get_comment_type(comment) 395 | case comment 396 | when /\s*\n+\s*.+\s*\n+\s*/ then :newline_wrapped_comment 397 | when /\s*\n+\s*.+/ then :newline_start_comment 398 | when /.+\s*\n+\s*/ then :newline_end_comment 399 | else :inline_comment 400 | end 401 | end 402 | 403 | def get_string_type(string) 404 | MULTILINE_INDENTABLE_LITERAL.match?(string) ? :indentable_string : :immutable_string 405 | end 406 | 407 | def new_placeholder_name(placeholder_type) 408 | "#{placeholder_type}_#{@counter[placeholder_type]}_#{SecureRandom.uuid}" 409 | end 410 | 411 | def get_sql_named_strs(sql) 412 | freq = Hash.new(0) 413 | sql.scan(NAMED_DOLLAR_QUOTED_STRINGS_REGEX).select do |str| 414 | freq[str] += 1 415 | freq[str] == 2 416 | end 417 | .flatten 418 | .map { |str| str.gsub!("$", '\$') } 419 | end 420 | 421 | def init_strings_regex 422 | # /($STR$.+$STR$|$$[^$]$$|'[^']'|"[^"]")/ 423 | strs = get_sql_named_strs(initial_sql).map { |dq_str| "#{dq_str}.+#{dq_str}" } 424 | strs = ["(#{strs.join("|")})"] if strs != [] 425 | @string_regex ||= /#{[*strs, DOLLAR_QUOTED_STRINGS, STRINGS].join("|")}/m 426 | end 427 | 428 | def comment?(piece_type) 429 | !literal?(piece_type) 430 | end 431 | 432 | def literal?(piece_type) 433 | [:indentable_string, :immutable_string].include?(piece_type) 434 | end 435 | 436 | def new_line_ending_comment?(comment_or_literal) 437 | [:newline_wrapped_comment, :newline_end_comment, :newline_start_comment] 438 | .include?(@literals_and_comments_types[comment_or_literal]) 439 | end 440 | 441 | def indent_multiline(keyword, indent) 442 | if keyword.match?(/.\s*\n\s*./) 443 | keyword.lines.map! { |ln| " " * indent + ln }.join("") 444 | else 445 | " " * indent + keyword 446 | end 447 | end 448 | end 449 | end 450 | 451 | class NiceQLConfig 452 | attr_accessor :indentation_base, :open_bracket_is_newliner 453 | 454 | def initialize 455 | self.indentation_base = 2 456 | self.open_bracket_is_newliner = false 457 | end 458 | end 459 | 460 | class << self 461 | def configure 462 | yield(config) 463 | end 464 | 465 | def config 466 | @config ||= NiceQLConfig.new 467 | end 468 | end 469 | end 470 | -------------------------------------------------------------------------------- /lib/niceql/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Niceql 4 | VERSION = "0.6.1" 5 | end 6 | -------------------------------------------------------------------------------- /niceql.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # frozen_string_literal: true 3 | 4 | lib = File.expand_path("../lib", __FILE__) 5 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 6 | require "niceql/version" 7 | 8 | Gem::Specification.new do |spec| 9 | spec.name = "niceql" 10 | spec.version = Niceql::VERSION 11 | spec.authors = ["alekseyl"] 12 | spec.email = ["leshchuk@gmail.com"] 13 | 14 | spec.summary = "This is a simple and nice gem for SQL prettifying and formatting. "\ 15 | "Niceql splits, indent and colorize SQL query and PG errors if any. " 16 | spec.description = "This is a simple and nice gem for SQL prettifying and formatting. "\ 17 | "Niceql splits, indent and colorize SQL query and PG errors if any. "\ 18 | "Could be used as a standalone gem without any dependencies. "\ 19 | "Seamless ActiveRecord integration via rails_sql_prettifier gem. " 20 | spec.homepage = "https://github.com/alekseyl/niceql" 21 | spec.license = "MIT" 22 | 23 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 24 | # to allow pushing to a single host or delete this section to allow pushing to any host. 25 | if spec.respond_to?(:metadata) 26 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 27 | else 28 | raise "RubyGems 2.0 or newer is required to protect against " \ 29 | "public gem pushes." 30 | end 31 | 32 | spec.files = %x(git ls-files -z).split("\x0").reject do |f| 33 | f.match(%r{^(test|spec|features)/}) 34 | end 35 | spec.bindir = "exe" 36 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 37 | spec.require_paths = ["lib"] 38 | 39 | spec.required_ruby_version = ">= 2.5" 40 | 41 | spec.add_development_dependency("awesome_print") 42 | spec.add_development_dependency("bundler", ">= 1") 43 | spec.add_development_dependency("minitest", "~> 5.0") 44 | spec.add_development_dependency("rake", ">= 12.3.3") 45 | spec.add_development_dependency("rubocop-shopify", "~> 2.0") 46 | 47 | spec.add_development_dependency("benchmark-ips") 48 | spec.add_development_dependency("differ") 49 | spec.add_development_dependency("pry-byebug") 50 | spec.add_development_dependency("sqlite3") 51 | end 52 | -------------------------------------------------------------------------------- /test/niceql/declarative.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveSupport 4 | module Testing 5 | module Declarative 6 | # Helper to define a test method using a String. Under the hood, it replaces 7 | # spaces with underscores and defines the test method. 8 | # 9 | # test "verify something" do 10 | # ... 11 | # end 12 | def test(name, &block) 13 | test_name = "test_#{name.gsub(/\s+/, "_")}".to_sym 14 | defined = method_defined?(test_name) 15 | raise "#{test_name} is already defined in #{self}" if defined 16 | 17 | if block_given? 18 | define_method(test_name, &block) 19 | else 20 | define_method(test_name) do 21 | flunk("No implementation provided for #{name}") 22 | end 23 | end 24 | end 25 | end 26 | end 27 | end unless defined?(ActiveSupport) 28 | -------------------------------------------------------------------------------- /test/niceql/niceql_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | require "differ" 5 | require "byebug" 6 | require "awesome_print" 7 | 8 | class NiceQLTest < Minitest::Test 9 | extend ::ActiveSupport::Testing::Declarative 10 | 11 | def assert_equal_standard(niceql_result, etalon) 12 | if etalon != niceql_result 13 | puts "ETALON:----------------------------" 14 | puts etalon 15 | puts "Niceql result:---------------------" 16 | puts niceql_result 17 | puts "DIFF:----------------------------" 18 | puts Differ.diff(etalon, niceql_result) 19 | end 20 | 21 | raise "Not equal" unless etalon == niceql_result 22 | end 23 | 24 | def test_niceql 25 | etalon = <<~PRETTY_RESULT 26 | -- valuable comment first line 27 | SELECT some, 28 | -- valuable comment to inline keyword 29 | column2, COUNT(attributes), /* some comment */#{" "} 30 | CASE WHEN some > 10 THEN '[{"attr": 2}]'::jsonb[] ELSE '{}'::jsonb[] END AS combined_attribute, more 31 | -- valuable comment to newline keyword 32 | FROM some_table st 33 | RIGHT INNER JOIN some_other so ON so.st_id = st.id 34 | /* multi line with semicolon; 35 | comment */ 36 | WHERE some NOT IN ( 37 | SELECT other_some 38 | FROM other_table 39 | WHERE id IN ARRAY[1,2]::bigint[] 40 | ) 41 | ORDER BY some 42 | GROUP BY some 43 | HAVING 2 > 1; 44 | --comment to second query with semicolon; 45 | SELECT other."column" 46 | FROM "table" 47 | WHERE id = 1; 48 | -- third query with complex string literals and UPDATE 49 | UPDATE some_table 50 | SET string=' 51 | multiline with 3 spaces string 52 | ', second_multiline_str = 'line one'#{" "} 53 | 'line two', dollar_quoted_string = $$ I'll be back $$, tagged_dollar_quoted_string = $tag$#{" "} 54 | with surprise $$!! $$ $not_tag$ still inside first string $not_tag$#{" "} 55 | $tag$ 56 | WHERE id = 1 AND SELECT_id = 2; 57 | PRETTY_RESULT 58 | 59 | pretty_sql = Niceql::Prettifier.prettify_multiple(<<~PRETTIFY_ME, false) 60 | -- valuable comment first line 61 | SELECT some, 62 | -- valuable comment to inline keyword 63 | column2, COUNT(attributes), /* some comment */ CASE WHEN some > 10 THEN '[{"attr": 2}]'::jsonb[] ELSE '{}'::jsonb[] END AS combined_attribute, more#{" "} 64 | -- valuable comment to newline keyword 65 | FROM some_table st RIGHT INNER JOIN some_other so ON so.st_id = st.id#{" "} 66 | /* multi line with semicolon; 67 | comment */ 68 | WHERE some NOT IN (SELECT other_some FROM other_table WHERE id IN ARRAY[1,2]::bigint[] ) ORDER BY some GROUP BY some HAVING 2 > 1; 69 | --comment to second query with semicolon; 70 | SELECT other."column" FROM "table" WHERE id = 1; 71 | 72 | -- third query with complex string literals and UPDATE 73 | UPDATE some_table SET string=' 74 | multiline with 3 spaces string 75 | ', second_multiline_str = 'line one'#{" "} 76 | 'line two',#{" "} 77 | dollar_quoted_string = $$ I'll be back $$, 78 | tagged_dollar_quoted_string = $tag$#{" "} 79 | with surprise $$!! $$ $not_tag$ still inside first string $not_tag$#{" "} 80 | $tag$ WHERE id = 1 AND SELECT_id = 2; 81 | PRETTIFY_ME 82 | 83 | # ETALON goes with \n at the end :( 84 | assert_equal_standard(pretty_sql, etalon.chop) 85 | end 86 | 87 | def test_regression_when_no_comments_present 88 | etalon = <<~ETALON 89 | SELECT "webinars".* 90 | FROM "webinars" 91 | WHERE "webinars"."deleted_at" IS NULL 92 | ETALON 93 | 94 | prettified = Niceql::Prettifier.prettify_multiple(<<~PRETTIFY_ME, false) 95 | SELECT "webinars".* FROM "webinars" WHERE "webinars"."deleted_at" IS NULL 96 | PRETTIFY_ME 97 | 98 | assert_equal_standard(prettified.chop, etalon.chop) 99 | end 100 | 101 | def broken_sql_sample 102 | <<~SQL 103 | SELECT err 104 | FROM ( VALUES(1), (2) ) 105 | WHERE id="100" 106 | ORDER BY 1 107 | SQL 108 | end 109 | 110 | def err_template 111 | <<~ERR 112 | SELECT err 113 | _COLORIZED_ERR_WHERE id="100" 114 | ORDER BY 1 115 | ERR 116 | end 117 | 118 | test "error prettifier" do 119 | err = <<~ERR 120 | ERROR: VALUES in FROM must have an alias 121 | LINE 2: FROM ( VALUES(1), (2) ) 122 | ^ 123 | HINT: For example, FROM (VALUES ...) [AS] foo. 124 | ERR 125 | 126 | sample_err = prepare_sample_err(err, err_template) 127 | 128 | assert_equal_standard(Niceql::Prettifier.prettify_pg_err(err, broken_sql_sample), sample_err) 129 | # err already has \n as last char so it goes err + sql NOT err + "\n" + sql 130 | assert_equal_standard(Niceql::Prettifier.prettify_pg_err(err + broken_sql_sample), sample_err) 131 | end 132 | 133 | test "error without HINT and ..." do 134 | err = <<~ERR 135 | ERROR: VALUES in FROM must have an alias 136 | LINE 2: FROM ( VALUES(1), (2) ) 137 | ^ 138 | ERR 139 | 140 | sample_err = prepare_sample_err(err, err_template) 141 | 142 | assert_equal_standard(Niceql::Prettifier.prettify_pg_err(err, broken_sql_sample), sample_err) 143 | # err already has \n as last char so it goes err + sql NOT err + "\n" + sql 144 | assert_equal_standard(Niceql::Prettifier.prettify_pg_err(err + broken_sql_sample), sample_err) 145 | end 146 | 147 | def prepare_sample_err(base_err, prt_err_sql) 148 | standard_err = base_err + prt_err_sql.gsub(/#{Niceql::Prettifier::KEYWORDS}/) do |keyword| 149 | Niceql::StringColorize.colorize_keyword(keyword) 150 | end 151 | .gsub(/#{Niceql::Prettifier::STRINGS}/) { |keyword| Niceql::StringColorize.colorize_str(keyword) } 152 | 153 | standard_err.gsub!("_COLORIZED_ERR_", Niceql::StringColorize.colorize_err("FROM ( VALUES(1), (2) )\n") + 154 | Niceql::StringColorize.colorize_err(" ^\n")) 155 | standard_err 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift(File.expand_path("../../lib", __FILE__)) 4 | $LOAD_PATH.unshift(File.expand_path("../../test/niceql", __FILE__)) 5 | 6 | require "minitest/autorun" 7 | require "declarative" 8 | require "niceql" 9 | -------------------------------------------------------------------------------- /to_niceql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alekseyl/niceql/c48dc4feaa1400299b16d97191a07a6fb557bd7f/to_niceql.png --------------------------------------------------------------------------------