├── .gitattributes ├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── build.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── app ├── assets │ ├── fonts │ │ └── blazer │ │ │ ├── glyphicons-halflings-regular.eot │ │ │ ├── glyphicons-halflings-regular.svg │ │ │ ├── glyphicons-halflings-regular.ttf │ │ │ ├── glyphicons-halflings-regular.woff │ │ │ └── glyphicons-halflings-regular.woff2 │ ├── images │ │ └── blazer │ │ │ └── favicon.png │ ├── javascripts │ │ └── blazer │ │ │ ├── Sortable.js │ │ │ ├── ace.js │ │ │ ├── ace │ │ │ ├── ace.js │ │ │ ├── ext-language_tools.js │ │ │ ├── mode-sql.js │ │ │ ├── snippets │ │ │ │ ├── sql.js │ │ │ │ └── text.js │ │ │ └── theme-twilight.js │ │ │ ├── application.js │ │ │ ├── bootstrap.js │ │ │ ├── chart.umd.js │ │ │ ├── chartjs-adapter-date-fns.bundle.js │ │ │ ├── chartkick.js │ │ │ ├── daterangepicker.js │ │ │ ├── fuzzysearch.js │ │ │ ├── highlight.min.js │ │ │ ├── jquery.js │ │ │ ├── jquery.stickytableheaders.js │ │ │ ├── mapkick.bundle.js │ │ │ ├── moment-timezone-with-data.js │ │ │ ├── moment.js │ │ │ ├── queries.js │ │ │ ├── rails-ujs.js │ │ │ ├── routes.js │ │ │ ├── selectize.js │ │ │ ├── stupidtable-custom-settings.js │ │ │ ├── stupidtable.js │ │ │ └── vue.global.prod.js │ └── stylesheets │ │ └── blazer │ │ ├── application.css │ │ ├── bootstrap-propshaft.css │ │ ├── bootstrap-sprockets.css.erb │ │ ├── bootstrap.css │ │ ├── daterangepicker.css │ │ ├── github.css │ │ └── selectize.css ├── controllers │ └── blazer │ │ ├── base_controller.rb │ │ ├── checks_controller.rb │ │ ├── dashboards_controller.rb │ │ ├── queries_controller.rb │ │ └── uploads_controller.rb ├── helpers │ └── blazer │ │ └── base_helper.rb ├── models │ └── blazer │ │ ├── audit.rb │ │ ├── check.rb │ │ ├── connection.rb │ │ ├── dashboard.rb │ │ ├── dashboard_query.rb │ │ ├── query.rb │ │ ├── record.rb │ │ ├── upload.rb │ │ └── uploads_connection.rb └── views │ ├── blazer │ ├── _nav.html.erb │ ├── _variables.html.erb │ ├── check_mailer │ │ ├── failing_checks.html.erb │ │ └── state_change.html.erb │ ├── checks │ │ ├── _form.html.erb │ │ ├── edit.html.erb │ │ ├── index.html.erb │ │ └── new.html.erb │ ├── dashboards │ │ ├── _form.html.erb │ │ ├── edit.html.erb │ │ ├── new.html.erb │ │ └── show.html.erb │ ├── queries │ │ ├── _caching.html.erb │ │ ├── _cohorts.html.erb │ │ ├── _form.html.erb │ │ ├── docs.html.erb │ │ ├── edit.html.erb │ │ ├── home.html.erb │ │ ├── new.html.erb │ │ ├── run.html.erb │ │ ├── schema.html.erb │ │ └── show.html.erb │ └── uploads │ │ ├── _form.html.erb │ │ ├── edit.html.erb │ │ ├── index.html.erb │ │ └── new.html.erb │ └── layouts │ └── blazer │ └── application.html.erb ├── blazer.gemspec ├── config └── routes.rb ├── gemfiles ├── rails70.gemfile ├── rails71.gemfile └── rails72.gemfile ├── lib ├── blazer.rb ├── blazer │ ├── adapters.rb │ ├── adapters │ │ ├── athena_adapter.rb │ │ ├── base_adapter.rb │ │ ├── bigquery_adapter.rb │ │ ├── cassandra_adapter.rb │ │ ├── drill_adapter.rb │ │ ├── druid_adapter.rb │ │ ├── elasticsearch_adapter.rb │ │ ├── hive_adapter.rb │ │ ├── ignite_adapter.rb │ │ ├── influxdb_adapter.rb │ │ ├── neo4j_adapter.rb │ │ ├── opensearch_adapter.rb │ │ ├── presto_adapter.rb │ │ ├── salesforce_adapter.rb │ │ ├── snowflake_adapter.rb │ │ ├── soda_adapter.rb │ │ ├── spark_adapter.rb │ │ └── sql_adapter.rb │ ├── anomaly_detectors.rb │ ├── check_mailer.rb │ ├── data_source.rb │ ├── engine.rb │ ├── forecasters.rb │ ├── result.rb │ ├── result_cache.rb │ ├── run_statement.rb │ ├── run_statement_job.rb │ ├── slack_notifier.rb │ ├── statement.rb │ └── version.rb ├── generators │ └── blazer │ │ ├── install_generator.rb │ │ ├── templates │ │ ├── config.yml.tt │ │ ├── install.rb.tt │ │ └── uploads.rb.tt │ │ └── uploads_generator.rb └── tasks │ └── blazer.rake ├── licenses ├── LICENSE-ace.txt ├── LICENSE-bootstrap.txt ├── LICENSE-chart.js.txt ├── LICENSE-chartjs-adapter-date-fns.txt ├── LICENSE-chartkick.js.txt ├── LICENSE-date-fns.txt ├── LICENSE-daterangepicker.txt ├── LICENSE-fuzzysearch.txt ├── LICENSE-highlight.js.txt ├── LICENSE-jquery.txt ├── LICENSE-kurkle-color.txt ├── LICENSE-mapkick-bundle.txt ├── LICENSE-moment-timezone.txt ├── LICENSE-moment.txt ├── LICENSE-rails-ujs.txt ├── LICENSE-selectize.txt ├── LICENSE-sortable.txt ├── LICENSE-stickytableheaders.txt ├── LICENSE-stupidtable.txt └── LICENSE-vue.txt └── test ├── adapters ├── athena_test.rb ├── bigquery_test.rb ├── cassandra_test.rb ├── drill_test.rb ├── druid_test.rb ├── elasticsearch_test.rb ├── hive_test.rb ├── ignite_test.rb ├── influxdb_test.rb ├── mysql_test.rb ├── neo4j_test.rb ├── opensearch_test.rb ├── postgresql_test.rb ├── presto_test.rb ├── redshift_test.rb ├── salesforce_test.rb ├── snowflake_test.rb ├── soda_test.rb ├── spark_test.rb ├── sqlite_test.rb └── sqlserver_test.rb ├── anomaly_checks_test.rb ├── archive_test.rb ├── cache_test.rb ├── charts_test.rb ├── checks_test.rb ├── cohort_analysis_test.rb ├── dashboards_test.rb ├── forecasting_test.rb ├── internal ├── app │ ├── assets │ │ └── config │ │ │ └── manifest.js │ ├── controllers │ │ └── application_controller.rb │ └── models │ │ └── user.rb ├── config │ ├── blazer.yml │ ├── database.yml │ └── routes.rb └── db │ └── schema.rb ├── maps_test.rb ├── permissions_test.rb ├── queries_test.rb ├── support ├── adapter_test.rb ├── adapters.yml ├── duplicate_columns.csv ├── line_items.csv └── malformed.csv ├── test_helper.rb └── uploads_test.rb /.gitattributes: -------------------------------------------------------------------------------- 1 | app/assets/** linguist-vendored 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Hi, 2 | 3 | Before creating an issue, please check out the Contributing Guide: 4 | 5 | https://github.com/ankane/blazer/blob/master/CONTRIBUTING.md 6 | 7 | Thanks! 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | include: 10 | - ruby: 3.4 11 | gemfile: Gemfile 12 | - ruby: 3.3 13 | gemfile: gemfiles/rails72.gemfile 14 | - ruby: 3.2 15 | gemfile: gemfiles/rails71.gemfile 16 | env: 17 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 18 | TEST_PROPHET: 1 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby }} 24 | bundler-cache: true 25 | - uses: ankane/setup-postgres@v1 26 | with: 27 | database: blazer_test 28 | - run: bundle exec rake test 29 | - run: bundle exec rake test:postgresql 30 | - run: bundle exec rake test:sqlite 31 | 32 | - uses: ankane/setup-mysql@v1 33 | with: 34 | database: blazer_test 35 | - run: mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql 36 | - run: ADAPTER=mysql2 bundle exec rake test 37 | - run: bundle exec rake test:mysql 38 | - run: ADAPTER=trilogy bundle exec rake test 39 | - run: MYSQL_ADAPTER=trilogy bundle exec rake test:mysql 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | *.bundle 11 | *.so 12 | *.o 13 | *.a 14 | *.log 15 | *.sqlite 16 | /test/internal/tmp 17 | *.lock 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First, thanks for wanting to contribute. You’re awesome! :heart: 4 | 5 | ## Help 6 | 7 | We’re not able to provide support through GitHub Issues. If you’re looking for help with your code, try posting on [Stack Overflow](https://stackoverflow.com/). 8 | 9 | All features should be documented. If you don’t see a feature in the docs, assume it doesn’t exist. 10 | 11 | ## Bugs 12 | 13 | Think you’ve discovered a bug? 14 | 15 | 1. Search existing issues to see if it’s been reported. 16 | 2. Try the `master` branch to make sure it hasn’t been fixed. 17 | 18 | ```rb 19 | gem "blazer", github: "ankane/blazer" 20 | ``` 21 | 22 | If the above steps don’t help, create an issue. Include: 23 | 24 | - Detailed steps to reproduce 25 | - Complete backtraces for exceptions 26 | 27 | ## New Features 28 | 29 | If you’d like to discuss a new feature, create an issue and start the title with `[Idea]`. 30 | 31 | ## Pull Requests 32 | 33 | Fork the project and create a pull request. A few tips: 34 | 35 | - Keep changes to a minimum. If you have multiple features or fixes, submit multiple pull requests. 36 | - Follow the existing style. The code should read like it’s written by a single person. 37 | 38 | Feel free to open an issue to get feedback on your idea before spending too much time on it. 39 | 40 | --- 41 | 42 | This contributing guide is released under [CCO](https://creativecommons.org/publicdomain/zero/1.0/) (public domain). Use it for your own project without attribution. 43 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem "rake" 6 | gem "minitest", ">= 5" 7 | gem "combustion" 8 | gem "rails", "~> 8.0.0" 9 | gem "pg" 10 | gem "sqlite3" 11 | gem "mysql2" 12 | gem "trilogy" 13 | gem "propshaft" 14 | 15 | # data sources 16 | # gem "activerecord7-redshift-adapter-pennylane" 17 | # gem "aws-sdk-athena" 18 | # gem "aws-sdk-glue" 19 | # gem "cassandra-driver" 20 | # gem "sorted_set" 21 | # gem "drill-sergeant" 22 | # gem "elasticsearch" 23 | # gem "google-cloud-bigquery" 24 | # gem "hexspace" 25 | # gem "ignite-client" 26 | # gem "influxdb" 27 | # gem "neo4j-core" 28 | # gem "neo4j-ruby-driver" 29 | # gem "odbc_adapter" 30 | # gem "opensearch-ruby" 31 | # gem "presto-client" 32 | # gem "restforce" 33 | # gem "tiny_tds" 34 | # gem "trino-client" 35 | # gem "activerecord-sqlserver-adapter" 36 | 37 | # anomaly detection and forecasting 38 | gem "anomaly_detection", platform: :mri 39 | gem "prophet-rb" if ENV["TEST_PROPHET"] 40 | gem "trend" if ENV["TEST_TREND"] 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2025 Andrew Kane 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | task default: :test 5 | Rake::TestTask.new do |t| 6 | t.libs << "test" 7 | t.pattern = "test/*_test.rb" 8 | t.warning = false # mail gem 9 | end 10 | 11 | %w( 12 | athena bigquery cassandra drill druid elasticsearch 13 | hive ignite influxdb mysql neo4j opensearch 14 | postgresql presto redshift salesforce snowflake 15 | soda spark sqlite sqlserver 16 | ).each do |adapter| 17 | namespace :test do 18 | Rake::TestTask.new(adapter) do |t| 19 | t.description = "Run tests for #{adapter}" 20 | t.test_files = FileList["test/adapters/#{adapter}_test.rb"] 21 | t.warning = false # mail gem 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/assets/fonts/blazer/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankane/blazer/e5ba950e0a9190d22654a33c9ff06caeb982c88c/app/assets/fonts/blazer/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /app/assets/fonts/blazer/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankane/blazer/e5ba950e0a9190d22654a33c9ff06caeb982c88c/app/assets/fonts/blazer/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /app/assets/fonts/blazer/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankane/blazer/e5ba950e0a9190d22654a33c9ff06caeb982c88c/app/assets/fonts/blazer/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /app/assets/fonts/blazer/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankane/blazer/e5ba950e0a9190d22654a33c9ff06caeb982c88c/app/assets/fonts/blazer/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /app/assets/images/blazer/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankane/blazer/e5ba950e0a9190d22654a33c9ff06caeb982c88c/app/assets/images/blazer/favicon.png -------------------------------------------------------------------------------- /app/assets/javascripts/blazer/ace.js: -------------------------------------------------------------------------------- 1 | //= require ./ace/ace 2 | //= require ./ace/ext-language_tools 3 | //= require ./ace/theme-twilight 4 | //= require ./ace/mode-sql 5 | //= require ./ace/snippets/text 6 | //= require ./ace/snippets/sql 7 | -------------------------------------------------------------------------------- /app/assets/javascripts/blazer/ace/snippets/sql.js: -------------------------------------------------------------------------------- 1 | define("ace/snippets/sql.snippets",["require","exports","module"], function(require, exports, module){module.exports = "snippet tbl\n\tcreate table ${1:table} (\n\t\t${2:columns}\n\t);\nsnippet col\n\t${1:name}\t${2:type}\t${3:default ''}\t${4:not null}\nsnippet ccol\n\t${1:name}\tvarchar2(${2:size})\t${3:default ''}\t${4:not null}\nsnippet ncol\n\t${1:name}\tnumber\t${3:default 0}\t${4:not null}\nsnippet dcol\n\t${1:name}\tdate\t${3:default sysdate}\t${4:not null}\nsnippet ind\n\tcreate index ${3:$1_$2} on ${1:table}(${2:column});\nsnippet uind\n\tcreate unique index ${1:name} on ${2:table}(${3:column});\nsnippet tblcom\n\tcomment on table ${1:table} is '${2:comment}';\nsnippet colcom\n\tcomment on column ${1:table}.${2:column} is '${3:comment}';\nsnippet addcol\n\talter table ${1:table} add (${2:column} ${3:type});\nsnippet seq\n\tcreate sequence ${1:name} start with ${2:1} increment by ${3:1} minvalue ${4:1};\nsnippet s*\n\tselect * from ${1:table}\n"; 2 | 3 | }); 4 | 5 | define("ace/snippets/sql",["require","exports","module","ace/snippets/sql.snippets"], function(require, exports, module){"use strict"; 6 | exports.snippetText = require("./sql.snippets"); 7 | exports.scope = "sql"; 8 | 9 | }); (function() { 10 | window.require(["ace/snippets/sql"], function(m) { 11 | if (typeof module == "object" && typeof exports == "object" && module) { 12 | module.exports = m; 13 | } 14 | }); 15 | })(); 16 | -------------------------------------------------------------------------------- /app/assets/javascripts/blazer/ace/snippets/text.js: -------------------------------------------------------------------------------- 1 | 2 | ; (function() { 3 | window.require(["ace/snippets/text"], function(m) { 4 | if (typeof module == "object" && typeof exports == "object" && module) { 5 | module.exports = m; 6 | } 7 | }); 8 | })(); 9 | -------------------------------------------------------------------------------- /app/assets/javascripts/blazer/ace/theme-twilight.js: -------------------------------------------------------------------------------- 1 | define("ace/theme/twilight.css",["require","exports","module"], function(require, exports, module){module.exports = ".ace-twilight .ace_gutter {\n background: #232323;\n color: #E2E2E2\n}\n\n.ace-twilight .ace_print-margin {\n width: 1px;\n background: #232323\n}\n\n.ace-twilight {\n background-color: #141414;\n color: #F8F8F8\n}\n\n.ace-twilight .ace_cursor {\n color: #A7A7A7\n}\n\n.ace-twilight .ace_marker-layer .ace_selection {\n background: rgba(221, 240, 255, 0.20)\n}\n\n.ace-twilight.ace_multiselect .ace_selection.ace_start {\n box-shadow: 0 0 3px 0px #141414;\n}\n\n.ace-twilight .ace_marker-layer .ace_step {\n background: rgb(102, 82, 0)\n}\n\n.ace-twilight .ace_marker-layer .ace_bracket {\n margin: -1px 0 0 -1px;\n border: 1px solid rgba(255, 255, 255, 0.25)\n}\n\n.ace-twilight .ace_marker-layer .ace_active-line {\n background: rgba(255, 255, 255, 0.031)\n}\n\n.ace-twilight .ace_gutter-active-line {\n background-color: rgba(255, 255, 255, 0.031)\n}\n\n.ace-twilight .ace_marker-layer .ace_selected-word {\n border: 1px solid rgba(221, 240, 255, 0.20)\n}\n\n.ace-twilight .ace_invisible {\n color: rgba(255, 255, 255, 0.25)\n}\n\n.ace-twilight .ace_keyword,\n.ace-twilight .ace_meta {\n color: #CDA869\n}\n\n.ace-twilight .ace_constant,\n.ace-twilight .ace_constant.ace_character,\n.ace-twilight .ace_constant.ace_character.ace_escape,\n.ace-twilight .ace_constant.ace_other,\n.ace-twilight .ace_heading,\n.ace-twilight .ace_markup.ace_heading,\n.ace-twilight .ace_support.ace_constant {\n color: #CF6A4C\n}\n\n.ace-twilight .ace_invalid.ace_illegal {\n color: #F8F8F8;\n background-color: rgba(86, 45, 86, 0.75)\n}\n\n.ace-twilight .ace_invalid.ace_deprecated {\n text-decoration: underline;\n font-style: italic;\n color: #D2A8A1\n}\n\n.ace-twilight .ace_support {\n color: #9B859D\n}\n\n.ace-twilight .ace_fold {\n background-color: #AC885B;\n border-color: #F8F8F8\n}\n\n.ace-twilight .ace_support.ace_function {\n color: #DAD085\n}\n\n.ace-twilight .ace_list,\n.ace-twilight .ace_markup.ace_list,\n.ace-twilight .ace_storage {\n color: #F9EE98\n}\n\n.ace-twilight .ace_entity.ace_name.ace_function,\n.ace-twilight .ace_meta.ace_tag {\n color: #AC885B\n}\n\n.ace-twilight .ace_string {\n color: #8F9D6A\n}\n\n.ace-twilight .ace_string.ace_regexp {\n color: #E9C062\n}\n\n.ace-twilight .ace_comment {\n font-style: italic;\n color: #5F5A60\n}\n\n.ace-twilight .ace_variable {\n color: #7587A6\n}\n\n.ace-twilight .ace_xml-pe {\n color: #494949\n}\n\n.ace-twilight .ace_indent-guide {\n background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWMQERFpYLC1tf0PAAgOAnPnhxyiAAAAAElFTkSuQmCC) right repeat-y\n}\n\n.ace-twilight .ace_indent-guide-active {\n background: url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAIGNIUk0AAHolAACAgwAA+f8AAIDpAAB1MAAA6mAAADqYAAAXb5JfxUYAAAAZSURBVHjaYvj///9/hivKyv8BAAAA//8DACLqBhbvk+/eAAAAAElFTkSuQmCC\") right repeat-y;\n}\n"; 2 | 3 | }); 4 | 5 | define("ace/theme/twilight",["require","exports","module","ace/theme/twilight.css","ace/lib/dom"], function(require, exports, module){exports.isDark = true; 6 | exports.cssClass = "ace-twilight"; 7 | exports.cssText = require("./twilight.css"); 8 | var dom = require("../lib/dom"); 9 | dom.importCssString(exports.cssText, exports.cssClass, false); 10 | 11 | }); (function() { 12 | window.require(["ace/theme/twilight"], function(m) { 13 | if (typeof module == "object" && typeof exports == "object" && module) { 14 | module.exports = m; 15 | } 16 | }); 17 | })(); 18 | -------------------------------------------------------------------------------- /app/assets/javascripts/blazer/application.js: -------------------------------------------------------------------------------- 1 | //= require ./jquery 2 | //= require ./rails-ujs 3 | //= require ./stupidtable 4 | //= require ./stupidtable-custom-settings 5 | //= require ./jquery.stickytableheaders 6 | //= require ./selectize 7 | //= require ./highlight.min 8 | //= require ./moment 9 | //= require ./moment-timezone-with-data 10 | //= require ./daterangepicker 11 | //= require ./chart.umd 12 | //= require ./chartjs-adapter-date-fns.bundle 13 | //= require ./chartkick 14 | //= require ./mapkick.bundle 15 | //= require ./ace 16 | //= require ./Sortable 17 | //= require ./bootstrap 18 | //= require ./vue.global.prod 19 | //= require ./routes 20 | //= require ./queries 21 | //= require ./fuzzysearch 22 | 23 | $(document).on('mouseenter', '.dropdown-toggle', function () { 24 | $(this).parent().addClass('open') 25 | }) 26 | 27 | $(document).on("change", "#bind input, #bind select", function () { 28 | submitIfCompleted($(this).closest("form")) 29 | }) 30 | 31 | $(document).on("click", "#code", function () { 32 | $(this).addClass("expanded") 33 | }) 34 | 35 | $(document).on("click", "a[disabled]", function (e) { 36 | e.preventDefault() 37 | }) 38 | 39 | function submitIfCompleted($form) { 40 | var completed = true 41 | $form.find("input[name], select").each( function () { 42 | if ($(this).val() == "") { 43 | completed = false 44 | } 45 | }) 46 | if (completed) { 47 | $form.submit() 48 | } 49 | } 50 | 51 | // Prevent backspace from navigating backwards. 52 | // Adapted from Biff MaGriff: http://stackoverflow.com/a/7895814/1196499 53 | function preventBackspaceNav() { 54 | $(document).keydown(function (e) { 55 | var preventKeyPress 56 | if (e.keyCode == 8) { 57 | var d = e.srcElement || e.target 58 | switch (d.tagName.toUpperCase()) { 59 | case 'TEXTAREA': 60 | preventKeyPress = d.readOnly || d.disabled 61 | break 62 | case 'INPUT': 63 | preventKeyPress = d.readOnly || d.disabled || (d.attributes["type"] && $.inArray(d.attributes["type"].value.toLowerCase(), ["radio", "reset", "checkbox", "submit", "button"]) >= 0) 64 | break 65 | case 'DIV': 66 | preventKeyPress = d.readOnly || d.disabled || !(d.attributes["contentEditable"] && d.attributes["contentEditable"].value == "true") 67 | break 68 | default: 69 | preventKeyPress = true 70 | break 71 | } 72 | } 73 | else { 74 | preventKeyPress = false 75 | } 76 | 77 | if (preventKeyPress) { 78 | e.preventDefault() 79 | } 80 | }) 81 | } 82 | 83 | preventBackspaceNav() 84 | 85 | -------------------------------------------------------------------------------- /app/assets/javascripts/blazer/fuzzysearch.js: -------------------------------------------------------------------------------- 1 | // https://github.com/bevacqua/fuzzysearch 2 | // Copyright 2015 Nicolas Bevacqua 3 | // MIT License 4 | 5 | function fuzzysearch (needle, haystack) { 6 | var hlen = haystack.length; 7 | var nlen = needle.length; 8 | if (nlen > hlen) { 9 | return false; 10 | } 11 | if (nlen === hlen) { 12 | return needle === haystack; 13 | } 14 | outer: for (var i = 0, j = 0; i < nlen; i++) { 15 | var nch = needle.charCodeAt(i); 16 | while (j < hlen) { 17 | if (haystack.charCodeAt(j++) === nch) { 18 | continue outer; 19 | } 20 | } 21 | return false; 22 | } 23 | return true; 24 | } 25 | -------------------------------------------------------------------------------- /app/assets/javascripts/blazer/queries.js: -------------------------------------------------------------------------------- 1 | var pendingQueries = [] 2 | var runningQueries = [] 3 | var maxQueries = 3 4 | 5 | function runQuery(data, success, error) { 6 | if (!data.data_source) { 7 | throw new Error("Data source is required to cancel queries") 8 | } 9 | data.run_id = uuid() 10 | var query = { 11 | data: data, 12 | success: success, 13 | error: error, 14 | run_id: data.run_id, 15 | data_source: data.data_source, 16 | canceled: false 17 | } 18 | pendingQueries.push(query) 19 | runNext() 20 | return query 21 | } 22 | 23 | function runNext() { 24 | if (runningQueries.length < maxQueries) { 25 | var query = pendingQueries.shift() 26 | if (query) { 27 | runningQueries.push(query) 28 | runQueryHelper(query) 29 | runNext() 30 | } 31 | } 32 | } 33 | 34 | function runQueryHelper(query) { 35 | var xhr = $.ajax({ 36 | url: Routes.run_queries_path(), 37 | method: "POST", 38 | data: query.data, 39 | dataType: "html" 40 | }).done( function (d) { 41 | if (d[0] == "{") { 42 | var response = $.parseJSON(d) 43 | query.data.blazer = response 44 | setTimeout( function () { 45 | if (!query.canceled) { 46 | runQueryHelper(query) 47 | } 48 | }, 1000) 49 | } else { 50 | if (!query.canceled) { 51 | query.success(d) 52 | } 53 | queryComplete(query) 54 | } 55 | }).fail( function(jqXHR, textStatus, errorThrown) { 56 | // check jqXHR.status instead of query.canceled 57 | // so it works for page navigation with Firefox and Safari 58 | if (jqXHR.status === 0) { 59 | cancelServerQuery(query) 60 | } else { 61 | var message = (typeof errorThrown === "string") ? errorThrown : errorThrown.message 62 | if (!message) { 63 | message = "An error occurred" 64 | } 65 | query.error(message) 66 | } 67 | queryComplete(query) 68 | }) 69 | query.xhr = xhr 70 | return xhr 71 | } 72 | 73 | function queryComplete(query) { 74 | var index = runningQueries.indexOf(query) 75 | runningQueries.splice(index, 1) 76 | runNext() 77 | } 78 | 79 | function uuid() { 80 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { 81 | var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8) 82 | return v.toString(16) 83 | }) 84 | } 85 | 86 | function cancelAllQueries() { 87 | pendingQueries = [] 88 | for (var i = 0; i < runningQueries.length; i++) { 89 | cancelQuery(runningQueries[i]) 90 | } 91 | } 92 | 93 | // needed for Chrome 94 | // queries are canceled before unload with Firefox and Safari 95 | $(window).on("unload", cancelAllQueries) 96 | 97 | function cancelQuery(query) { 98 | query.canceled = true 99 | if (query.xhr) { 100 | query.xhr.abort() 101 | } 102 | } 103 | 104 | function cancelServerQuery(query) { 105 | // tell server 106 | var path = Routes.cancel_queries_path() 107 | var data = {run_id: query.run_id, data_source: query.data_source} 108 | if (navigator.sendBeacon) { 109 | // use FormData over Blob and URLSearchParams for maximum compatibility 110 | // Blob works with Chrome 81+ and URLSearchParams works with Chrome 88+ 111 | var formdata = new FormData() 112 | var params = csrfProtect(data) 113 | for (var key in params) { 114 | if (Object.prototype.hasOwnProperty.call(params, key)) { 115 | formdata.append(key, params[key]) 116 | } 117 | } 118 | navigator.sendBeacon(path, formdata) 119 | } else { 120 | // TODO make sync 121 | $.post(path, data) 122 | } 123 | } 124 | 125 | function csrfProtect(payload) { 126 | var param = $("meta[name=csrf-param]").attr("content") 127 | var token = $("meta[name=csrf-token]").attr("content") 128 | if (param && token) payload[param] = token 129 | return payload 130 | } 131 | -------------------------------------------------------------------------------- /app/assets/javascripts/blazer/routes.js: -------------------------------------------------------------------------------- 1 | var Routes = { 2 | run_queries_path: function() { 3 | return rootPath + "queries/run" 4 | }, 5 | cancel_queries_path: function() { 6 | return rootPath + "queries/cancel" 7 | }, 8 | schema_queries_path: function(params) { 9 | return rootPath + "queries/schema?" + $.param(params) 10 | }, 11 | docs_queries_path: function(params) { 12 | return rootPath + "queries/docs?" + $.param(params) 13 | }, 14 | tables_queries_path: function(params) { 15 | return rootPath + "queries/tables?" + $.param(params) 16 | }, 17 | queries_path: function() { 18 | return rootPath + "queries" 19 | }, 20 | query_path: function(id) { 21 | return rootPath + "queries/" + id 22 | }, 23 | dashboard_path: function(id) { 24 | return rootPath + "dashboards/" + id 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/assets/javascripts/blazer/stupidtable-custom-settings.js: -------------------------------------------------------------------------------- 1 | function removeCommas(string) { 2 | return string.replace(/,/g, "") 3 | } 4 | 5 | // Remove commas for integers and floats to fix issues with sorting in the stupidtable plugin 6 | var stupidtableCustomSettings = { 7 | "int": function(a, b) { 8 | return parseInt(removeCommas(a), 10) - parseInt(removeCommas(b), 10); 9 | }, 10 | "float": function(a, b) { 11 | return parseFloat(removeCommas(a)) - parseFloat(removeCommas(b)); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/assets/stylesheets/blazer/bootstrap-propshaft.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.4.1 (https://getbootstrap.com/) 3 | * Copyright 2011-2019 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | @font-face { 7 | font-family: "Glyphicons Halflings"; 8 | src: url('/blazer/glyphicons-halflings-regular.eot'); 9 | src: url('/blazer/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('/blazer/glyphicons-halflings-regular.woff2') format('woff2'), url('/blazer/glyphicons-halflings-regular.woff') format('woff'), url('/blazer/glyphicons-halflings-regular.ttf') format('truetype'), url('/blazer/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); 10 | } 11 | -------------------------------------------------------------------------------- /app/assets/stylesheets/blazer/bootstrap-sprockets.css.erb: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v3.4.1 (https://getbootstrap.com/) 3 | * Copyright 2011-2019 Twitter, Inc. 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | @font-face { 7 | font-family: "Glyphicons Halflings"; 8 | src: url('<%= font_path("blazer/glyphicons-halflings-regular.eot") %>'); 9 | src: url('<%= font_path("blazer/glyphicons-halflings-regular.eot?#iefix") %>') format('embedded-opentype'), url('<%= font_path("blazer/glyphicons-halflings-regular.woff2") %>') format('woff2'), url('<%= font_path("blazer/glyphicons-halflings-regular.woff") %>') format('woff'), url('<%= font_path("blazer/glyphicons-halflings-regular.ttf") %>') format('truetype'), url('<%= font_path("blazer/glyphicons-halflings-regular.svg#glyphicons_halflingsregular") %>') format('svg'); 10 | } 11 | -------------------------------------------------------------------------------- /app/assets/stylesheets/blazer/github.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | github.com style (c) Vasily Polovnyov 4 | 5 | */ 6 | 7 | .hljs { 8 | display: block; 9 | color: #333; 10 | } 11 | 12 | .hljs-comment, 13 | .hljs-template_comment, 14 | .diff .hljs-header, 15 | .hljs-javadoc { 16 | color: #998; 17 | font-style: italic 18 | } 19 | 20 | .hljs-keyword, 21 | .css .rule .hljs-keyword, 22 | .hljs-winutils, 23 | .javascript .hljs-title, 24 | .nginx .hljs-title, 25 | .hljs-subst, 26 | .hljs-request, 27 | .hljs-status { 28 | color: #333; 29 | font-weight: bold 30 | } 31 | 32 | .hljs-number, 33 | .hljs-hexcolor, 34 | .ruby .hljs-constant { 35 | color: #099; 36 | } 37 | 38 | .hljs-string, 39 | .hljs-tag .hljs-value, 40 | .hljs-phpdoc, 41 | .tex .hljs-formula { 42 | color: #d14 43 | } 44 | 45 | .hljs-title, 46 | .hljs-id, 47 | .coffeescript .hljs-params, 48 | .scss .hljs-preprocessor { 49 | color: #900; 50 | font-weight: bold 51 | } 52 | 53 | .javascript .hljs-title, 54 | .lisp .hljs-title, 55 | .clojure .hljs-title, 56 | .hljs-subst { 57 | font-weight: normal 58 | } 59 | 60 | .hljs-class .hljs-title, 61 | .haskell .hljs-type, 62 | .vhdl .hljs-literal, 63 | .tex .hljs-command { 64 | color: #458; 65 | font-weight: bold 66 | } 67 | 68 | .hljs-tag, 69 | .hljs-tag .hljs-title, 70 | .hljs-rules .hljs-property, 71 | .django .hljs-tag .hljs-keyword { 72 | color: #000080; 73 | font-weight: normal 74 | } 75 | 76 | .hljs-attribute, 77 | .lisp .hljs-body { 78 | color: #008080 79 | } 80 | 81 | .hljs-variable, 82 | .hljs-regexp { 83 | color: #009926; 84 | font-weight: bold 85 | } 86 | 87 | .hljs-symbol, 88 | .ruby .hljs-symbol .hljs-string, 89 | .lisp .hljs-keyword, 90 | .tex .hljs-special, 91 | .hljs-prompt { 92 | color: #990073 93 | } 94 | 95 | .hljs-built_in, 96 | .lisp .hljs-title, 97 | .clojure .hljs-built_in { 98 | color: #0086b3 99 | } 100 | 101 | .hljs-preprocessor, 102 | .hljs-pragma, 103 | .hljs-pi, 104 | .hljs-doctype, 105 | .hljs-shebang, 106 | .hljs-cdata { 107 | color: #999; 108 | font-weight: bold 109 | } 110 | 111 | .hljs-deletion { 112 | background: #fdd 113 | } 114 | 115 | .hljs-addition { 116 | background: #dfd 117 | } 118 | 119 | .diff .hljs-change { 120 | background: #0086b3 121 | } 122 | 123 | .hljs-chunk { 124 | color: #aaa 125 | } 126 | -------------------------------------------------------------------------------- /app/controllers/blazer/checks_controller.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class ChecksController < BaseController 3 | before_action :set_check, only: [:edit, :update, :destroy, :run] 4 | 5 | def index 6 | state_order = [nil, "disabled", "error", "timed out", "failing", "passing"] 7 | @checks = Blazer::Check.joins(:query).includes(:query).order("blazer_queries.name, blazer_checks.id").to_a.sort_by { |q| state_order.index(q.state) || 99 } 8 | @checks.select! { |c| "#{c.query.name} #{c.emails}".downcase.include?(params[:q]) } if params[:q] 9 | end 10 | 11 | def new 12 | @check = Blazer::Check.new(query_id: params[:query_id]) 13 | end 14 | 15 | def create 16 | @check = Blazer::Check.new(check_params) 17 | # use creator_id instead of creator 18 | # since we setup association without checking if column exists 19 | @check.creator = blazer_user if @check.respond_to?(:creator_id=) && blazer_user 20 | 21 | if @check.save 22 | redirect_to query_path(@check.query) 23 | else 24 | render_errors @check 25 | end 26 | end 27 | 28 | def update 29 | if @check.update(check_params) 30 | redirect_to query_path(@check.query) 31 | else 32 | render_errors @check 33 | end 34 | end 35 | 36 | def destroy 37 | @check.destroy 38 | redirect_to checks_path 39 | end 40 | 41 | def run 42 | @query = @check.query 43 | redirect_to query_path(@query) 44 | end 45 | 46 | private 47 | 48 | def check_params 49 | params.require(:check).permit(:query_id, :emails, :slack_channels, :invert, :check_type, :schedule) 50 | end 51 | 52 | def set_check 53 | @check = Blazer::Check.find(params[:id]) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /app/controllers/blazer/dashboards_controller.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class DashboardsController < BaseController 3 | before_action :set_dashboard, only: [:show, :edit, :update, :destroy, :refresh] 4 | 5 | def new 6 | @dashboard = Blazer::Dashboard.new 7 | end 8 | 9 | def create 10 | @dashboard = Blazer::Dashboard.new 11 | # use creator_id instead of creator 12 | # since we setup association without checking if column exists 13 | @dashboard.creator = blazer_user if @dashboard.respond_to?(:creator_id=) && blazer_user 14 | 15 | if update_dashboard(@dashboard) 16 | redirect_to dashboard_path(@dashboard) 17 | else 18 | render_errors @dashboard 19 | end 20 | end 21 | 22 | def show 23 | @queries = @dashboard.dashboard_queries.order(:position).preload(:query).map(&:query) 24 | @queries.each do |query| 25 | @success = process_vars(query.statement_object) 26 | end 27 | @bind_vars ||= [] 28 | 29 | @smart_vars = {} 30 | @sql_errors = [] 31 | @data_sources = @queries.map { |q| Blazer.data_sources[q.data_source] }.uniq 32 | @bind_vars.each do |var| 33 | @data_sources.each do |data_source| 34 | smart_var, error = parse_smart_variables(var, data_source) 35 | ((@smart_vars[var] ||= []).concat(smart_var)).uniq! if smart_var 36 | @sql_errors << error if error 37 | end 38 | end 39 | 40 | add_cohort_analysis_vars if @queries.any?(&:cohort_analysis?) 41 | end 42 | 43 | def edit 44 | end 45 | 46 | def update 47 | if update_dashboard(@dashboard) 48 | redirect_to dashboard_path(@dashboard, params: variable_params(@dashboard)) 49 | else 50 | render_errors @dashboard 51 | end 52 | end 53 | 54 | def destroy 55 | @dashboard.destroy 56 | redirect_to root_path 57 | end 58 | 59 | def refresh 60 | @dashboard.queries.each do |query| 61 | refresh_query(query) 62 | end 63 | redirect_to dashboard_path(@dashboard, params: variable_params(@dashboard)) 64 | end 65 | 66 | private 67 | 68 | def dashboard_params 69 | params.require(:dashboard).permit(:name) 70 | end 71 | 72 | def set_dashboard 73 | @dashboard = Blazer::Dashboard.find(params[:id]) 74 | end 75 | 76 | def update_dashboard(dashboard) 77 | dashboard.assign_attributes(dashboard_params) 78 | Blazer::Dashboard.transaction do 79 | if params[:query_ids].is_a?(Array) 80 | query_ids = params[:query_ids].map(&:to_i) 81 | @queries = Blazer::Query.find(query_ids).sort_by { |q| query_ids.index(q.id) } 82 | end 83 | if dashboard.save 84 | if @queries 85 | @queries.each_with_index do |query, i| 86 | dashboard_query = dashboard.dashboard_queries.where(query_id: query.id).first_or_initialize 87 | dashboard_query.position = i 88 | dashboard_query.save! 89 | end 90 | if dashboard.persisted? 91 | dashboard.dashboard_queries.where.not(query_id: query_ids).destroy_all 92 | end 93 | end 94 | true 95 | end 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /app/helpers/blazer/base_helper.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module BaseHelper 3 | def blazer_title(title = nil) 4 | if title 5 | content_for(:title) { title } 6 | else 7 | content_for?(:title) ? content_for(:title) : nil 8 | end 9 | end 10 | 11 | BLAZER_URL_REGEX = /\Ahttps?:\/\/[\S]+\z/ 12 | BLAZER_IMAGE_EXT = %w[png jpg jpeg gif] 13 | 14 | def blazer_format_value(key, value) 15 | if value.is_a?(Numeric) && !key.to_s.end_with?("id") && !key.to_s.start_with?("id") 16 | number_with_delimiter(value) 17 | elsif value.is_a?(String) && value =~ BLAZER_URL_REGEX 18 | # see if image or link 19 | if Blazer.images && (key.include?("image") || BLAZER_IMAGE_EXT.include?(value.split(".").last.split("?").first.try(:downcase))) 20 | link_to value, target: "_blank" do 21 | image_tag value, referrerpolicy: "no-referrer" 22 | end 23 | else 24 | link_to value, value, target: "_blank" 25 | end 26 | else 27 | value 28 | end 29 | end 30 | 31 | def blazer_js_var(name, value) 32 | "var #{name} = #{json_escape(value.to_json(root: false))};".html_safe 33 | end 34 | 35 | def blazer_series_name(k) 36 | k.nil? ? "null" : k.to_s 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/models/blazer/audit.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class Audit < Record 3 | belongs_to :user, optional: true, class_name: Blazer.user_class.to_s 4 | belongs_to :query, optional: true 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/models/blazer/check.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class Check < Record 3 | belongs_to :creator, optional: true, class_name: Blazer.user_class.to_s if Blazer.user_class 4 | belongs_to :query 5 | 6 | validates :query_id, presence: true 7 | validate :validate_emails 8 | validate :validate_variables, if: -> { query_id_changed? } 9 | 10 | before_validation :set_state 11 | before_validation :fix_emails 12 | 13 | def split_emails 14 | emails.to_s.downcase.split(",").map(&:strip) 15 | end 16 | 17 | def split_slack_channels 18 | if Blazer.slack? 19 | slack_channels.to_s.downcase.split(",").map(&:strip) 20 | else 21 | [] 22 | end 23 | end 24 | 25 | def update_state(result) 26 | check_type = 27 | if respond_to?(:check_type) 28 | self.check_type 29 | elsif respond_to?(:invert) 30 | invert ? "missing_data" : "bad_data" 31 | else 32 | "bad_data" 33 | end 34 | 35 | message = result.error 36 | 37 | self.state = 38 | if result.timed_out? 39 | "timed out" 40 | elsif result.error 41 | "error" 42 | elsif check_type == "anomaly" 43 | anomaly, message = result.detect_anomaly 44 | if anomaly.nil? 45 | "error" 46 | elsif anomaly 47 | "failing" 48 | else 49 | "passing" 50 | end 51 | elsif result.rows.any? 52 | check_type == "missing_data" ? "passing" : "failing" 53 | else 54 | check_type == "missing_data" ? "failing" : "passing" 55 | end 56 | 57 | self.last_run_at = Time.now if respond_to?(:last_run_at=) 58 | self.message = message if respond_to?(:message=) 59 | 60 | if respond_to?(:timeouts=) 61 | if result.timed_out? 62 | self.timeouts += 1 63 | self.state = "disabled" if timeouts >= 3 64 | else 65 | self.timeouts = 0 66 | end 67 | end 68 | 69 | # do not notify on creation, except when not passing 70 | if (state_was != "new" || state != "passing") && state != state_was 71 | Blazer::CheckMailer.state_change(self, state, state_was, result.rows.size, message, result.columns, result.rows.first(10).as_json, result.column_types, check_type).deliver_now if emails.present? 72 | Blazer::SlackNotifier.state_change(self, state, state_was, result.rows.size, message, check_type) 73 | end 74 | save! if changed? 75 | end 76 | 77 | private 78 | 79 | def set_state 80 | self.state ||= "new" 81 | end 82 | 83 | def fix_emails 84 | # some people like doing ; instead of , 85 | # but we know what they mean, so let's fix it 86 | # also, some people like to use whitespace 87 | if emails.present? 88 | self.emails = emails.strip.gsub(/[;\s]/, ",").gsub(/,+/, ", ") 89 | end 90 | end 91 | 92 | def validate_emails 93 | unless split_emails.all? { |e| e =~ /\A\S+@\S+\.\S+\z/ } 94 | errors.add(:base, "Invalid emails") 95 | end 96 | end 97 | 98 | def validate_variables 99 | if query.variables.any? 100 | errors.add(:base, "Query can't have variables") 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /app/models/blazer/connection.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class Connection < ActiveRecord::Base 3 | self.abstract_class = true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/models/blazer/dashboard.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class Dashboard < Record 3 | belongs_to :creator, optional: true, class_name: Blazer.user_class.to_s if Blazer.user_class 4 | has_many :dashboard_queries, dependent: :destroy 5 | has_many :queries, through: :dashboard_queries 6 | 7 | validates :name, presence: true 8 | 9 | def variables 10 | queries.flat_map { |q| q.variables }.uniq 11 | end 12 | 13 | def to_param 14 | [id, name.gsub("'", "").parameterize].join("-") 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/blazer/dashboard_query.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class DashboardQuery < Record 3 | belongs_to :dashboard 4 | belongs_to :query 5 | 6 | validates :dashboard_id, presence: true 7 | validates :query_id, presence: true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/models/blazer/query.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class Query < Record 3 | belongs_to :creator, optional: true, class_name: Blazer.user_class.to_s if Blazer.user_class 4 | has_many :checks, dependent: :destroy 5 | has_many :dashboard_queries, dependent: :destroy 6 | has_many :dashboards, through: :dashboard_queries 7 | has_many :audits 8 | 9 | validates :statement, presence: true 10 | 11 | scope :active, -> { column_names.include?("status") ? where(status: ["active", nil]) : all } 12 | scope :named, -> { where.not(name: "") } 13 | 14 | def to_param 15 | [id, name].compact.join("-").gsub("'", "").parameterize 16 | end 17 | 18 | def friendly_name 19 | name.to_s.sub(/\A[#\*]/, "").gsub(/\[.+\]/, "").strip 20 | end 21 | 22 | def editable?(user) 23 | !persisted? || (name.present? && name.first != "*" && name.first != "#") || user == try(:creator) 24 | end 25 | 26 | def variables 27 | # don't require data_source to be loaded 28 | variables = Statement.new(statement).variables 29 | variables += ["cohort_period"] if cohort_analysis? 30 | variables 31 | end 32 | 33 | def cohort_analysis? 34 | # don't require data_source to be loaded 35 | Statement.new(statement).cohort_analysis? 36 | end 37 | 38 | def statement_object 39 | Statement.new(statement, data_source) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /app/models/blazer/record.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class Record < ActiveRecord::Base 3 | self.abstract_class = true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/models/blazer/upload.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class Upload < Record 3 | belongs_to :creator, optional: true, class_name: Blazer.user_class.to_s if Blazer.user_class 4 | 5 | validates :table, presence: true, uniqueness: true, format: {with: /\A[a-z0-9_]+\z/, message: "can only contain lowercase letters, numbers, and underscores"}, length: {maximum: 63} 6 | 7 | def table_name 8 | Blazer.uploads_table_name(table) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /app/models/blazer/uploads_connection.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class UploadsConnection < ActiveRecord::Base 3 | self.abstract_class = true 4 | 5 | establish_connection Blazer.settings["uploads"]["url"] if Blazer.uploads? 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/views/blazer/_nav.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= link_to "Home", root_path, class: "btn btn-primary" %> 3 | 7 | 18 |
19 | -------------------------------------------------------------------------------- /app/views/blazer/check_mailer/failing_checks.html.erb: -------------------------------------------------------------------------------- 1 | 7 |

<%= link_to "Manage checks", checks_url %>

8 | -------------------------------------------------------------------------------- /app/views/blazer/check_mailer/state_change.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | <%# check queries shouldn't have variables, but in any case, don't pass them to url helpers %> 6 |

<%= link_to "View", query_url(@check.query_id) %>

7 | <% if @error %> 8 |

<%= @error %>

9 | <% elsif @rows_count > 0 && @check_type == "bad_data" %> 10 |

11 | <% if @rows_count <= 10 %> 12 | <%= pluralize(@rows_count, "row") %> 13 | <% else %> 14 | Showing 10 of <%= @rows_count %> rows 15 | <% end %> 16 |

17 | 18 | 19 | 20 | <% @columns.first(5).each do |column| %> 21 | 24 | <% end %> 25 | 26 | 27 | 28 | <% @rows.first(10).each do |row| %> 29 | 30 | <% @columns.first(5).each_with_index do |column, i| %> 31 | 38 | <% end %> 39 | 40 | <% end %> 41 | 42 |
22 | <%= column %> 23 |
32 | <% value = row[i] %> 33 | <% if @column_types[i] == "time" && value.to_s.length > 10 %> 34 | <% value = Time.parse(value).in_time_zone(Blazer.time_zone) rescue value %> 35 | <% end %> 36 | <%= value %> 37 |
43 | <% if @columns.size > 5 %> 44 |

Only first 5 columns shown

45 | <% end %> 46 | <% end %> 47 | 48 | 49 | -------------------------------------------------------------------------------- /app/views/blazer/checks/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for @check, html: {class: "small-form"} do |f| %> 2 | <% unless @check.respond_to?(:check_type) || @check.respond_to?(:invert) %> 3 |

Checks are designed to identify bad data. A check fails if there are any results.

4 | <% end %> 5 | 6 | <% if @check.errors.any? %> 7 |
<%= @check.errors.full_messages.first %>
8 | <% end %> 9 | 10 |
11 | <%= f.label :query_id, "Query" %> 12 |
13 | <%= f.select :query_id, [], {include_blank: true} %> 14 |
15 | <%= javascript_tag nonce: true do %> 16 | <%= blazer_js_var "queries", Blazer::Query.active.named.order(:name).select("id, name").map { |q| {text: q.name, value: q.id} } %> 17 | <%= blazer_js_var "items", [@check.query_id].compact %> 18 | 19 | $("#check_query_id").selectize({options: queries, items: items, highlight: false, maxOptions: 100}).parents(".hide").removeClass("hide"); 20 | <% end %> 21 |
22 | 23 | <% if @check.respond_to?(:check_type) %> 24 |
25 | <%= f.label :check_type, "Alert if" %> 26 |
27 | <% check_options = [["Any results (bad data)", "bad_data"], ["No results (missing data)", "missing_data"]] %> 28 | <% check_options << ["Anomaly (most recent data point)", "anomaly"] if Blazer.anomaly_checks %> 29 | <%= f.select :check_type, check_options %> 30 |
31 | <%= javascript_tag nonce: true do %> 32 | $("#check_check_type").selectize({}).parent().removeClass("hide"); 33 | <% end %> 34 |
35 | <% elsif @check.respond_to?(:invert) %> 36 |
37 | <%= f.label :invert, "Fails if" %> 38 |
39 | <%= f.select :invert, [["Any results (bad data)", false], ["No results (missing data)", true]] %> 40 |
41 | <%= javascript_tag nonce: true do %> 42 | $("#check_invert").selectize({}).parent().removeClass("hide"); 43 | <% end %> 44 |
45 | <% end %> 46 | 47 | <% if @check.respond_to?(:schedule) && Blazer.check_schedules %> 48 |
49 | <%= f.label :schedule, "Run every" %> 50 |
51 | <%= f.select :schedule, Blazer.check_schedules.map { |v| [v, v] } %> 52 |
53 | <%= javascript_tag nonce: true do %> 54 | $("#check_schedule").selectize({}).parent().removeClass("hide"); 55 | <% end %> 56 |
57 | <% end %> 58 | 59 |
60 | <%= f.label :emails %> 61 | <%= f.text_field :emails, placeholder: "Optional, comma separated", class: "form-control" %> 62 |
63 | 64 | <% if Blazer.slack? %> 65 |
66 | <%= f.label :slack_channels %> 67 | <%= f.text_field :slack_channels, placeholder: "Optional, comma separated", class: "form-control" %> 68 |
69 | <% end %> 70 | 71 |

Emails <%= Blazer.slack? ? "and Slack notifications " : nil %>are sent when a check starts failing, and when it starts passing again. 72 |

73 | <% if @check.persisted? %> 74 | <%= link_to "Delete", check_path(@check), method: :delete, "data-confirm" => "Are you sure?", class: "btn btn-danger" %> 75 | <% end %> 76 | <%= f.submit "Save", class: "btn btn-success" %> 77 | <%= link_to "Back", :back, class: "btn btn-link" %> 78 |

79 | <% end %> 80 | -------------------------------------------------------------------------------- /app/views/blazer/checks/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title "Edit Check" %> 2 | 3 | <%= render partial: "form" %> 4 | -------------------------------------------------------------------------------- /app/views/blazer/checks/index.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title "Checks" %> 2 | 3 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | <% @checks.each do |check| %> 38 | 39 | 40 | 45 | 46 | 56 | 60 | 61 | <% end %> 62 | 63 |
QueryStateRunNotify
<%= link_to check.query.name, check.query %> <%= check.try(:check_type).to_s.gsub("_", " ") %> 41 | <% if check.state %> 42 | "><%= check.state.upcase %> 43 | <% end %> 44 | <%= check.schedule if check.respond_to?(:schedule) %> 47 |
    48 | <% check.split_emails.each do |email| %> 49 |
  • <%= email %>
  • 50 | <% end %> 51 | <% check.split_slack_channels.each do |channel| %> 52 |
  • <%= channel %>
  • 53 | <% end %> 54 |
55 |
57 | <%= link_to "Edit", edit_check_path(check), class: "btn btn-info" %> 58 | <%= button_to "Run Now", refresh_query_path(check.query), class: "btn btn-primary" %> 59 |
64 | 65 | <%= javascript_tag nonce: true do %> 66 | $("#search").on("keyup", function() { 67 | var value = $(this).val().toLowerCase() 68 | $("#checks tbody tr").filter( function() { 69 | $(this).toggle($(this).text().toLowerCase().indexOf(value) > -1) 70 | }) 71 | }).focus() 72 | <% end %> 73 | -------------------------------------------------------------------------------- /app/views/blazer/checks/new.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title "New Check" %> 2 | 3 | <%= render partial: "form" %> 4 | -------------------------------------------------------------------------------- /app/views/blazer/dashboards/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for @dashboard, url: (@dashboard.persisted? ? dashboard_path(@dashboard, params: variable_params(@dashboard)) : dashboards_path(params: variable_params(@dashboard))), html: {id: "app", class: "small-form"} do |f| %> 2 | <% if @dashboard.errors.any? %> 3 |
<%= @dashboard.errors.full_messages.first %>
4 | <% end %> 5 | 6 |
7 | <%= f.label :name %> 8 | <%= f.text_field :name, class: "form-control" %> 9 |
10 |
11 | <%= f.label :charts %> 12 | 19 |
20 |
21 | <%= f.label :query_id, "Add Chart" %> 22 | <%= select_tag :query_id, nil, {include_blank: true, placeholder: "Select chart"} %> 23 |
24 |

25 | <% if @dashboard.persisted? %> 26 | <%= link_to "Delete", dashboard_path(@dashboard), method: :delete, "data-confirm" => "Are you sure?", class: "btn btn-danger" %> 27 | <% end %> 28 | <%= f.submit "Save", class: "btn btn-success" %> 29 | <%= link_to "Back", :back, class: "btn btn-link" %> 30 |

31 | <% end %> 32 | 33 | <%= javascript_tag nonce: true do %> 34 | <%= blazer_js_var "queries", Blazer::Query.active.named.order(:name).select("id, name").map { |q| {text: q.name, value: q.id} } %> 35 | <%= blazer_js_var "dashboardQueries", @queries || @dashboard.dashboard_queries.order(:position).map(&:query) %> 36 | 37 | var app = Vue.createApp({ 38 | data: function() { 39 | return { 40 | queries: dashboardQueries 41 | } 42 | }, 43 | methods: { 44 | remove: function(index) { 45 | this.queries.splice(index, 1) 46 | } 47 | }, 48 | mounted: function() { 49 | var app = this 50 | $("#query_id").selectize({ 51 | options: queries, 52 | highlight: false, 53 | maxOptions: 100, 54 | onChange: function(val) { 55 | if (val) { 56 | var item = this.getItem(val) 57 | 58 | // if duplicate query is added, remove the first one 59 | for (var i = 0; i < app.queries.length; i++) { 60 | if (app.queries[i].id == val) { 61 | app.queries.splice(i, 1) 62 | break 63 | } 64 | } 65 | 66 | app.queries.push({id: val, name: item.text()}) 67 | this.setValue("") 68 | } 69 | } 70 | }) 71 | } 72 | }) 73 | app.config.compilerOptions.whitespace = "preserve" 74 | app.mount("#app") 75 | 76 | Sortable.create($("#queries").get(0), { 77 | onEnd: function(e) { 78 | var app = window.app._component.data() 79 | app.queries.splice(e.newIndex, 0, app.queries.splice(e.oldIndex, 1)[0]) 80 | } 81 | }) 82 | <% end %> 83 | -------------------------------------------------------------------------------- /app/views/blazer/dashboards/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title "Edit Dashboard" %> 2 | 3 | <%= render partial: "form" %> 4 | -------------------------------------------------------------------------------- /app/views/blazer/dashboards/new.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title "New Dashboard" %> 2 | 3 | <%= render partial: "form" %> 4 | -------------------------------------------------------------------------------- /app/views/blazer/dashboards/show.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title @dashboard.name %> 2 | 3 |
4 |
5 |
6 |
7 | <%= render partial: "blazer/nav" %> 8 |

9 | <%= @dashboard.name %> 10 |

11 |
12 |
13 | <%= link_to "Edit", edit_dashboard_path(@dashboard, params: variable_params(@dashboard)), class: "btn btn-info" %> 14 |
15 |
16 |
17 |
18 | 19 |
20 | 21 | <% if @data_sources.any? { |ds| ds.cache_mode != "off" } %> 22 |

23 | Some queries may be cached 24 | <%= link_to "Refresh", refresh_dashboard_path(@dashboard, params: variable_params(@dashboard)), method: :post %> 25 |

26 | <% end %> 27 | 28 | <% if @bind_vars.any? %> 29 | <%= render partial: "blazer/variables", locals: {action: dashboard_path(@dashboard)} %> 30 | <% else %> 31 |
32 | <% end %> 33 | 34 | <% @queries.each_with_index do |query, i| %> 35 |
36 |

<%= link_to query.friendly_name, query_path(query, params: variable_params(query)), target: "_blank" %>

37 |
38 |

Loading...

39 |
40 |
41 | <%= javascript_tag nonce: true do %> 42 | <% data = {statement: query.statement, query_id: query.id, data_source: query.data_source, variables: variable_params(query), only_chart: true} %> 43 | <% data.merge!(cohort_period: params[:cohort_period]) if params[:cohort_period] %> 44 | <%= blazer_js_var "data", data %> 45 | 46 | runQuery(data, function (data) { 47 | $("#chart-<%= i %>").html(data) 48 | $("#chart-<%= i %> table").stupidtable(stupidtableCustomSettings) 49 | }, function (message) { 50 | $("#chart-<%= i %>").addClass("query-error").html(message) 51 | }); 52 | <% end %> 53 | <% end %> 54 | -------------------------------------------------------------------------------- /app/views/blazer/queries/_caching.html.erb: -------------------------------------------------------------------------------- 1 | <% if @cached_at || @just_cached %> 2 |

3 | <% if @cached_at %> 4 | Cached <%= time_ago_in_words(@cached_at, include_seconds: true) %> ago 5 | <% elsif params[:query_id] %> 6 | Cached just now 7 | <% if @data_source.cache_mode == "slow" %> 8 | (over <%= "%g" % @data_source.cache_slow_threshold %>s) 9 | <% end %> 10 | <% end %> 11 | 12 | <% if @query && params[:query_id] %> 13 | <%= link_to "Refresh", refresh_query_path(@query, params: variable_params(@query, @var_params)), method: :post %> 14 | <% end %> 15 |

16 | <% end %> 17 | -------------------------------------------------------------------------------- /app/views/blazer/queries/_cohorts.html.erb: -------------------------------------------------------------------------------- 1 | <% unless @only_chart %> 2 | <%= render partial: "caching" %> 3 |

4 | <%= pluralize(@rows.size, "cohort") %> 5 |

6 | <% end %> 7 | <% if @rows.any? %> 8 |
9 | 10 | 11 | 12 | 13 | <% 12.times do |i| %> 14 | 15 | <% end %> 16 | 17 | 18 | 19 | <% @rows.each do |row| %> 20 | 21 | 25 | <% 12.times do |i| %> 26 | 40 | <% end %> 41 | 42 | <% end %> 43 | 44 |
Cohort<%= @conversion_period.titleize %> <%= i + 1 %>
22 | <%= row[0] %> 23 |
<%= row[1] == 1 ? "1 user" : "#{number_with_delimiter(row[1])} users" %>
24 |
27 | <% num = row[i + 2] %> 28 | <% if num %> 29 | <% denom = row[1] %> 30 | <% if denom > 0 %> 31 | <%= (100.0 * num / denom).round %>% 32 | <% else %> 33 | - 34 | <% end %> 35 |
<%= number_with_delimiter(num) %>
36 | <% else %> 37 | - 38 | <% end %> 39 |
45 |
46 | <% elsif @only_chart %> 47 |

No cohorts

48 | <% end %> 49 | -------------------------------------------------------------------------------- /app/views/blazer/queries/docs.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title "Docs: #{@data_source.name}" %> 2 | 3 |

Docs: <%= @data_source.name %>

4 | 5 |
6 | 7 |

Smart Variables

8 | 9 | <% if @smart_variables.any? %> 10 |

Use these variable names to get a dropdown of values.

11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | <% @smart_variables.each do |k, _| %> 20 | 21 | 22 | 23 | <% end %> 24 | 25 |
Variable
{<%= k %>}
26 | 27 |

Use {start_time} and {end_time} for a date range selector. End a variable name with _at for a date selector.

28 | <% else %> 29 |

None set - add them in config/blazer.yml.

30 | <% end %> 31 | 32 |

Linked Columns

33 | 34 | <% if @linked_columns.any? %> 35 |

Use these column names to link results to other pages.

36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | <% @linked_columns.each do |k, v| %> 46 | 47 | 48 | 49 | 50 | <% end %> 51 | 52 |
NameURL
<%= k %><%= v %>
53 | 54 |

Values that match the format of a URL will be linked automatically.

55 | <% else %> 56 |

None set - add them in config/blazer.yml.

57 | <% end %> 58 | 59 |

Smart Columns

60 | 61 | <% if @smart_columns.any? %> 62 |

Use these column names to show additional data.

63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | <% @smart_columns.each do |k, _| %> 72 | 73 | 74 | 75 | <% end %> 76 | 77 |
Name
<%= k %>
78 | <% else %> 79 |

None set - add them in config/blazer.yml.

80 | <% end %> 81 | 82 |

Charts

83 | 84 |

Use specific combinations of column types to generate charts.

85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 127 | 128 | 129 | 130 | 137 | 138 | 139 |
ChartColumn Types
Line2+ columns - timestamp, numeric(s)
Line3 columns - timestamp, string, numeric
Column2+ columns - string, numeric(s)
Column3 columns - string, string, numeric
Scatter2 columns - both numeric
Pie2 columns - string, numeric - and last column named pie
Map 121 | Named latitude and longitude, or lat and lon, or lat and lng 122 | <% if !Blazer.maps? %> 123 |
124 | Needs configured 125 | <% end %> 126 |
Area Map 131 | Named geojson 132 | <% if !Blazer.maps? %> 133 |
134 | Needs configured 135 | <% end %> 136 |
140 | 141 |

Use the column name target to draw a line for goals.

142 | 143 | <% if @data_source.supports_cohort_analysis? %> 144 |

Cohort Analysis

145 | 146 |

Create a query with the comment /* cohort analysis */. The result should have columns named user_id and conversion_time and optionally cohort_time.

147 | <% end %> 148 | -------------------------------------------------------------------------------- /app/views/blazer/queries/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title "Edit - #{@query.name}" %> 2 | <%= render partial: "form" %> 3 | -------------------------------------------------------------------------------- /app/views/blazer/queries/new.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title "New Query" %> 2 | <%= render partial: "form" %> 3 | -------------------------------------------------------------------------------- /app/views/blazer/queries/schema.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title "Schema: #{@data_source.name}" %> 2 | 3 |

Schema: <%= @data_source.name %>

4 | 5 |
6 | 7 | 10 | 11 | <% @schema.each do |table| %> 12 | 13 | 14 | 15 | 18 | 19 | 20 | 21 | <% table[:columns].each do |column| %> 22 | 23 | 24 | 25 | 26 | <% end %> 27 | 28 |
16 | <% if table[:schema] && table[:schema] != "public" %><%= table[:schema] %>.<% end %><%= table[:table] %> 17 |
<%= column[:name] %><%= column[:data_type] %>
29 | <% end %> 30 | 31 | <%= javascript_tag nonce: true do %> 32 | $("#search").on("keyup", function() { 33 | var value = $(this).val().toLowerCase() 34 | $(".schema-table").filter(function() { 35 | // if found in table name, show entire table 36 | // if just found in rows, show row 37 | 38 | var found = $(this).find("thead").text().toLowerCase().indexOf(value) > -1 39 | 40 | if (found) { 41 | $(this).find("tbody tr").toggle(true) 42 | } else { 43 | $(this).find("tbody tr").filter(function() { 44 | var found2 = $(this).text().toLowerCase().indexOf(value) > -1 45 | $(this).toggle(found2) 46 | if (found2) { 47 | found = true 48 | } 49 | }) 50 | } 51 | 52 | $(this).toggle(found) 53 | }) 54 | }).focus() 55 | <% end %> 56 | -------------------------------------------------------------------------------- /app/views/blazer/queries/show.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title @query.name %> 2 | 3 |
4 |
5 |
6 |
7 | <%= render partial: "blazer/nav" %> 8 |

9 | <%= @query.name %> 10 |

11 |
12 |
13 | <%= link_to "Edit", edit_query_path(@query, params: variable_params(@query)), class: "btn btn-default", disabled: !@query.editable?(blazer_user) %> 14 | <%= link_to "Fork", new_query_path(params: {variables: variable_params(@query), fork_query_id: @query.id, data_source: @query.data_source, name: @query.name}), class: "btn btn-info" %> 15 | 16 | <% if !@error && @success %> 17 | <%= button_to "Download", run_queries_path(format: "csv"), params: @run_data, class: "btn btn-primary" %> 18 | <% end %> 19 |
20 |
21 |
22 |
23 | 24 |
25 | 26 | <% if @sql_errors.any? %> 27 |
28 | 33 |
34 | <% end %> 35 | 36 | <% if @query.description.present? %> 37 |

<%= @query.description %>

38 | <% end %> 39 | 40 | <%= render partial: "blazer/variables", locals: {action: query_path(@query)} %> 41 | 42 |
<%= @statement.display_statement %>
43 | 44 | <% if @success %> 45 |
46 |

Loading...

47 |
48 | 49 | <%= javascript_tag nonce: true do %> 50 | function showRun(data) { 51 | $("#results").html(data) 52 | $("#results table").stupidtable(stupidtableCustomSettings).stickyTableHeaders({fixedOffset: 60}) 53 | } 54 | 55 | function showError(message) { 56 | $("#results").addClass("query-error").html(message) 57 | } 58 | 59 | <%= blazer_js_var "data", @run_data %> 60 | 61 | runQuery(data, showRun, showError) 62 | <% end %> 63 | <% end %> 64 | 65 | <%= javascript_tag nonce: true do %> 66 | // do not highlight really long queries 67 | // this can lead to performance issues 68 | var code = $("#code code") 69 | if (code.text().length < 10000) { 70 | hljs.highlightElement(code.get(0)) 71 | } 72 | <% end %> 73 | -------------------------------------------------------------------------------- /app/views/blazer/uploads/_form.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for @upload, html: {class: "small-form"} do |f| %> 2 | <% if @upload.errors.any? %> 3 |
<%= @upload.errors.full_messages.first %>
4 | <% elsif !@upload.persisted? %> 5 |

Create a database table from a CSV file. The table will be created in the <%= Blazer.settings["uploads"]["schema"] %> schema.

6 | <% end %> 7 | 8 |
9 | <%= f.label :table %> 10 | <%= f.text_field :table, class: "form-control" %> 11 |
12 |
13 | <%= f.label :description %> 14 | <%= f.text_area :description, placeholder: "Optional", style: "height: 60px;", class: "form-control" %> 15 |
16 |
17 | <%= f.label :file %> 18 | <%= f.file_field :file, accept: "text/csv", style: "margin-top: 6px; margin-bottom: 21px;" %> 19 |
20 |

21 | <% if @upload.persisted? %> 22 | <%= link_to "Delete", upload_path(@upload), method: :delete, "data-confirm" => "Are you sure?", class: "btn btn-danger" %> 23 | <% end %> 24 | <%= f.submit "Save", class: "btn btn-success" %> 25 | <%= link_to "Back", :back, class: "btn btn-link" %> 26 |

27 | <% end %> 28 | -------------------------------------------------------------------------------- /app/views/blazer/uploads/edit.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title "Edit Upload" %> 2 | 3 | <%= render partial: "form" %> 4 | -------------------------------------------------------------------------------- /app/views/blazer/uploads/index.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title "Uploads" %> 2 | 3 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | <% if Blazer.user_class %> 31 | 32 | <% end%> 33 | 34 | 35 | 36 | <% @uploads.each do |upload| %> 37 | 38 | 39 | 40 | <% if Blazer.user_class %> 41 | 42 | <% end %> 43 | 44 | <% end %> 45 | 46 |
TableMastermind
<%= link_to upload.table, edit_upload_path(upload) %><%= truncate(upload.description, length: 100, separator: " ") %><%= blazer_user && upload.creator == blazer_user ? "You" : upload.creator.try(Blazer.user_name) %>
47 | 48 | <%= javascript_tag nonce: true do %> 49 | $("#search").on("keyup", function() { 50 | var value = $(this).val().toLowerCase() 51 | $("#uploads tbody tr").filter( function() { 52 | $(this).toggle($(this).text().toLowerCase().indexOf(value) > -1) 53 | }) 54 | }).focus() 55 | <% end %> 56 | -------------------------------------------------------------------------------- /app/views/blazer/uploads/new.html.erb: -------------------------------------------------------------------------------- 1 | <% blazer_title "New Upload" %> 2 | 3 | <%= render partial: "form" %> 4 | -------------------------------------------------------------------------------- /app/views/layouts/blazer/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= blazer_title ? blazer_title : "Blazer" %> 5 | 6 | 7 | <%= favicon_link_tag "blazer/favicon.png" %> 8 | <% if defined?(Propshaft::Railtie) && Rails.application.assets.is_a?(Propshaft::Assembly) %> 9 | <%= stylesheet_link_tag "blazer/bootstrap-propshaft", "blazer/bootstrap", "blazer/selectize", "blazer/github", "blazer/daterangepicker", "blazer/application" %> 10 | <%= javascript_include_tag "blazer/jquery", "blazer/rails-ujs", "blazer/stupidtable", "blazer/stupidtable-custom-settings", "blazer/jquery.stickytableheaders", "blazer/selectize", "blazer/highlight.min", "blazer/moment", "blazer/moment-timezone-with-data", "blazer/daterangepicker", "blazer/chart.umd", "blazer/chartjs-adapter-date-fns.bundle", "blazer/chartkick", "blazer/mapkick.bundle", "blazer/ace/ace", "blazer/ace/ext-language_tools", "blazer/ace/theme-twilight", "blazer/ace/mode-sql", "blazer/ace/snippets/text", "blazer/ace/snippets/sql", "blazer/Sortable", "blazer/bootstrap", "blazer/vue.global.prod", "blazer/routes", "blazer/queries", "blazer/fuzzysearch", "blazer/application", nonce: true %> 11 | <% else %> 12 | <%= stylesheet_link_tag "blazer/application" %> 13 | <%= javascript_include_tag "blazer/application", nonce: true %> 14 | <% end %> 15 | <%= javascript_tag nonce: true do %> 16 | <%= blazer_js_var "rootPath", root_path %> 17 | <% end %> 18 | <%= csrf_meta_tags %> 19 | 20 | 21 |
22 | <%= yield %> 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /blazer.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/blazer/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "blazer" 5 | spec.version = Blazer::VERSION 6 | spec.summary = "Explore your data with SQL. Easily create charts and dashboards, and share them with your team." 7 | spec.homepage = "https://github.com/ankane/blazer" 8 | spec.license = "MIT" 9 | 10 | spec.author = "Andrew Kane" 11 | spec.email = "andrew@ankane.org" 12 | 13 | spec.files = Dir["*.{md,txt}", "{app,config,lib,licenses}/**/*"] 14 | spec.require_path = "lib" 15 | 16 | spec.required_ruby_version = ">= 3.2" 17 | 18 | spec.add_dependency "railties", ">= 7.1" 19 | spec.add_dependency "activerecord", ">= 7.1" 20 | spec.add_dependency "chartkick", ">= 5" 21 | spec.add_dependency "safely_block", ">= 0.4" 22 | spec.add_dependency "csv" 23 | end 24 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Blazer::Engine.routes.draw do 2 | resources :queries do 3 | post :run, on: :collection # err on the side of caution 4 | post :cancel, on: :collection 5 | post :refresh, on: :member 6 | get :tables, on: :collection 7 | get :schema, on: :collection 8 | get :docs, on: :collection 9 | end 10 | 11 | resources :checks, except: [:show] do 12 | get :run, on: :member 13 | end 14 | 15 | resources :dashboards, except: [:index] do 16 | post :refresh, on: :member 17 | end 18 | 19 | if Blazer.uploads? 20 | resources :uploads do 21 | end 22 | end 23 | 24 | root to: "queries#home" 25 | end 26 | -------------------------------------------------------------------------------- /gemfiles/rails70.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest", ">= 5" 7 | gem "combustion" 8 | gem "rails", "~> 7.0.0" 9 | gem "pg" 10 | gem "sqlite3", "< 2" 11 | gem "mysql2" 12 | gem "activerecord-trilogy-adapter" 13 | gem "sprockets-rails" 14 | gem "anomaly_detection" 15 | gem "prophet-rb" if ENV["TEST_PROPHET"] 16 | -------------------------------------------------------------------------------- /gemfiles/rails71.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest", ">= 5" 7 | gem "combustion" 8 | gem "rails", "~> 7.1.0" 9 | gem "pg" 10 | gem "sqlite3" 11 | gem "mysql2" 12 | gem "trilogy" 13 | gem "sprockets-rails" 14 | gem "anomaly_detection" 15 | gem "prophet-rb" if ENV["TEST_PROPHET"] 16 | -------------------------------------------------------------------------------- /gemfiles/rails72.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: ".." 4 | 5 | gem "rake" 6 | gem "minitest", ">= 5" 7 | gem "combustion" 8 | gem "rails", "~> 7.2.0" 9 | gem "pg" 10 | gem "sqlite3" 11 | gem "mysql2" 12 | gem "trilogy" 13 | gem "sprockets-rails" 14 | gem "anomaly_detection" 15 | gem "prophet-rb" if ENV["TEST_PROPHET"] 16 | -------------------------------------------------------------------------------- /lib/blazer/adapters.rb: -------------------------------------------------------------------------------- 1 | Blazer.register_adapter "athena", Blazer::Adapters::AthenaAdapter 2 | Blazer.register_adapter "bigquery", Blazer::Adapters::BigQueryAdapter 3 | Blazer.register_adapter "cassandra", Blazer::Adapters::CassandraAdapter 4 | Blazer.register_adapter "drill", Blazer::Adapters::DrillAdapter 5 | Blazer.register_adapter "druid", Blazer::Adapters::DruidAdapter 6 | Blazer.register_adapter "elasticsearch", Blazer::Adapters::ElasticsearchAdapter 7 | Blazer.register_adapter "hive", Blazer::Adapters::HiveAdapter 8 | Blazer.register_adapter "ignite", Blazer::Adapters::IgniteAdapter 9 | Blazer.register_adapter "influxdb", Blazer::Adapters::InfluxdbAdapter 10 | Blazer.register_adapter "neo4j", Blazer::Adapters::Neo4jAdapter 11 | Blazer.register_adapter "opensearch", Blazer::Adapters::OpensearchAdapter 12 | Blazer.register_adapter "presto", Blazer::Adapters::PrestoAdapter 13 | Blazer.register_adapter "salesforce", Blazer::Adapters::SalesforceAdapter 14 | Blazer.register_adapter "soda", Blazer::Adapters::SodaAdapter 15 | Blazer.register_adapter "spark", Blazer::Adapters::SparkAdapter 16 | Blazer.register_adapter "sql", Blazer::Adapters::SqlAdapter 17 | Blazer.register_adapter "snowflake", Blazer::Adapters::SnowflakeAdapter 18 | Blazer.register_adapter "trino", Blazer::Adapters::PrestoAdapter 19 | -------------------------------------------------------------------------------- /lib/blazer/adapters/base_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class BaseAdapter 4 | attr_reader :data_source 5 | 6 | def initialize(data_source) 7 | @data_source = data_source 8 | end 9 | 10 | def run_statement(statement, comment) 11 | # required 12 | end 13 | 14 | def quoting 15 | # required, how to quote variables 16 | # :backslash_escape - single quote strings and convert ' to \' and \ to \\ 17 | # :single_quote_escape - single quote strings and convert ' to '' 18 | # ->(value) { ... } - custom method 19 | end 20 | 21 | def parameter_binding 22 | # optional, but recommended when possible for security 23 | # if specified, quoting is only used for display 24 | # :positional - ? 25 | # :numeric - $1 26 | # ->(statement, values) { ... } - custom method 27 | end 28 | 29 | def tables 30 | [] # optional, but nice to have 31 | end 32 | 33 | def schema 34 | [] # optional, but nice to have 35 | end 36 | 37 | def preview_statement 38 | "" # also optional, but nice to have 39 | end 40 | 41 | def reconnect 42 | # optional 43 | end 44 | 45 | def cost(statement) 46 | # optional 47 | end 48 | 49 | def explain(statement) 50 | # optional 51 | end 52 | 53 | def cancel(run_id) 54 | # optional 55 | end 56 | 57 | def cachable?(statement) 58 | true # optional 59 | end 60 | 61 | def supports_cohort_analysis? 62 | false # optional 63 | end 64 | 65 | def cohort_analysis_statement(statement, period:, days:) 66 | # optional 67 | end 68 | 69 | protected 70 | 71 | def settings 72 | @data_source.settings 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/blazer/adapters/bigquery_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class BigQueryAdapter < BaseAdapter 4 | def run_statement(statement, comment, bind_params) 5 | columns = [] 6 | rows = [] 7 | error = nil 8 | 9 | begin 10 | results = bigquery.query(statement, params: bind_params) 11 | 12 | # complete? was removed in google-cloud-bigquery 0.29.0 13 | # code is for backward compatibility 14 | if !results.respond_to?(:complete?) || results.complete? 15 | columns = results.first.keys.map(&:to_s) if results.size > 0 16 | rows = results.all.map(&:values) 17 | else 18 | error = Blazer::TIMEOUT_MESSAGE 19 | end 20 | rescue => e 21 | error = e.message 22 | error = Blazer::VARIABLE_MESSAGE if error.include?("Syntax error: Unexpected \"?\"") 23 | end 24 | 25 | [columns, rows, error] 26 | end 27 | 28 | def tables 29 | table_refs.map { |t| "#{t.project_id}.#{t.dataset_id}.#{t.table_id}" } 30 | end 31 | 32 | def schema 33 | table_refs.map do |table_ref| 34 | { 35 | schema: table_ref.dataset_id, 36 | table: table_ref.table_id, 37 | columns: table_columns(table_ref) 38 | } 39 | end 40 | end 41 | 42 | def preview_statement 43 | "SELECT * FROM `{table}` LIMIT 10" 44 | end 45 | 46 | # https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#string_and_bytes_literals 47 | def quoting 48 | :backslash_escape 49 | end 50 | 51 | # https://cloud.google.com/bigquery/docs/parameterized-queries 52 | def parameter_binding 53 | :positional 54 | end 55 | 56 | private 57 | 58 | def bigquery 59 | @bigquery ||= begin 60 | require "google/cloud/bigquery" 61 | Google::Cloud::Bigquery.new( 62 | project: settings["project"], 63 | keyfile: settings["keyfile"] 64 | ) 65 | end 66 | end 67 | 68 | def table_refs 69 | bigquery.datasets.map(&:tables).flat_map { |table_list| table_list.map(&:table_ref) } 70 | end 71 | 72 | def table_columns(table_ref) 73 | schema = bigquery.service.get_table(table_ref.dataset_id, table_ref.table_id).schema 74 | return [] if schema.nil? 75 | schema.fields.map { |field| {name: field.name, data_type: field.type} } 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/blazer/adapters/cassandra_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class CassandraAdapter < BaseAdapter 4 | def run_statement(statement, comment, bind_params) 5 | columns = [] 6 | rows = [] 7 | error = nil 8 | 9 | begin 10 | response = session.execute("#{statement} /*#{comment}*/", arguments: bind_params) 11 | rows = response.map { |r| r.values } 12 | columns = rows.any? ? response.first.keys : [] 13 | rescue => e 14 | error = e.message 15 | error = Blazer::VARIABLE_MESSAGE if error.include?("no viable alternative at input '?'") 16 | end 17 | 18 | [columns, rows, error] 19 | end 20 | 21 | def tables 22 | session.execute("SELECT table_name FROM system_schema.tables WHERE keyspace_name = #{data_source.quote(keyspace)}").map { |r| r["table_name"] } 23 | end 24 | 25 | def schema 26 | result = session.execute("SELECT keyspace_name, table_name, column_name, type, position FROM system_schema.columns WHERE keyspace_name = #{data_source.quote(keyspace)}") 27 | result.map(&:values).group_by { |r| [r[0], r[1]] }.map { |k, vs| {schema: k[0], table: k[1], columns: vs.sort_by { |v| v[2] }.map { |v| {name: v[2], data_type: v[3]} }} } 28 | end 29 | 30 | def preview_statement 31 | "SELECT * FROM {table} LIMIT 10" 32 | end 33 | 34 | # https://docs.datastax.com/en/cql-oss/3.3/cql/cql_reference/escape_char_r.html 35 | def quoting 36 | :single_quote_escape 37 | end 38 | 39 | # https://docs.datastax.com/en/developer/nodejs-driver/3.0/features/parameterized-queries/ 40 | def parameter_binding 41 | :positional 42 | end 43 | 44 | private 45 | 46 | def cluster 47 | @cluster ||= begin 48 | require "cassandra" 49 | options = {hosts: [uri.host]} 50 | options[:port] = uri.port if uri.port 51 | options[:username] = uri.user if uri.user 52 | options[:password] = uri.password if uri.password 53 | ::Cassandra.cluster(options) 54 | end 55 | end 56 | 57 | def session 58 | @session ||= cluster.connect(keyspace) 59 | end 60 | 61 | def uri 62 | @uri ||= URI.parse(data_source.settings["url"]) 63 | end 64 | 65 | def keyspace 66 | @keyspace ||= uri.path[1..-1] 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/blazer/adapters/drill_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class DrillAdapter < BaseAdapter 4 | def run_statement(statement, comment) 5 | columns = [] 6 | rows = [] 7 | error = nil 8 | 9 | begin 10 | # remove trailing semicolon 11 | response = drill.query(statement.sub(/;\s*\z/, "")) 12 | rows = response.map { |r| r.values } 13 | columns = rows.any? ? response.first.keys : [] 14 | rescue => e 15 | error = e.message 16 | end 17 | 18 | [columns, rows, error] 19 | end 20 | 21 | # https://drill.apache.org/docs/lexical-structure/#string 22 | def quoting 23 | :single_quote_escape 24 | end 25 | 26 | # https://issues.apache.org/jira/browse/DRILL-5079 27 | def parameter_binding 28 | # not supported 29 | end 30 | 31 | private 32 | 33 | def drill 34 | @drill ||= ::Drill.new(url: settings["url"]) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/blazer/adapters/druid_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class DruidAdapter < BaseAdapter 4 | TIMESTAMP_REGEX = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\z/ 5 | 6 | def run_statement(statement, comment, bind_params) 7 | require "json" 8 | require "net/http" 9 | require "uri" 10 | 11 | columns = [] 12 | rows = [] 13 | error = nil 14 | 15 | params = 16 | bind_params.map do |v| 17 | type = 18 | case v 19 | when Integer 20 | "BIGINT" 21 | when Float 22 | "DOUBLE" 23 | when ActiveSupport::TimeWithZone 24 | v = (v.to_f * 1000).round 25 | "TIMESTAMP" 26 | else 27 | "VARCHAR" 28 | end 29 | {type: type, value: v} 30 | end 31 | 32 | header = {"Content-Type" => "application/json", "Accept" => "application/json"} 33 | timeout = data_source.timeout ? data_source.timeout.to_i : 300 34 | data = { 35 | query: statement, 36 | parameters: params, 37 | context: { 38 | timeout: timeout * 1000 39 | } 40 | } 41 | 42 | uri = URI.parse("#{settings["url"]}/druid/v2/sql/") 43 | http = Net::HTTP.new(uri.host, uri.port) 44 | http.read_timeout = timeout 45 | 46 | begin 47 | response = JSON.parse(http.post(uri.request_uri, data.to_json, header).body) 48 | if response.is_a?(Hash) 49 | error = response["errorMessage"] || "Unknown error: #{response.inspect}" 50 | if error.include?("timed out") 51 | error = Blazer::TIMEOUT_MESSAGE 52 | elsif error.include?("Encountered \"?\" at") 53 | error = Blazer::VARIABLE_MESSAGE 54 | end 55 | else 56 | columns = (response.first || {}).keys 57 | rows = response.map { |r| r.values } 58 | 59 | # Druid doesn't return column types 60 | # and no timestamp type in JSON 61 | rows.each do |row| 62 | row.each_with_index do |v, i| 63 | if v.is_a?(String) && TIMESTAMP_REGEX.match(v) 64 | row[i] = Time.parse(v) 65 | end 66 | end 67 | end 68 | end 69 | rescue => e 70 | error = e.message 71 | end 72 | 73 | [columns, rows, error] 74 | end 75 | 76 | def tables 77 | result = data_source.run_statement("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA NOT IN ('INFORMATION_SCHEMA') ORDER BY TABLE_NAME") 78 | result.rows.map(&:first) 79 | end 80 | 81 | def schema 82 | result = data_source.run_statement("SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE, ORDINAL_POSITION FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA NOT IN ('INFORMATION_SCHEMA') ORDER BY 1, 2") 83 | result.rows.group_by { |r| [r[0], r[1]] }.map { |k, vs| {schema: k[0], table: k[1], columns: vs.sort_by { |v| v[2] }.map { |v| {name: v[2], data_type: v[3]} }} } 84 | end 85 | 86 | def preview_statement 87 | "SELECT * FROM {table} LIMIT 10" 88 | end 89 | 90 | # https://druid.apache.org/docs/latest/querying/sql.html#identifiers-and-literals 91 | # docs only mention double quotes 92 | def quoting 93 | :single_quote_escape 94 | end 95 | 96 | # https://druid.apache.org/docs/latest/querying/sql.html#dynamic-parameters 97 | def parameter_binding 98 | :positional 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/blazer/adapters/elasticsearch_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class ElasticsearchAdapter < BaseAdapter 4 | def run_statement(statement, comment, bind_params) 5 | columns = [] 6 | rows = [] 7 | error = nil 8 | 9 | begin 10 | response = client.transport.perform_request("POST", endpoint, {}, {query: "#{statement} /*#{comment}*/", params: bind_params}).body 11 | columns = response["columns"].map { |v| v["name"] } 12 | # Elasticsearch does not differentiate between dates and times 13 | date_indexes = response["columns"].each_index.select { |i| ["date", "datetime"].include?(response["columns"][i]["type"]) } 14 | if columns.any? 15 | rows = response["rows"] 16 | date_indexes.each do |i| 17 | rows.each do |row| 18 | row[i] &&= Time.parse(row[i]) 19 | end 20 | end 21 | end 22 | rescue => e 23 | error = e.message 24 | error = Blazer::VARIABLE_MESSAGE if error.include?("mismatched input '?'") 25 | end 26 | 27 | [columns, rows, error] 28 | end 29 | 30 | def tables 31 | indices = client.cat.indices(format: "json").map { |v| v["index"] } 32 | aliases = client.cat.aliases(format: "json").map { |v| v["alias"] } 33 | (indices + aliases).uniq.sort 34 | end 35 | 36 | def preview_statement 37 | "SELECT * FROM \"{table}\" LIMIT 10" 38 | end 39 | 40 | # https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-lexical-structure.html#sql-syntax-string-literals 41 | def quoting 42 | :single_quote_escape 43 | end 44 | 45 | # https://www.elastic.co/guide/en/elasticsearch/reference/current/sql-rest-params.html 46 | def parameter_binding 47 | :positional 48 | end 49 | 50 | protected 51 | 52 | def endpoint 53 | @endpoint ||= client.info["version"]["number"].to_i >= 7 ? "_sql" : "_xpack/sql" 54 | end 55 | 56 | def client 57 | @client ||= Elasticsearch::Client.new(url: settings["url"]) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/blazer/adapters/hive_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class HiveAdapter < BaseAdapter 4 | def run_statement(statement, comment) 5 | columns = [] 6 | rows = [] 7 | error = nil 8 | 9 | begin 10 | result = client.execute("#{statement} /*#{comment}*/") 11 | columns = result.any? ? result.first.keys : [] 12 | rows = result.map(&:values) 13 | rescue => e 14 | error = e.message 15 | end 16 | 17 | [columns, rows, error] 18 | end 19 | 20 | def tables 21 | client.execute("SHOW TABLES").map { |r| r["tab_name"] } 22 | end 23 | 24 | def preview_statement 25 | "SELECT * FROM {table} LIMIT 10" 26 | end 27 | 28 | # https://cwiki.apache.org/confluence/display/Hive/LanguageManual+Types#LanguageManualTypes-StringsstringStrings 29 | def quoting 30 | :backslash_escape 31 | end 32 | 33 | # has variable substitution, but sets for session 34 | # https://cwiki.apache.org/confluence/display/Hive/LanguageManual+VariableSubstitution 35 | def parameter_binding 36 | end 37 | 38 | protected 39 | 40 | def client 41 | @client ||= begin 42 | uri = URI.parse(settings["url"]) 43 | Hexspace::Client.new( 44 | host: uri.host, 45 | port: uri.port, 46 | username: uri.user, 47 | password: uri.password, 48 | database: uri.path.delete_prefix("/"), 49 | mode: uri.scheme.to_sym 50 | ) 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/blazer/adapters/ignite_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class IgniteAdapter < BaseAdapter 4 | def run_statement(statement, comment, bind_params) 5 | columns = [] 6 | rows = [] 7 | error = nil 8 | 9 | begin 10 | result = client.query("#{statement} /*#{comment}*/", bind_params, schema: default_schema, statement_type: :select, timeout: data_source.timeout) 11 | columns = result.any? ? result.first.keys : [] 12 | rows = result.map(&:values) 13 | rescue => e 14 | error = e.message 15 | end 16 | 17 | [columns, rows, error] 18 | end 19 | 20 | def preview_statement 21 | "SELECT * FROM {table} LIMIT 10" 22 | end 23 | 24 | def tables 25 | sql = "SELECT table_schema, table_name FROM information_schema.tables WHERE table_schema NOT IN ('INFORMATION_SCHEMA', 'SYS')" 26 | result = data_source.run_statement(sql) 27 | result.rows.reject { |row| row[1].start_with?("__") }.map do |row| 28 | (row[0] == default_schema ? row[1] : "#{row[0]}.#{row[1]}").downcase 29 | end 30 | end 31 | 32 | # TODO figure out error 33 | # Table `__T0` can be accessed only within Ignite query context. 34 | # def schema 35 | # sql = "SELECT table_schema, table_name, column_name, data_type, ordinal_position FROM information_schema.columns WHERE table_schema NOT IN ('INFORMATION_SCHEMA', 'SYS')" 36 | # result = data_source.run_statement(sql) 37 | # result.rows.group_by { |r| [r[0], r[1]] }.map { |k, vs| {schema: k[0], table: k[1], columns: vs.sort_by { |v| v[2] }.map { |v| {name: v[2], data_type: v[3]} }} }.sort_by { |t| [t[:schema] == default_schema ? "" : t[:schema], t[:table]] } 38 | # end 39 | 40 | def quoting 41 | :single_quote_escape 42 | end 43 | 44 | # query arguments 45 | # https://ignite.apache.org/docs/latest/binary-client-protocol/sql-and-scan-queries#op_query_sql 46 | def parameter_binding 47 | :positional 48 | end 49 | 50 | private 51 | 52 | def default_schema 53 | "PUBLIC" 54 | end 55 | 56 | def client 57 | @client ||= begin 58 | uri = URI(settings["url"]) 59 | Ignite::Client.new(host: uri.host, port: uri.port, username: uri.user, password: uri.password) 60 | end 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/blazer/adapters/influxdb_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class InfluxdbAdapter < BaseAdapter 4 | def run_statement(statement, comment) 5 | columns = [] 6 | rows = [] 7 | error = nil 8 | 9 | begin 10 | result = client.query(statement, denormalize: false).first 11 | 12 | if result 13 | columns = result["columns"] 14 | rows = result["values"] 15 | 16 | # parse time columns 17 | # current approach isn't ideal, but result doesn't include types 18 | # another approach would be to check the format 19 | time_index = columns.index("time") 20 | if time_index 21 | rows.each do |row| 22 | row[time_index] = Time.parse(row[time_index]) if row[time_index] 23 | end 24 | end 25 | end 26 | rescue => e 27 | error = e.message 28 | end 29 | 30 | [columns, rows, error] 31 | end 32 | 33 | def tables 34 | client.list_series 35 | end 36 | 37 | def preview_statement 38 | "SELECT * FROM {table} LIMIT 10" 39 | end 40 | 41 | # https://docs.influxdata.com/influxdb/v1.8/query_language/spec/#strings 42 | def quoting 43 | :backslash_escape 44 | end 45 | 46 | def parameter_binding 47 | # not supported 48 | end 49 | 50 | protected 51 | 52 | def client 53 | @client ||= InfluxDB::Client.new(url: settings["url"]) 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/blazer/adapters/neo4j_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class Neo4jAdapter < BaseAdapter 4 | def run_statement(statement, comment, bind_params) 5 | columns = [] 6 | rows = [] 7 | error = nil 8 | 9 | begin 10 | if bolt? 11 | result = session.run("#{statement} /*#{comment}*/", bind_params).to_a 12 | columns = result.any? ? result.first.keys.map(&:to_s) : [] 13 | rows = result.map(&:values) 14 | else 15 | result = session.query("#{statement} /*#{comment}*/", bind_params) 16 | columns = result.columns.map(&:to_s) 17 | rows = [] 18 | result.each do |row| 19 | rows << columns.map do |c| 20 | v = row.send(c) 21 | v = v.properties if v.respond_to?(:properties) 22 | v 23 | end 24 | end 25 | end 26 | rescue => e 27 | error = e.message 28 | error = Blazer::VARIABLE_MESSAGE if error.include?("Invalid input '$'") 29 | end 30 | 31 | [columns, rows, error] 32 | end 33 | 34 | def tables 35 | if bolt? 36 | result = session.run("CALL db.labels()").to_a 37 | result.map { |r| r.values.first } 38 | else 39 | result = session.query("CALL db.labels()") 40 | result.rows.map(&:first) 41 | end 42 | end 43 | 44 | def preview_statement 45 | "MATCH (n:{table}) RETURN n LIMIT 10" 46 | end 47 | 48 | # https://neo4j.com/docs/cypher-manual/current/syntax/expressions/#cypher-expressions-string-literals 49 | def quoting 50 | :backslash_escape 51 | end 52 | 53 | def parameter_binding 54 | proc do |statement, variables| 55 | variables.each_key do |k| 56 | statement = statement.gsub("{#{k}}") { "$#{k} " } 57 | end 58 | [statement, variables] 59 | end 60 | end 61 | 62 | protected 63 | 64 | def session 65 | @session ||= begin 66 | if bolt? 67 | uri = URI.parse(settings["url"]) 68 | auth = Neo4j::Driver::AuthTokens.basic(uri.user, uri.password) 69 | database = uri.path.delete_prefix("/") 70 | uri.user = nil 71 | uri.password = nil 72 | uri.path = "" 73 | Neo4j::Driver::GraphDatabase.driver(uri, auth).session(database: database) 74 | else 75 | require "neo4j/core/cypher_session/adaptors/http" 76 | http_adaptor = Neo4j::Core::CypherSession::Adaptors::HTTP.new(settings["url"]) 77 | Neo4j::Core::CypherSession.new(http_adaptor) 78 | end 79 | end 80 | end 81 | 82 | def bolt? 83 | if !defined?(@bolt) 84 | @bolt = settings["url"].start_with?("bolt") 85 | end 86 | @bolt 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/blazer/adapters/opensearch_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class OpensearchAdapter < BaseAdapter 4 | def run_statement(statement, comment) 5 | columns = [] 6 | rows = [] 7 | error = nil 8 | 9 | begin 10 | response = client.transport.perform_request("POST", "_plugins/_sql", {}, {query: "#{statement} /*#{comment}*/"}).body 11 | columns = response["schema"].map { |v| v["name"] } 12 | # TODO typecast more types 13 | # https://github.com/opensearch-project/sql/blob/main/docs/user/general/datatypes.rst 14 | date_indexes = response["schema"].each_index.select { |i| response["schema"][i]["type"] == "timestamp" } 15 | if columns.any? 16 | rows = response["datarows"] 17 | utc = ActiveSupport::TimeZone["Etc/UTC"] 18 | date_indexes.each do |i| 19 | rows.each do |row| 20 | row[i] &&= utc.parse(row[i]) 21 | end 22 | end 23 | end 24 | rescue => e 25 | error = e.message 26 | end 27 | 28 | [columns, rows, error] 29 | end 30 | 31 | def tables 32 | indices = client.cat.indices(format: "json").map { |v| v["index"] } 33 | aliases = client.cat.aliases(format: "json").map { |v| v["alias"] } 34 | (indices + aliases).uniq.sort 35 | end 36 | 37 | def preview_statement 38 | "SELECT * FROM `{table}` LIMIT 10" 39 | end 40 | 41 | def quoting 42 | # unknown 43 | end 44 | 45 | protected 46 | 47 | def client 48 | @client ||= OpenSearch::Client.new(url: settings["url"]) 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/blazer/adapters/presto_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class PrestoAdapter < BaseAdapter 4 | def run_statement(statement, comment) 5 | columns = [] 6 | rows = [] 7 | error = nil 8 | 9 | begin 10 | columns, rows = client.run("#{statement} /*#{comment}*/") 11 | columns = columns.map(&:name) 12 | rescue => e 13 | error = e.message 14 | end 15 | 16 | [columns, rows, error] 17 | end 18 | 19 | def tables 20 | _, rows = client.run("SHOW TABLES") 21 | rows.map(&:first) 22 | end 23 | 24 | def preview_statement 25 | "SELECT * FROM {table} LIMIT 10" 26 | end 27 | 28 | def quoting 29 | :single_quote_escape 30 | end 31 | 32 | # TODO support prepared statements - https://prestodb.io/docs/current/sql/prepare.html 33 | # feature request for variables - https://github.com/prestodb/presto/issues/5918 34 | def parameter_binding 35 | end 36 | 37 | protected 38 | 39 | def client 40 | @client ||= begin 41 | uri = URI.parse(settings["url"]) 42 | query = uri.query ? CGI.parse(uri.query) : {} 43 | cls = uri.scheme == "trino" ? Trino::Client : Presto::Client 44 | cls.new( 45 | server: "#{uri.host}:#{uri.port}", 46 | catalog: uri.path.to_s.delete_prefix("/"), 47 | schema: query["schema"] || "public", 48 | user: uri.user, 49 | http_debug: false 50 | ) 51 | end 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/blazer/adapters/salesforce_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class SalesforceAdapter < BaseAdapter 4 | def run_statement(statement, comment) 5 | columns = [] 6 | rows = [] 7 | error = nil 8 | 9 | # remove comments manually 10 | statement = statement.gsub(/--.+/, "") 11 | # only supports single line /* */ comments 12 | # regex not perfect, but should be good enough 13 | statement = statement.gsub(/\/\*.+\*\//, "") 14 | 15 | # remove trailing semicolon 16 | statement = statement.sub(/;\s*\z/, "") 17 | 18 | begin 19 | response = client.query(statement) 20 | rows = response.map { |r| r.to_hash.except("attributes").values } 21 | columns = rows.any? ? response.first.to_hash.except("attributes").keys : [] 22 | rescue => e 23 | error = e.message 24 | end 25 | 26 | [columns, rows, error] 27 | end 28 | 29 | def tables 30 | # cache 31 | @tables ||= client.describe.select { |r| r.queryable }.map(&:name) 32 | end 33 | 34 | def preview_statement 35 | "SELECT Id FROM {table} LIMIT 10" 36 | end 37 | 38 | # https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/sforce_api_calls_soql_select_quotedstringescapes.htm 39 | def quoting 40 | :backslash_escape 41 | end 42 | 43 | protected 44 | 45 | def client 46 | @client ||= Restforce.new 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/blazer/adapters/snowflake_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class SnowflakeAdapter < SqlAdapter 4 | def initialize(data_source) 5 | @data_source = data_source 6 | 7 | @@registered ||= begin 8 | require "active_record/connection_adapters/odbc_adapter" 9 | require "odbc_adapter/adapters/postgresql_odbc_adapter" 10 | 11 | ODBCAdapter.register(/snowflake/, ODBCAdapter::Adapters::PostgreSQLODBCAdapter) do 12 | # Explicitly turning off prepared statements as they are not yet working with 13 | # snowflake + the ODBC ActiveRecord adapter 14 | def prepared_statements 15 | false 16 | end 17 | 18 | # Quoting needs to be changed for snowflake 19 | def quote_column_name(name) 20 | name.to_s 21 | end 22 | 23 | private 24 | 25 | # Override dbms_type_cast to get the values encoded in UTF-8 26 | def dbms_type_cast(columns, values) 27 | int_column = {} 28 | columns.each_with_index do |c, i| 29 | int_column[i] = c.type == 3 && c.scale == 0 30 | end 31 | 32 | float_column = {} 33 | columns.each_with_index do |c, i| 34 | float_column[i] = c.type == 3 && c.scale != 0 35 | end 36 | 37 | values.each do |row| 38 | row.each_index do |idx| 39 | val = row[idx] 40 | if val 41 | if int_column[idx] 42 | row[idx] = val.to_i 43 | elsif float_column[idx] 44 | row[idx] = val.to_f 45 | elsif val.is_a?(String) 46 | row[idx] = val.force_encoding('UTF-8') 47 | end 48 | end 49 | end 50 | end 51 | end 52 | end 53 | end 54 | 55 | @connection_model = 56 | Class.new(Blazer::Connection) do 57 | def self.name 58 | "Blazer::Connection::SnowflakeAdapter#{object_id}" 59 | end 60 | if data_source.settings["conn_str"] 61 | establish_connection(adapter: "odbc", conn_str: data_source.settings["conn_str"]) 62 | elsif data_source.settings["dsn"] 63 | establish_connection(adapter: "odbc", dsn: data_source.settings["dsn"]) 64 | end 65 | end 66 | end 67 | 68 | def cancel(run_id) 69 | # todo 70 | end 71 | 72 | # https://docs.snowflake.com/en/sql-reference/data-types-text.html#escape-sequences 73 | def quoting 74 | :backslash_escape 75 | end 76 | 77 | def parameter_binding 78 | # TODO 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/blazer/adapters/soda_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class SodaAdapter < BaseAdapter 4 | def run_statement(statement, comment) 5 | require "json" 6 | require "net/http" 7 | require "uri" 8 | 9 | columns = [] 10 | rows = [] 11 | error = nil 12 | 13 | # remove comments manually 14 | statement = statement.gsub(/--.+/, "") 15 | # only supports single line /* */ comments 16 | # regex not perfect, but should be good enough 17 | statement = statement.gsub(/\/\*.+\*\//, "") 18 | 19 | # remove trailing semicolon 20 | statement = statement.sub(/;\s*\z/, "") 21 | 22 | # remove whitespace 23 | statement = statement.squish 24 | 25 | uri = URI(settings["url"]) 26 | uri.query = URI.encode_www_form("$query" => statement) 27 | 28 | req = Net::HTTP::Get.new(uri) 29 | req["X-App-Token"] = settings["app_token"] if settings["app_token"] 30 | 31 | options = { 32 | use_ssl: uri.scheme == "https", 33 | open_timeout: 3, 34 | read_timeout: 30 35 | } 36 | 37 | begin 38 | # use Net::HTTP instead of soda-ruby for types and better error messages 39 | res = Net::HTTP.start(uri.hostname, uri.port, options) do |http| 40 | http.request(req) 41 | end 42 | 43 | if res.is_a?(Net::HTTPSuccess) 44 | body = JSON.parse(res.body) 45 | 46 | columns = JSON.parse(res["x-soda2-fields"]) 47 | column_types = columns.zip(JSON.parse(res["x-soda2-types"])).to_h 48 | 49 | columns.reject! { |f| f.start_with?(":@") } 50 | # rows can be missing some keys in JSON, so need to map by column 51 | rows = body.map { |r| columns.map { |c| r[c] } } 52 | 53 | columns.each_with_index do |column, i| 54 | # nothing to do for boolean 55 | case column_types[column] 56 | when "number" 57 | # check if likely an integer column 58 | if rows.all? { |r| r[i].to_i == r[i].to_f } 59 | rows.each do |row| 60 | row[i] = row[i].to_i 61 | end 62 | else 63 | rows.each do |row| 64 | row[i] = row[i].to_f 65 | end 66 | end 67 | when "floating_timestamp" 68 | # check if likely a date column 69 | if rows.all? { |r| r[i].end_with?("T00:00:00.000") } 70 | rows.each do |row| 71 | row[i] = Date.parse(row[i]) 72 | end 73 | else 74 | utc = ActiveSupport::TimeZone["Etc/UTC"] 75 | rows.each do |row| 76 | row[i] = utc.parse(row[i]) 77 | end 78 | end 79 | end 80 | end 81 | else 82 | error = JSON.parse(res.body)["message"] rescue "Bad response: #{res.code}" 83 | end 84 | rescue => e 85 | error = e.message 86 | end 87 | 88 | [columns, rows, error] 89 | end 90 | 91 | def preview_statement 92 | "SELECT * LIMIT 10" 93 | end 94 | 95 | def tables 96 | ["all"] 97 | end 98 | 99 | # https://dev.socrata.com/docs/datatypes/text.html 100 | def quoting 101 | :single_quote_escape 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/blazer/adapters/spark_adapter.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | module Adapters 3 | class SparkAdapter < HiveAdapter 4 | def tables 5 | client.execute("SHOW TABLES").map { |r| r["tableName"] } 6 | end 7 | 8 | # https://spark.apache.org/docs/latest/sql-ref-literals.html 9 | def quoting 10 | :backslash_escape 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/blazer/anomaly_detectors.rb: -------------------------------------------------------------------------------- 1 | Blazer.register_anomaly_detector "anomaly_detection" do |series| 2 | anomalies = AnomalyDetection.detect(series.to_h, period: :auto) 3 | anomalies.include?(series.last[0]) 4 | end 5 | 6 | Blazer.register_anomaly_detector "prophet" do |series| 7 | df = Rover::DataFrame.new(series[0..-2].map { |v| {"ds" => v[0], "y" => v[1]} }) 8 | m = Prophet.new(interval_width: 0.99) 9 | m.logger.level = ::Logger::FATAL # no logging 10 | m.fit(df) 11 | future = Rover::DataFrame.new(series[-1..-1].map { |v| {"ds" => v[0]} }) 12 | forecast = m.predict(future).to_a[0] 13 | lower = forecast["yhat_lower"] 14 | upper = forecast["yhat_upper"] 15 | value = series.last[1] 16 | value < lower || value > upper 17 | end 18 | 19 | Blazer.register_anomaly_detector "trend" do |series| 20 | anomalies = Trend.anomalies(series.to_h) 21 | anomalies.include?(series.last[0]) 22 | end 23 | -------------------------------------------------------------------------------- /lib/blazer/check_mailer.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class CheckMailer < ActionMailer::Base 3 | include ActionView::Helpers::TextHelper 4 | 5 | default from: Blazer.from_email if Blazer.from_email 6 | layout false 7 | 8 | def state_change(check, state, state_was, rows_count, error, columns, rows, column_types, check_type) 9 | @check = check 10 | @state = state 11 | @state_was = state_was 12 | @rows_count = rows_count 13 | @error = error 14 | @columns = columns 15 | @rows = rows 16 | @column_types = column_types 17 | @check_type = check_type 18 | mail to: check.emails, reply_to: check.emails, subject: "Check #{state.titleize}: #{check.query.name}" 19 | end 20 | 21 | def failing_checks(email, checks) 22 | @checks = checks 23 | # add reply_to for mailing lists 24 | mail to: email, reply_to: email, subject: "#{pluralize(checks.size, "Check")} Failing" 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/blazer/engine.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class Engine < ::Rails::Engine 3 | isolate_namespace Blazer 4 | 5 | initializer "blazer" do |app| 6 | if app.config.respond_to?(:assets) && defined?(Sprockets) 7 | if Sprockets::VERSION.to_i >= 4 8 | app.config.assets.precompile += [ 9 | "blazer/application.js", 10 | "blazer/application.css", 11 | "blazer/glyphicons-halflings-regular.eot", 12 | "blazer/glyphicons-halflings-regular.svg", 13 | "blazer/glyphicons-halflings-regular.ttf", 14 | "blazer/glyphicons-halflings-regular.woff", 15 | "blazer/glyphicons-halflings-regular.woff2", 16 | "blazer/favicon.png" 17 | ] 18 | else 19 | # use a proc instead of a string 20 | app.config.assets.precompile << proc { |path| path =~ /\Ablazer\/application\.(js|css)\z/ } 21 | app.config.assets.precompile << proc { |path| path =~ /\Ablazer\/.+\.(eot|svg|ttf|woff|woff2)\z/ } 22 | app.config.assets.precompile << proc { |path| path == "blazer/favicon.png" } 23 | end 24 | end 25 | 26 | Blazer.time_zone ||= Blazer.settings["time_zone"] || Time.zone 27 | Blazer.audit = Blazer.settings.key?("audit") ? Blazer.settings["audit"] : true 28 | Blazer.user_name = Blazer.settings["user_name"] if Blazer.settings["user_name"] 29 | Blazer.from_email = Blazer.settings["from_email"] if Blazer.settings["from_email"] 30 | Blazer.before_action = Blazer.settings["before_action_method"] if Blazer.settings["before_action_method"] 31 | Blazer.check_schedules = Blazer.settings["check_schedules"] if Blazer.settings.key?("check_schedules") 32 | Blazer.cache ||= Rails.cache 33 | 34 | Blazer.anomaly_checks = Blazer.settings["anomaly_checks"] || false 35 | Blazer.forecasting = Blazer.settings["forecasting"] || false 36 | Blazer.async = Blazer.settings["async"] || false 37 | Blazer.images = Blazer.settings["images"] || false 38 | Blazer.override_csp = Blazer.settings["override_csp"] || false 39 | Blazer.slack_oauth_token = Blazer.settings["slack_oauth_token"] || ENV["BLAZER_SLACK_OAUTH_TOKEN"] 40 | Blazer.slack_webhook_url = Blazer.settings["slack_webhook_url"] || ENV["BLAZER_SLACK_WEBHOOK_URL"] 41 | Blazer.mapbox_access_token = Blazer.settings["mapbox_access_token"] || ENV["MAPBOX_ACCESS_TOKEN"] 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/blazer/forecasters.rb: -------------------------------------------------------------------------------- 1 | Blazer.register_forecaster "prophet" do |series, count:| 2 | Prophet.forecast(series, count: count) 3 | end 4 | 5 | Blazer.register_forecaster "trend" do |series, count:| 6 | Trend.forecast(series, count: count) 7 | end 8 | -------------------------------------------------------------------------------- /lib/blazer/result_cache.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class ResultCache 3 | def initialize(data_source) 4 | @data_source = data_source 5 | end 6 | 7 | def write_run(run_id, result) 8 | write(run_cache_key(run_id), result, expires_in: 30.seconds) 9 | end 10 | 11 | def read_run(run_id) 12 | read(run_cache_key(run_id)) 13 | end 14 | 15 | def delete_run(run_id) 16 | delete(run_cache_key(run_id)) 17 | end 18 | 19 | def write_statement(statement, result, expires_in:) 20 | write(statement_cache_key(statement), result, expires_in: expires_in) if caching? 21 | end 22 | 23 | def read_statement(statement) 24 | read(statement_cache_key(statement)) if caching? 25 | end 26 | 27 | def delete_statement(statement) 28 | delete(statement_cache_key(statement)) if caching? 29 | end 30 | 31 | private 32 | 33 | def write(key, result, expires_in:) 34 | raise ArgumentError, "expected Blazer::Result" unless result.is_a?(Blazer::Result) 35 | value = [result.columns, result.rows, result.error, result.cached_at, result.just_cached] 36 | cache.write(key, value, expires_in: expires_in) 37 | end 38 | 39 | def read(key) 40 | value = cache.read(key) 41 | if value 42 | columns, rows, error, cached_at, just_cached = value 43 | Blazer::Result.new(@data_source, columns, rows, error, cached_at, just_cached) 44 | end 45 | end 46 | 47 | def delete(key) 48 | cache.delete(key) 49 | end 50 | 51 | def caching? 52 | @data_source.cache_mode != "off" 53 | end 54 | 55 | def cache_key(key) 56 | (["blazer", "v5", @data_source.id] + key).join("/") 57 | end 58 | 59 | def statement_cache_key(statement) 60 | cache_key(["statement", Digest::SHA256.hexdigest(statement.bind_statement.to_s.gsub("\r\n", "\n") + statement.bind_values.to_json)]) 61 | end 62 | 63 | def run_cache_key(run_id) 64 | cache_key(["run", run_id]) 65 | end 66 | 67 | def cache 68 | Blazer.cache 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/blazer/run_statement.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class RunStatement 3 | def perform(statement, options = {}) 4 | query = options[:query] 5 | 6 | data_source = statement.data_source 7 | statement.bind 8 | 9 | # audit 10 | if Blazer.audit 11 | audit_statement = statement.bind_statement 12 | audit_statement += "\n\n#{statement.bind_values.to_json}" if statement.bind_values.any? 13 | audit = Blazer::Audit.new(statement: audit_statement) 14 | audit.query = query 15 | audit.data_source = data_source.id 16 | # only set user if present to avoid error with Rails 7.1 when no user model 17 | audit.user = options[:user] unless options[:user].nil? 18 | audit.save! 19 | end 20 | 21 | start_time = Blazer.monotonic_time 22 | result = data_source.run_statement(statement, options) 23 | duration = Blazer.monotonic_time - start_time 24 | 25 | if Blazer.audit 26 | audit.duration = duration if audit.respond_to?(:duration=) 27 | audit.error = result.error if audit.respond_to?(:error=) 28 | audit.timed_out = result.timed_out? if audit.respond_to?(:timed_out=) 29 | audit.cached = result.cached? if audit.respond_to?(:cached=) 30 | if !result.cached? && duration >= 10 31 | audit.cost = data_source.cost(statement) if audit.respond_to?(:cost=) 32 | end 33 | audit.save! if audit.changed? 34 | end 35 | 36 | if query && !result.timed_out? && !result.cached? && !query.variables.any? 37 | query.checks.each do |check| 38 | check.update_state(result) 39 | end 40 | end 41 | 42 | result 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/blazer/run_statement_job.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class RunStatementJob < ActiveJob::Base 3 | self.queue_adapter = :async 4 | 5 | def perform(data_source_id, statement, options) 6 | statement = Blazer::Statement.new(statement, data_source_id) 7 | statement.values = options.delete(:values) 8 | data_source = statement.data_source 9 | begin 10 | ActiveRecord::Base.connection_pool.with_connection do 11 | Blazer::RunStatement.new.perform(statement, options) 12 | end 13 | rescue Exception => e 14 | result = Blazer::Result.new(data_source, [], [], "Unknown error", nil, false) 15 | data_source.result_cache.write_run(options[:run_id], result) 16 | raise e 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/blazer/slack_notifier.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | 3 | module Blazer 4 | class SlackNotifier 5 | def self.state_change(check, state, state_was, rows_count, error, check_type) 6 | check.split_slack_channels.each do |channel| 7 | text = 8 | if error 9 | error 10 | elsif rows_count > 0 && check_type == "bad_data" 11 | pluralize(rows_count, "row") 12 | end 13 | 14 | payload = { 15 | channel: channel, 16 | attachments: [ 17 | { 18 | title: escape("Check #{state.titleize}: #{check.query.name}"), 19 | title_link: query_url(check.query_id), 20 | text: escape(text), 21 | color: state == "passing" ? "good" : "danger" 22 | } 23 | ] 24 | } 25 | 26 | post(payload) 27 | end 28 | end 29 | 30 | def self.failing_checks(channel, checks) 31 | text = 32 | checks.map do |check| 33 | "<#{query_url(check.query_id)}|#{escape(check.query.name)}> #{escape(check.state)}" 34 | end 35 | 36 | payload = { 37 | channel: channel, 38 | attachments: [ 39 | { 40 | title: escape("#{pluralize(checks.size, "Check")} Failing"), 41 | text: text.join("\n"), 42 | color: "warning" 43 | } 44 | ] 45 | } 46 | 47 | post(payload) 48 | end 49 | 50 | # https://api.slack.com/docs/message-formatting#how_to_escape_characters 51 | # - Replace the ampersand, &, with & 52 | # - Replace the less-than sign, < with < 53 | # - Replace the greater-than sign, > with > 54 | # That's it. Don't HTML entity-encode the entire message. 55 | def self.escape(str) 56 | str.gsub("&", "&").gsub("<", "<").gsub(">", ">") if str 57 | end 58 | 59 | def self.pluralize(*args) 60 | ActionController::Base.helpers.pluralize(*args) 61 | end 62 | 63 | # checks shouldn't have variables, but in any case, 64 | # avoid passing variable params to url helpers 65 | # (known unsafe parameters are removed, but still not ideal) 66 | def self.query_url(id) 67 | Blazer::Engine.routes.url_helpers.query_url(id, ActionMailer::Base.default_url_options) 68 | end 69 | 70 | # TODO use return value 71 | def self.post(payload) 72 | if Blazer.slack_webhook_url.present? 73 | response = post_api(Blazer.slack_webhook_url, payload, {}) 74 | response.is_a?(Net::HTTPSuccess) && response.body == "ok" 75 | else 76 | headers = { 77 | "Authorization" => "Bearer #{Blazer.slack_oauth_token}", 78 | "Content-type" => "application/json" 79 | } 80 | response = post_api("https://slack.com/api/chat.postMessage", payload, headers) 81 | response.is_a?(Net::HTTPSuccess) && (JSON.parse(response.body)["ok"] rescue false) 82 | end 83 | end 84 | 85 | def self.post_api(url, payload, headers) 86 | uri = URI.parse(url) 87 | http = Net::HTTP.new(uri.host, uri.port) 88 | http.use_ssl = true 89 | http.open_timeout = 3 90 | http.read_timeout = 5 91 | http.post(uri.request_uri, payload.to_json, headers) 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/blazer/statement.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | class Statement 3 | attr_reader :statement, :data_source, :bind_statement, :bind_values 4 | attr_accessor :values 5 | 6 | def initialize(statement, data_source = nil) 7 | @statement = statement 8 | @data_source = data_source.is_a?(String) ? Blazer.data_sources[data_source] : data_source 9 | @values = {} 10 | end 11 | 12 | def variables 13 | # strip commented out lines 14 | # and regex {1} or {1,2} 15 | @variables ||= statement.to_s.gsub(/\-\-.+/, "").gsub(/\/\*.+\*\//m, "").scan(/\{\w*?\}/i).map { |v| v[1...-1] }.reject { |v| /\A\d+(\,\d+)?\z/.match(v) || v.empty? }.uniq 16 | end 17 | 18 | def add_values(var_params) 19 | variables.each do |var| 20 | value = var_params[var].presence 21 | value = nil unless value.is_a?(String) # ignore arrays and hashes 22 | if value 23 | if ["start_time", "end_time"].include?(var) 24 | value = value.to_s.gsub(" ", "+") # fix for Quip bug 25 | end 26 | 27 | if var.end_with?("_at") 28 | begin 29 | value = Blazer.time_zone.parse(value) 30 | rescue 31 | # do nothing 32 | end 33 | end 34 | 35 | unless value.is_a?(ActiveSupport::TimeWithZone) 36 | if value.match?(/\A\d+\z/) 37 | # check no leading zeros (when not zero) 38 | if value == value.to_i.to_s 39 | value = value.to_i 40 | end 41 | elsif value.match?(/\A\d+\.\d+\z/) 42 | value = value.to_f 43 | end 44 | end 45 | end 46 | value = Blazer.transform_variable.call(var, value) if Blazer.transform_variable 47 | @values[var] = value 48 | end 49 | end 50 | 51 | def cohort_analysis? 52 | /\/\*\s*cohort analysis\s*\*\//i.match?(statement) 53 | end 54 | 55 | def apply_cohort_analysis(period:, days:) 56 | @statement = data_source.cohort_analysis_statement(statement, period: period, days: days).sub("{placeholder}") { statement } 57 | end 58 | 59 | # should probably transform before cohort analysis 60 | # but keep previous order for now 61 | def transformed_statement 62 | statement = self.statement.dup 63 | Blazer.transform_statement.call(data_source, statement) if Blazer.transform_statement 64 | statement 65 | end 66 | 67 | def bind 68 | @bind_statement, @bind_values = data_source.bind_params(transformed_statement, values) 69 | end 70 | 71 | def display_statement 72 | data_source.sub_variables(transformed_statement, values) 73 | end 74 | 75 | def clear_cache 76 | bind if bind_statement.nil? 77 | data_source.clear_cache(self) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/blazer/version.rb: -------------------------------------------------------------------------------- 1 | module Blazer 2 | VERSION = "3.3.0" 3 | end 4 | -------------------------------------------------------------------------------- /lib/generators/blazer/install_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators/active_record" 2 | 3 | module Blazer 4 | module Generators 5 | class InstallGenerator < Rails::Generators::Base 6 | include ActiveRecord::Generators::Migration 7 | source_root File.join(__dir__, "templates") 8 | 9 | def copy_migration 10 | migration_template "install.rb", "db/migrate/install_blazer.rb", migration_version: migration_version 11 | end 12 | 13 | def copy_config 14 | template "config.yml", "config/blazer.yml" 15 | end 16 | 17 | def migration_version 18 | "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/generators/blazer/templates/config.yml.tt: -------------------------------------------------------------------------------- 1 | # see https://github.com/ankane/blazer for more info 2 | 3 | data_sources: 4 | main: 5 | url: <%%= ENV["BLAZER_DATABASE_URL"] %> 6 | 7 | # statement timeout, in seconds 8 | # none by default 9 | # timeout: 15 10 | 11 | # caching settings 12 | # can greatly improve speed 13 | # off by default 14 | # cache: 15 | # mode: slow # or all 16 | # expires_in: 60 # min 17 | # slow_threshold: 15 # sec, only used in slow mode 18 | 19 | # wrap queries in a transaction for safety 20 | # not necessary if you use a read-only user 21 | # true by default 22 | # use_transaction: false 23 | 24 | smart_variables: 25 | # zone_id: "SELECT id, name FROM zones ORDER BY name ASC" 26 | # period: ["day", "week", "month"] 27 | # status: {0: "Active", 1: "Archived"} 28 | 29 | linked_columns: 30 | # user_id: "/admin/users/{value}" 31 | 32 | smart_columns: 33 | # user_id: "SELECT id, name FROM users WHERE id IN {value}" 34 | 35 | # create audits 36 | audit: true 37 | 38 | # change the time zone 39 | # time_zone: "Pacific Time (US & Canada)" 40 | 41 | # class name of the user model 42 | # user_class: User 43 | 44 | # method name for the current user 45 | # user_method: current_user 46 | 47 | # method name for the display name 48 | # user_name: name 49 | 50 | # custom before_action to use for auth 51 | # before_action_method: require_admin 52 | 53 | # email to send checks from 54 | # from_email: blazer@example.org 55 | 56 | # webhook for Slack 57 | # slack_webhook_url: <%%= ENV["BLAZER_SLACK_WEBHOOK_URL"] %> 58 | 59 | check_schedules: 60 | - "1 day" 61 | - "1 hour" 62 | - "5 minutes" 63 | 64 | # enable anomaly detection 65 | # note: with trend, time series are sent to https://trendapi.org 66 | # anomaly_checks: prophet / trend / anomaly_detection 67 | 68 | # enable forecasting 69 | # note: with trend, time series are sent to https://trendapi.org 70 | # forecasting: prophet / trend 71 | 72 | # enable map 73 | # mapbox_access_token: <%%= ENV["MAPBOX_ACCESS_TOKEN"] %> 74 | 75 | # enable uploads 76 | # uploads: 77 | # url: <%%= ENV["BLAZER_UPLOADS_URL"] %> 78 | # schema: uploads 79 | # data_source: main 80 | -------------------------------------------------------------------------------- /lib/generators/blazer/templates/install.rb.tt: -------------------------------------------------------------------------------- 1 | class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %> 2 | def change 3 | create_table :blazer_queries do |t| 4 | t.references :creator 5 | t.string :name 6 | t.text :description 7 | t.text :statement 8 | t.string :data_source 9 | t.string :status 10 | t.timestamps null: false 11 | end 12 | 13 | create_table :blazer_audits do |t| 14 | t.references :user 15 | t.references :query 16 | t.text :statement 17 | t.string :data_source 18 | t.datetime :created_at 19 | end 20 | 21 | create_table :blazer_dashboards do |t| 22 | t.references :creator 23 | t.string :name 24 | t.timestamps null: false 25 | end 26 | 27 | create_table :blazer_dashboard_queries do |t| 28 | t.references :dashboard 29 | t.references :query 30 | t.integer :position 31 | t.timestamps null: false 32 | end 33 | 34 | create_table :blazer_checks do |t| 35 | t.references :creator 36 | t.references :query 37 | t.string :state 38 | t.string :schedule 39 | t.text :emails 40 | t.text :slack_channels 41 | t.string :check_type 42 | t.text :message 43 | t.datetime :last_run_at 44 | t.timestamps null: false 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/generators/blazer/templates/uploads.rb.tt: -------------------------------------------------------------------------------- 1 | class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %> 2 | def change 3 | create_table :blazer_uploads do |t| 4 | t.references :creator 5 | t.string :table 6 | t.text :description 7 | t.timestamps null: false 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/generators/blazer/uploads_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators/active_record" 2 | 3 | module Blazer 4 | module Generators 5 | class UploadsGenerator < Rails::Generators::Base 6 | include ActiveRecord::Generators::Migration 7 | source_root File.join(__dir__, "templates") 8 | 9 | def copy_migration 10 | migration_template "uploads.rb", "db/migrate/create_blazer_uploads.rb", migration_version: migration_version 11 | end 12 | 13 | def migration_version 14 | "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/tasks/blazer.rake: -------------------------------------------------------------------------------- 1 | namespace :blazer do 2 | desc "run checks" 3 | task :run_checks, [:schedule] => :environment do |_, args| 4 | Blazer.run_checks(schedule: args[:schedule] || ENV["SCHEDULE"]) 5 | end 6 | 7 | desc "send failing checks" 8 | task send_failing_checks: :environment do 9 | Blazer.send_failing_checks 10 | end 11 | 12 | desc "archive queries" 13 | task archive_queries: :environment do 14 | begin 15 | Blazer.archive_queries 16 | rescue => e 17 | abort e.message 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /licenses/LICENSE-ace.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010, Ajax.org B.V. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of Ajax.org B.V. nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL AJAX.ORG B.V. BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /licenses/LICENSE-bootstrap.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2011-2019 Twitter, Inc. 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 | -------------------------------------------------------------------------------- /licenses/LICENSE-chart.js.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2022 Chart.js Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /licenses/LICENSE-chartjs-adapter-date-fns.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Chart.js Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /licenses/LICENSE-chartkick.js.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013-2023 Andrew Kane 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /licenses/LICENSE-date-fns.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Sasha Koss and Lesha Koss https://kossnocorp.mit-license.org 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /licenses/LICENSE-daterangepicker.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2012-2020 Dan Grossman 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 | -------------------------------------------------------------------------------- /licenses/LICENSE-fuzzysearch.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © 2015 Nicolas Bevacqua 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /licenses/LICENSE-highlight.js.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2006, Ivan Sagalaev. 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /licenses/LICENSE-jquery.txt: -------------------------------------------------------------------------------- 1 | Copyright OpenJS Foundation and other contributors, https://openjsf.org/ 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /licenses/LICENSE-kurkle-color.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-2021 Jukka Kurkela 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /licenses/LICENSE-moment-timezone.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) JS Foundation and other contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /licenses/LICENSE-moment.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) JS Foundation and other contributors 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /licenses/LICENSE-rails-ujs.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2004-2022 David Heinemeier Hansson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /licenses/LICENSE-sortable.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 All contributors to Sortable 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /licenses/LICENSE-stickytableheaders.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Jonas Mosbech 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /licenses/LICENSE-stupidtable.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Joseph McCullough 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /licenses/LICENSE-vue.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018-present, Yuxi (Evan) You 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 | -------------------------------------------------------------------------------- /test/adapters/athena_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class AthenaTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "athena" 8 | end 9 | 10 | def test_run 11 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 12 | end 13 | 14 | def test_audit 15 | if engine_version > 1 16 | assert_audit "SELECT ? AS hello\n\n[\"world\"]", "SELECT {var} AS hello", var: "world" 17 | else 18 | assert_audit "SELECT 'world' AS hello", "SELECT {var} AS hello", var: "world" 19 | end 20 | end 21 | 22 | def test_string 23 | assert_result [{"hello" => "world"}], "SELECT {var} AS hello", var: "world" 24 | end 25 | 26 | def test_integer 27 | assert_result [{"hello" => "1"}], "SELECT {var} AS hello", var: "1" 28 | end 29 | 30 | def test_float 31 | assert_result [{"hello" => "1.5"}], "SELECT {var} AS hello", var: "1.5" 32 | end 33 | 34 | def test_time 35 | assert_result [{"hello" => "2022-01-01 08:00:00"}], "SELECT {created_at} AS hello", created_at: "2022-01-01 08:00:00" 36 | end 37 | 38 | def test_nil 39 | assert_result [{"hello" => nil}], "SELECT {var} AS hello", var: "" 40 | end 41 | 42 | def test_single_quote 43 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 44 | end 45 | 46 | def test_double_quote 47 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 48 | end 49 | 50 | def test_backslash 51 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 52 | end 53 | 54 | def test_bad_position 55 | if engine_version > 1 56 | assert_error "Exception parsing query", "SELECT 'world' AS {var}", var: "hello" 57 | else 58 | assert_error "mismatched input", "SELECT 'world' AS {var}", var: "hello" 59 | end 60 | end 61 | 62 | def test_quoted 63 | if engine_version > 1 64 | assert_error "Incorrect number of parameters: expected 0 but found 1", "SELECT '{var}' AS hello", var: "world" 65 | end 66 | end 67 | 68 | private 69 | 70 | def engine_version 71 | Blazer.data_sources[data_source].settings["engine_version"].to_i 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/adapters/bigquery_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class BigqueryTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "bigquery" 8 | end 9 | 10 | def test_run 11 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 12 | end 13 | 14 | def test_audit 15 | assert_audit "SELECT ? AS hello\n\n[\"world\"]", "SELECT {var} AS hello", var: "world" 16 | end 17 | 18 | def test_string 19 | assert_result [{"hello" => "world"}], "SELECT {var} AS hello", var: "world" 20 | end 21 | 22 | def test_integer 23 | assert_result [{"hello" => "1"}], "SELECT {var} AS hello", var: "1" 24 | end 25 | 26 | def test_float 27 | assert_result [{"hello" => "1.5"}], "SELECT {var} AS hello", var: "1.5" 28 | end 29 | 30 | def test_time 31 | assert_result [{"hello" => "2022-01-01 08:00:00 UTC"}], "SELECT {created_at} AS hello", created_at: "2022-01-01 08:00:00" 32 | end 33 | 34 | # TODO fix 35 | def test_nil 36 | assert_error "nil params are not supported, must assign optional type", "SELECT {var} AS hello", var: "" 37 | end 38 | 39 | def test_single_quote 40 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 41 | end 42 | 43 | def test_double_quote 44 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 45 | end 46 | 47 | def test_backslash 48 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 49 | end 50 | 51 | def test_bad_position 52 | assert_bad_position "SELECT 'world' AS {var}", var: "hello" 53 | end 54 | 55 | # does not raise error for too many params 56 | def test_quoted 57 | assert_result [{"hello" => "?"}], "SELECT '{var}' AS hello", var: "world" 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/adapters/cassandra_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class CassandraTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "cassandra" 8 | end 9 | 10 | def setup 11 | @@once ||= begin 12 | require "cassandra" 13 | cluster = Cassandra.cluster(hosts: ["localhost"]) 14 | 15 | session = cluster.connect("system") 16 | session.execute("CREATE KEYSPACE IF NOT EXISTS blazer_test WITH REPLICATION = { 'class' : 'SimpleStrategy', 'replication_factor' : 1 }") 17 | 18 | session = cluster.connect("blazer_test") 19 | session.execute("DROP TABLE IF EXISTS items") 20 | session.execute("CREATE TABLE items (id int, hello text, PRIMARY KEY (id))") 21 | session.execute("INSERT INTO items (id, hello) VALUES (1, 'world')") 22 | session.execute("INSERT INTO items (id, hello) VALUES (2, '''')") 23 | session.execute("INSERT INTO items (id, hello) VALUES (3, '\"')") 24 | session.execute("INSERT INTO items (id, hello) VALUES (4, '\\')") 25 | true 26 | end 27 | end 28 | 29 | def test_tables 30 | assert_equal ["items"], tables 31 | end 32 | 33 | def test_run 34 | expected = [{"hello" => 'world'}] 35 | assert_result expected, "SELECT hello FROM items WHERE hello = 'world' ALLOW FILTERING" 36 | end 37 | 38 | def test_audit 39 | expected = "SELECT hello FROM items WHERE hello = ? ALLOW FILTERING\n\n[\"world\"]" 40 | assert_audit expected, "SELECT hello FROM items WHERE hello = {var} ALLOW FILTERING", var: "world" 41 | end 42 | 43 | def test_single_quote 44 | expected = [{"hello" => "'"}] 45 | assert_result expected, "SELECT hello FROM items WHERE hello = {var} ALLOW FILTERING", var: "'" 46 | end 47 | 48 | def test_double_quote 49 | expected = [{"hello" => '"'}] 50 | assert_result expected, "SELECT hello FROM items WHERE hello = {var} ALLOW FILTERING", var: '"' 51 | end 52 | 53 | def test_backslash 54 | expected = [{"hello" => "\\"}] 55 | assert_result expected, "SELECT hello FROM items WHERE hello = {var} ALLOW FILTERING", var: "\\" 56 | end 57 | 58 | def test_bad_position 59 | assert_bad_position "SELECT hello FROM items WHERE hello {var} 'world' ALLOW FILTERING", var: "=" 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /test/adapters/drill_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class DrillTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "drill" 8 | end 9 | 10 | def test_run 11 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 12 | end 13 | 14 | def test_audit 15 | assert_audit "SELECT 'world' AS hello", "SELECT {var} AS hello", var: "world" 16 | end 17 | 18 | def test_single_quote 19 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 20 | end 21 | 22 | def test_double_quote 23 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 24 | end 25 | 26 | def test_backslash 27 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/adapters/druid_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class DruidTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "druid" 8 | end 9 | 10 | def test_run 11 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 12 | end 13 | 14 | def test_audit 15 | assert_audit "SELECT ? AS hello\n\n[\"world\"]", "SELECT {var} AS hello", var: "world" 16 | end 17 | 18 | def test_string 19 | assert_result [{"hello" => "world"}], "SELECT {var} AS hello", var: "world" 20 | end 21 | 22 | def test_integer 23 | assert_result [{"hello" => "1"}], "SELECT {var} AS hello", var: "1" 24 | end 25 | 26 | def test_float 27 | assert_result [{"hello" => "1.5"}], "SELECT {var} AS hello", var: "1.5" 28 | end 29 | 30 | def test_time 31 | assert_result [{"hello" => "2022-01-01 08:00:00 UTC"}], "SELECT {created_at} AS hello", created_at: "2022-01-01 08:00:00" 32 | end 33 | 34 | # TODO fix 35 | def test_nil 36 | # assert_result [{"hello" => nil}], "SELECT {var} AS hello", var: "" 37 | end 38 | 39 | def test_single_quote 40 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 41 | end 42 | 43 | def test_double_quote 44 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 45 | end 46 | 47 | def test_backslash 48 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 49 | end 50 | 51 | def test_bad_position 52 | assert_bad_position "SELECT hello AS {var}", var: "world" 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/adapters/elasticsearch_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class ElasticsearchTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "elasticsearch" 8 | end 9 | 10 | def test_run 11 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 12 | end 13 | 14 | def test_audit 15 | assert_audit "SELECT ? AS hello\n\n[\"world\"]", "SELECT {var} AS hello", var: "world" 16 | end 17 | 18 | def test_string 19 | assert_result [{"hello" => "world"}], "SELECT {var} AS hello", var: "world" 20 | end 21 | 22 | def test_integer 23 | assert_result [{"hello" => "1"}], "SELECT {var} AS hello", var: "1" 24 | end 25 | 26 | def test_float 27 | assert_result [{"hello" => "1.5"}], "SELECT {var} AS hello", var: "1.5" 28 | end 29 | 30 | def test_time 31 | assert_result [{"hello" => "2022-01-01T08:00:00.000Z"}], "SELECT {created_at} AS hello", created_at: "2022-01-01 08:00:00" 32 | end 33 | 34 | def test_nil 35 | assert_result [{"hello" => nil}], "SELECT {var} AS hello", var: "" 36 | end 37 | 38 | def test_single_quote 39 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 40 | end 41 | 42 | def test_double_quote 43 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 44 | end 45 | 46 | def test_backslash 47 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 48 | end 49 | 50 | def test_bad_position 51 | assert_bad_position "SELECT 'world' AS {var}", var: "hello" 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/adapters/hive_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | # bin/beeline -u jdbc:hive2://localhost:10000 -e 'CREATE DATABASE blazer_test;' 4 | 5 | class HiveTest < ActionDispatch::IntegrationTest 6 | include AdapterTest 7 | 8 | def data_source 9 | "hive" 10 | end 11 | 12 | def test_run 13 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 14 | end 15 | 16 | def test_audit 17 | assert_audit "SELECT 'world' AS hello", "SELECT {var} AS hello", var: "world" 18 | end 19 | 20 | def test_string 21 | assert_result [{"hello" => "world"}], "SELECT {var} AS hello", var: "world" 22 | end 23 | 24 | def test_integer 25 | assert_result [{"hello" => "1"}], "SELECT {var} AS hello", var: "1" 26 | end 27 | 28 | def test_float 29 | assert_result [{"hello" => "1.5"}], "SELECT {var} AS hello", var: "1.5" 30 | end 31 | 32 | def test_time 33 | assert_result [{"hello" => "2022-01-01 08:00:00"}], "SELECT {created_at} AS hello", created_at: "2022-01-01 08:00:00" 34 | end 35 | 36 | def test_nil 37 | assert_result [{"hello" => nil}], "SELECT {var} AS hello", var: "" 38 | end 39 | 40 | def test_single_quote 41 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 42 | end 43 | 44 | def test_double_quote 45 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 46 | end 47 | 48 | def test_backslash 49 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/adapters/ignite_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class IgniteTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "ignite" 8 | end 9 | 10 | def test_run 11 | assert_result [{"HELLO" => "world"}], "SELECT 'world' AS hello" 12 | end 13 | 14 | def test_audit 15 | assert_audit "SELECT ? AS hello\n\n[\"world\"]", "SELECT {var} AS hello", var: "world" 16 | end 17 | 18 | def test_string 19 | assert_result [{"HELLO" => "world"}], "SELECT {var} AS hello", var: "world" 20 | end 21 | 22 | def test_integer 23 | assert_result [{"HELLO" => "1"}], "SELECT {var} AS hello", var: "1" 24 | end 25 | 26 | def test_float 27 | assert_result [{"HELLO" => "1.5"}], "SELECT {var} AS hello", var: "1.5" 28 | end 29 | 30 | def test_time 31 | assert_result [{"HELLO" => "2022-01-01 08:00:00 UTC"}], "SELECT {created_at} AS hello", created_at: "2022-01-01 08:00:00" 32 | end 33 | 34 | def test_nil 35 | assert_result [{"HELLO" => nil}], "SELECT {var} AS hello", var: "" 36 | end 37 | 38 | def test_single_quote 39 | assert_result [{"HELLO" => "'"}], "SELECT {var} AS hello", var: "'" 40 | end 41 | 42 | def test_double_quote 43 | assert_result [{"HELLO" => '"'}], "SELECT {var} AS hello", var: '"' 44 | end 45 | 46 | def test_backslash 47 | assert_result [{"HELLO" => "\\"}], "SELECT {var} AS hello", var: "\\" 48 | end 49 | 50 | def test_bad_position 51 | assert_error "Syntax error in SQL statement", "SELECT 'world' AS {var}", var: "hello" 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/adapters/influxdb_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class InfluxdbTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "influxdb" 8 | end 9 | 10 | def setup 11 | @@once ||= begin 12 | client = InfluxDB::Client.new(url: "http://localhost:8086/blazer_test") 13 | client.delete_series("items") 14 | client.write_point("items", {values: {value: 1}, tags: {hello: "world"}, timestamp: 0}) 15 | client.write_point("items", {values: {value: 1}, tags: {hello: "'"}, timestamp: 0}) 16 | client.write_point("items", {values: {value: 1}, tags: {hello: '"'}, timestamp: 0}) 17 | # InfluxDB does not like trailing backslashes 18 | # https://github.com/influxdata/influxdb/issues/5231 19 | # https://github.com/influxdata/influxdb-ruby/issues/225 20 | client.write_point("items", {values: {value: 1}, tags: {hello: "\\a"}, timestamp: 0}) 21 | true 22 | end 23 | end 24 | 25 | def test_run 26 | expected = [{"time" => "1970-01-01 00:00:00 UTC", "hello" => "world", "value" => "1"}] 27 | assert_result expected, "SELECT * FROM items WHERE hello = 'world'" 28 | end 29 | 30 | def test_audit 31 | assert_audit "SELECT * FROM items WHERE hello = 'world'", "SELECT * FROM items WHERE hello = {var}", var: "world" 32 | end 33 | 34 | def test_single_quote 35 | expected = [{"time" => "1970-01-01 00:00:00 UTC", "hello" => "'", "value" => "1"}] 36 | assert_result expected, "SELECT * FROM items WHERE hello = {var}", var: "'" 37 | end 38 | 39 | def test_double_quote 40 | expected = [{"time" => "1970-01-01 00:00:00 UTC", "hello" => '"', "value" => "1"}] 41 | assert_result expected, "SELECT * FROM items WHERE hello = {var}", var: '"' 42 | end 43 | 44 | def test_backslash 45 | expected = [{"time" => "1970-01-01 00:00:00 UTC", "hello" => "\\a", "value" => "1"}] 46 | assert_result expected, "SELECT * FROM items WHERE hello = {var}", var: "\\a" 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/adapters/mysql_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class MysqlTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | ENV["MYSQL_ADAPTER"] || "mysql2" 8 | end 9 | 10 | def test_run 11 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 12 | end 13 | 14 | def test_audit 15 | if prepared_statements? 16 | assert_audit "SELECT ? AS hello\n\n[\"world\"]", "SELECT {var} AS hello", var: "world" 17 | else 18 | assert_audit "SELECT 'world' AS hello", "SELECT {var} AS hello", var: "world" 19 | end 20 | end 21 | 22 | def test_string 23 | assert_result [{"hello" => "world"}], "SELECT {var} AS hello", var: "world" 24 | end 25 | 26 | def test_integer 27 | assert_result [{"hello" => "1"}], "SELECT {var} AS hello", var: "1" 28 | end 29 | 30 | def test_float 31 | assert_result [{"hello" => "1.5"}], "SELECT {var} AS hello", var: "1.5" 32 | end 33 | 34 | def test_time 35 | assert_result [{"hello" => "2022-01-01 08:00:00"}], "SELECT {created_at} AS hello", created_at: "2022-01-01 08:00:00" 36 | end 37 | 38 | def test_nil 39 | assert_result [{"hello" => nil}], "SELECT {var} AS hello", var: "" 40 | end 41 | 42 | def test_single_quote 43 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 44 | end 45 | 46 | def test_double_quote 47 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 48 | end 49 | 50 | def test_backslash 51 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 52 | end 53 | 54 | def test_multiple_variables 55 | assert_result [{"c1" => "one", "c2" => "two", "c3" => "one"}], "SELECT {var} AS c1, {var2} AS c2, {var} AS c3", var: "one", var2: "two" 56 | end 57 | 58 | def test_bad_position 59 | if prepared_statements? 60 | assert_bad_position "SELECT 'world' AS {var}", var: "hello" 61 | else 62 | assert_result [{"hello"=>"world"}], "SELECT 'world' AS {var}", var: "hello" 63 | end 64 | end 65 | 66 | def test_bad_position_before 67 | if prepared_statements? 68 | assert_result [{"?" => "world"}], "SELECT{var}", var: "world" 69 | else 70 | assert_result [{"world" => "world"}], "SELECT{var}", var: "world" 71 | end 72 | end 73 | 74 | def test_bad_position_after 75 | if prepared_statements? 76 | assert_bad_position "SELECT {var}456", var: "world" 77 | else 78 | assert_error "You have an error in your SQL syntax", "SELECT {var}456", var: "world" 79 | end 80 | end 81 | 82 | def test_quoted 83 | if prepared_statements? 84 | assert_error "Bind parameter count (0) doesn't match number of arguments (1)", "SELECT '{var}' AS hello", var: "world" 85 | else 86 | assert_error "You have an error in your SQL syntax", "SELECT '{var}' AS hello", var: "world" 87 | end 88 | end 89 | 90 | def test_binary 91 | # checks for successful response 92 | run_statement "SELECT UNHEX('F6'), 1", format: "html" 93 | end 94 | 95 | def test_binary_output 96 | assert_raises(CSV::InvalidEncodingError) do 97 | assert_result [{"hello" => "0xF6"}], "SELECT UNHEX('F6') AS hello" 98 | end 99 | end 100 | 101 | def test_json_output 102 | assert_result [{"json" => '{"hello": "world"}'}], %!SELECT JSON_OBJECT('hello', 'world') AS json! 103 | end 104 | 105 | private 106 | 107 | def prepared_statements? 108 | Blazer.data_sources[data_source].settings["url"].include?("prepared_statements=true") 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /test/adapters/neo4j_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class Neo4jTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "neo4j" 8 | end 9 | 10 | def test_run 11 | assert_result [{"hello" => "world"}], "OPTIONAL MATCH () RETURN 'world' AS `hello`" 12 | end 13 | 14 | def test_audit 15 | assert_audit "OPTIONAL MATCH () RETURN $var AS `hello`\n\n{\"var\":\"world\"}", "OPTIONAL MATCH () RETURN {var} AS `hello`", var: "world" 16 | end 17 | 18 | def test_string 19 | assert_result [{"hello" => "world"}], "OPTIONAL MATCH () RETURN {var} AS `hello`", var: "world" 20 | end 21 | 22 | def test_integer 23 | assert_result [{"hello" => "1"}], "OPTIONAL MATCH () RETURN {var} AS `hello`", var: "1" 24 | end 25 | 26 | def test_float 27 | assert_result [{"hello" => "1.5"}], "OPTIONAL MATCH () RETURN {var} AS `hello`", var: "1.5" 28 | end 29 | 30 | def test_time 31 | assert_result [{"hello" => "2022-01-01 08:00:00 UTC"}], "OPTIONAL MATCH () RETURN {created_at} AS `hello`", created_at: "2022-01-01 08:00:00" 32 | end 33 | 34 | def test_nil 35 | assert_result [{"hello" => nil}], "OPTIONAL MATCH () RETURN {var} AS `hello`", var: "" 36 | end 37 | 38 | def test_single_quote 39 | assert_result [{"hello" => "'"}], "OPTIONAL MATCH () RETURN {var} AS `hello`", var: "'" 40 | end 41 | 42 | def test_double_quote 43 | assert_result [{"hello" => '"'}], "OPTIONAL MATCH () RETURN {var} AS `hello`", var: '"' 44 | end 45 | 46 | def test_backslash 47 | assert_result [{"hello" => "\\"}], "OPTIONAL MATCH () RETURN {var} AS `hello`", var: "\\" 48 | end 49 | 50 | def test_bad_position 51 | assert_bad_position "OPTIONAL MATCH () RETURN 'world' AS {var}", var: "hello" 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/adapters/opensearch_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class OpensearchTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "opensearch" 8 | end 9 | 10 | def test_run 11 | assert_result [{"'world'" => "world"}], "SELECT 'world' AS hello" 12 | end 13 | 14 | def test_single_quote 15 | assert_error "Quoting not specified", "SELECT {var} AS hello", var: "'" 16 | end 17 | 18 | def test_double_quote 19 | assert_error "Quoting not specified", "SELECT {var} AS hello", var: '"' 20 | end 21 | 22 | def test_backslash 23 | assert_error "Quoting not specified", "SELECT {var} AS hello", var: "\\" 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/adapters/postgresql_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class PostgresqlTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "postgresql" 8 | end 9 | 10 | def test_run 11 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 12 | end 13 | 14 | def test_audit 15 | assert_audit "SELECT $1 AS hello\n\n[\"world\"]", "SELECT {var} AS hello", var: "world" 16 | end 17 | 18 | def test_string 19 | assert_result [{"hello" => "world"}], "SELECT {var} AS hello", var: "world" 20 | end 21 | 22 | def test_integer 23 | assert_result [{"hello" => "1"}], "SELECT {var} AS hello", var: "1" 24 | end 25 | 26 | def test_leading_zeros 27 | assert_result [{"hello" => "0123"}], "SELECT {var} AS hello", var: "0123" 28 | end 29 | 30 | def test_float 31 | assert_result [{"hello" => "1.5"}], "SELECT {var} AS hello", var: "1.5" 32 | end 33 | 34 | def test_time 35 | assert_result [{"hello" => "2022-01-01 08:00:00"}], "SELECT {created_at} AS hello", created_at: "2022-01-01 08:00:00" 36 | end 37 | 38 | def test_nil 39 | assert_result [{"hello" => nil}], "SELECT {var} AS hello", var: "" 40 | end 41 | 42 | def test_single_quote 43 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 44 | end 45 | 46 | def test_double_quote 47 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 48 | end 49 | 50 | def test_backslash 51 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 52 | end 53 | 54 | def test_multiple_variables 55 | assert_result [{"c1" => "one", "c2" => "two", "c3" => "one"}], "SELECT {var} AS c1, {var2} AS c2, {var} AS c3", var: "one", var2: "two" 56 | end 57 | 58 | def test_bad_position 59 | assert_bad_position "SELECT 'world' AS {var}", var: "hello" 60 | end 61 | 62 | def test_bad_position_before 63 | assert_error "syntax error at or near \"SELECT$1\"", "SELECT{var}", var: "world" 64 | end 65 | 66 | def test_bad_position_after 67 | assert_error "syntax error at or near \"456\"\nLINE 1: SELECT $1 456", "SELECT {var}456", var: "world" 68 | assert_equal "SELECT $1 456\n\n[\"world\"]", Blazer::Audit.last.statement 69 | end 70 | 71 | def test_quoted 72 | assert_error "could not determine data type of parameter $1", "SELECT '{var}' AS hello", var: "world" 73 | end 74 | 75 | def test_binary_output 76 | assert_result [{"bytea" => "\\x68656c6c6f"}], "SELECT 'hello'::bytea" 77 | end 78 | 79 | def test_json_output 80 | assert_result [{"json" => '{"hello": "world"}'}], %!SELECT '{"hello": "world"}'::json! 81 | end 82 | 83 | def test_jsonb_output 84 | assert_result [{"jsonb" => '{"hello": "world"}'}], %!SELECT '{"hello": "world"}'::jsonb! 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/adapters/presto_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class PrestoTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "presto" 8 | end 9 | 10 | def test_tables 11 | # needs different connector 12 | end 13 | 14 | def test_run 15 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 16 | end 17 | 18 | def test_audit 19 | assert_audit "SELECT 'world' AS hello", "SELECT {var} AS hello", var: "world" 20 | end 21 | 22 | def test_single_quote 23 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 24 | end 25 | 26 | def test_double_quote 27 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 28 | end 29 | 30 | def test_backslash 31 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/adapters/redshift_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class RedshiftTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "redshift" 8 | end 9 | 10 | def test_run 11 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 12 | end 13 | 14 | def test_audit 15 | assert_audit "SELECT 'world' AS hello", "SELECT {var} AS hello", var: "world" 16 | end 17 | 18 | def test_string 19 | assert_result [{"hello" => "world"}], "SELECT {var} AS hello", var: "world" 20 | end 21 | 22 | def test_integer 23 | assert_result [{"hello" => "1"}], "SELECT {var} AS hello", var: "1" 24 | end 25 | 26 | def test_float 27 | assert_result [{"hello" => "1.5"}], "SELECT {var} AS hello", var: "1.5" 28 | end 29 | 30 | def test_time 31 | assert_result [{"hello" => "2022-01-01 08:00:00"}], "SELECT {created_at} AS hello", created_at: "2022-01-01 08:00:00" 32 | end 33 | 34 | def test_nil 35 | assert_result [{"hello" => nil}], "SELECT {var} AS hello", var: "" 36 | end 37 | 38 | def test_single_quote 39 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 40 | end 41 | 42 | def test_double_quote 43 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 44 | end 45 | 46 | def test_backslash 47 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 48 | end 49 | 50 | def test_multiple_variables 51 | assert_result [{"c1" => "one", "c2" => "two", "c3" => "one"}], "SELECT {var} AS c1, {var2} AS c2, {var} AS c3", var: "one", var2: "two" 52 | end 53 | 54 | def test_bad_position 55 | assert_error "syntax error at or near", "SELECT 'world' AS {var}", var: "hello" 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/adapters/salesforce_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | # https://stackoverflow.com/questions/12794302/salesforce-authentication-failing/29112224#29112224 4 | # create accounts named world, ', ", and \ 5 | 6 | # ENV["SALESFORCE_USERNAME"] = "username" 7 | # ENV["SALESFORCE_PASSWORD"] = "password" 8 | # ENV["SALESFORCE_SECURITY_TOKEN"] = "security token" 9 | # ENV["SALESFORCE_CLIENT_ID"] = "client id" 10 | # ENV["SALESFORCE_CLIENT_SECRET"] = "client secret" 11 | # ENV["SALESFORCE_API_VERSION"] = "41.0" 12 | 13 | class SalesforceTest < ActionDispatch::IntegrationTest 14 | include AdapterTest 15 | 16 | def data_source 17 | "salesforce" 18 | end 19 | 20 | def test_run 21 | assert_result [{"Name" => "world"}], "SELECT Name FROM Account WHERE Name = 'world'" 22 | end 23 | 24 | def test_single_quote 25 | assert_result [{"Name" => "'"}], "SELECT Name FROM Account WHERE Name = {var}", var: "'" 26 | end 27 | 28 | def test_double_quote 29 | assert_result [{"Name" => '"'}], "SELECT Name FROM Account WHERE Name = {var}", var: '"' 30 | end 31 | 32 | def test_backslash 33 | assert_result [{"Name" => "\\"}], "SELECT Name FROM Account WHERE Name = {var}", var: "\\" 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/adapters/snowflake_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class SnowflakeTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "snowflake" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/adapters/soda_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class SodaTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "soda" 8 | end 9 | 10 | def test_tables 11 | assert_equal ["all"], tables 12 | end 13 | 14 | def test_run 15 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello LIMIT 1" 16 | end 17 | 18 | def test_single_quote 19 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello LIMIT 1", var: "'" 20 | end 21 | 22 | def test_double_quote 23 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello LIMIT 1", var: '"' 24 | end 25 | 26 | def test_backslash 27 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello LIMIT 1", var: "\\" 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/adapters/spark_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | # bin/beeline -u jdbc:hive2://localhost:10000 -e 'CREATE DATABASE blazer_test;' 4 | 5 | class SparkTest < ActionDispatch::IntegrationTest 6 | include AdapterTest 7 | 8 | def data_source 9 | "spark" 10 | end 11 | 12 | def test_run 13 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 14 | end 15 | 16 | def test_audit 17 | assert_audit "SELECT 'world' AS hello", "SELECT {var} AS hello", var: "world" 18 | end 19 | 20 | def test_string 21 | assert_result [{"hello" => "world"}], "SELECT {var} AS hello", var: "world" 22 | end 23 | 24 | def test_integer 25 | assert_result [{"hello" => "1"}], "SELECT {var} AS hello", var: "1" 26 | end 27 | 28 | def test_float 29 | assert_result [{"hello" => "1.5"}], "SELECT {var} AS hello", var: "1.5" 30 | end 31 | 32 | def test_time 33 | assert_result [{"hello" => "2022-01-01 08:00:00"}], "SELECT {created_at} AS hello", created_at: "2022-01-01 08:00:00" 34 | end 35 | 36 | def test_nil 37 | assert_result [{"hello" => nil}], "SELECT {var} AS hello", var: "" 38 | end 39 | 40 | def test_single_quote 41 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 42 | end 43 | 44 | def test_double_quote 45 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 46 | end 47 | 48 | def test_backslash 49 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/adapters/sqlite_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | class SqliteTest < ActionDispatch::IntegrationTest 4 | include AdapterTest 5 | 6 | def data_source 7 | "sqlite" 8 | end 9 | 10 | def test_run 11 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 12 | end 13 | 14 | def test_audit 15 | assert_audit "SELECT $1 AS hello\n\n[\"world\"]", "SELECT {var} AS hello", var: "world" 16 | end 17 | 18 | def test_string 19 | assert_result [{"hello" => "world"}], "SELECT {var} AS hello", var: "world" 20 | end 21 | 22 | def test_integer 23 | assert_result [{"hello" => "1"}], "SELECT {var} AS hello", var: "1" 24 | end 25 | 26 | def test_float 27 | assert_result [{"hello" => "1.5"}], "SELECT {var} AS hello", var: "1.5" 28 | end 29 | 30 | def test_time 31 | assert_result [{"hello" => "2022-01-01 08:00:00"}], "SELECT {created_at} AS hello", created_at: "2022-01-01 08:00:00" 32 | end 33 | 34 | def test_nil 35 | assert_result [{"hello" => nil}], "SELECT {var} AS hello", var: "" 36 | end 37 | 38 | def test_single_quote 39 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 40 | end 41 | 42 | def test_double_quote 43 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 44 | end 45 | 46 | def test_backslash 47 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 48 | end 49 | 50 | def test_multiple_variables 51 | assert_result [{"c1" => "one", "c2" => "two", "c3" => "one"}], "SELECT {var} AS c1, {var2} AS c2, {var} AS c3", var: "one", var2: "two" 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /test/adapters/sqlserver_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "../test_helper" 2 | 3 | # brew install freetds 4 | # docker run -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=YourStrong!Passw0rd' -p 1433:1433 -d mcr.microsoft.com/mssql/server:2019-latest 5 | # docker exec -it /opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P YourStrong\!Passw0rd -Q "CREATE DATABASE blazer_test" 6 | 7 | class SqlserverTest < ActionDispatch::IntegrationTest 8 | include AdapterTest 9 | 10 | def data_source 11 | "sqlserver" 12 | end 13 | 14 | def test_run 15 | assert_result [{"hello" => "world"}], "SELECT 'world' AS hello" 16 | end 17 | 18 | def test_audit 19 | assert_audit "SELECT @0 AS hello\n\n[\"world\"]", "SELECT {var} AS hello", var: "world" 20 | end 21 | 22 | def test_string 23 | assert_result [{"hello" => "world"}], "SELECT {var} AS hello", var: "world" 24 | end 25 | 26 | def test_integer 27 | assert_result [{"hello" => "1"}], "SELECT {var} AS hello", var: "1" 28 | end 29 | 30 | # https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/issues/643 31 | # should be 1.5 32 | def test_float 33 | assert_result [{"hello" => "1"}], "SELECT {var} AS hello", var: "1.5" 34 | end 35 | 36 | def test_time 37 | assert_result [{"hello" => "01-01-2022 08:00:00.0"}], "SELECT {created_at} AS hello", created_at: "2022-01-01 08:00:00" 38 | end 39 | 40 | def test_nil 41 | assert_result [{"hello" => nil}], "SELECT {var} AS hello", var: "" 42 | end 43 | 44 | def test_single_quote 45 | assert_result [{"hello" => "'"}], "SELECT {var} AS hello", var: "'" 46 | end 47 | 48 | def test_double_quote 49 | assert_result [{"hello" => '"'}], "SELECT {var} AS hello", var: '"' 50 | end 51 | 52 | def test_backslash 53 | assert_result [{"hello" => "\\"}], "SELECT {var} AS hello", var: "\\" 54 | end 55 | 56 | def test_bad_position 57 | assert_bad_position "SELECT 'world' AS {var}", var: "hello" 58 | end 59 | 60 | def test_quoted 61 | assert_result [{"hello"=>"@0 "}], "SELECT '{var}' AS hello", var: "world" 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/anomaly_checks_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class AnomalyChecksTest < ActionDispatch::IntegrationTest 4 | def setup 5 | Blazer::Check.delete_all 6 | Blazer::Query.delete_all 7 | end 8 | 9 | def test_prophet 10 | skip unless ENV["TEST_PROPHET"] 11 | 12 | assert_anomaly("prophet") 13 | end 14 | 15 | def test_trend 16 | skip unless ENV["TEST_TREND"] 17 | 18 | assert_anomaly("trend") 19 | end 20 | 21 | def test_anomaly_detection 22 | assert_anomaly("anomaly_detection") 23 | end 24 | 25 | def assert_anomaly(anomaly_checks) 26 | skip if !postgresql? || RUBY_ENGINE == "truffleruby" 27 | 28 | Blazer.stub(:anomaly_checks, anomaly_checks) do 29 | query = create_query(statement: "SELECT current_date + n AS day, 0.1 FROM generate_series(1, 30) n") 30 | check = create_check(query: query, check_type: "anomaly") 31 | 32 | Blazer.run_checks(schedule: "5 minutes") 33 | check.reload 34 | assert_equal "passing", check.state 35 | 36 | query.update!(statement: "SELECT current_date + n AS day, 0.1 * random() FROM generate_series(1, 30) n UNION ALL SELECT current_date + 31, 2") 37 | 38 | Blazer.run_checks(schedule: "5 minutes") 39 | check.reload 40 | assert_equal "failing", check.state 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/archive_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class ArchiveTest < ActionDispatch::IntegrationTest 4 | def setup 5 | Blazer::Audit.delete_all 6 | Blazer::Query.delete_all 7 | end 8 | 9 | def test_archive_queries 10 | query = create_query 11 | query2 = create_query 12 | query2.audits.create! 13 | 14 | Blazer.archive_queries 15 | 16 | query.reload 17 | assert_equal "archived", query.status 18 | query2.reload 19 | assert_equal "active", query2.status 20 | 21 | get blazer.query_path(query) 22 | assert_response :success 23 | query.reload 24 | assert_equal "active", query.status 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/cache_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class CacheTest < ActionDispatch::IntegrationTest 4 | def setup 5 | Rails.cache.clear 6 | end 7 | 8 | def test_all 9 | with_caching({"mode" => "all"}) do 10 | run_query "SELECT 1" 11 | refute_match "Cached", response.body 12 | run_query "SELECT 1" 13 | assert_match "Cached", response.body 14 | end 15 | end 16 | 17 | def test_slow_under_threshold 18 | with_caching({"mode" => "slow"}) do 19 | run_query "SELECT 1" 20 | refute_match "Cached", response.body 21 | run_query "SELECT 1" 22 | refute_match "Cached", response.body 23 | end 24 | end 25 | 26 | def test_slow_over_threshold 27 | skip unless postgresql? 28 | 29 | with_caching({"mode" => "slow", "slow_threshold" => 0.01}) do 30 | run_query "SELECT pg_sleep(0.01)::text" 31 | refute_match "Cached", response.body 32 | run_query "SELECT pg_sleep(0.01)::text" 33 | assert_match "Cached", response.body 34 | end 35 | end 36 | 37 | def test_variables 38 | with_caching({"mode" => "all"}) do 39 | run_query "SELECT {str_var}, {int_var}", variables: {str_var: "hello", int_var: 1} 40 | assert_match "hello", response.body 41 | end 42 | end 43 | 44 | private 45 | 46 | def with_caching(value) 47 | Blazer.data_sources["main"].stub(:cache, value) do 48 | yield 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/charts_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class ChartsTest < ActionDispatch::IntegrationTest 4 | def test_line_chart_format1 5 | run_query "SELECT NOW(), 1" 6 | assert_match "LineChart", response.body 7 | end 8 | 9 | def test_line_chart_format2 10 | run_query "SELECT NOW(), 'Label', 1" 11 | assert_match "LineChart", response.body 12 | end 13 | 14 | def test_column_chart_format1 15 | run_query "SELECT 'Label' AS label, 1" 16 | assert_match "ColumnChart", response.body 17 | end 18 | 19 | def test_column_chart_format2 20 | run_query "SELECT 'Label' AS label, 'Group' AS group2, 1" 21 | assert_match "ColumnChart", response.body 22 | assert_match %{"name":"Group"}, response.body 23 | end 24 | 25 | def test_scatter_chart 26 | run_query "SELECT 1, 2" 27 | assert_match "ScatterChart", response.body 28 | end 29 | 30 | def test_pie_chart 31 | run_query "SELECT 'Label', 1 AS pie" 32 | assert_match "PieChart", response.body 33 | end 34 | 35 | def test_target 36 | run_query "SELECT NOW(), 1, 2 AS target" 37 | assert_match %{"name":"target"}, response.body 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/checks_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class ChecksTest < ActionDispatch::IntegrationTest 4 | def setup 5 | Blazer::Check.delete_all 6 | Blazer::Query.delete_all 7 | end 8 | 9 | def test_index 10 | get blazer.checks_path 11 | assert_response :success 12 | end 13 | 14 | def test_bad_data 15 | query = create_query 16 | check = create_check(query: query, check_type: "bad_data") 17 | 18 | Blazer.run_checks(schedule: "5 minutes") 19 | check.reload 20 | assert_equal "failing", check.state 21 | 22 | query.update!(statement: "SELECT 1 LIMIT 0") 23 | 24 | Blazer.run_checks(schedule: "5 minutes") 25 | check.reload 26 | assert_equal "passing", check.state 27 | end 28 | 29 | def test_missing_data 30 | query = create_query 31 | check = create_check(query: query, check_type: "missing_data") 32 | 33 | Blazer.run_checks(schedule: "5 minutes") 34 | check.reload 35 | assert_equal "passing", check.state 36 | 37 | query.update!(statement: "SELECT 1 LIMIT 0") 38 | 39 | Blazer.run_checks(schedule: "5 minutes") 40 | check.reload 41 | assert_equal "failing", check.state 42 | end 43 | 44 | def test_error 45 | query = create_query(statement: "invalid") 46 | check = create_check(query: query, check_type: "bad_data") 47 | Blazer.run_checks(schedule: "5 minutes") 48 | check.reload 49 | assert_equal "error", check.state 50 | end 51 | 52 | def test_emails 53 | query = create_query 54 | check = create_check(query: query, check_type: "bad_data", emails: "hi@example.org,hi2@example.org") 55 | 56 | assert_emails 0 do 57 | Blazer.send_failing_checks 58 | end 59 | 60 | assert_emails 1 do 61 | Blazer.run_checks(schedule: "5 minutes") 62 | end 63 | 64 | assert_emails 2 do 65 | Blazer.send_failing_checks 66 | end 67 | end 68 | 69 | def test_slack 70 | query = create_query 71 | check = create_check(query: query, check_type: "bad_data", slack_channels: "#general,#random") 72 | 73 | assert_slack_messages 0 do 74 | Blazer.send_failing_checks 75 | end 76 | 77 | assert_slack_messages 2 do 78 | Blazer.run_checks(schedule: "5 minutes") 79 | end 80 | 81 | assert_slack_messages 2 do 82 | Blazer.send_failing_checks 83 | end 84 | end 85 | 86 | def assert_slack_messages(expected) 87 | count = 0 88 | Blazer::SlackNotifier.stub :post_api, ->(*) { count += 1 } do 89 | yield 90 | end 91 | assert_equal expected, count 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/cohort_analysis_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class CohortAnalysisTest < ActionDispatch::IntegrationTest 4 | def test_works 5 | run_query "SELECT 1 AS user_id, NOW() AS conversion_time /* cohort analysis */", query_id: 1 6 | assert_match "1 cohort", response.body 7 | end 8 | 9 | def test_cohort_time 10 | run_query "SELECT 1 AS user_id, NOW() AS cohort_time, NOW() AS conversion_time /* cohort analysis */", query_id: 1 11 | assert_match "1 cohort", response.body 12 | end 13 | 14 | def test_cohort_period_default 15 | query = create_query(statement: "SELECT 1 AS user_id, NOW() AS conversion_time /* cohort analysis */") 16 | get blazer.query_path(query) 17 | assert_response :success 18 | assert_match %{selected="selected" value="week"}, response.body 19 | end 20 | 21 | def test_missing_columns 22 | run_query "SELECT 1 /* cohort analysis */", query_id: 1 23 | assert_match "alert-danger", response.body 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/dashboards_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class DashboardsTest < ActionDispatch::IntegrationTest 4 | def setup 5 | Blazer::Query.delete_all 6 | Blazer::Dashboard.delete_all 7 | end 8 | 9 | def test_new 10 | get blazer.new_dashboard_path 11 | assert_response :success 12 | end 13 | 14 | def test_show 15 | dashboard = create_dashboard 16 | get blazer.dashboard_path(dashboard) 17 | assert_response :success 18 | end 19 | 20 | def test_destroy 21 | dashboard = create_dashboard 22 | delete blazer.dashboard_path(dashboard) 23 | assert_response :redirect 24 | end 25 | 26 | def test_refresh 27 | dashboard = create_dashboard 28 | dashboard.queries << create_query 29 | post blazer.refresh_dashboard_path(dashboard) 30 | assert_response :redirect 31 | end 32 | 33 | def create_dashboard 34 | Blazer::Dashboard.create!(name: "Test") 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/forecasting_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class ForecastingTest < ActionDispatch::IntegrationTest 4 | def setup 5 | Blazer::Query.delete_all 6 | end 7 | 8 | def test_prophet 9 | skip unless ENV["TEST_PROPHET"] 10 | 11 | assert_forecast("prophet") 12 | end 13 | 14 | def test_trend 15 | skip unless ENV["TEST_TREND"] 16 | 17 | assert_forecast("trend") 18 | end 19 | 20 | def assert_forecast(forecasting) 21 | skip unless postgresql? 22 | 23 | Blazer.stub(:forecasting, forecasting) do 24 | query = create_query(statement: "SELECT current_date + n AS day, n FROM generate_series(1, 30) n") 25 | run_query query.statement, query_id: query.id, forecast: "t" 26 | assert_match %{"name":"forecast"}, response.body 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/internal/app/assets/config/manifest.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ankane/blazer/e5ba950e0a9190d22654a33c9ff06caeb982c88c/test/internal/app/assets/config/manifest.js -------------------------------------------------------------------------------- /test/internal/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | def current_user 3 | User.last 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/internal/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/internal/config/blazer.yml: -------------------------------------------------------------------------------- 1 | # see https://github.com/ankane/blazer for more info 2 | 3 | data_sources: 4 | main: 5 | url: <%= ENV["ADAPTER"] || "postgresql" %>://localhost/blazer_test 6 | 7 | # statement timeout, in seconds 8 | # none by default 9 | # timeout: 15 10 | 11 | # caching settings 12 | # can greatly improve speed 13 | # off by default 14 | # cache: 15 | # mode: slow # or all 16 | # expires_in: 60 # min 17 | # slow_threshold: 15 # sec, only used in slow mode 18 | 19 | # wrap queries in a transaction for safety 20 | # not necessary if you use a read-only user 21 | # true by default 22 | # use_transaction: false 23 | 24 | smart_variables: 25 | # zone_id: "SELECT id, name FROM zones ORDER BY name ASC" 26 | period: ["day", "week", "month"] 27 | # status: {0: "Active", 1: "Archived"} 28 | 29 | linked_columns: 30 | user_id: "/admin/users/{value}" 31 | 32 | smart_columns: 33 | # user_id: "SELECT id, name FROM users WHERE id IN {value}" 34 | status: {0: "Active", 1: "Archived"} 35 | 36 | variable_defaults: 37 | default_var: default_value 38 | 39 | # create audits 40 | audit: true 41 | 42 | # change the time zone 43 | # time_zone: "Pacific Time (US & Canada)" 44 | 45 | # class name of the user model 46 | # user_class: User 47 | 48 | # method name for the current user 49 | # user_method: current_user 50 | 51 | # method name for the display name 52 | # user_name: name 53 | 54 | # custom before_action to use for auth 55 | # before_action_method: require_admin 56 | 57 | # email to send checks from 58 | from_email: blazer@example.org 59 | 60 | # webhook for Slack 61 | slack_webhook_url: http://localhost:3000 62 | 63 | check_schedules: 64 | - "1 day" 65 | - "1 hour" 66 | - "5 minutes" 67 | 68 | # enable anomaly detection 69 | # note: with trend, time series are sent to https://trendapi.org 70 | # anomaly_checks: prophet / trend / r 71 | 72 | # enable forecasting 73 | # note: with trend, time series are sent to https://trendapi.org 74 | # forecasting: prophet / trend 75 | 76 | # enable map 77 | mapbox_access_token: pk.token 78 | 79 | # enable uploads 80 | uploads: 81 | url: postgres://localhost/blazer_test 82 | schema: uploads 83 | data_source: main 84 | -------------------------------------------------------------------------------- /test/internal/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: <%= ENV["ADAPTER"] || "postgresql" %> 3 | database: blazer_test 4 | <% if ENV["ADAPTER"] == "trilogy" %> 5 | host: 127.0.0.1 6 | <% end %> 7 | -------------------------------------------------------------------------------- /test/internal/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount Blazer::Engine, at: "/" 3 | end 4 | -------------------------------------------------------------------------------- /test/internal/db/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | create_table :blazer_queries do |t| 3 | t.references :creator 4 | t.string :name 5 | t.text :description 6 | t.text :statement 7 | t.string :data_source 8 | t.string :status 9 | t.timestamps null: false 10 | end 11 | 12 | create_table :blazer_audits do |t| 13 | t.references :user 14 | t.references :query 15 | t.text :statement 16 | t.string :data_source 17 | t.datetime :created_at 18 | end 19 | 20 | create_table :blazer_dashboards do |t| 21 | t.references :creator 22 | t.string :name 23 | t.timestamps null: false 24 | end 25 | 26 | create_table :blazer_dashboard_queries do |t| 27 | t.references :dashboard 28 | t.references :query 29 | t.integer :position 30 | t.timestamps null: false 31 | end 32 | 33 | create_table :blazer_checks do |t| 34 | t.references :creator 35 | t.references :query 36 | t.string :state 37 | t.string :schedule 38 | t.text :emails 39 | t.text :slack_channels 40 | t.string :check_type 41 | t.text :message 42 | t.datetime :last_run_at 43 | t.timestamps null: false 44 | end 45 | 46 | create_table :blazer_uploads do |t| 47 | t.references :creator 48 | t.string :table 49 | t.text :description 50 | t.timestamps null: false 51 | end 52 | 53 | create_table :users do |t| 54 | t.string :name 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/maps_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class MapsTest < ActionDispatch::IntegrationTest 4 | def test_latitude_longitude 5 | run_query "SELECT 1.2 AS latitude, 3.4 AS longitude" 6 | assert_match "Map", response.body 7 | end 8 | 9 | def test_lat_lon 10 | run_query "SELECT 1.2 AS lat, 3.4 AS lon" 11 | assert_match "Map", response.body 12 | end 13 | 14 | def test_lat_lng 15 | run_query "SELECT 1.2 AS lat, 3.4 AS lng" 16 | assert_match "Map", response.body 17 | end 18 | 19 | def test_geojson 20 | run_query "SELECT '{}' AS geojson" 21 | assert_match "AreaMap", response.body 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/permissions_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class PermissionsTest < ActionDispatch::IntegrationTest 4 | def setup 5 | Blazer::Query.delete_all 6 | User.delete_all 7 | end 8 | 9 | def test_list 10 | with_new_user do |user| 11 | create_query(name: "# Test", creator: user) 12 | get blazer.root_path 13 | assert_response :success 14 | assert_match "# Test", response.body 15 | end 16 | 17 | with_new_user do 18 | get blazer.root_path 19 | assert_response :success 20 | refute_match "# Test", response.body 21 | end 22 | end 23 | 24 | def test_edit 25 | query = 26 | with_new_user do |user| 27 | create_query(name: "* Test", creator: user) 28 | end 29 | 30 | with_new_user do 31 | patch blazer.query_path(query), params: {query: {name: "Renamed"}} 32 | assert_response :unprocessable_entity 33 | assert_match "Sorry, permission denied", response.body 34 | 35 | delete blazer.query_path(query) 36 | # TODO error response 37 | assert_response :redirect 38 | assert Blazer::Query.exists?(query.id) 39 | end 40 | end 41 | 42 | def test_change_creator 43 | with_new_user do |user| 44 | query = create_query(name: "Test", creator: user) 45 | 46 | patch blazer.query_path(query), params: {query: {name: "* Test"}} 47 | assert_response :redirect 48 | 49 | patch blazer.query_path(query), params: {query: {name: "# Test"}} 50 | assert_response :redirect 51 | end 52 | end 53 | 54 | private 55 | 56 | def with_new_user 57 | user = User.create! 58 | yield user 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /test/support/adapter_test.rb: -------------------------------------------------------------------------------- 1 | module AdapterTest 2 | def setup 3 | settings = YAML.load_file("test/support/adapters.yml") 4 | Blazer.instance_variable_set(:@settings, settings) 5 | end 6 | 7 | # some adapter tests override this method 8 | def test_tables 9 | assert_kind_of Array, tables 10 | end 11 | 12 | def test_schema 13 | get blazer.schema_queries_path(data_source: data_source) 14 | assert_response :success 15 | end 16 | 17 | private 18 | 19 | def tables 20 | get blazer.tables_queries_path(data_source: data_source) 21 | assert_response :success 22 | JSON.parse(response.body) 23 | end 24 | 25 | def assert_result(expected, statement, **variables) 26 | assert_equal expected, run_statement(statement, **variables) 27 | end 28 | 29 | def assert_audit(expected, statement, **variables) 30 | run_statement(statement, **variables) 31 | assert_equal expected, Blazer::Audit.last.statement 32 | end 33 | 34 | def assert_error(message, statement, **variables) 35 | error = assert_raises(Blazer::Error) do 36 | run_statement(statement, **variables) 37 | end 38 | assert_match message, error.message 39 | end 40 | 41 | def assert_bad_position(statement, **variables) 42 | assert_error "Variable cannot be used in this position", statement, **variables 43 | end 44 | 45 | def run_statement(statement, format: "csv", **variables) 46 | run_query statement, data_source: data_source, format: format, variables: variables 47 | CSV.parse(response.body, headers: true).map(&:to_h) if format == "csv" 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /test/support/adapters.yml: -------------------------------------------------------------------------------- 1 | data_sources: 2 | athena: 3 | adapter: athena 4 | database: blazer_test 5 | 6 | bigquery: 7 | adapter: bigquery 8 | project: your-project 9 | keyfile: path/to/keyfile.json 10 | 11 | cassandra: 12 | url: cassandra://localhost:9042/blazer_test 13 | 14 | drill: 15 | adapter: drill 16 | url: http://localhost:8047 17 | 18 | druid: 19 | adapter: druid 20 | url: http://localhost:8082 21 | 22 | elasticsearch: 23 | adapter: elasticsearch 24 | url: http://localhost:9200 25 | 26 | hive: 27 | adapter: hive 28 | url: sasl://localhost:10000/blazer_test 29 | 30 | ignite: 31 | url: ignite://localhost:10800 32 | 33 | influxdb: 34 | adapter: influxdb 35 | url: http://localhost:8086/blazer_test 36 | 37 | mysql2: 38 | url: mysql2://localhost:3306/blazer_test # ?prepared_statements=true 39 | 40 | neo4j: 41 | adapter: neo4j 42 | url: bolt://neo4j:secret@127.0.0.1:7687/neo4j 43 | 44 | opensearch: 45 | adapter: opensearch 46 | url: http://localhost:9200 47 | 48 | postgresql: 49 | url: postgres://localhost/blazer_test 50 | 51 | presto: 52 | url: trino://user@localhost:8080/jmx 53 | 54 | redshift: 55 | url: redshift://localhost:5439/blazer_test 56 | 57 | salesforce: 58 | adapter: salesforce 59 | 60 | soda: 61 | adapter: soda 62 | # url: https://... 63 | # app_token: ... 64 | 65 | spark: 66 | adapter: spark 67 | url: sasl://localhost:10000/blazer_test 68 | 69 | snowflake: 70 | adapter: snowflake 71 | # conn_str: ... 72 | 73 | sqlite: 74 | url: "sqlite3::memory:" 75 | 76 | sqlserver: 77 | url: sqlserver://SA:YourStrong!Passw0rd@localhost:1433/blazer_test 78 | 79 | trilogy: 80 | url: trilogy://localhost:3306/blazer_test 81 | -------------------------------------------------------------------------------- /test/support/duplicate_columns.csv: -------------------------------------------------------------------------------- 1 | a,a 2 | -------------------------------------------------------------------------------- /test/support/line_items.csv: -------------------------------------------------------------------------------- 1 | a,b,c,d,e,f 2 | 1,1.1,2021-01-01 00:00:00,2021-01-01,one,1 3 | 2,2,2021-01-02 00:00:00,2021-01-02,two,1.1 4 | 3,3,2021-01-03 00:00:00,2021-01-03,three,2021-01-01 5 | -------------------------------------------------------------------------------- /test/support/malformed.csv: -------------------------------------------------------------------------------- 1 | " 2 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "combustion" 3 | Bundler.require(:default) 4 | require "minitest/autorun" 5 | require "minitest/pride" 6 | 7 | logger = ActiveSupport::Logger.new(ENV["VERBOSE"] ? STDERR : nil) 8 | 9 | Combustion.path = "test/internal" 10 | Combustion.initialize! :active_record, :action_controller, :action_mailer, :active_job do 11 | config.load_defaults Rails::VERSION::STRING.to_f 12 | config.action_controller.logger = logger 13 | config.action_mailer.logger = logger 14 | config.active_job.logger = logger 15 | config.active_record.logger = logger 16 | config.cache_store = :memory_store 17 | 18 | # fixes warning with adapter tests 19 | config.action_dispatch.show_exceptions = :none 20 | end 21 | 22 | Rails.cache.logger = logger 23 | 24 | class ActionDispatch::IntegrationTest 25 | def run_query(statement, format: nil, **params) 26 | post blazer.run_queries_path(format: format), params: {statement: statement, data_source: "main"}.merge(params), xhr: true 27 | assert_response :success 28 | end 29 | 30 | def create_query(statement: "SELECT 1", **attributes) 31 | Blazer::Query.create!(statement: statement, data_source: "main", status: "active", **attributes) 32 | end 33 | 34 | def create_check(**attributes) 35 | Blazer::Check.create!(schedule: "5 minutes", **attributes) 36 | end 37 | 38 | def postgresql? 39 | ENV["ADAPTER"].nil? 40 | end 41 | end 42 | 43 | require_relative "support/adapter_test" 44 | -------------------------------------------------------------------------------- /test/uploads_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class UploadsTest < ActionDispatch::IntegrationTest 4 | def setup 5 | skip unless postgresql? 6 | 7 | Blazer::Upload.delete_all 8 | Blazer::UploadsConnection.connection.execute("DROP SCHEMA IF EXISTS uploads CASCADE") 9 | Blazer::UploadsConnection.connection.execute("CREATE SCHEMA uploads") 10 | end 11 | 12 | def test_index 13 | get blazer.uploads_path 14 | assert_response :success 15 | end 16 | 17 | def test_new 18 | get blazer.new_upload_path 19 | assert_response :success 20 | end 21 | 22 | def test_create 23 | create_upload 24 | assert_response :redirect 25 | 26 | upload = Blazer::Upload.last 27 | assert_equal "line_items", upload.table 28 | assert_equal "Billing line items", upload.description 29 | 30 | run_query "SELECT * FROM uploads.line_items" 31 | assert_response :success 32 | 33 | column_types = Blazer::UploadsConnection.connection.select_all("SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'uploads' AND table_name = 'line_items'").rows.to_h 34 | assert_equal "bigint", column_types["a"] 35 | assert_equal "numeric", column_types["b"] 36 | assert_equal "timestamp with time zone", column_types["c"] 37 | assert_equal "date", column_types["d"] 38 | assert_equal "text", column_types["e"] 39 | assert_equal "text", column_types["f"] 40 | end 41 | 42 | def test_create_duplicate_table 43 | create_upload 44 | assert_response :redirect 45 | Blazer::Upload.delete_all 46 | 47 | create_upload 48 | assert_response :unprocessable_entity 49 | assert_match "Table already exists", response.body 50 | end 51 | 52 | def test_rename 53 | create_upload 54 | assert_response :redirect 55 | 56 | upload = Blazer::Upload.last 57 | patch blazer.upload_path(upload), params: {upload: {table: "items"}} 58 | assert_response :redirect 59 | 60 | tables = Blazer::UploadsConnection.connection.select_all("SELECT table_name FROM information_schema.tables WHERE table_schema = 'uploads'").rows.map(&:first) 61 | assert_equal ["items"], tables 62 | end 63 | 64 | def test_bad_content_type 65 | create_upload(content_type: "text/plain") 66 | assert_response :unprocessable_entity 67 | assert_match "File is not a CSV", response.body 68 | end 69 | 70 | def test_malformed_csv 71 | create_upload(file: "malformed.csv") 72 | assert_response :unprocessable_entity 73 | if RUBY_VERSION.to_f >= 2.6 74 | assert_match "Unclosed quoted field in line 1", response.body 75 | else 76 | assert_match "Unclosed quoted field on line 1", response.body 77 | end 78 | end 79 | 80 | def test_duplicate_columns 81 | create_upload(file: "duplicate_columns.csv") 82 | assert_response :unprocessable_entity 83 | assert_match "Duplicate column name: a", response.body 84 | end 85 | 86 | def create_upload(file: "line_items.csv", content_type: "text/csv") 87 | post blazer.uploads_path, params: {upload: {table: "line_items", description: "Billing line items", file: fixture_file_upload("test/support/#{file}", content_type)}} 88 | end 89 | end 90 | --------------------------------------------------------------------------------