├── .github └── workflows │ └── main.yml ├── .gitignore ├── .rubocop.yml ├── .standard.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── spectacles.rb └── spectacles │ ├── abstract_adapter_override.rb │ ├── configuration.rb │ ├── materialized_view.rb │ ├── railtie.rb │ ├── schema_dumper.rb │ ├── schema_statements.rb │ ├── schema_statements │ ├── abstract_adapter.rb │ ├── mysql2_adapter.rb │ ├── postgresql_adapter.rb │ ├── sqlite3_adapter.rb │ ├── sqlserver_adapter.rb │ └── vertica_adapter.rb │ ├── version.rb │ └── view.rb ├── spectacles.gemspec └── test ├── adapters ├── mysql2_adapter_test.rb ├── postgresql_adapter_test.rb └── sqlite3_adapter_test.rb ├── spectacles ├── abstract_adapter_override_test.rb ├── schema_statements │ └── abstract_adapter_test.rb └── view_test.rb ├── support ├── minitest │ └── shared_examples.rb ├── schema_statement_examples.rb ├── test_classes.rb └── view_examples.rb └── test_helper.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 2 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 3 | name: build 4 | 5 | on: 6 | push: 7 | branches: [main] 8 | pull_request: 9 | branches: "*" 10 | 11 | jobs: 12 | style: 13 | name: Styles (Standard) 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: ruby/setup-ruby@v1 18 | with: 19 | ruby-version: "3.2" 20 | bundler-cache: true 21 | - run: bundle exec standardrb --format github 22 | 23 | test: 24 | name: Tests (Spectacles) 25 | runs-on: ubuntu-latest 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | ruby-version: [3.2, head, jruby-head] 30 | continue-on-error: ${{ endsWith(matrix.ruby-version, 'head') || matrix.ruby-version == 'debug' }} 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: ${{ matrix.ruby-version }} 36 | bundler-cache: true 37 | - run: bundle exec rake test:spectacles 38 | 39 | test-mysql: 40 | name: Tests (MySQL) 41 | runs-on: ubuntu-latest 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | ruby-version: [3.2, head, jruby-head] 46 | continue-on-error: ${{ endsWith(matrix.ruby-version, 'head') || matrix.ruby-version == 'debug' }} 47 | 48 | services: 49 | mysql: 50 | image: mysql:5.7 51 | ports: 52 | - 3306 53 | 54 | env: 55 | JRUBY_OPTS: "-J-Xms64M -J-Xmx1024M" 56 | MYSQL_USER: root 57 | MYSQL_PASSWORD: root 58 | 59 | steps: 60 | - uses: actions/checkout@v4 61 | - name: Set up Ruby 62 | uses: ruby/setup-ruby@v1 63 | with: 64 | ruby-version: ${{ matrix.ruby-version }} 65 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 66 | - name: Setup database 67 | run: | 68 | sudo service mysql start 69 | - name: Run tests 70 | run: | 71 | bundle exec rake test:mysql2 72 | 73 | test-pgsql: 74 | name: Tests (Postgres) 75 | runs-on: ubuntu-latest 76 | strategy: 77 | fail-fast: false 78 | matrix: 79 | ruby-version: [3.2, head, jruby-head] 80 | continue-on-error: ${{ endsWith(matrix.ruby-version, 'head') || matrix.ruby-version == 'debug' }} 81 | 82 | services: 83 | postgres: 84 | image: postgres:11 85 | env: 86 | POSTGRES_PASSWORD: postgres 87 | POSTGRES_HOST_AUTH_METHOD: trust 88 | ports: 89 | - 5432:5432 90 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 91 | 92 | env: 93 | JRUBY_OPTS: "-J-Xms64M -J-Xmx1024M" 94 | PGHOST: localhost 95 | PGPORT: 5432 96 | PGUSER: postgres 97 | 98 | steps: 99 | - uses: actions/checkout@v4 100 | - name: Set up Ruby 101 | uses: ruby/setup-ruby@v1 102 | with: 103 | ruby-version: ${{ matrix.ruby-version }} 104 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 105 | - name: Setup database 106 | env: 107 | PGPASSWORD: postgres 108 | run: | 109 | createdb spectacles_test 110 | - name: Run tests 111 | run: | 112 | bundle exec rake test:postgresql 113 | 114 | test-sqlite: 115 | name: Tests (SQLite) 116 | runs-on: ubuntu-latest 117 | strategy: 118 | fail-fast: false 119 | matrix: 120 | ruby-version: [3.2, head, jruby-head] 121 | continue-on-error: ${{ endsWith(matrix.ruby-version, 'head') || matrix.ruby-version == 'debug' }} 122 | 123 | env: 124 | JRUBY_OPTS: "-J-Xms64M -J-Xmx1024M" 125 | 126 | steps: 127 | - uses: actions/checkout@v4 128 | - name: Set up Ruby 129 | uses: ruby/setup-ruby@v1 130 | with: 131 | ruby-version: ${{ matrix.ruby-version }} 132 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 133 | - name: Run tests 134 | run: | 135 | bundle exec rake test:sqlite3 136 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /Gemfile.lock 2 | /.bundle/ 3 | /.yardoc 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /test/*.db* 10 | /tmp/ 11 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | inherit_gem: 3 | standard: config/ruby-3.2.yml 4 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/standardrb/standard 3 | ruby_version: 3.2 # default: RUBY_VERSION 4 | format: progress # default: Standard::Formatter 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | All notable changes to this project will be documented in this file. 2 | 3 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). 4 | 5 | Spectacles adheres to a shifted version of [semver](https://semver.org/spec/v2.0.0.html) 6 | (a la Rails): major/minor versions shadow Rails [versions](https://guides.rubyonrails.org/maintenance_policy.html#versioning) 7 | since Spectacles depends on specific Rails versions. 8 | 9 | ## [Unreleased] 10 | 11 | ## [8.0.0] – 2024-11-27 12 | 13 | - Require Ruby 3.2+ 14 | - Require Rails 8.0 15 | 16 | ## [7.2.0] – 2024-11-27 17 | 18 | - Require Ruby 3.1+ 19 | - Require Rails 7.2 20 | 21 | ## [7.1.0] – 2024-11-27 22 | 23 | - Require Rails 7.1 24 | 25 | ## [7.0.0] – 2024-11-27 26 | 27 | - Add support for dumping views from multiple schemas in Postgres ([#32](https://github.com/liveh2o/spectacles/pull/32)) 28 | - Require Ruby 2.7+ 29 | - Require Rails 7.0 30 | 31 | ## [6.0.0] – 2022-02-21 32 | 33 | - Add Rails 6.0 support 34 | 35 | ## [2.0.0] – 2019-12-17 36 | 37 | - Add support for materialized views 38 | - Drop support for the the mysql gem 39 | - Require Ruby 2.2+ 40 | 41 | ## [1.2.0] – 2017-07-21 42 | 43 | - Add support for skipping views when dumping the schema 44 | 45 | ## [1.1.0] – 2016-01-15 46 | 47 | ## [1.0.1] – 2015-09-29 48 | 49 | ## [1.0.0] – 2015-09-28 50 | 51 | ## [0.5.3] – 2015-03-16 52 | 53 | ## [0.5.2] – 2014-12-05 54 | 55 | ## [0.5.1] – 2014-02-07 56 | 57 | ## [0.5.0] – 2014-01-03 58 | 59 | ## [0.4.1] – 2013-11-14 60 | 61 | ## [0.4.0] – 2013-10-10 62 | 63 | ## [0.3.1] – 2013-10-04 64 | 65 | ## [0.3.0] – 2013-09-27 66 | 67 | ## [0.2.0] – 2013-04-22 68 | 69 | ## [0.1.0] – 2013-01-14 70 | 71 | ## [0.0.2] – 2012-02-10 72 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, caste, color, religion, or sexual 10 | identity and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | [INSERT CONTACT METHOD]. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [Mozilla CoC]: https://github.com/mozilla/diversity 131 | [FAQ]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in spectacles.gemspec 4 | gemspec 5 | 6 | platforms :jruby do 7 | gem "activerecord-jdbc-adapter", 8 | git: "https://github.com/jruby/activerecord-jdbc-adapter", 9 | glob: "activerecord-jdbc-adapter.gemspec" 10 | gem "activerecord-jdbcsqlite3-adapter", 11 | git: "https://github.com/jruby/activerecord-jdbc-adapter", 12 | glob: "activerecord-jdbcsqlite3-adapter/activerecord-jdbcsqlite3-adapter.gemspec" 13 | gem "activerecord-jdbcpostgresql-adapter", 14 | git: "https://github.com/jruby/activerecord-jdbc-adapter", 15 | glob: "activerecord-jdbcpostgresql-adapter/activerecord-jdbcpostgresql-adapter.gemspec" 16 | gem "activerecord-jdbcmysql-adapter", 17 | git: "https://github.com/jruby/activerecord-jdbc-adapter", 18 | glob: "activerecord-jdbcmysql-adapter/activerecord-jdbcmysql-adapter.gemspec" 19 | end 20 | 21 | platforms :ruby do 22 | gem "mysql2" 23 | gem "pg" 24 | gem "sqlite3" 25 | end 26 | 27 | group :test do 28 | gem "simplecov", require: false 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2012-2024 Adam Hutchison, Brandon Dewitt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/liveh2o/spectacles/actions/workflows/main.yml/badge.svg)](https://github.com/liveh2o/spectacles/actions) 2 | [![Ruby Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/standardrb/standard) 3 | [![Gem Version](https://badge.fury.io/rb/spectacles.svg)](https://badge.fury.io/rb/spectacles) 4 | 5 | # Spectacles 6 | 7 | Spectacles adds database view functionality to ActiveRecord. It is heavily inspired by Rails SQL Views (created by https://github.com/aeden but no longer maintained) and built from the ground up to work with Rails. 8 | 9 | Spectacles provides the ability to create views in migrations using a similar format to creating tables. It also provides an abstract view class that inherits from `ActiveRecord::Base` that can be used to create view-backed models. 10 | 11 | It currently works with the SQLite, MySQL2, PostgreSQL, and Vertica drivers. 12 | 13 | # Using Spectacles 14 | 15 | Install it 16 | 17 | ```shell 18 | $ gem install spectacles # => OR include it in your Gemfile 19 | ``` 20 | 21 | ## Migrations 22 | 23 | Create a migration from an query string: 24 | 25 | ```ruby 26 | create_view :product_users do 27 | "SELECT name AS product_name, first_name AS username FROM products JOIN users ON users.id = products.user_id" 28 | end 29 | ``` 30 | 31 | Create a migration from an ARel object: 32 | 33 | ```ruby 34 | create_view :product_users do 35 | Product.select("products.name AS product_name).select("users.first_name AS username").join(:users) 36 | end 37 | ``` 38 | 39 | ## Models 40 | 41 | ```ruby 42 | class ProductUser < Spectacles::View # Add relationships 43 | # Use scopes 44 | 45 | # Your fancy methods 46 | end 47 | ``` 48 | 49 | ## Materialized Views 50 | 51 | _This feature is only supported for PostgreSQL backends._ 52 | 53 | These are essentially views that cache their result set. In this way they are kind of a cross between tables (which persist data) and views 54 | (which are windows onto other tables). 55 | 56 | ```ruby 57 | create_materialized_view :product_users do 58 | <<-SQL.squish 59 | SELECT name AS product_name, first_name AS username 60 | FROM products 61 | JOIN users ON users.id = products.user_id 62 | SQL 63 | end 64 | 65 | class ProductUser < Spectacles::MaterializedView # just like Spectacles::View 66 | end 67 | ``` 68 | 69 | Because materialized views cache a snapshot of the data as it exists at a point in time (typically when the view was created), you 70 | need to manually _refresh_ the view when new data is added to the original tables. You can do this with the `#refresh!` method on 71 | the `Spectacles::MaterializedView` subclass: 72 | 73 | ```ruby 74 | User.create(first_name: "Bob", email: "bob@example.com") 75 | ProductUser.refresh! 76 | ``` 77 | 78 | Also, you can specify a few different options to `create_materialized_view` to affect how the new view is created: 79 | 80 | - `:force` - if `false` (the default), the create will fail if a 81 | materialized view with the given name already exists. If `true`, 82 | any materialized view with that name will be dropped before the 83 | create runs. 84 | 85 | ```ruby 86 | create_materialized_view :product_users, force: true do 87 | # ... 88 | end 89 | ``` 90 | 91 | - `:data` - if `true` (the default), the view is immediately populated 92 | with the corresponding data. If `false`, the view will be empty initially, 93 | and must be populated by invoking the `#refresh!` method. 94 | 95 | ```ruby 96 | create_materialized_view :product_users, data: false do 97 | # ... 98 | end 99 | ``` 100 | 101 | - `:columns` - an optional array of names to give the columns in the view. 102 | By default, columns in the view will use the names given in the query. 103 | 104 | ```ruby 105 | create_materialized_view :product_users, columns: %i(product_name username) do 106 | <<-SQL.squish 107 | SELECT products.name, users.first_name 108 | FROM products 109 | JOIN users ON users.id = products.user_id 110 | SQL 111 | end 112 | ``` 113 | 114 | - `:tablespace` - an optional identifier (string or symbol) indicating 115 | which namespace the materialized view ought to be created in. 116 | 117 | ```ruby 118 | create_materialized_view :product_users, tablespace: "awesomesauce" do 119 | # ... 120 | end 121 | ``` 122 | 123 | - `:storage` - an optional hash of (database-specific) storage parameters to 124 | optimize how the materialized view is stored. (See 125 | http://www.postgresql.org/docs/9.4/static/sql-createtable.html#SQL-CREATETABLE-STORAGE-PARAMETERS 126 | for details.) 127 | 128 | ```ruby 129 | create_materialized_view :product_users, storage: { fillfactor: 70 } do 130 | # ... 131 | end 132 | ``` 133 | 134 | # License 135 | 136 | Spectacles is licensed under MIT license (Read the LICENSE file for full license) 137 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "minitest/test_task" 5 | 6 | namespace :test do 7 | Minitest::TestTask.create :spectacles do |t| 8 | t.test_globs = ["test/spectacles/**/*_test.rb"] 9 | t.warning = false 10 | end 11 | 12 | adapters = %i[mysql2 postgresql sqlite3] 13 | adapters.each do |adapter| 14 | Minitest::TestTask.create adapter do |t| 15 | t.test_globs = ["test/adapters/#{t.name}*_test.rb"] 16 | t.warning = false 17 | end 18 | end 19 | 20 | task all: %i[spectacles] + adapters 21 | end 22 | 23 | require "standard/rake" 24 | 25 | task default: %i[test:all standard:fix] 26 | -------------------------------------------------------------------------------- /lib/spectacles.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | require "active_support/core_ext" 5 | require "spectacles/schema_statements" 6 | require "spectacles/schema_dumper" 7 | require "spectacles/view" 8 | require "spectacles/materialized_view" 9 | require "spectacles/version" 10 | require "spectacles/configuration" 11 | require "spectacles/abstract_adapter_override" 12 | 13 | require "spectacles/railtie" if defined?(Rails) 14 | 15 | module Spectacles 16 | def self.configuration 17 | @configuration ||= ::Spectacles::Configuration.new 18 | end 19 | 20 | def self.configure 21 | yield(configuration) if block_given? 22 | end 23 | 24 | class << self 25 | alias_method :config, :configuration 26 | end 27 | end 28 | 29 | ActiveRecord::SchemaDumper.class_eval do 30 | alias_method(:_spectacles_orig_trailer, :trailer) 31 | 32 | def trailer(stream) 33 | ::Spectacles::SchemaDumper.dump_views(stream, @connection) 34 | ::Spectacles::SchemaDumper.dump_materialized_views(self, stream, @connection) 35 | _spectacles_orig_trailer(stream) 36 | end 37 | end 38 | 39 | Spectacles.load_adapters 40 | -------------------------------------------------------------------------------- /lib/spectacles/abstract_adapter_override.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::ConnectionAdapters::AbstractAdapter.class_eval do 2 | class << self 3 | alias_method(:_spectacles_orig_inherited, :inherited) if method_defined?(:inherited) 4 | 5 | def inherited(klass) 6 | ::Spectacles.load_adapters 7 | _spectacles_orig_inherited(klass) if methods.include?(:_spectacles_orig_inherited) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/spectacles/configuration.rb: -------------------------------------------------------------------------------- 1 | module Spectacles 2 | class Configuration 3 | attr_accessor :enable_schema_dump, :skip_views 4 | 5 | def initialize 6 | @enable_schema_dump = true 7 | @skip_views = [] 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/spectacles/materialized_view.rb: -------------------------------------------------------------------------------- 1 | module Spectacles 2 | class MaterializedView < ActiveRecord::Base 3 | self.abstract_class = true 4 | 5 | def self.new(*) 6 | raise NotImplementedError, "#{self} is an abstract class and cannot be instantiated." 7 | end 8 | 9 | def self.materialized_view_exists? 10 | connection.materialized_view_exists?(view_name) 11 | end 12 | 13 | def self.refresh!(concurrently: false) 14 | if concurrently 15 | connection.refresh_materialized_view_concurrently(view_name) 16 | else 17 | connection.refresh_materialized_view(view_name) 18 | end 19 | end 20 | 21 | def self.refresh_concurrently! 22 | refresh!(concurrently: true) 23 | end 24 | 25 | class << self 26 | alias_method :table_exists?, :materialized_view_exists? 27 | alias_method :view_name, :table_name 28 | end 29 | 30 | def ==(other) 31 | super || 32 | other.instance_of?(self.class) && 33 | attributes.present? && 34 | other.attributes == attributes 35 | end 36 | 37 | def persisted? 38 | false 39 | end 40 | 41 | def readonly? 42 | true 43 | end 44 | end 45 | 46 | ::ActiveSupport.run_load_hooks(:spectacles, MaterializedView) 47 | end 48 | -------------------------------------------------------------------------------- /lib/spectacles/railtie.rb: -------------------------------------------------------------------------------- 1 | require "spectacles" 2 | require "rails" 3 | 4 | module Spectacles 5 | class Railtie < ::Rails::Railtie 6 | config.spectacles = ::ActiveSupport::OrderedOptions.new 7 | 8 | initializer "spectacles.configure" do |app| 9 | Spectacles.configure do |config| 10 | if app.config.spectacles.key?(:enable_schema_dump) 11 | config.enable_schema_dump = app.config.spectacles[:enable_schema_dump] 12 | end 13 | if app.config.spectacles.key?(:skip_views) 14 | config.skip_views = app.config.spectacles[:skip_views] 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/spectacles/schema_dumper.rb: -------------------------------------------------------------------------------- 1 | module Spectacles 2 | module SchemaDumper 3 | def self.dump_views(stream, connection) 4 | unless Spectacles.config.enable_schema_dump == false 5 | connection.views.sort.each do |view| 6 | next if skip_view?(view) 7 | dump_view(stream, connection, view) 8 | end 9 | end 10 | end 11 | 12 | def self.dump_materialized_views(dumper, stream, connection) 13 | unless Spectacles.config.enable_schema_dump == false 14 | if connection.supports_materialized_views? 15 | connection.materialized_views.sort.each do |view| 16 | next if skip_view?(view) 17 | dump_materialized_view(stream, connection, view) 18 | dumper.send(:indexes, view, stream) 19 | end 20 | end 21 | end 22 | end 23 | 24 | def self.dump_view(stream, connection, view_name) 25 | stream.print <<-CREATEVIEW 26 | 27 | create_view :#{view_name}, :force => true do 28 | "#{connection.view_build_query(view_name)}" 29 | end 30 | 31 | CREATEVIEW 32 | end 33 | 34 | def self.dump_materialized_view(stream, connection, view_name) 35 | definition, options = connection.materialized_view_build_query(view_name) 36 | options[:force] = true 37 | 38 | stream.print <<-CREATEVIEW 39 | 40 | create_materialized_view #{view_name.to_sym.inspect}, #{format_option_hash(options)} do 41 | <<-SQL 42 | #{definition} 43 | SQL 44 | end 45 | CREATEVIEW 46 | end 47 | 48 | def self.format_option_hash(hash) 49 | hash.map do |key, value| 50 | "#{key}: #{format_option_value(value)}" 51 | end.join(", ") 52 | end 53 | 54 | def self.format_option_value(value) 55 | case value 56 | when Hash then "{ #{format_option_hash(value)} }" 57 | when /^\d+$/ then value.to_i 58 | when /^\d+\.\d+$/ then value.to_f 59 | when true, false then value.inspect 60 | else raise "can't format #{value.inspect}" 61 | end 62 | end 63 | 64 | def self.skip_view?(view) 65 | Spectacles.config.skip_views.any? { |item| item === view } 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/spectacles/schema_statements.rb: -------------------------------------------------------------------------------- 1 | require "spectacles/schema_statements/abstract_adapter" 2 | 3 | module Spectacles 4 | SUPPORTED_ADAPTERS = %w[Mysql2 PostgreSQL SQLServer SQLite SQLite3 Vertica] 5 | 6 | def self.load_adapters 7 | SUPPORTED_ADAPTERS.each do |db| 8 | adapter_class = "#{db}Adapter" 9 | 10 | if ActiveRecord::ConnectionAdapters.const_defined?(adapter_class) 11 | require "spectacles/schema_statements/#{db.downcase}_adapter" 12 | 13 | adapter = ActiveRecord::ConnectionAdapters.const_get(adapter_class) 14 | extension = Spectacles::SchemaStatements.const_get(adapter_class) 15 | 16 | adapter.send :prepend, extension 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/spectacles/schema_statements/abstract_adapter.rb: -------------------------------------------------------------------------------- 1 | module Spectacles 2 | module SchemaStatements 3 | module AbstractAdapter 4 | def create_view(view_name, *args) 5 | options = args.extract_options! 6 | build_query = args.shift 7 | 8 | raise "#{self.class} requires a query or block" if build_query.nil? && !block_given? 9 | 10 | build_query = yield if block_given? 11 | build_query = build_query.to_sql if build_query.respond_to?(:to_sql) 12 | 13 | if options[:force] && view_exists?(view_name) 14 | drop_view(view_name) 15 | end 16 | 17 | query = create_view_statement(view_name, build_query) 18 | if defined?(ActiveRecord::Base.connection_handler) && ActiveRecord.respond_to?(:legacy_connection_handling) && ActiveRecord.legacy_connection_handling 19 | ActiveRecord::Base.connection_handler.while_preventing_writes(false) do 20 | execute(query) 21 | end 22 | else 23 | execute(query) 24 | end 25 | end 26 | 27 | def create_view_statement(view_name, create_query) 28 | # query = "CREATE VIEW ? AS #{create_query}" 29 | # query_array = [query, view_name.to_s] 30 | 31 | # return ActiveRecord::Base.__send__(:sanitize_sql_array, query_array) 32 | "CREATE VIEW #{view_name} AS #{create_query}" 33 | end 34 | 35 | def drop_view(view_name) 36 | query = drop_view_statement(view_name) 37 | execute(query) 38 | end 39 | 40 | def drop_view_statement(view_name) 41 | # query = "DROP VIEW IF EXISTS ? " 42 | # query_array = [query, view_name.to_s] 43 | 44 | # return ActiveRecord::Base.__send__(:sanitize_sql_array, query_array) 45 | "DROP VIEW IF EXISTS #{view_name} " 46 | end 47 | 48 | def view_exists?(name) 49 | views.include?(name.to_s) 50 | end 51 | 52 | def views 53 | raise "Override view for your db adapter in #{self.class}" 54 | end 55 | 56 | def supports_materialized_views? 57 | false 58 | end 59 | 60 | def materialized_view_exists?(name) 61 | materialized_views.include?(name.to_s) 62 | end 63 | 64 | def materialized_views 65 | raise NotImplementedError, "Override materialized_views for your db adapter in #{self.class}" 66 | end 67 | 68 | def materialized_view_build_query(view_name) 69 | raise NotImplementedError, "Override materialized_view_build_query for your db adapter in #{self.class}" 70 | end 71 | 72 | def create_materialized_view(view_name, *args) 73 | raise NotImplementedError, "Override create_materialized_view for your db adapter in #{self.class}" 74 | end 75 | 76 | def drop_materialized_view(view_name) 77 | raise NotImplementedError, "Override drop_materialized_view for your db adapter in #{self.class}" 78 | end 79 | 80 | def refresh_materialized_view(view_name) 81 | raise NotImplementedError, "Override refresh_materialized_view for your db adapter in #{self.class}" 82 | end 83 | 84 | def refresh_materialized_view_concurrently(view_name) 85 | raise NotImplementedError, "Override refresh_materialized_view_concurrently for your db adapter in #{self.class}" 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/spectacles/schema_statements/mysql2_adapter.rb: -------------------------------------------------------------------------------- 1 | module Spectacles 2 | module SchemaStatements 3 | module Mysql2Adapter 4 | include Spectacles::SchemaStatements::AbstractAdapter 5 | 6 | def views(name = nil) # :nodoc: 7 | result = execute("SHOW FULL TABLES WHERE TABLE_TYPE='VIEW'") 8 | values_from(result).map(&:first) 9 | end 10 | 11 | def view_build_query(view, name = nil) 12 | result = execute("SHOW CREATE VIEW #{view}", name) 13 | algorithm_string = values_from(result).first[1] 14 | 15 | algorithm_string.gsub(/CREATE .*? (AS)+/i, "") 16 | rescue ActiveRecord::StatementInvalid => e 17 | raise "No view called #{view} found, #{e}" 18 | end 19 | 20 | private 21 | 22 | def values_from(result) 23 | result.first.respond_to?(:values) ? result.map(&:values) : result 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/spectacles/schema_statements/postgresql_adapter.rb: -------------------------------------------------------------------------------- 1 | require "spectacles/schema_statements/abstract_adapter" 2 | 3 | module Spectacles 4 | module SchemaStatements 5 | module PostgreSQLAdapter 6 | include Spectacles::SchemaStatements::AbstractAdapter 7 | 8 | def views(name = nil) # :nodoc: 9 | q = <<-SQL 10 | SELECT t.table_name 11 | FROM information_schema.views AS t 12 | INNER JOIN pg_class AS c ON c.relname = t.table_name AND c.relnamespace = to_regnamespace(t.table_schema)::oid 13 | WHERE t.table_schema = ANY(current_schemas(true)) 14 | AND table_schema NOT IN ('information_schema', 'pg_catalog') 15 | AND pg_catalog.pg_get_userbyid(c.relowner) = #{quote(database_username)} 16 | SQL 17 | 18 | execute(q, name).map { |row| row["table_name"] } 19 | end 20 | 21 | def view_build_query(view, name = nil) 22 | q = <<-SQL 23 | SELECT view_definition 24 | FROM information_schema.views 25 | WHERE table_catalog = (SELECT catalog_name FROM information_schema.information_schema_catalog_name) 26 | AND table_schema = ANY(current_schemas(false)) 27 | AND table_name = '#{view}' 28 | SQL 29 | 30 | view_sql = select_value(q, name) or raise "No view called #{view} found" 31 | view_sql.gsub("\"", "\\\"") 32 | end 33 | 34 | def supports_materialized_views? 35 | true 36 | end 37 | 38 | def materialized_views(name = nil) 39 | query = <<-SQL.squish 40 | SELECT relname 41 | FROM pg_class 42 | WHERE relnamespace IN ( 43 | SELECT oid 44 | FROM pg_namespace 45 | WHERE nspname = ANY(current_schemas(false))) 46 | AND relkind = 'm'; 47 | SQL 48 | 49 | execute(query, name).map { |row| row["relname"] } 50 | end 51 | 52 | # Returns a tuple [string, hash], where string is the query used 53 | # to construct the view, and hash contains the options given when 54 | # the view was created. 55 | def materialized_view_build_query(view, name = nil) 56 | result = execute <<-SQL.squish, name 57 | SELECT a.reloptions, b.tablespace, b.ispopulated, b.definition 58 | FROM pg_class a, pg_matviews b 59 | WHERE a.relname=#{quote(view)} 60 | AND b.matviewname=a.relname 61 | SQL 62 | row = result.to_a[0] 63 | 64 | storage = row["reloptions"] 65 | tablespace = row["tablespace"] 66 | ispopulated = row["ispopulated"] 67 | definition = row["definition"].strip.sub(/;$/, "") 68 | 69 | options = {} 70 | options[:data] = false if ispopulated == "f" || ispopulated == false 71 | options[:storage] = parse_storage_definition(storage) if storage.present? 72 | options[:tablespace] = tablespace if tablespace.present? 73 | 74 | [definition, options] 75 | end 76 | 77 | def create_materialized_view_statement(view_name, query, options = {}) 78 | columns = if options[:columns] 79 | "(" + options[:columns].map { |c| quote_column_name(c) }.join(",") + ")" 80 | else 81 | "" 82 | end 83 | 84 | storage = if options[:storage]&.any? 85 | "WITH (" + options[:storage].map { |key, value| "#{key}=#{value}" }.join(", ") + ")" 86 | else 87 | "" 88 | end 89 | 90 | tablespace = if options[:tablespace] 91 | "TABLESPACE #{quote_table_name(options[:tablespace])}" 92 | else 93 | "" 94 | end 95 | 96 | with_data = if options.fetch(:data, true) 97 | "WITH DATA" 98 | else 99 | "WITH NO DATA" 100 | end 101 | 102 | <<-SQL.squish 103 | CREATE MATERIALIZED VIEW #{quote_table_name(view_name)} 104 | #{columns} 105 | #{storage} 106 | #{tablespace} 107 | AS #{query} 108 | #{with_data} 109 | SQL 110 | end 111 | 112 | def create_materialized_view(view_name, *args) 113 | options = args.extract_options! 114 | build_query = args.shift 115 | 116 | raise "#create_materialized_view requires a query or block" if build_query.nil? && !block_given? 117 | 118 | build_query = yield if block_given? 119 | build_query = build_query.to_sql if build_query.respond_to?(:to_sql) 120 | 121 | if options[:force] && materialized_view_exists?(view_name) 122 | drop_materialized_view(view_name) 123 | end 124 | 125 | query = create_materialized_view_statement(view_name, build_query, options) 126 | execute(query) 127 | end 128 | 129 | def drop_materialized_view(view_name) 130 | execute "DROP MATERIALIZED VIEW IF EXISTS #{quote_table_name(view_name)}" 131 | end 132 | 133 | def refresh_materialized_view(view_name) 134 | execute "REFRESH MATERIALIZED VIEW #{quote_table_name(view_name)}" 135 | end 136 | 137 | def refresh_materialized_view_concurrently(view_name) 138 | execute "REFRESH MATERIALIZED VIEW CONCURRENTLY #{quote_table_name(view_name)}" 139 | end 140 | 141 | def parse_storage_definition(storage) 142 | # JRuby 9000 returns storage as an Array, whereas 143 | # MRI returns a string. 144 | storage = storage.first if storage.is_a?(Array) 145 | 146 | storage = storage.gsub(/^{|}$/, "") 147 | storage.split(",").each_with_object({}) do |item, hash| 148 | key, value = item.strip.split("=") 149 | hash[key.to_sym] = value 150 | end 151 | end 152 | 153 | def database_username 154 | @config[:username] 155 | end 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/spectacles/schema_statements/sqlite3_adapter.rb: -------------------------------------------------------------------------------- 1 | require "spectacles/schema_statements/abstract_adapter" 2 | 3 | module Spectacles 4 | module SchemaStatements 5 | module SQLite3Adapter 6 | include Spectacles::SchemaStatements::AbstractAdapter 7 | 8 | def generate_view_query(*columns) 9 | <<-SQL 10 | SELECT #{columns.join(",")} 11 | FROM sqlite_master 12 | WHERE type = 'view' 13 | SQL 14 | end 15 | 16 | def views # :nodoc: 17 | sql = generate_view_query(:name) 18 | 19 | exec_query(sql, "SCHEMA").map do |row| 20 | row["name"] 21 | end 22 | end 23 | 24 | def view_build_query(table_name) 25 | sql = generate_view_query(:sql) 26 | sql << " AND name = #{quote_table_name(table_name)}" 27 | 28 | row = exec_query(sql, "SCHEMA").first 29 | row["sql"].gsub(/CREATE VIEW .*? AS/i, "") 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/spectacles/schema_statements/sqlserver_adapter.rb: -------------------------------------------------------------------------------- 1 | require "spectacles/schema_statements/abstract_adapter" 2 | 3 | module Spectacles 4 | module SchemaStatements 5 | module SQLServerAdapter 6 | include Spectacles::SchemaStatements::AbstractAdapter 7 | 8 | def views(name = nil) # :nodoc: 9 | select_values("SELECT table_name FROM information_schema.views", name) 10 | end 11 | 12 | def view_build_query(view, name = nil) 13 | q = <<-ENDSQL 14 | SELECT view_definition FROM information_schema.views 15 | WHERE table_name = '#{view}' 16 | ENDSQL 17 | 18 | q = select_value(q, name) or raise "No view called #{view} found" 19 | q.gsub(/CREATE VIEW .*? AS/i, "") 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/spectacles/schema_statements/vertica_adapter.rb: -------------------------------------------------------------------------------- 1 | require "spectacles/schema_statements/abstract_adapter" 2 | 3 | module Spectacles 4 | module SchemaStatements 5 | module VerticaAdapter 6 | include Spectacles::SchemaStatements::AbstractAdapter 7 | 8 | def views(name = nil) 9 | q = <<-SQL 10 | SELECT table_name FROM v_catalog.views 11 | SQL 12 | 13 | execute(q, name).map { |row| row["table_name"] } 14 | end 15 | 16 | def view_build_query(view, name = nil) 17 | q = <<-SQL 18 | SELECT view_definition FROM v_catalog.views WHERE table_name = '#{view}' 19 | SQL 20 | 21 | select_value(q, name) or raise "No view called #{view} found" 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/spectacles/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Spectacles 4 | VERSION = "8.0.0" 5 | end 6 | -------------------------------------------------------------------------------- /lib/spectacles/view.rb: -------------------------------------------------------------------------------- 1 | module Spectacles 2 | class View < ActiveRecord::Base 3 | self.abstract_class = true 4 | 5 | def self.new(*) 6 | raise NotImplementedError, "#{self} is an abstract class and cannot be instantiated." 7 | end 8 | 9 | def self.view_exists? 10 | connection.view_exists?(view_name) 11 | end 12 | 13 | class << self 14 | alias_method :table_exists?, :view_exists? 15 | alias_method :view_name, :table_name 16 | end 17 | 18 | def ==(other) 19 | super || 20 | other.instance_of?(self.class) && 21 | attributes.present? && 22 | other.attributes == attributes 23 | end 24 | 25 | def persisted? 26 | false 27 | end 28 | 29 | def readonly? 30 | true 31 | end 32 | end 33 | 34 | ::ActiveSupport.run_load_hooks(:spectacles, View) 35 | end 36 | -------------------------------------------------------------------------------- /spectacles.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/spectacles/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.version = Spectacles::VERSION 7 | spec.name = "spectacles" 8 | spec.authors = ["Adam Hutchison, Brandon Dewitt"] 9 | spec.email = ["liveh2o@gmail.com, brandonsdewitt@gmail.com"] 10 | 11 | spec.summary = "Spectacles adds database view functionality to ActiveRecord." 12 | spec.description = "Spectacles adds database view functionality to ActiveRecord. Current supported adapters include Postgres, SQLite, Vertica, and MySQL." 13 | spec.homepage = "http://github.com/liveh2o/spectacles" 14 | spec.license = "MIT" 15 | spec.required_ruby_version = ">= 3.2.0" 16 | 17 | spec.metadata["homepage_uri"] = spec.homepage 18 | spec.metadata["source_code_uri"] = spec.homepage 19 | spec.metadata["changelog_uri"] = spec.homepage + "/blob/main/CHANGELOG.md" 20 | 21 | # Specify which files should be added to the gem when it is released. 22 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 23 | gemspec = File.basename(__FILE__) 24 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 25 | ls.readlines("\x0", chomp: true).reject do |f| 26 | (f == gemspec) || 27 | f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile]) 28 | end 29 | end 30 | spec.bindir = "exe" 31 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 32 | spec.require_paths = ["lib"] 33 | 34 | spec.add_dependency "activerecord", "~> 8.0.0" 35 | spec.add_dependency "activesupport", "~> 8.0.0" 36 | 37 | spec.add_development_dependency "minitest", ">= 5.0" 38 | spec.add_development_dependency "rake" 39 | spec.add_development_dependency "standard" 40 | end 41 | -------------------------------------------------------------------------------- /test/adapters/mysql2_adapter_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe "Spectacles::SchemaStatements::Mysql2Adapter" do 4 | config = { 5 | adapter: "mysql2", 6 | host: ENV["MYSQL_HOST"] || "localhost", 7 | username: ENV["MYSQL_USER"] || "root", 8 | password: ENV["MYSQL_PASSWORD"] 9 | } 10 | 11 | if defined? JRUBY_VERSION 12 | config[:properties] ||= {} 13 | config[:properties]["allowPublicKeyRetrieval"] = true 14 | end 15 | 16 | configure_database(config) 17 | recreate_database("spectacles_test") 18 | load_schema 19 | 20 | it_behaves_like "an adapter", "Mysql2Adapter" 21 | it_behaves_like "a view model" 22 | end 23 | -------------------------------------------------------------------------------- /test/adapters/postgresql_adapter_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe "Spectacles::SchemaStatements::PostgreSQLAdapter" do 4 | config = { 5 | adapter: "postgresql", 6 | host: ENV["POSTGRES_HOST"] || "localhost", 7 | username: ENV["POSTGRES_USER"] || "postgres", 8 | port: ENV["POSTGRES_port"] || 5432 9 | } 10 | configure_database(config) 11 | recreate_database("spectacles_test") 12 | load_schema 13 | 14 | it_behaves_like "an adapter", "PostgreSQLAdapter" 15 | it_behaves_like "a view model" 16 | 17 | test_base = Class.new do 18 | extend Spectacles::SchemaStatements::PostgreSQLAdapter 19 | def self.schema_search_path 20 | "" 21 | end 22 | 23 | def self.select_value(_, _) 24 | "\"products\"" 25 | end 26 | 27 | def self.quote_table_name(name) 28 | name 29 | end 30 | 31 | def self.quote_column_name(name) 32 | name 33 | end 34 | end 35 | 36 | describe "#view_build_query" do 37 | it "should escape double-quotes returned by Postgres" do 38 | _(test_base.view_build_query(:new_product_users)).must_match(/\\"/) 39 | end 40 | end 41 | 42 | describe "#materialized_views" do 43 | it "should support materialized views" do 44 | _(test_base.supports_materialized_views?).must_equal true 45 | end 46 | end 47 | 48 | describe "#create_materialized_view_statement" do 49 | it "should work with no options" do 50 | query = test_base.create_materialized_view_statement(:view_name, "select_query_here") 51 | _(query).must_match(/create materialized view view_name as select_query_here with data/i) 52 | end 53 | 54 | it "should allow column names to be specified" do 55 | query = test_base.create_materialized_view_statement(:view_name, "select_query_here", 56 | columns: %i[first second third]) 57 | _(query).must_match(/create materialized view view_name \(first,second,third\) as select_query_here with data/i) 58 | end 59 | 60 | it "should allow storage parameters to be specified" do 61 | query = test_base.create_materialized_view_statement(:view_name, "select_query_here", 62 | storage: {bats_in_belfry: true, max_wingspan: 15}) 63 | _(query).must_match(/create materialized view view_name with \(bats_in_belfry=true, max_wingspan=15\) as select_query_here with data/i) 64 | end 65 | 66 | it "should allow tablespace to be specified" do 67 | query = test_base.create_materialized_view_statement(:view_name, "select_query_here", 68 | tablespace: :the_final_frontier) 69 | _(query).must_match(/create materialized view view_name tablespace the_final_frontier as select_query_here with data/i) 70 | end 71 | 72 | it "should allow empty view to be created" do 73 | query = test_base.create_materialized_view_statement(:view_name, "select_query_here", 74 | data: false) 75 | _(query).must_match(/create materialized view view_name as select_query_here with no data/i) 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/adapters/sqlite3_adapter_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe "Spectacles::SchemaStatements::SQLite3Adapter" do 4 | begin 5 | File.delete(File.expand_path(File.dirname(__FILE__) + "/../test.db")) 6 | rescue 7 | nil 8 | end 9 | 10 | ActiveRecord::Base.establish_connection( 11 | adapter: "sqlite3", 12 | database: "test/test.db" 13 | ) 14 | load_schema 15 | 16 | it_behaves_like "an adapter", "SQLite3Adapter" 17 | it_behaves_like "a view model" 18 | end 19 | -------------------------------------------------------------------------------- /test/spectacles/abstract_adapter_override_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe "loading an adapter" do 4 | it "calls the original AR::CA::AbstractAdapter.inherited method" do 5 | ActiveRecord::ConnectionAdapters::AbstractAdapter.class_eval do 6 | def self.inherited(subclass) 7 | @_spectacles_inherited_called = true 8 | end 9 | end 10 | load File.join(__dir__, "../../lib/spectacles/abstract_adapter_override.rb") 11 | Class.new(ActiveRecord::ConnectionAdapters::AbstractAdapter) 12 | _(ActiveRecord::ConnectionAdapters::AbstractAdapter.instance_variable_get(:@_spectacles_inherited_called)).must_equal true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/spectacles/schema_statements/abstract_adapter_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe Spectacles::SchemaStatements::AbstractAdapter do 4 | describe "#create_view" do 5 | it "throws error when block not given and no build_query" do 6 | _(lambda { TestBase.create_view(:view_name) }).must_raise(RuntimeError) 7 | end 8 | end 9 | 10 | describe "#views" do 11 | it "throws error when accessed on AbstractAdapter" do 12 | _(lambda { TestBase.views }).must_raise(RuntimeError) 13 | end 14 | end 15 | 16 | describe "#supports_materialized_views?" do 17 | it "returns false when accessed on AbstractAdapter" do 18 | _(TestBase.supports_materialized_views?).must_equal false 19 | end 20 | end 21 | 22 | describe "#materialized_views" do 23 | it "throws error when accessed on AbstractAdapter" do 24 | _(lambda { TestBase.materialized_views }).must_raise(NotImplementedError) 25 | end 26 | end 27 | 28 | describe "#materialized_view_exists?" do 29 | it "is true when materialized_views includes the view" do 30 | TestBase.with_materialized_views(%w[alpha beta gamma]) do 31 | _(TestBase.materialized_view_exists?(:beta)).must_equal true 32 | end 33 | end 34 | 35 | it "is false when materialized_views does not include the view" do 36 | TestBase.with_materialized_views(%w[alpha beta gamma]) do 37 | _(TestBase.materialized_view_exists?(:delta)).must_equal false 38 | end 39 | end 40 | end 41 | 42 | describe "#materialized_view_build_query" do 43 | it "throws error when accessed on AbstractAdapter" do 44 | _(lambda { TestBase.materialized_view_build_query(:books) }).must_raise(NotImplementedError) 45 | end 46 | end 47 | 48 | describe "#create_materialized_view" do 49 | it "throws error when accessed on AbstractAdapter" do 50 | _(lambda { TestBase.create_materialized_view(:books) }).must_raise(NotImplementedError) 51 | end 52 | end 53 | 54 | describe "#drop_materialized_view" do 55 | it "throws error when accessed on AbstractAdapter" do 56 | _(lambda { TestBase.drop_materialized_view(:books) }).must_raise(NotImplementedError) 57 | end 58 | end 59 | 60 | describe "#refresh_materialized_view" do 61 | it "throws error when accessed on AbstractAdapter" do 62 | _(lambda { TestBase.refresh_materialized_view(:books) }).must_raise(NotImplementedError) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/spectacles/view_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | describe Spectacles::View do 4 | it "is an abstract class" do 5 | _(Spectacles::View.abstract_class?).must_equal true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/support/minitest/shared_examples.rb: -------------------------------------------------------------------------------- 1 | require "minitest/spec" 2 | 3 | Minitest::Spec.class_eval do 4 | def self.shared_examples 5 | @shared_examples ||= {} 6 | end 7 | end 8 | 9 | module Minitest::Spec::SharedExamples 10 | def shared_examples_for(desc, &block) 11 | Minitest::Spec.shared_examples[desc] = block 12 | end 13 | 14 | def it_behaves_like(desc, *args) 15 | examples = Minitest::Spec.shared_examples[desc] 16 | instance_exec(*args, &examples) 17 | end 18 | end 19 | 20 | Object.class_eval { include(Minitest::Spec::SharedExamples) } 21 | -------------------------------------------------------------------------------- /test/support/schema_statement_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "an adapter" do |adapter| 2 | shared_base = Class.new do 3 | extend Spectacles::SchemaStatements.const_get(adapter) 4 | def self.quote_table_name(name) 5 | name 6 | end 7 | 8 | def self.quote_column_name(name) 9 | name 10 | end 11 | 12 | def self.execute(query) 13 | query 14 | end 15 | end 16 | 17 | describe "ActiveRecord::SchemaDumper#dump" do 18 | before(:each) do 19 | ActiveRecord::Base.connection.drop_view(:new_product_users) 20 | ActiveRecord::Base.connection.drop_view(:other_product_users) 21 | 22 | ActiveRecord::Base.connection.create_view(:new_product_users) do 23 | "SELECT name AS product_name, first_name AS username FROM 24 | products JOIN users ON users.id = products.user_id" 25 | end 26 | 27 | ActiveRecord::Base.connection.create_view(:other_product_users) do 28 | "SELECT name AS product_name, first_name AS username FROM 29 | products JOIN users ON users.id = products.user_id" 30 | end 31 | 32 | if ActiveRecord::Base.connection.supports_materialized_views? 33 | ActiveRecord::Base.connection.drop_materialized_view(:materialized_product_users) 34 | ActiveRecord::Base.connection.drop_materialized_view(:empty_materialized_product_users) 35 | 36 | ActiveRecord::Base.connection.create_materialized_view(:materialized_product_users, force: true) do 37 | "SELECT name AS product_name, first_name AS username FROM 38 | products JOIN users ON users.id = products.user_id" 39 | end 40 | 41 | ActiveRecord::Base.connection.add_index :materialized_product_users, :product_name 42 | 43 | ActiveRecord::Base.connection.create_materialized_view(:empty_materialized_product_users, storage: {fillfactor: 50}, data: false, force: true) do 44 | "SELECT name AS product_name, first_name AS username FROM 45 | products JOIN users ON users.id = products.user_id" 46 | end 47 | end 48 | end 49 | 50 | it "should return create_view in dump stream" do 51 | stream = StringIO.new 52 | ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection_pool, stream) 53 | _(stream.string).must_match(/create_view/) 54 | end 55 | 56 | if ActiveRecord::Base.connection.supports_materialized_views? 57 | it "should return create_materialized_view in dump stream" do 58 | stream = StringIO.new 59 | ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection_pool, stream) 60 | _(stream.string).must_match(/create_materialized_view/) 61 | end 62 | 63 | it "should return add_index in dump stream" do 64 | stream = StringIO.new 65 | ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection_pool, stream) 66 | _(stream.string).must_match(/add_index/) 67 | end 68 | 69 | it "should include options for create_materialized_view" do 70 | stream = StringIO.new 71 | ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection_pool, stream) 72 | _(stream.string).must_match(/create_materialized_view.*fillfactor: 50/) 73 | _(stream.string).must_match(/create_materialized_view.*data: false/) 74 | end 75 | end 76 | 77 | it "should rebuild views in dump stream" do 78 | stream = StringIO.new 79 | ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection_pool, stream) 80 | 81 | if ActiveRecord::Base.connection.supports_materialized_views? 82 | ActiveRecord::Base.connection.materialized_views.each do |view| 83 | ActiveRecord::Base.connection.drop_materialized_view(view) 84 | end 85 | end 86 | 87 | ActiveRecord::Base.connection.views.each do |view| 88 | ActiveRecord::Base.connection.drop_view(view) 89 | end 90 | 91 | ActiveRecord::Base.connection.tables.each do |table| 92 | ActiveRecord::Base.connection.drop_table(table) 93 | end 94 | 95 | # TODO: Find a better way to do this... – AH 96 | eval(stream.string) # rubocop:disable Security/Eval 97 | 98 | _(ActiveRecord::Base.connection.views).must_include("new_product_users") 99 | 100 | if ActiveRecord::Base.connection.supports_materialized_views? 101 | _(ActiveRecord::Base.connection.materialized_views).must_include("materialized_product_users") 102 | end 103 | end 104 | end 105 | 106 | describe "#create_view" do 107 | let(:view_name) { :view_name } 108 | 109 | it "throws error when block not given and no build_query" do 110 | _(lambda { shared_base.create_view(view_name) }).must_raise(RuntimeError) 111 | end 112 | 113 | describe "view_name" do 114 | it "takes a symbol as the view_name" do 115 | _(shared_base.create_view(view_name.to_sym, Product.all)).must_match(/#{view_name}/) 116 | end 117 | 118 | it "takes a string as the view_name" do 119 | _(shared_base.create_view(view_name.to_s, Product.all)).must_match(/#{view_name}/) 120 | end 121 | end 122 | 123 | describe "build_query" do 124 | it "uses a string if passed" do 125 | select_statement = "SELECT * FROM products" 126 | _(shared_base.create_view(view_name, select_statement)).must_match(/#{Regexp.escape(select_statement)}/) 127 | end 128 | 129 | it "uses an Arel::Relation if passed" do 130 | select_statement = Product.all.to_sql 131 | _(shared_base.create_view(view_name, Product.all)).must_match(/#{Regexp.escape(select_statement)}/) 132 | end 133 | end 134 | 135 | describe "block" do 136 | it "can use an Arel::Relation from the yield" do 137 | select_statement = Product.all.to_sql 138 | _(shared_base.create_view(view_name) { Product.all }).must_match(/#{Regexp.escape(select_statement)}/) 139 | end 140 | 141 | it "can use a String from the yield" do 142 | select_statement = "SELECT * FROM products" 143 | _(shared_base.create_view(view_name) { "SELECT * FROM products" }).must_match(/#{Regexp.escape(select_statement)}/) 144 | end 145 | end 146 | end 147 | 148 | describe "#drop_view" do 149 | let(:view_name) { :view_name } 150 | 151 | describe "view_name" do 152 | it "takes a symbol as the view_name" do 153 | _(shared_base.drop_view(view_name.to_sym)).must_match(/#{view_name}/) 154 | end 155 | 156 | it "takes a string as the view_name" do 157 | _(shared_base.drop_view(view_name.to_s)).must_match(/#{view_name}/) 158 | end 159 | end 160 | end 161 | 162 | describe "#tables" do 163 | it "returns an array of all table names" do 164 | _(ActiveRecord::Base.connection.tables).must_include("products") 165 | _(ActiveRecord::Base.connection.tables).must_include("users") 166 | end 167 | 168 | it "does not include the names of the views" do 169 | _(ActiveRecord::Base.connection.tables).wont_include("new_product_users") 170 | end 171 | end 172 | 173 | describe "#views" do 174 | it "returns an array of all views" do 175 | _(ActiveRecord::Base.connection.views).must_include("new_product_users") 176 | end 177 | end 178 | 179 | if shared_base.supports_materialized_views? 180 | describe "#create_materialized_view" do 181 | let(:view_name) { :view_name } 182 | 183 | it "throws error when block not given and no build_query" do 184 | _(lambda { shared_base.create_materialized_view(view_name) }).must_raise(RuntimeError) 185 | end 186 | 187 | describe "view_name" do 188 | it "takes a symbol as the view_name" do 189 | _(shared_base.create_materialized_view(view_name.to_sym, Product.all)).must_match(/#{view_name}/) 190 | end 191 | 192 | it "takes a string as the view_name" do 193 | _(shared_base.create_materialized_view(view_name.to_s, Product.all)).must_match(/#{view_name}/) 194 | end 195 | end 196 | 197 | describe "build_query" do 198 | it "uses a string if passed" do 199 | select_statement = "SELECT * FROM products" 200 | _(shared_base.create_materialized_view(view_name, select_statement)).must_match(/#{Regexp.escape(select_statement)}/) 201 | end 202 | 203 | it "uses an Arel::Relation if passed" do 204 | select_statement = Product.all.to_sql 205 | _(shared_base.create_materialized_view(view_name, Product.all)).must_match(/#{Regexp.escape(select_statement)}/) 206 | end 207 | end 208 | 209 | describe "block" do 210 | it "can use an Arel::Relation from the yield" do 211 | select_statement = Product.all.to_sql 212 | _(shared_base.create_materialized_view(view_name) { Product.all }).must_match(/#{Regexp.escape(select_statement)}/) 213 | end 214 | 215 | it "can use a String from the yield" do 216 | select_statement = "SELECT * FROM products" 217 | _(shared_base.create_materialized_view(view_name) { "SELECT * FROM products" }).must_match(/#{Regexp.escape(select_statement)}/) 218 | end 219 | end 220 | end 221 | 222 | describe "#drop_materialized_view" do 223 | let(:view_name) { :view_name } 224 | 225 | describe "view_name" do 226 | it "takes a symbol as the view_name" do 227 | _(shared_base.drop_materialized_view(view_name.to_sym)).must_match(/#{view_name}/) 228 | end 229 | 230 | it "takes a string as the view_name" do 231 | _(shared_base.drop_materialized_view(view_name.to_s)).must_match(/#{view_name}/) 232 | end 233 | end 234 | end 235 | 236 | describe "#refresh_materialized_view" do 237 | let(:view_name) { :view_name } 238 | 239 | describe "view_name" do 240 | it "takes a symbol as the view_name" do 241 | _(shared_base.refresh_materialized_view(view_name.to_sym)).must_match(/#{view_name}/) 242 | end 243 | 244 | it "takes a string as the view_name" do 245 | _(shared_base.refresh_materialized_view(view_name.to_s)).must_match(/#{view_name}/) 246 | end 247 | end 248 | end 249 | else 250 | describe "#materialized_views" do 251 | it "should not be supported by #{adapter}" do 252 | _(lambda { shared_base.materialized_views }).must_raise(NotImplementedError) 253 | end 254 | end 255 | end 256 | end 257 | -------------------------------------------------------------------------------- /test/support/test_classes.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | has_many :products 3 | end 4 | 5 | class Product < ActiveRecord::Base 6 | belongs_to :user 7 | end 8 | 9 | class NewProductUser < Spectacles::View 10 | scope :duck_lovers, lambda { where(product_name: "Rubber Duck") } 11 | end 12 | 13 | class TestBase 14 | extend Spectacles::SchemaStatements::AbstractAdapter 15 | 16 | def self.materialized_views 17 | @materialized_views ||= nil 18 | @materialized_views || super 19 | end 20 | 21 | def self.with_materialized_views(list) 22 | @materialized_views = list 23 | yield 24 | ensure 25 | @materialized_views = nil 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/support/view_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "a view model" do 2 | ActiveRecord::Base.connection.create_view(:new_product_users) do 3 | "SELECT name AS product_name, first_name AS username FROM 4 | products JOIN users ON users.id = products.user_id" 5 | end 6 | 7 | describe "Spectacles::View" do 8 | describe "inherited class" do 9 | before(:each) do 10 | User.destroy_all 11 | Product.destroy_all 12 | @john = User.create(first_name: "John", last_name: "Doe") 13 | @john.products.create(name: "Rubber Duck", value: 10) 14 | end 15 | 16 | let(:new_product_user) { NewProductUser.duck_lovers.load.first } 17 | 18 | it "can have scopes" do 19 | _(new_product_user.username).must_equal @john.first_name 20 | end 21 | 22 | describe "an instance" do 23 | it "is readonly" do 24 | _(new_product_user.readonly?).must_equal true 25 | end 26 | end 27 | end 28 | end 29 | 30 | if ActiveRecord::Base.connection.supports_materialized_views? 31 | ActiveRecord::Base.connection.create_materialized_view(:materialized_product_users) do 32 | "SELECT name AS product_name, first_name AS username FROM 33 | products JOIN users ON users.id = products.user_id" 34 | end 35 | 36 | class MaterializedProductUser < Spectacles::MaterializedView 37 | scope :duck_lovers, lambda { where(product_name: "Rubber Duck") } 38 | end 39 | 40 | describe "Spectacles::MaterializedView" do 41 | before(:each) do 42 | User.delete_all 43 | Product.delete_all 44 | @john = User.create(first_name: "John", last_name: "Doe") 45 | @duck = @john.products.create(name: "Rubber Duck", value: 10) 46 | MaterializedProductUser.refresh! 47 | end 48 | 49 | it "can have scopes" do 50 | _(MaterializedProductUser.duck_lovers.load.first.username).must_equal @john.first_name 51 | end 52 | 53 | it "is readonly" do 54 | _(MaterializedProductUser.first.readonly?).must_equal true 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | 5 | SimpleCov.start do 6 | add_filter "/spec" 7 | end 8 | 9 | require "rubygems" 10 | require "bundler" 11 | Bundler.require(:default, :development, :test) 12 | 13 | require "minitest/spec" 14 | require "minitest/autorun" 15 | require "minitest/pride" 16 | 17 | require "support/minitest/shared_examples" 18 | require "support/schema_statement_examples" 19 | require "support/test_classes" 20 | require "support/view_examples" 21 | 22 | ActiveRecord::Schema.verbose = false 23 | 24 | def configure_database(config) 25 | @database_config = config 26 | end 27 | 28 | def load_schema 29 | ActiveRecord::Schema.define(version: 1) do 30 | create_table :users do |t| 31 | t.string :first_name 32 | t.string :last_name 33 | end 34 | 35 | create_table :products do |t| 36 | t.string :name 37 | t.integer :value 38 | t.boolean :available, default: true 39 | t.belongs_to :user 40 | end 41 | end 42 | end 43 | 44 | def recreate_database(database) 45 | ActiveRecord::Base.establish_connection(@database_config) 46 | begin 47 | ActiveRecord::Base.connection.drop_database(database) 48 | rescue 49 | nil 50 | end 51 | ActiveRecord::Base.connection.create_database(database) 52 | ActiveRecord::Base.establish_connection(@database_config.merge(database: database)) 53 | end 54 | --------------------------------------------------------------------------------