├── .devcontainer ├── Dockerfile ├── devcontainer.json └── docker-compose.yml ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .yardopts ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── SECURITY.md ├── bin ├── rake ├── rspec ├── setup ├── standardrb └── yard ├── lib ├── generators │ └── scenic │ │ ├── generators.rb │ │ ├── materializable.rb │ │ ├── model │ │ ├── USAGE │ │ ├── model_generator.rb │ │ └── templates │ │ │ └── model.erb │ │ └── view │ │ ├── USAGE │ │ ├── templates │ │ └── db │ │ │ └── migrate │ │ │ ├── create_view.erb │ │ │ └── update_view.erb │ │ └── view_generator.rb ├── scenic.rb └── scenic │ ├── adapters │ ├── postgres.rb │ └── postgres │ │ ├── connection.rb │ │ ├── errors.rb │ │ ├── index_creation.rb │ │ ├── index_migration.rb │ │ ├── index_reapplication.rb │ │ ├── indexes.rb │ │ ├── refresh_dependencies.rb │ │ ├── side_by_side.rb │ │ ├── temporary_name.rb │ │ └── views.rb │ ├── command_recorder.rb │ ├── command_recorder │ └── statement_arguments.rb │ ├── configuration.rb │ ├── definition.rb │ ├── index.rb │ ├── railtie.rb │ ├── schema_dumper.rb │ ├── statements.rb │ ├── unaffixed_name.rb │ ├── version.rb │ └── view.rb ├── scenic.gemspec └── spec ├── acceptance └── user_manages_views_spec.rb ├── acceptance_helper.rb ├── dummy ├── .gitignore ├── Rakefile ├── app │ └── models │ │ └── application_record.rb ├── bin │ ├── bundle │ ├── rails │ └── rake ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ └── environment.rb └── db │ ├── migrate │ ├── .keep │ └── 20220112154220_add_pg_stat_statements_extension.rb │ ├── schema.rb │ └── views │ └── .keep ├── generators └── scenic │ ├── model │ └── model_generator_spec.rb │ └── view │ └── view_generator_spec.rb ├── integration └── revert_spec.rb ├── scenic ├── adapters │ ├── postgres │ │ ├── connection_spec.rb │ │ ├── index_creation_spec.rb │ │ ├── index_migration_spec.rb │ │ ├── refresh_dependencies_spec.rb │ │ ├── side_by_side_spec.rb │ │ ├── temporary_name_spec.rb │ │ └── views_spec.rb │ └── postgres_spec.rb ├── command_recorder │ └── statement_arguments_spec.rb ├── command_recorder_spec.rb ├── configuration_spec.rb ├── definition_spec.rb ├── schema_dumper_spec.rb └── statements_spec.rb ├── spec_helper.rb └── support ├── database_schema_helpers.rb ├── generator_spec_setup.rb ├── rails_configuration_helpers.rb └── view_definition_helpers.rb /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG VARIANT=2-bullseye 2 | FROM mcr.microsoft.com/vscode/devcontainers/ruby:0-${VARIANT} 3 | 4 | ENV RAILS_DEVELOPMENT_HOSTS=".githubpreview.dev" 5 | ENV POSTGRES_USER="postgres" 6 | ENV POSTGRES_PASSWORD="postgres" 7 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Scenic Development", 3 | "dockerComposeFile": "docker-compose.yml", 4 | "service": "app", 5 | "workspaceFolder": "/workspace", 6 | "settings": { }, 7 | "extensions": ["rebornix.Ruby"], 8 | "postCreateCommand": "bin/setup", 9 | "remoteUser": "vscode", 10 | "features": { "github-cli": "latest" } 11 | } 12 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | app: 5 | build: 6 | context: .. 7 | dockerfile: .devcontainer/Dockerfile 8 | args: 9 | VARIANT: "3" 10 | volumes: 11 | - ..:/workspace:cached 12 | command: sleep infinity 13 | network_mode: service:db 14 | db: 15 | image: postgres:latest 16 | restart: unless-stopped 17 | volumes: 18 | - postgres-data:/var/lib/postgresql/data 19 | environment: 20 | POSTGRES_USER: postgres 21 | POSTGRES_DB: postgres 22 | POSTGRES_PASSWORD: postgres 23 | volumes: 24 | postgres-data: null 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: main 6 | pull_request: 7 | branches: "*" 8 | 9 | jobs: 10 | standard: 11 | name: Lint with Standard 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Run standardrb 19 | uses: standardrb/standard-ruby-action@f533e61f461ccb766b2d9c235abf59be02aea793 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | permissions: 24 | checks: write 25 | contents: read 26 | 27 | build: 28 | name: Ruby ${{ matrix.ruby }}, Rails ${{ matrix.rails }} 29 | continue-on-error: ${{ matrix.continue-on-error }} 30 | 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | ruby: ["3.4", "3.3"] 35 | rails: ["8.0", "7.2"] 36 | continue-on-error: [false] 37 | include: 38 | - ruby: "3.4" 39 | rails: "main" 40 | continue-on-error: true 41 | - ruby: "head" 42 | rails: "main" 43 | continue-on-error: true 44 | 45 | runs-on: ubuntu-latest 46 | 47 | services: 48 | postgres: 49 | image: postgres 50 | env: 51 | POSTGRES_USER: "postgres" 52 | POSTGRES_PASSWORD: "postgres" 53 | ports: 54 | - 5432:5432 55 | options: >- 56 | --health-cmd pg_isready 57 | --health-interval 10s 58 | --health-timeout 5s 59 | --health-retries 5 60 | 61 | env: 62 | RAILS_VERSION: ${{ matrix.rails }} 63 | POSTGRES_USER: "postgres" 64 | POSTGRES_PASSWORD: "postgres" 65 | 66 | steps: 67 | - name: Checkout 68 | uses: actions/checkout@v4 69 | 70 | - name: Install Ruby ${{ matrix.ruby }} 71 | uses: ruby/setup-ruby@v1 72 | with: 73 | ruby-version: ${{ matrix.ruby }} 74 | 75 | - name: Install dependent libraries 76 | run: sudo apt-get install libpq-dev 77 | 78 | - name: Generate lockfile 79 | run: bundle lock 80 | 81 | - name: Cache dependencies 82 | uses: actions/cache@v4 83 | with: 84 | path: vendor/bundle 85 | key: bundle-${{ hashFiles('Gemfile.lock') }} 86 | 87 | - name: Set up Scenic 88 | run: bin/setup 89 | 90 | - name: Run fast tests 91 | run: bundle exec rake spec 92 | continue-on-error: ${{ matrix.continue-on-error }} 93 | 94 | - name: Run acceptance tests 95 | run: bundle exec rake spec:acceptance 96 | continue-on-error: ${{ matrix.continue-on-error }} 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | gemfiles/*.lock 19 | .DS_Store 20 | .ruby-version 21 | .vscode/ 22 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --hide-api private 2 | --exclude templates 3 | --markup markdown 4 | --markup-provider redcarpet 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The noteworthy changes for each Scenic version are included here. For a complete 4 | changelog, see the [commits] for each version via the version links. 5 | 6 | [commits]: https://github.com/scenic-views/scenic/commits/master 7 | 8 | ## [1.8.0] - March 28, 2024 9 | 10 | [1.8.0]: https://github.com/scenic-views/scenic/compare/v1.7.0...v1.8.0 11 | 12 | ### Added 13 | 14 | * Added `#populated?` to check the state of materialized views - *Daisuke 15 | Fujimura*, *Dr Nic Williams* 16 | 17 | ## [1.7.0] - December 8, 2022 18 | 19 | [1.7.0]: https://github.com/scenic-views/scenic/compare/v1.6.0...v1.7.0 20 | 21 | ### Added 22 | 23 | * Added the `--replace` CLI flag to generate a migration that uses the 24 | `replace_view` schema statement - *Dan Hixon* 25 | 26 | ### Fixed 27 | 28 | * Fixed deprecation notice from newer versions of ERB when using scenic 29 | generators - *Ali Ismayilov* 30 | 31 | ## [1.6.0] - February 13, 2022 32 | 33 | [1.6.0]: https://github.com/scenic-views/scenic/compare/v1.5.5...v1.6.0 34 | 35 | ### Fixed 36 | 37 | * Exclude pg_stat_statements_info (#349) 76bface - *Caleb Hearth* 38 | * Fix serialization of views with backslashes c625d1b - *Ben Sheldon* 39 | * Handle ActiveRecord table name prefix and suffix b1544dc - *Derek Prior* 40 | 41 | ## [1.5.5] - December 15, 2021 42 | 43 | ### Fixed 44 | 45 | - Fixed an issue reverting migrations under Ruby 3 46 | - Fixed an issue in index reapplication where sometimes `say` was undefined 47 | 48 | [1.5.5]: https://github.com/scenic-views/scenic/compare/v1.5.4...v1.5.5 49 | 50 | ## [1.5.4] - September 16, 2020 51 | 52 | [1.5.4]: https://github.com/scenic-views/scenic/compare/v1.5.3...v1.5.4 53 | 54 | ### Fixed 55 | 56 | - Added missing changelog for v1.5.3. 57 | 58 | ## [1.5.3] - September 15, 2020 59 | 60 | [1.5.3]: https://github.com/scenic-views/scenic/compare/v1.5.2...v1.5.3 61 | 62 | ### Fixed 63 | 64 | - `scenic-oracle_enhanced_adapter` has been pulled from rubygems. 65 | `scenic-oracle_adapter` is a current, maintained alternative. 66 | - Updated code snippets - since Rails 5.0, all models inherit from 67 | ApplicationRecord (#302) 68 | - Update Caleb's last name 69 | 70 | ### Added 71 | 72 | - Add Security Policy 73 | 74 | ## [1.5.2] - February 6, 2020 75 | 76 | ### Fixed 77 | 78 | - The schema statement `create_view` is now reversible when passed a `version` 79 | argument. 80 | - Calling `refresh_materialized_view` with both `concurrently` and `cascade` set 81 | to `true` now correctly cascades the concurrent refresh to dependent views. 82 | - File generation and lookup now operates correctly for schema-qualified names 83 | like `warehouse.archived_posts`. 84 | 85 | [1.5.2]: https://github.com/scenic-views/scenic/compare/v1.5.1...v1.5.2 86 | 87 | ## [1.5.1] - February 10, 2019 88 | 89 | ### Fixed 90 | 91 | - Passing `no_data: true` when creating a materialized view would error if the 92 | corresponding SQL file had statement-terminating semicolon. 93 | 94 | [1.5.1]: https://github.com/scenic-views/scenic/compare/v1.5.0...v1.5.1 95 | 96 | ## [1.5.0] - February 8, 2019 97 | 98 | ### Added 99 | 100 | - `create_view` can now be passed `materialized: { no_data: true }` to create 101 | the materialized view without populating it. Generators have been updated to 102 | accept a `--no-data` option. 103 | 104 | ### Fixed 105 | 106 | - Passing `cascade: true` when refreshing a materialized view will no longer 107 | error when the view in question has no dependencies. 108 | - Fixed runtime deprecation warnings when using `pg` 0.21 and newer. 109 | - Fixed a cascading refresh issue when the name of the view to trigger the 110 | refresh is a substring of one of its dependencies. 111 | 112 | 113 | [1.5.0]: https://github.com/scenic-views/scenic/compare/v1.4.1...v1.5.0 114 | 115 | ## [1.4.1] - December 15, 2017 116 | 117 | ### Fixed 118 | 119 | - View migrations created under Rails 5 and newer will use the current migration 120 | version in the generated migration class rather than always using `5.0`. 121 | 122 | [1.4.1]: https://github.com/scenic-views/scenic/compare/v1.4.0...v1.4.1 123 | 124 | ## [1.4.0] - May 11, 2017 125 | 126 | ### Added 127 | 128 | - `refresh_materialized_view` now accepts a `cascade` option, which defaults to 129 | `false`. Setting this option to `true` will refresh any materialized views the 130 | current view depends on first, ensuring the view being refreshed has the most 131 | up-to-date information. 132 | - `sql_definition` argument is now supported when using `update_view`. 133 | 134 | ### Fixed 135 | 136 | - View migrations created under Rails 5 and newer will no longer result in 137 | warnings. 138 | - `ar_internal_metadata` is no longer included in the schema dump for Rails 5 139 | and newer apps. 140 | - Using the `scenic:model` generator will no longer create a fixture or factory. 141 | 142 | [1.4.0]: https://github.com/scenic-views/scenic/compare/v1.3.0...v1.4.0 143 | 144 | ## [1.3.0] - May 27, 2016 145 | 146 | ### Added 147 | - Add `replace_view` migration statement, which issues `CREATE OR REPLACE 148 | VIEW` rather than `CREATE VIEW` or `DROP VIEW` and `CREATE VIEW`. 149 | - Schema-qualify views outside the 'public' namespace, such as 150 | `scenic.searches` 151 | 152 | ### Fixed 153 | * Singularize generated model name when injecting into class. 154 | Previously, pluralized names would issue a warning and Scenic would 155 | attempt to insert model code into the pluralized model file. 156 | * Convert shell-based smoke tests to RSpec syntax. 157 | 158 | [1.3.0]: https://github.com/scenic-views/scenic/compare/v1.2.0...v1.3.0 159 | 160 | ## [1.2.0] - February 5, 2016 161 | 162 | ### Added 163 | - The generators now accept namespaced view definitions. For example: `rails 164 | generate scenic:view my_app.users`. 165 | 166 | ### Fixed 167 | - Materialized view indexes are now properly dumped to `db/schema.rb`. This was 168 | an oversight in previous releases, meaning `rake db:schema:load` was missing 169 | indexes. 170 | - Calling `update_view` for a materialized view now properly finds associated 171 | indexes for automatic reapplication. An issue in the previous index query was 172 | returning no indexes. 173 | 174 | **Note**: Dumping materialized view indexes will produce an invalid 175 | `db/schema.rb` file under Rails 5 beta 1 and beta 2. This is fixed on Rails 176 | master. 177 | 178 | [1.2.0]: https://github.com/scenic-views/scenic/compare/v1.1.1...v1.2.0 179 | 180 | ## [1.1.1] - January 29, 2016 181 | 182 | ### Fixed 183 | - Some schema operations were failing with a `PG::ConnectionBad: connection is 184 | closed` error. This has been fixed by ensuring we grab a fresh connection for 185 | all operations. 186 | 187 | [1.1.1]: https://github.com/scenic-views/scenic/compare/v1.1.0...v1.1.1 188 | 189 | ## [1.1.0] - January 8, 2016 190 | 191 | ### Added 192 | - Added support for updating materialized view definitions while maintaining 193 | existing indexes that are still applicable after the update. 194 | - Added support for refreshing materialized views concurrently (requires 195 | Postgres 9.4 or newer). 196 | 197 | ### Fixed 198 | - The schema dumper will now dump views and materialized views together in the 199 | order they are returned by Postgres. This fixes issues when loading views that 200 | depend on other views via `rake db:schema:load`. 201 | - Scenic now works on [supported versions of Postgres] older than 9.3.0. 202 | Attempts to use database features not supported by your specific version of 203 | Postgres will raise descriptive errors. 204 | - Fixed inability to dump materialized views in Rails 5.0.0.beta1. 205 | 206 | [supported versions of Postgres]: http://www.postgresql.org/support/versioning/ 207 | [1.1.0]: https://github.com/scenic-views/scenic/compare/v1.0.0...v1.1.0 208 | 209 | ## [1.0.0] - November 23, 2015 210 | 211 | ### Added 212 | - Added support for [materialized views]. 213 | - Allow changing the database adapter via `Scenic::Configuration`. 214 | 215 | ### Fixed 216 | - Improved formatting of the view when dumped to `schema.rb`. 217 | - Fixed generation of namespaced models by using ActiveRecord's own model 218 | generator. 219 | - Eliminated `alias_method_chain` deprecation when running with Rails master 220 | (5.0). 221 | 222 | [materialized views]:https://github.com/scenic-views/scenic/blob/v1.0.0/README.md 223 | [1.0.0]: https://github.com/scenic-views/scenic/compare/v0.3.0...v1.0.0 224 | 225 | ## [0.3.0] - January 23, 2015 226 | 227 | ### Added 228 | - Previous view definition is copied into new view definition file when updating 229 | an existing view. 230 | 231 | ### Fixed 232 | - We avoid dumping views that belong to Postgres extensions. 233 | - `db/schema.rb` is prettier thanks to a blank line after each view definition. 234 | 235 | [0.3.0]: https://github.com/scenic-views/scenic/compare/v0.2.1...v0.3.0 236 | 237 | ## [0.2.1] - January 5, 2015 238 | 239 | ### Fixed 240 | - View generator will now create `db/views` directory if necessary. 241 | 242 | [0.2.1]: https://github.com/scenic-views/scenic/compare/v0.2.0...v0.2.1 243 | 244 | ## [0.2.0] - August 11, 2014 245 | 246 | ### Added 247 | - Teach view generator to update existing views. 248 | 249 | ### Fixed 250 | - Raise an error if view definition is empty. 251 | 252 | [0.2.0]: https://github.com/scenic-views/scenic/compare/v0.1.0...v0.2.0 253 | 254 | ## [0.1.0] - August 4, 2014 255 | 256 | Scenic makes it easier to work with Postgres views in Rails. 257 | 258 | It introduces view methods to ActiveRecord::Migration and allows views to be 259 | dumped to db/schema.rb. It provides generators for models, view definitions, 260 | and migrations. It is built around a basic versioning system for view 261 | definition files. 262 | 263 | In short, go add a view to your app. 264 | 265 | [0.1.0]: https://github.com/scenic-views/scenic/compare/8599daa132880cd6c07efb0395c0fb023b171f47...v0.1.0 266 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and 9 | expression, level of experience, education, socio-economic status, nationality, 10 | personal appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at derekprior@gmail.com. All complaints 59 | will be reviewed and investigated and will result in a response that is deemed 60 | necessary and appropriate to the circumstances. The project team is obligated to 61 | maintain confidentiality with regard to the reporter of an incident. Further 62 | details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 71 | version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We love contributions from everyone. By participating in this project, you 4 | agree to abide by our [code of conduct]. 5 | 6 | [code of conduct]: CODE_OF_CONDUCT.md 7 | 8 | ## Contributing Code 9 | 10 | 1. Fork the repository. 11 | 2. Run `bin/setup`, which will install dependencies and create the dummy 12 | application database. 13 | 3. Run `rake` to verify that the tests pass against the version of Rails you are 14 | running locally. 15 | 4. Make your change with new passing tests, following existing style. 16 | 5. Run `standardrb --fix` to ensure your code is formatted correctly. 17 | 5. Write a [good commit message], push your fork, and submit a pull request. 18 | 6. CI will run the test suite on all configured versions of Ruby and Rails. 19 | Address any failures. 20 | 21 | [good commit message]: http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html 22 | 23 | Others will give constructive feedback. This is a time for discussion and 24 | improvements, and making the necessary changes will be required before we can 25 | merge the contribution. 26 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [calebhearth, derekprior] 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in scenic.gemspec 4 | gemspec 5 | 6 | rails_version = ENV.fetch("RAILS_VERSION", "8.0") 7 | 8 | rails_constraint = if rails_version == "main" 9 | {github: "rails/rails"} 10 | else 11 | "~> #{rails_version}.0" 12 | end 13 | 14 | gem "rails", rails_constraint 15 | gem "sprockets", "< 4.0.0" 16 | gem "pg", "~> 1.1" 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2020 Derek Prior, Caleb Hearth, and thoughtbot. 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scenic 2 | 3 | ![Scenic Landscape](https://user-images.githubusercontent.com/152152/49344534-a8817480-f646-11e8-8431-3d95d349c070.png) 4 | 5 | [![Build Status](https://github.com/scenic-views/scenic/actions/workflows/ci.yml/badge.svg)](https://github.com/scenic-views/scenic/actions/workflows/ci.yml) 6 | [![Documentation Quality](http://inch-ci.org/github/scenic-views/scenic.svg?branch=master)](http://inch-ci.org/github/scenic-views/scenic) 7 | 8 | Scenic adds methods to `ActiveRecord::Migration` to create and manage database 9 | views in Rails. 10 | 11 | Using Scenic, you can bring the power of SQL views to your Rails application 12 | without having to switch your schema format to SQL. Scenic provides a convention 13 | for versioning views that keeps your migration history consistent and reversible 14 | and avoids having to duplicate SQL strings across migrations. As an added bonus, 15 | you define the structure of your view in a SQL file, meaning you get full SQL 16 | syntax highlighting in the editor of your choice and can easily test your SQL in 17 | the database console during development. 18 | 19 | Scenic ships with support for PostgreSQL. The adapter is configurable (see 20 | `Scenic::Configuration`) and has a minimal interface (see 21 | `Scenic::Adapters::Postgres`) that other gems can provide. 22 | 23 | ## So how do I install this? 24 | 25 | If you're using Postgres, Add `gem "scenic"` to your Gemfile and run `bundle 26 | install`. If you're using something other than Postgres, check out the available 27 | [third-party adapters](https://github.com/scenic-views/scenic#when-will-you-support-mysql-sqlite-or-other-databases). 28 | 29 | ## Great, how do I create a view? 30 | 31 | You've got this great idea for a view you'd like to call `search_results`. You 32 | can create the migration and the corresponding view definition file with the 33 | following command: 34 | 35 | ```sh 36 | $ rails generate scenic:view search_results 37 | create db/views/search_results_v01.sql 38 | create db/migrate/[TIMESTAMP]_create_search_results.rb 39 | ``` 40 | 41 | Edit the `db/views/search_results_v01.sql` file with the SQL statement that 42 | defines your view. In our example, this might look something like this: 43 | 44 | ```sql 45 | SELECT 46 | statuses.id AS searchable_id, 47 | 'Status' AS searchable_type, 48 | comments.body AS term 49 | FROM statuses 50 | JOIN comments ON statuses.id = comments.status_id 51 | 52 | UNION 53 | 54 | SELECT 55 | statuses.id AS searchable_id, 56 | 'Status' AS searchable_type, 57 | statuses.body AS term 58 | FROM statuses 59 | ``` 60 | 61 | The generated migration will contain a `create_view` statement. Run the 62 | migration, and [baby, you got a view going][carl]. The migration is reversible 63 | and the schema will be dumped into your `schema.rb` file. 64 | 65 | [carl]: https://www.youtube.com/watch?v=Sr2PlqXw03Y 66 | 67 | ```sh 68 | $ rake db:migrate 69 | ``` 70 | 71 | ## Cool, but what if I need to change that view? 72 | 73 | Here's where Scenic really shines. Run that same view generator once more: 74 | 75 | ```sh 76 | $ rails generate scenic:view search_results 77 | create db/views/search_results_v02.sql 78 | create db/migrate/[TIMESTAMP]_update_search_results_to_version_2.rb 79 | ``` 80 | 81 | Scenic detected that we already had an existing `search_results` view at version 82 | 1, created a copy of that definition as version 2, and created a migration to 83 | update to the version 2 schema. All that's left for you to do is tweak the 84 | schema in the new definition and run the `update_view` migration. 85 | 86 | ## What if I want to change a view without dropping it? 87 | 88 | The `update_view` statement used by default will drop your view then create a 89 | new version of it. This may not be desirable when you have complicated 90 | hierarchies of dependent views. 91 | 92 | Scenic offers a `replace_view` schema statement, resulting in a `CREATE OR 93 | REPLACE VIEW` SQL query which will update the supplied view in place, retaining 94 | all dependencies. Materialized views cannot be replaced in this fashion, though 95 | the `side_by_side` update strategy may yield similar results (see below). 96 | 97 | You can generate a migration that uses the `replace_view` schema statement by 98 | passing the `--replace` option to the `scenic:view` generator: 99 | 100 | ```sh 101 | $ rails generate scenic:view search_results --replace 102 | create db/views/search_results_v02.sql 103 | create db/migrate/[TIMESTAMP]_update_search_results_to_version_2.rb 104 | ``` 105 | 106 | The migration will look something like this: 107 | 108 | ```ruby 109 | class UpdateSearchResultsToVersion2 < ActiveRecord::Migration 110 | def change 111 | replace_view :search_results, version: 2, revert_to_version: 1 112 | end 113 | end 114 | ``` 115 | 116 | ## Can I use this view to back a model? 117 | 118 | You bet! Using view-backed models can help promote concepts hidden in your 119 | relational data to first-class domain objects and can clean up complex 120 | ActiveRecord or ARel queries. As far as ActiveRecord is concerned, a view is 121 | no different than a table. 122 | 123 | ```ruby 124 | class SearchResult < ApplicationRecord 125 | belongs_to :searchable, polymorphic: true 126 | 127 | # If you want to be able to call +Model.find+, you 128 | # must declare the primary key. It can not be 129 | # inferred from column information. 130 | # self.primary_key = :id 131 | 132 | # this isn't strictly necessary, but it will prevent 133 | # rails from calling save, which would fail anyway. 134 | def readonly? 135 | true 136 | end 137 | end 138 | ``` 139 | 140 | Scenic even provides a `scenic:model` generator that is a superset of 141 | `scenic:view`. It will act identically to the Rails `model` generator except 142 | that it will create a Scenic view migration rather than a table migration. 143 | 144 | There is no special base class or mixin needed. If desired, any code the model 145 | generator adds can be removed without worry. 146 | 147 | ```sh 148 | $ rails generate scenic:model recent_status 149 | invoke active_record 150 | create app/models/recent_status.rb 151 | invoke test_unit 152 | create test/models/recent_status_test.rb 153 | create test/fixtures/recent_statuses.yml 154 | create db/views/recent_statuses_v01.sql 155 | create db/migrate/20151112015036_create_recent_statuses.rb 156 | ``` 157 | 158 | ## What about materialized views? 159 | 160 | Materialized views are essentially SQL queries whose results can be cached to a 161 | table, indexed, and periodically refreshed when desired. Does Scenic support 162 | those? Of course! 163 | 164 | The `scenic:view` and `scenic:model` generators accept a `--materialized` 165 | option for this purpose. When used with the model generator, your model will 166 | have the following method defined as a convenience to aid in scheduling 167 | refreshes: 168 | 169 | ```ruby 170 | def self.refresh 171 | Scenic.database.refresh_materialized_view(table_name, concurrently: false, cascade: false) 172 | end 173 | ``` 174 | 175 | This will perform a non-concurrent refresh, locking the view for selects until 176 | the refresh is complete. You can avoid locking the view by passing 177 | `concurrently: true` but this requires both PostgreSQL 9.4 and your view to have 178 | at least one unique index that covers all rows. You can add or update indexes for 179 | materialized views using table migration methods (e.g. `add_index table_name`) 180 | and these will be automatically re-applied when views are updated. 181 | 182 | The `cascade` option is to refresh materialized views that depend on other 183 | materialized views. For example, say you have materialized view A, which selects 184 | data from materialized view B. To get the most up to date information in view A 185 | you would need to refresh view B first, then right after refresh view A. If you 186 | would like this cascading refresh of materialized views, set `cascade: true` 187 | when you refresh your materialized view. 188 | 189 | ## Can I update the definition of a materialized view without dropping it? 190 | 191 | No, but Scenic can help you approximate this behavior with its `side_by_side` 192 | update strategy. 193 | 194 | Generally, changing the definition of a materialized view requires dropping it 195 | and recreating it, either without data or with a non-concurrent refresh. The 196 | materialized view will be locked for selects during the refresh process, which 197 | can cause problems in your application if the refresh is not fast. 198 | 199 | The `side_by_side` update strategy prepares the new version of the view under a 200 | temporary name. This includes copying the indexes from the original view and 201 | refreshing the data. Once prepared, the original view is dropped and the new 202 | view is renamed to the original view's name. This process minimizes the time the 203 | view is locked for selects at the cost of additional disk space. 204 | 205 | You can generate a migration that uses the `side_by_side` strategy by passing 206 | the `--side-by-side` option to the `scenic:view` generator: 207 | 208 | ```sh 209 | $ rails generate scenic:view search_results --materialized --side-by-side 210 | create db/views/search_results_v02.sql 211 | create db/migrate/[TIMESTAMP]_update_search_results_to_version_2.rb 212 | ``` 213 | 214 | The migration will look something like this: 215 | 216 | ```ruby 217 | class UpdateSearchResultsToVersion2 < ActiveRecord::Migration 218 | def change 219 | update_view :search_results, 220 | version: 2, 221 | revert_to_version: 1, 222 | materialized: { side_by_side: true } 223 | end 224 | end 225 | ``` 226 | 227 | ## I don't need this view anymore. Make it go away. 228 | 229 | Scenic gives you `drop_view` too: 230 | 231 | ```ruby 232 | def change 233 | drop_view :search_results, revert_to_version: 2 234 | drop_view :materialized_admin_reports, revert_to_version: 3, materialized: true 235 | end 236 | ``` 237 | 238 | ## FAQs 239 | 240 | ### Why do I get an error when querying a view-backed model with `find`, `last`, or `first`? 241 | 242 | ActiveRecord's `find` method expects to query based on your model's primary key, 243 | but views do not have primary keys. Additionally, the `first` and `last` methods 244 | will produce queries that attempt to sort based on the primary key. 245 | 246 | You can get around these issues by setting the primary key column on your Rails 247 | model like so: 248 | 249 | ```ruby 250 | class People < ApplicationRecord 251 | self.primary_key = :my_unique_identifier_field 252 | end 253 | ``` 254 | 255 | ### Why is my view missing columns from the underlying table? 256 | 257 | Did you create the view with `SELECT [table_name].*`? Most (possibly all) 258 | relational databases freeze the view definition at the time of creation. New 259 | columns will not be available in the view until the definition is updated once 260 | again. This can be accomplished by "updating" the view to its current definition 261 | to bake in the new meaning of `*`. 262 | 263 | ```ruby 264 | add_column :posts, :title, :string 265 | update_view :posts_with_aggregate_data, version: 2, revert_to_version: 2 266 | ``` 267 | 268 | ### When will you support MySQL, SQLite, or other databases? 269 | 270 | We have no plans to add first-party adapters for other relational databases at 271 | this time because we (the maintainers) do not currently have a use for them. 272 | It's our experience that maintaining a library effectively requires regular use 273 | of its features. We're not in a good position to support MySQL, SQLite or other 274 | database users. 275 | 276 | Scenic _does_ support configuring different database adapters and should be 277 | extendable with adapter libraries. If you implement such an adapter, we're happy 278 | to review and link to it. We're also happy to make changes that would better 279 | accommodate adapter gems. 280 | 281 | We are aware of the following existing adapter libraries for Scenic which may 282 | meet your needs: 283 | 284 | - [`scenic_sqlite_adapter`](https://github.com/pdebelak/scenic_sqlite_adapter) 285 | - [`scenic-mysql_adapter`](https://github.com/cainlevy/scenic-mysql_adapter) 286 | - [`scenic-sqlserver-adapter`](https://github.com/ClickMechanic/scenic_sqlserver_adapter) 287 | - [`scenic-oracle_adapter`](https://github.com/cdinger/scenic-oracle_adapter) 288 | 289 | Please note that the maintainers of Scenic make no assertions about the 290 | quality or security of the above adapters. 291 | 292 | ## About 293 | 294 | ### Used By 295 | 296 | Scenic is used by some popular open source Rails apps: 297 | [Mastodon](https://github.com/mastodon/mastodon/), 298 | [Code.org](https://github.com/code-dot-org/code-dot-org), and 299 | [Lobste.rs](https://github.com/lobsters/lobsters/). 300 | 301 | ### Related projects 302 | 303 | - [`fx`](https://github.com/teoljungberg/fx) Versioned database functions and 304 | triggers for Rails 305 | 306 | ### Media 307 | 308 | Here are a few posts we've seen discussing Scenic: 309 | 310 | - [Announcing Scenic - Versioned Database Views for Rails](https://thoughtbot.com/blog/announcing-scenic--versioned-database-views-for-rails) by Derek Prior for thoughtbot 311 | - [Effectively Using Materialized Views in Ruby on Rails](https://pganalyze.com/blog/materialized-views-ruby-rails) by Leigh Halliday for pganalyze 312 | - [Optimizing String Concatenation in Ruby on Rails](https://dev.to/pimp_my_ruby/from-slow-to-lightning-fast-optimizing-string-concatenation-in-ruby-on-rails-28nk) 313 | - [Materialized Views In Ruby On Rails With Scenic](https://www.ideamotive.co/blog/materialized-views-ruby-rails-scenic) by Dawid Karczewski for Ideamotive 314 | - [Using Scenic and SQL views to aggregate data](https://dev.to/weareredlight/using-scenic-and-sql-views-to-aggregate-data-226k) by André Perdigão for Redlight Software 315 | 316 | ### Maintainers 317 | 318 | Scenic is maintained by [Derek Prior], [Caleb Hearth], and you, our 319 | contributors. 320 | 321 | [Derek Prior]: http://prioritized.net 322 | [Caleb Hearth]: http://calebhearth.com 323 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :smoke do 7 | exec "spec/smoke" 8 | end 9 | 10 | namespace :dummy do 11 | require_relative "spec/dummy/config/application" 12 | Dummy::Application.load_tasks 13 | end 14 | 15 | task(:spec).clear 16 | desc "Run specs other than spec/acceptance" 17 | RSpec::Core::RakeTask.new("spec") do |task| 18 | task.exclude_pattern = "spec/acceptance/**/*_spec.rb" 19 | task.verbose = false 20 | end 21 | 22 | desc "Run acceptance specs in spec/acceptance" 23 | RSpec::Core::RakeTask.new("spec:acceptance") do |task| 24 | task.pattern = "spec/acceptance/**/*_spec.rb" 25 | task.verbose = false 26 | end 27 | 28 | desc "Run the specs and acceptance tests" 29 | task default: %w[spec spec:acceptance] 30 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Scenic maintainenance is a volunteer effort. We will do our best to fix 6 | forward but do not offer backported fixes. As such, the only "supported" version of Scenic is whichever was most recently released. 7 | 8 | ## Reporting a Vulnerability 9 | 10 | Please report any discovered security vulnerabilities to Scenic's primary 11 | volunteer maintainers, derekprior@gmail.com and caleb@calebhearth.com. 12 | 13 | We will respond as soon as possible with any follow-up questions or details 14 | on how we plan to handle the issue. 15 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'rake' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require "pathname" 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require "rubygems" 15 | require "bundler/setup" 16 | 17 | load Gem.bin_path("rake", "rake") 18 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'rspec' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require "pathname" 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require "rubygems" 15 | require "bundler/setup" 16 | 17 | load Gem.bin_path("rspec-core", "rspec") 18 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # CI-specific setup 6 | if [ -n "$GITHUB_ACTIONS" ]; then 7 | bundle config path vendor/bundle 8 | bundle config jobs 4 9 | bundle config retry 3 10 | git config --global user.name 'GitHub Actions' 11 | git config --global user.email 'github-actions@example.com' 12 | fi 13 | 14 | gem install bundler --conservative 15 | bundle check || bundle install 16 | 17 | bundle exec rake dummy:db:drop 18 | bundle exec rake dummy:db:create 19 | -------------------------------------------------------------------------------- /bin/standardrb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'standardrb' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("standard", "standardrb") 28 | -------------------------------------------------------------------------------- /bin/yard: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # 3 | # This file was generated by Bundler. 4 | # 5 | # The application 'yard' is installed as part of a gem, and 6 | # this file is here to facilitate running it. 7 | # 8 | 9 | require 'pathname' 10 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path("../../Gemfile", 11 | Pathname.new(__FILE__).realpath) 12 | 13 | require 'rubygems' 14 | require 'bundler/setup' 15 | 16 | load Gem.bin_path('yard', 'yard') 17 | -------------------------------------------------------------------------------- /lib/generators/scenic/generators.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | # Scenic provides generators for creating and updating views and ActiveRecord 3 | # models that are backed by views. 4 | # 5 | # See: 6 | # 7 | # * {file:lib/generators/scenic/model/USAGE Model Generator} 8 | # * {file:lib/generators/scenic/view/USAGE View Generator} 9 | # * {file:README.md README} 10 | module Generators 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/generators/scenic/materializable.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | module Generators 3 | # @api private 4 | module Materializable 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | class_option :materialized, 9 | type: :boolean, 10 | required: false, 11 | desc: "Makes the view materialized", 12 | default: false 13 | class_option :no_data, 14 | type: :boolean, 15 | required: false, 16 | desc: "Adds WITH NO DATA when materialized view creates/updates", 17 | default: false, 18 | aliases: ["--no-data"] 19 | class_option :side_by_side, 20 | type: :boolean, 21 | required: false, 22 | desc: "Uses side-by-side strategy to update materialized view", 23 | default: false, 24 | aliases: ["--side-by-side"] 25 | class_option :replace, 26 | type: :boolean, 27 | required: false, 28 | desc: "Uses replace_view instead of update_view", 29 | default: false 30 | end 31 | 32 | private 33 | 34 | def materialized? 35 | options[:materialized] 36 | end 37 | 38 | def replace_view? 39 | options[:replace] 40 | end 41 | 42 | def no_data? 43 | options[:no_data] 44 | end 45 | 46 | def side_by_side? 47 | options[:side_by_side] 48 | end 49 | 50 | def materialized_view_update_options 51 | set_options = {no_data: no_data?, side_by_side: side_by_side?} 52 | .select { |_, v| v } 53 | 54 | if set_options.empty? 55 | "true" 56 | else 57 | string_options = set_options.reduce("") do |memo, (key, value)| 58 | memo + "#{key}: #{value}, " 59 | end 60 | 61 | "{ #{string_options.chomp(", ")} }" 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/generators/scenic/model/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Create a new database view and ActiveRecord::Base subclass for your 3 | application. 4 | 5 | To create a materialized view, pass the '--materialized' option. 6 | 7 | Examples: 8 | rails generate scenic:model search 9 | 10 | create: app/models/search.rb 11 | create: db/views/searches_v1.sql 12 | create: db/migrate/20140803191158_create_searches.rb 13 | -------------------------------------------------------------------------------- /lib/generators/scenic/model/model_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators" 2 | require "rails/generators/rails/model/model_generator" 3 | require "generators/scenic/view/view_generator" 4 | require "generators/scenic/materializable" 5 | 6 | module Scenic 7 | module Generators 8 | # @api private 9 | class ModelGenerator < Rails::Generators::NamedBase 10 | include Scenic::Generators::Materializable 11 | source_root File.expand_path("templates", __dir__) 12 | 13 | def invoke_rails_model_generator 14 | invoke "model", 15 | [file_path.singularize], 16 | options.merge( 17 | fixture_replacement: false, 18 | migration: false 19 | ) 20 | end 21 | 22 | def inject_model_methods 23 | if materialized? && generating? 24 | inject_into_class "app/models/#{file_path.singularize}.rb", class_name do 25 | evaluate_template("model.erb") 26 | end 27 | end 28 | end 29 | 30 | def invoke_view_generator 31 | invoke "scenic:view", [table_name], options 32 | end 33 | 34 | private 35 | 36 | def evaluate_template(source) 37 | source = File.expand_path(find_in_source_paths(source.to_s)) 38 | context = instance_eval("binding", __FILE__, __LINE__) 39 | 40 | erb = ERB.new( 41 | ::File.binread(source), 42 | trim_mode: "-", 43 | eoutvar: "@output_buffer" 44 | ) 45 | 46 | erb.result(context) 47 | end 48 | 49 | def generating? 50 | behavior != :revoke 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/generators/scenic/model/templates/model.erb: -------------------------------------------------------------------------------- 1 | def self.refresh 2 | Scenic.database.refresh_materialized_view(table_name, concurrently: false, cascade: false) 3 | end 4 | 5 | def self.populated? 6 | Scenic.database.populated?(table_name) 7 | end 8 | -------------------------------------------------------------------------------- /lib/generators/scenic/view/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Create a new database view for your application. This will create a new 3 | view definition file and the accompanying migration. 4 | 5 | If a view of the given name already exists, create a new version of the view 6 | and a migration to replace the old version with the new. 7 | 8 | To create a materialized view, pass the '--materialized' option. 9 | To create a materialized view with NO DATA, pass '--no-data' option. 10 | 11 | Examples: 12 | rails generate scenic:view searches 13 | 14 | create: db/views/searches_v01.sql 15 | create: db/migrate/20140803191158_create_searches.rb 16 | 17 | rails generate scenic:view searches 18 | 19 | create: db/views/searches_v02.sql 20 | create: db/migrate/20140804191158_update_searches_to_version_2.rb 21 | -------------------------------------------------------------------------------- /lib/generators/scenic/view/templates/db/migrate/create_view.erb: -------------------------------------------------------------------------------- 1 | class <%= migration_class_name %> < <%= activerecord_migration_class %> 2 | def change 3 | create_view <%= formatted_plural_name %><%= create_view_options %> 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/generators/scenic/view/templates/db/migrate/update_view.erb: -------------------------------------------------------------------------------- 1 | class <%= migration_class_name %> < <%= activerecord_migration_class %> 2 | def change 3 | <%- method_name = replace_view? ? 'replace_view' : 'update_view' -%> 4 | <%- if materialized? -%> 5 | <%= method_name %> <%= formatted_plural_name %>, 6 | version: <%= version %>, 7 | revert_to_version: <%= previous_version %>, 8 | materialized: <%= materialized_view_update_options %> 9 | <%- else -%> 10 | <%= method_name %> <%= formatted_plural_name %>, version: <%= version %>, revert_to_version: <%= previous_version %> 11 | <%- end -%> 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/generators/scenic/view/view_generator.rb: -------------------------------------------------------------------------------- 1 | require "rails/generators" 2 | require "rails/generators/active_record" 3 | require "generators/scenic/materializable" 4 | 5 | module Scenic 6 | module Generators 7 | # @api private 8 | class ViewGenerator < Rails::Generators::NamedBase 9 | include Rails::Generators::Migration 10 | include Scenic::Generators::Materializable 11 | source_root File.expand_path("templates", __dir__) 12 | 13 | def create_views_directory 14 | unless views_directory_path.exist? 15 | empty_directory(views_directory_path) 16 | end 17 | end 18 | 19 | def create_view_definition 20 | if creating_new_view? 21 | create_file definition.path 22 | else 23 | copy_file previous_definition.full_path, definition.full_path 24 | end 25 | end 26 | 27 | def create_migration_file 28 | if creating_new_view? || destroying_initial_view? 29 | migration_template( 30 | "db/migrate/create_view.erb", 31 | "db/migrate/create_#{plural_file_name}.rb" 32 | ) 33 | else 34 | migration_template( 35 | "db/migrate/update_view.erb", 36 | "db/migrate/update_#{plural_file_name}_to_version_#{version}.rb" 37 | ) 38 | end 39 | end 40 | 41 | def self.next_migration_number(dir) 42 | ::ActiveRecord::Generators::Base.next_migration_number(dir) 43 | end 44 | 45 | no_tasks do 46 | def previous_version 47 | @previous_version ||= 48 | Dir.entries(views_directory_path) 49 | .map { |name| version_regex.match(name).try(:[], "version").to_i } 50 | .max 51 | end 52 | 53 | def version 54 | @version ||= destroying? ? previous_version : previous_version.next 55 | end 56 | 57 | def migration_class_name 58 | if creating_new_view? 59 | "Create#{class_name.tr(".", "").pluralize}" 60 | else 61 | "Update#{class_name.pluralize}ToVersion#{version}" 62 | end 63 | end 64 | 65 | def activerecord_migration_class 66 | if ActiveRecord::Migration.respond_to?(:current_version) 67 | "ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]" 68 | else 69 | "ActiveRecord::Migration" 70 | end 71 | end 72 | end 73 | 74 | private 75 | 76 | alias_method :singular_name, :file_name 77 | 78 | def file_name 79 | super.tr(".", "_") 80 | end 81 | 82 | def views_directory_path 83 | @views_directory_path ||= Rails.root.join("db", "views") 84 | end 85 | 86 | def version_regex 87 | /\A#{plural_file_name}_v(?\d+)\.sql\z/ 88 | end 89 | 90 | def creating_new_view? 91 | previous_version.zero? 92 | end 93 | 94 | def definition 95 | Scenic::Definition.new(plural_file_name, version) 96 | end 97 | 98 | def previous_definition 99 | Scenic::Definition.new(plural_file_name, previous_version) 100 | end 101 | 102 | def destroying? 103 | behavior == :revoke 104 | end 105 | 106 | def formatted_plural_name 107 | if plural_name.include?(".") 108 | "\"#{plural_name}\"" 109 | else 110 | ":#{plural_name}" 111 | end 112 | end 113 | 114 | def create_view_options 115 | if materialized? 116 | ", materialized: #{no_data? ? "{ no_data: true }" : true}" 117 | else 118 | "" 119 | end 120 | end 121 | 122 | def destroying_initial_view? 123 | destroying? && version == 1 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/scenic.rb: -------------------------------------------------------------------------------- 1 | require "scenic/configuration" 2 | require "scenic/adapters/postgres" 3 | require "scenic/command_recorder" 4 | require "scenic/definition" 5 | require "scenic/railtie" 6 | require "scenic/schema_dumper" 7 | require "scenic/statements" 8 | require "scenic/unaffixed_name" 9 | require "scenic/version" 10 | require "scenic/view" 11 | require "scenic/index" 12 | 13 | # Scenic adds methods `ActiveRecord::Migration` to create and manage database 14 | # views in Rails applications. 15 | module Scenic 16 | # Hooks Scenic into Rails. 17 | # 18 | # Enables scenic migration methods, migration reversability, and `schema.rb` 19 | # dumping. 20 | def self.load 21 | ActiveRecord::ConnectionAdapters::AbstractAdapter.include Scenic::Statements 22 | ActiveRecord::Migration::CommandRecorder.include Scenic::CommandRecorder 23 | ActiveRecord::SchemaDumper.prepend Scenic::SchemaDumper 24 | end 25 | 26 | # The current database adapter used by Scenic. 27 | # 28 | # This defaults to {Adapters::Postgres} but can be overridden 29 | # via {Configuration}. 30 | def self.database 31 | configuration.database 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/scenic/adapters/postgres.rb: -------------------------------------------------------------------------------- 1 | require_relative "postgres/connection" 2 | require_relative "postgres/errors" 3 | require_relative "postgres/index_reapplication" 4 | require_relative "postgres/indexes" 5 | require_relative "postgres/views" 6 | require_relative "postgres/refresh_dependencies" 7 | require_relative "postgres/side_by_side" 8 | require_relative "postgres/index_creation" 9 | require_relative "postgres/index_migration" 10 | require_relative "postgres/temporary_name" 11 | 12 | module Scenic 13 | # Scenic database adapters. 14 | # 15 | # Scenic ships with a Postgres adapter only but can be extended with 16 | # additional adapters. The {Adapters::Postgres} adapter provides the 17 | # interface. 18 | module Adapters 19 | # An adapter for managing Postgres views. 20 | # 21 | # These methods are used internally by Scenic and are not intended for direct 22 | # use. Methods that alter database schema are intended to be called via 23 | # {Statements}, while {#refresh_materialized_view} is called via 24 | # {Scenic.database}. 25 | # 26 | # The methods are documented here for insight into specifics of how Scenic 27 | # integrates with Postgres and the responsibilities of {Adapters}. 28 | class Postgres 29 | # Creates an instance of the Scenic Postgres adapter. 30 | # 31 | # This is the default adapter for Scenic. Configuring it via 32 | # {Scenic.configure} is not required, but the example below shows how one 33 | # would explicitly set it. 34 | # 35 | # @param [#connection] connectable An object that returns the connection 36 | # for Scenic to use. Defaults to `ActiveRecord::Base`. 37 | # 38 | # @example 39 | # Scenic.configure do |config| 40 | # config.database = Scenic::Adapters::Postgres.new 41 | # end 42 | def initialize(connectable = ActiveRecord::Base) 43 | @connectable = connectable 44 | end 45 | 46 | # Returns an array of views in the database. 47 | # 48 | # This collection of views is used by the [Scenic::SchemaDumper] to 49 | # populate the `schema.rb` file. 50 | # 51 | # @return [Array] 52 | def views 53 | Views.new(connection).all 54 | end 55 | 56 | # Creates a view in the database. 57 | # 58 | # This is typically called in a migration via {Statements#create_view}. 59 | # 60 | # @param name The name of the view to create 61 | # @param sql_definition The SQL schema for the view. 62 | # 63 | # @return [void] 64 | def create_view(name, sql_definition) 65 | execute "CREATE VIEW #{quote_table_name(name)} AS #{sql_definition};" 66 | end 67 | 68 | # Updates a view in the database. 69 | # 70 | # This results in a {#drop_view} followed by a {#create_view}. The 71 | # explicitness of that two step process is preferred to `CREATE OR 72 | # REPLACE VIEW` because the former ensures that the view you are trying to 73 | # update did, in fact, already exist. Additionally, `CREATE OR REPLACE 74 | # VIEW` is allowed only to add new columns to the end of an existing 75 | # view schema. Existing columns cannot be re-ordered, removed, or have 76 | # their types changed. Drop and create overcomes this limitation as well. 77 | # 78 | # This is typically called in a migration via {Statements#update_view}. 79 | # 80 | # @param name The name of the view to update 81 | # @param sql_definition The SQL schema for the updated view. 82 | # 83 | # @return [void] 84 | def update_view(name, sql_definition) 85 | drop_view(name) 86 | create_view(name, sql_definition) 87 | end 88 | 89 | # Replaces a view in the database using `CREATE OR REPLACE VIEW`. 90 | # 91 | # This results in a `CREATE OR REPLACE VIEW`. Most of the time the 92 | # explicitness of the two step process used in {#update_view} is preferred 93 | # to `CREATE OR REPLACE VIEW` because the former ensures that the view you 94 | # are trying to update did, in fact, already exist. Additionally, 95 | # `CREATE OR REPLACE VIEW` is allowed only to add new columns to the end 96 | # of an existing view schema. Existing columns cannot be re-ordered, 97 | # removed, or have their types changed. Drop and create overcomes this 98 | # limitation as well. 99 | # 100 | # However, when there is a tangled dependency tree 101 | # `CREATE OR REPLACE VIEW` can be preferable. 102 | # 103 | # This is typically called in a migration via 104 | # {Statements#replace_view}. 105 | # 106 | # @param name The name of the view to update 107 | # @param sql_definition The SQL schema for the updated view. 108 | # 109 | # @return [void] 110 | def replace_view(name, sql_definition) 111 | execute "CREATE OR REPLACE VIEW #{quote_table_name(name)} AS #{sql_definition};" 112 | end 113 | 114 | # Drops the named view from the database 115 | # 116 | # This is typically called in a migration via {Statements#drop_view}. 117 | # 118 | # @param name The name of the view to drop 119 | # 120 | # @return [void] 121 | def drop_view(name) 122 | execute "DROP VIEW #{quote_table_name(name)};" 123 | end 124 | 125 | # Creates a materialized view in the database 126 | # 127 | # @param name The name of the materialized view to create 128 | # @param sql_definition The SQL schema that defines the materialized view. 129 | # @param no_data [Boolean] Default: false. Set to true to create 130 | # materialized view without running the associated query. You will need 131 | # to perform a refresh to populate with data. 132 | # 133 | # This is typically called in a migration via {Statements#create_view}. 134 | # 135 | # @raise [MaterializedViewsNotSupportedError] if the version of Postgres 136 | # in use does not support materialized views. 137 | # 138 | # @return [void] 139 | def create_materialized_view(name, sql_definition, no_data: false) 140 | raise_unless_materialized_views_supported 141 | 142 | execute <<-SQL 143 | CREATE MATERIALIZED VIEW #{quote_table_name(name)} AS 144 | #{sql_definition.rstrip.chomp(";")} 145 | #{"WITH NO DATA" if no_data}; 146 | SQL 147 | end 148 | 149 | # Updates a materialized view in the database. 150 | # 151 | # Drops and recreates the materialized view. Attempts to maintain all 152 | # previously existing and still applicable indexes on the materialized 153 | # view after the view is recreated. 154 | # 155 | # This is typically called in a migration via {Statements#update_view}. 156 | # 157 | # @param name The name of the view to update 158 | # @param sql_definition The SQL schema for the updated view. 159 | # @param no_data [Boolean] Default: false. Set to true to create 160 | # materialized view without running the associated query. You will need 161 | # to perform a refresh to populate with data. 162 | # @param side_by_side [Boolean] Default: false. Set to true to create the 163 | # new version under a different name and atomically swap them, limiting 164 | # the time that a view is inaccessible at the cost of doubling disk usage 165 | # 166 | # @raise [MaterializedViewsNotSupportedError] if the version of Postgres 167 | # in use does not support materialized views. 168 | # 169 | # @return [void] 170 | def update_materialized_view(name, sql_definition, no_data: false, side_by_side: false) 171 | raise_unless_materialized_views_supported 172 | 173 | if side_by_side 174 | SideBySide 175 | .new(adapter: self, name: name, definition: sql_definition) 176 | .update 177 | else 178 | IndexReapplication.new(connection: connection).on(name) do 179 | drop_materialized_view(name) 180 | create_materialized_view(name, sql_definition, no_data: no_data) 181 | end 182 | end 183 | end 184 | 185 | # Drops a materialized view in the database 186 | # 187 | # This is typically called in a migration via {Statements#update_view}. 188 | # 189 | # @param name The name of the materialized view to drop. 190 | # @raise [MaterializedViewsNotSupportedError] if the version of Postgres 191 | # in use does not support materialized views. 192 | # 193 | # @return [void] 194 | def drop_materialized_view(name) 195 | raise_unless_materialized_views_supported 196 | execute "DROP MATERIALIZED VIEW #{quote_table_name(name)};" 197 | end 198 | 199 | # Refreshes a materialized view from its SQL schema. 200 | # 201 | # This is typically called from application code via {Scenic.database}. 202 | # 203 | # @param name The name of the materialized view to refresh. 204 | # @param concurrently [Boolean] Whether the refreshs hould happen 205 | # concurrently or not. A concurrent refresh allows the view to be 206 | # refreshed without locking the view for select but requires that the 207 | # table have at least one unique index that covers all rows. Attempts to 208 | # refresh concurrently without a unique index will raise a descriptive 209 | # error. This option is ignored if the view is not populated, as it 210 | # would cause an error to be raised by Postgres. Default: false. 211 | # @param cascade [Boolean] Whether to refresh dependent materialized 212 | # views. Default: false. 213 | # 214 | # @raise [MaterializedViewsNotSupportedError] if the version of Postgres 215 | # in use does not support materialized views. 216 | # @raise [ConcurrentRefreshesNotSupportedError] when attempting a 217 | # concurrent refresh on version of Postgres that does not support 218 | # concurrent materialized view refreshes. 219 | # 220 | # @example Non-concurrent refresh 221 | # Scenic.database.refresh_materialized_view(:search_results) 222 | # @example Concurrent refresh 223 | # Scenic.database.refresh_materialized_view(:posts, concurrently: true) 224 | # @example Cascade refresh 225 | # Scenic.database.refresh_materialized_view(:posts, cascade: true) 226 | # 227 | # @return [void] 228 | def refresh_materialized_view(name, concurrently: false, cascade: false) 229 | raise_unless_materialized_views_supported 230 | 231 | if concurrently 232 | raise_unless_concurrent_refresh_supported 233 | end 234 | 235 | if cascade 236 | refresh_dependencies_for(name, concurrently: concurrently) 237 | end 238 | 239 | if concurrently && populated?(name) 240 | execute "REFRESH MATERIALIZED VIEW CONCURRENTLY #{quote_table_name(name)};" 241 | else 242 | execute "REFRESH MATERIALIZED VIEW #{quote_table_name(name)};" 243 | end 244 | end 245 | 246 | # True if supplied relation name is populated. 247 | # 248 | # @param name The name of the relation 249 | # 250 | # @raise [MaterializedViewsNotSupportedError] if the version of Postgres 251 | # in use does not support materialized views. 252 | # 253 | # @return [boolean] 254 | def populated?(name) 255 | raise_unless_materialized_views_supported 256 | 257 | schemaless_name = name.to_s.split(".").last 258 | 259 | sql = "SELECT relispopulated FROM pg_class WHERE relname = '#{schemaless_name}'" 260 | relations = execute(sql) 261 | 262 | if relations.count.positive? 263 | relations.first["relispopulated"].in?(["t", true]) 264 | else 265 | false 266 | end 267 | end 268 | 269 | # A decorated ActiveRecord connection object with some Scenic-specific 270 | # methods. Not intended for direct use outside of the Postgres adapter. 271 | # 272 | # @api private 273 | def connection 274 | Connection.new(connectable.connection) 275 | end 276 | 277 | private 278 | 279 | attr_reader :connectable 280 | delegate :execute, :quote_table_name, to: :connection 281 | 282 | def raise_unless_materialized_views_supported 283 | unless connection.supports_materialized_views? 284 | raise MaterializedViewsNotSupportedError 285 | end 286 | end 287 | 288 | def raise_unless_concurrent_refresh_supported 289 | unless connection.supports_concurrent_refreshes? 290 | raise ConcurrentRefreshesNotSupportedError 291 | end 292 | end 293 | 294 | def refresh_dependencies_for(name, concurrently: false) 295 | Scenic::Adapters::Postgres::RefreshDependencies.call( 296 | name, 297 | self, 298 | connection, 299 | concurrently: concurrently 300 | ) 301 | end 302 | end 303 | end 304 | end 305 | -------------------------------------------------------------------------------- /lib/scenic/adapters/postgres/connection.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | module Adapters 3 | class Postgres 4 | # Decorates an ActiveRecord connection with methods that help determine 5 | # the connections capabilities. 6 | # 7 | # Every attempt is made to use the versions of these methods defined by 8 | # Rails where they are available and public before falling back to our own 9 | # implementations for older Rails versions. 10 | # 11 | # @api private 12 | class Connection < SimpleDelegator 13 | # True if the connection supports materialized views. 14 | # 15 | # Delegates to the method of the same name if it is already defined on 16 | # the connection. This is the case for Rails 4.2 or higher. 17 | # 18 | # @return [Boolean] 19 | def supports_materialized_views? 20 | if undecorated_connection.respond_to?(:supports_materialized_views?) 21 | super 22 | else 23 | postgresql_version >= 90300 24 | end 25 | end 26 | 27 | # True if the connection supports concurrent refreshes of materialized 28 | # views. 29 | # 30 | # @return [Boolean] 31 | def supports_concurrent_refreshes? 32 | postgresql_version >= 90400 33 | end 34 | 35 | # An integer representing the version of Postgres we're connected to. 36 | # 37 | # postgresql_version is public in Rails 5, but protected in earlier 38 | # versions. 39 | # 40 | # @return [Integer] 41 | def postgresql_version 42 | if undecorated_connection.respond_to?(:postgresql_version) 43 | super 44 | else 45 | undecorated_connection.send(:postgresql_version) 46 | end 47 | end 48 | 49 | private 50 | 51 | def undecorated_connection 52 | __getobj__ 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/scenic/adapters/postgres/errors.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | module Adapters 3 | class Postgres 4 | # Raised when a materialized view operation is attempted on a database 5 | # version that does not support materialized views. 6 | # 7 | # Materialized views are supported on Postgres 9.3 or newer. 8 | class MaterializedViewsNotSupportedError < StandardError 9 | def initialize 10 | super("Materialized views require Postgres 9.3 or newer") 11 | end 12 | end 13 | 14 | # Raised when attempting a concurrent materialized view refresh on a 15 | # database version that does not support that. 16 | # 17 | # Concurrent materialized view refreshes are supported on Postgres 9.4 or 18 | # newer. 19 | class ConcurrentRefreshesNotSupportedError < StandardError 20 | def initialize 21 | super("Concurrent materialized view refreshes require Postgres 9.4 or newer") 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/scenic/adapters/postgres/index_creation.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | module Adapters 3 | class Postgres 4 | # Used to resiliently create indexes on a materialized view. If the index 5 | # cannot be applied to the view (e.g. the columns don't exist any longer), 6 | # we log that information and continue rather than raising an error. It is 7 | # left to the user to judge whether the index is necessary and recreate 8 | # it. 9 | # 10 | # Used when updating a materialized view to ensure the new version has all 11 | # apprioriate indexes. 12 | # 13 | # @api private 14 | class IndexCreation 15 | # Creates the index creation object. 16 | # 17 | # @param connection [Connection] The connection to execute SQL against. 18 | # @param speaker [#say] (ActiveRecord::Migration) The object used for 19 | # logging the results of creating indexes. 20 | def initialize(connection:, speaker: ActiveRecord::Migration.new) 21 | @connection = connection 22 | @speaker = speaker 23 | end 24 | 25 | # Creates the provided indexes. If an index cannot be created, it is 26 | # logged and the process continues. 27 | # 28 | # @param indexes [Array] The indexes to create. 29 | # 30 | # @return [void] 31 | def try_create(indexes) 32 | Array(indexes).each(&method(:try_index_create)) 33 | end 34 | 35 | private 36 | 37 | attr_reader :connection, :speaker 38 | 39 | def try_index_create(index) 40 | success = with_savepoint(index.index_name) do 41 | connection.execute(index.definition) 42 | end 43 | 44 | if success 45 | say "index '#{index.index_name}' on '#{index.object_name}' has been created" 46 | else 47 | say "index '#{index.index_name}' on '#{index.object_name}' is no longer valid and has been dropped." 48 | end 49 | end 50 | 51 | def with_savepoint(name) 52 | connection.execute("SAVEPOINT #{name}") 53 | yield 54 | connection.execute("RELEASE SAVEPOINT #{name}") 55 | true 56 | rescue 57 | connection.execute("ROLLBACK TO SAVEPOINT #{name}") 58 | false 59 | end 60 | 61 | def say(message) 62 | subitem = true 63 | speaker.say(message, subitem) 64 | end 65 | end 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /lib/scenic/adapters/postgres/index_migration.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | module Adapters 3 | class Postgres 4 | # Used during side-by-side materialized view updates to migrate indexes 5 | # from the original view to the new view. 6 | # 7 | # @api private 8 | class IndexMigration 9 | # Creates the index migration object. 10 | # 11 | # @param connection [Connection] The connection to execute SQL against. 12 | # @param speaker [#say] (ActiveRecord::Migration) The object used for 13 | # logging the results of migrating indexes. 14 | def initialize(connection:, speaker: ActiveRecord::Migration.new) 15 | @connection = connection 16 | @speaker = speaker 17 | end 18 | 19 | # Retreives the indexes on the original view, renames them to avoid 20 | # collisions, retargets the indexes to the destination view, and then 21 | # creates the retargeted indexes. 22 | # 23 | # @param from [String] The name of the original view. 24 | # @param to [String] The name of the destination view. 25 | # 26 | # @return [void] 27 | def migrate(from:, to:) 28 | source_indexes = Indexes.new(connection: connection).on(from) 29 | retargeted_indexes = source_indexes.map { |i| retarget(i, to: to) } 30 | source_indexes.each(&method(:rename)) 31 | 32 | if source_indexes.any? 33 | say "indexes on '#{from}' have been renamed to avoid collisions" 34 | end 35 | 36 | IndexCreation 37 | .new(connection: connection, speaker: speaker) 38 | .try_create(retargeted_indexes) 39 | end 40 | 41 | private 42 | 43 | attr_reader :connection, :speaker 44 | 45 | def retarget(index, to:) 46 | new_definition = index.definition.sub( 47 | /ON (.*)\.#{index.object_name}/, 48 | 'ON \1.' + to + " " 49 | ) 50 | 51 | Scenic::Index.new( 52 | object_name: to, 53 | index_name: index.index_name, 54 | definition: new_definition 55 | ) 56 | end 57 | 58 | def rename(index) 59 | temporary_name = TemporaryName.new(index.index_name).to_s 60 | connection.rename_index(index.object_name, index.index_name, temporary_name) 61 | end 62 | 63 | def say(message) 64 | subitem = true 65 | speaker.say(message, subitem) 66 | end 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/scenic/adapters/postgres/index_reapplication.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | module Adapters 3 | class Postgres 4 | # Updating a materialized view causes the view to be dropped and 5 | # recreated. This causes any associated indexes to be dropped as well. 6 | # This object can be used to capture the existing indexes before the drop 7 | # and then reapply appropriate indexes following the create. 8 | # 9 | # @api private 10 | class IndexReapplication 11 | # Creates the index reapplication object. 12 | # 13 | # @param connection [Connection] The connection to execute SQL against. 14 | # @param speaker [#say] (ActiveRecord::Migration) The object used for 15 | # logging the results of reapplying indexes. 16 | def initialize(connection:, speaker: ActiveRecord::Migration.new) 17 | @connection = connection 18 | @speaker = speaker 19 | end 20 | 21 | # Caches indexes on the provided object before executing the block and 22 | # then reapplying the indexes. Each recreated or skipped index is 23 | # announced to STDOUT by default. This can be overridden in the 24 | # constructor. 25 | # 26 | # @param name The name of the object we are reapplying indexes on. 27 | # @yield Operations to perform before reapplying indexes. 28 | # 29 | # @return [void] 30 | def on(name) 31 | indexes = Indexes.new(connection: connection).on(name) 32 | 33 | yield 34 | 35 | IndexCreation 36 | .new(connection: connection, speaker: speaker) 37 | .try_create(indexes) 38 | end 39 | 40 | private 41 | 42 | attr_reader :connection, :speaker 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/scenic/adapters/postgres/indexes.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | module Adapters 3 | class Postgres 4 | # Fetches indexes on objects from the Postgres connection. 5 | # 6 | # @api private 7 | class Indexes 8 | def initialize(connection:) 9 | @connection = connection 10 | end 11 | 12 | # Indexes on the provided object. 13 | # 14 | # @param name [String] The name of the object we want indexes from. 15 | # @return [Array] 16 | def on(name) 17 | indexes_on(name).map(&method(:index_from_database)) 18 | end 19 | 20 | private 21 | 22 | attr_reader :connection 23 | delegate :quote_table_name, to: :connection 24 | 25 | def indexes_on(name) 26 | connection.execute(<<-SQL) 27 | SELECT 28 | t.relname as object_name, 29 | i.relname as index_name, 30 | pg_get_indexdef(d.indexrelid) AS definition 31 | FROM pg_class t 32 | INNER JOIN pg_index d ON t.oid = d.indrelid 33 | INNER JOIN pg_class i ON d.indexrelid = i.oid 34 | LEFT JOIN pg_namespace n ON n.oid = i.relnamespace 35 | WHERE i.relkind = 'i' 36 | AND d.indisprimary = 'f' 37 | AND t.relname = '#{name}' 38 | AND n.nspname = ANY (current_schemas(false)) 39 | ORDER BY i.relname 40 | SQL 41 | end 42 | 43 | def index_from_database(result) 44 | Scenic::Index.new( 45 | object_name: result["object_name"], 46 | index_name: result["index_name"], 47 | definition: result["definition"] 48 | ) 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/scenic/adapters/postgres/refresh_dependencies.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | module Adapters 3 | class Postgres 4 | class RefreshDependencies 5 | def self.call(name, adapter, connection, concurrently: false) 6 | new(name, adapter, connection, concurrently: concurrently).call 7 | end 8 | 9 | def initialize(name, adapter, connection, concurrently:) 10 | @name = name 11 | @adapter = adapter 12 | @connection = connection 13 | @concurrently = concurrently 14 | end 15 | 16 | def call 17 | dependencies.each do |dependency| 18 | adapter.refresh_materialized_view( 19 | dependency, 20 | concurrently: concurrently 21 | ) 22 | end 23 | end 24 | 25 | private 26 | 27 | attr_reader :name, :adapter, :connection, :concurrently 28 | 29 | class DependencyParser 30 | def initialize(raw_dependencies, view_to_refresh) 31 | @raw_dependencies = raw_dependencies 32 | @view_to_refresh = view_to_refresh 33 | end 34 | 35 | # We're given an array from the SQL query that looks kind of like this 36 | # [["view_name", "{'dependency_1', 'dependency_2'}"]] 37 | # 38 | # We need to parse that into a more easy to understand data type so we 39 | # can use the Tsort module from the Standard Library to topologically 40 | # sort those out so we can refresh in the correct order, so we parse 41 | # that raw data into a hash. 42 | # 43 | # Then, once Tsort has worked it magic, we're given a sorted 1-D array 44 | # ["dependency_1", "dependency_2", "view_name"] 45 | # 46 | # So we then need to slice off just the bit leading up to the view 47 | # that we're refreshing, so we find where in the topologically sorted 48 | # array our given view is, and return all the dependencies up to that 49 | # point. 50 | def to_sorted_array 51 | dependency_hash = parse_to_hash(raw_dependencies) 52 | sorted_arr = tsort(dependency_hash) 53 | 54 | idx = sorted_arr.find_index do |dep| 55 | if view_to_refresh.to_s.include?(".") 56 | dep == view_to_refresh.to_s 57 | else 58 | dep.ends_with?(".#{view_to_refresh}") 59 | end 60 | end 61 | 62 | if idx.present? 63 | sorted_arr[0...idx] 64 | else 65 | [] 66 | end 67 | end 68 | 69 | private 70 | 71 | attr_reader :raw_dependencies, :view_to_refresh 72 | 73 | def parse_to_hash(dependency_rows) 74 | dependency_rows.each_with_object({}) do |row, hash| 75 | formatted_dependencies = row.last.tr("{}", "").split(",") 76 | formatted_dependencies.each do |dependency| 77 | hash[dependency] = [] unless hash[dependency] 78 | end 79 | hash[row.first] = formatted_dependencies 80 | end 81 | end 82 | 83 | def tsort(hash) 84 | each_node = lambda { |&b| hash.each_key(&b) } 85 | each_child = lambda { |n, &b| hash[n].each(&b) } 86 | TSort.tsort(each_node, each_child) 87 | end 88 | end 89 | 90 | DEPENDENCY_SQL = <<-SQL.freeze 91 | SELECT rewrite_namespace.nspname || '.' || class_for_rewrite.relname AS materialized_view, 92 | array_agg(depend_namespace.nspname || '.' || class_for_depend.relname) AS depends_on 93 | FROM pg_rewrite AS rewrite 94 | JOIN pg_class AS class_for_rewrite ON rewrite.ev_class = class_for_rewrite.oid 95 | JOIN pg_depend AS depend ON rewrite.oid = depend.objid 96 | JOIN pg_class AS class_for_depend ON depend.refobjid = class_for_depend.oid 97 | JOIN pg_namespace AS rewrite_namespace ON rewrite_namespace.oid = class_for_rewrite.relnamespace 98 | JOIN pg_namespace AS depend_namespace ON depend_namespace.oid = class_for_depend.relnamespace 99 | WHERE class_for_depend.relkind = 'm' 100 | AND class_for_rewrite.relkind = 'm' 101 | AND class_for_depend.relname != class_for_rewrite.relname 102 | GROUP BY class_for_rewrite.relname, rewrite_namespace.nspname 103 | ORDER BY class_for_rewrite.relname; 104 | SQL 105 | 106 | private_constant :DependencyParser 107 | private_constant :DEPENDENCY_SQL 108 | 109 | def dependencies 110 | raw_dependency_info = connection.select_rows(DEPENDENCY_SQL) 111 | DependencyParser.new(raw_dependency_info, name).to_sorted_array 112 | end 113 | end 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/scenic/adapters/postgres/side_by_side.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | module Adapters 3 | class Postgres 4 | # Updates a view using the `side-by-side` strategy where the new view is 5 | # created and populated under a temporary name before the existing view is 6 | # dropped and the temporary view is renamed to the original name. 7 | class SideBySide 8 | def initialize(adapter:, name:, definition:, speaker: ActiveRecord::Migration.new) 9 | @adapter = adapter 10 | @name = name 11 | @definition = definition 12 | @temporary_name = TemporaryName.new(name).to_s 13 | @speaker = speaker 14 | end 15 | 16 | def update 17 | adapter.create_materialized_view(temporary_name, definition) 18 | say "temporary materialized view '#{temporary_name}' has been created" 19 | 20 | IndexMigration 21 | .new(connection: adapter.connection, speaker: speaker) 22 | .migrate(from: name, to: temporary_name) 23 | 24 | adapter.drop_materialized_view(name) 25 | say "materialized view '#{name}' has been dropped" 26 | 27 | rename_materialized_view(temporary_name, name) 28 | say "temporary materialized view '#{temporary_name}' has been renamed to '#{name}'" 29 | end 30 | 31 | private 32 | 33 | attr_reader :adapter, :name, :definition, :temporary_name, :speaker 34 | 35 | def connection 36 | adapter.connection 37 | end 38 | 39 | def rename_materialized_view(from, to) 40 | connection.execute("ALTER MATERIALIZED VIEW #{from} RENAME TO #{to}") 41 | end 42 | 43 | def say(message) 44 | subitem = true 45 | speaker.say(message, subitem) 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/scenic/adapters/postgres/temporary_name.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | module Adapters 3 | class Postgres 4 | # Generates a temporary object name used internally by Scenic. This is 5 | # used during side-by-side materialized view updates to avoid naming 6 | # collisions. The generated name is based on a SHA1 hash of the original 7 | # which ensures we do not exceed the 63 character limit for object names. 8 | # 9 | # @api private 10 | class TemporaryName 11 | # The prefix used for all temporary names. 12 | PREFIX = "_scenic_sbs_".freeze 13 | 14 | # Creates a new temporary name object. 15 | # 16 | # @param name [String] The original name to base the temporary name on. 17 | def initialize(name) 18 | @name = name 19 | @salt = SecureRandom.hex(4) 20 | @temporary_name = "#{PREFIX}#{Digest::SHA1.hexdigest(name + salt)}" 21 | end 22 | 23 | # @return [String] The temporary name. 24 | def to_s 25 | temporary_name 26 | end 27 | 28 | private 29 | 30 | attr_reader :name, :temporary_name, :salt 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/scenic/adapters/postgres/views.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | module Adapters 3 | class Postgres 4 | # Fetches defined views from the postgres connection. 5 | # @api private 6 | class Views 7 | def initialize(connection) 8 | @connection = connection 9 | end 10 | 11 | # All of the views that this connection has defined, sorted according to 12 | # dependencies between the views to facilitate dumping and loading. 13 | # 14 | # This will include materialized views if those are supported by the 15 | # connection. 16 | # 17 | # @return [Array] 18 | def all 19 | scenic_views = views_from_postgres.map(&method(:to_scenic_view)) 20 | sort(scenic_views) 21 | end 22 | 23 | private 24 | 25 | def sort(scenic_views) 26 | scenic_view_names = scenic_views.map(&:name) 27 | 28 | tsorted_views(scenic_view_names).map do |view_name| 29 | scenic_views.find do |sv| 30 | sv.name == view_name || sv.name == view_name.split(".").last 31 | end 32 | end.compact 33 | end 34 | 35 | # When dumping the views, their order must be topologically 36 | # sorted to take into account dependencies 37 | def tsorted_views(views_names) 38 | views_hash = TSortableHash.new 39 | 40 | ::Scenic.database.execute(DEPENDENT_SQL).each do |relation| 41 | source_v = [ 42 | relation["source_schema"], 43 | relation["source_table"] 44 | ].compact.join(".") 45 | 46 | dependent = [ 47 | relation["dependent_schema"], 48 | relation["dependent_view"] 49 | ].compact.join(".") 50 | 51 | views_hash[dependent] ||= [] 52 | views_hash[source_v] ||= [] 53 | views_hash[dependent] << source_v 54 | 55 | views_names.delete(relation["source_table"]) 56 | views_names.delete(relation["dependent_view"]) 57 | end 58 | 59 | # after dependencies, there might be some views left 60 | # that don't have any dependencies 61 | views_names.sort.each { |v| views_hash[v] ||= [] } 62 | views_hash.tsort 63 | end 64 | 65 | attr_reader :connection 66 | 67 | # Query for the dependencies between views 68 | DEPENDENT_SQL = <<~SQL.freeze 69 | SELECT distinct dependent_ns.nspname AS dependent_schema 70 | , dependent_view.relname AS dependent_view 71 | , source_ns.nspname AS source_schema 72 | , source_table.relname AS source_table 73 | FROM pg_depend 74 | JOIN pg_rewrite ON pg_depend.objid = pg_rewrite.oid 75 | JOIN pg_class as dependent_view ON pg_rewrite.ev_class = dependent_view.oid 76 | JOIN pg_class as source_table ON pg_depend.refobjid = source_table.oid 77 | JOIN pg_namespace dependent_ns ON dependent_ns.oid = dependent_view.relnamespace 78 | JOIN pg_namespace source_ns ON source_ns.oid = source_table.relnamespace 79 | WHERE dependent_ns.nspname = ANY (current_schemas(false)) AND source_ns.nspname = ANY (current_schemas(false)) 80 | AND source_table.relname != dependent_view.relname 81 | AND source_table.relkind IN ('m', 'v') AND dependent_view.relkind IN ('m', 'v') 82 | ORDER BY dependent_view.relname; 83 | SQL 84 | private_constant :DEPENDENT_SQL 85 | 86 | class TSortableHash < Hash 87 | include TSort 88 | 89 | alias_method :tsort_each_node, :each_key 90 | def tsort_each_child(node, &) 91 | fetch(node).each(&) 92 | end 93 | end 94 | private_constant :TSortableHash 95 | 96 | def views_from_postgres 97 | connection.execute(<<-SQL) 98 | SELECT 99 | c.relname as viewname, 100 | pg_get_viewdef(c.oid) AS definition, 101 | c.relkind AS kind, 102 | n.nspname AS namespace 103 | FROM pg_class c 104 | LEFT JOIN pg_namespace n ON n.oid = c.relnamespace 105 | WHERE 106 | c.relkind IN ('m', 'v') 107 | AND c.relname NOT IN (SELECT extname FROM pg_extension) 108 | AND c.relname != 'pg_stat_statements_info' 109 | AND n.nspname = ANY (current_schemas(false)) 110 | ORDER BY c.oid 111 | SQL 112 | end 113 | 114 | def to_scenic_view(result) 115 | Scenic::View.new( 116 | name: namespaced_view_name(result), 117 | definition: result["definition"].strip, 118 | materialized: result["kind"] == "m" 119 | ) 120 | end 121 | 122 | def namespaced_view_name(result) 123 | namespace, viewname = result.values_at("namespace", "viewname") 124 | 125 | if namespace != "public" 126 | "#{pg_identifier(namespace)}.#{pg_identifier(viewname)}" 127 | else 128 | pg_identifier(viewname) 129 | end 130 | end 131 | 132 | def pg_identifier(name) 133 | return name if /^[a-zA-Z_][a-zA-Z0-9_]*$/.match?(name) 134 | 135 | pgconn.quote_ident(name) 136 | end 137 | 138 | def pgconn 139 | if defined?(PG::Connection) 140 | PG::Connection 141 | else 142 | PGconn 143 | end 144 | end 145 | end 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /lib/scenic/command_recorder.rb: -------------------------------------------------------------------------------- 1 | require "scenic/command_recorder/statement_arguments" 2 | 3 | module Scenic 4 | # @api private 5 | module CommandRecorder 6 | def create_view(*args) 7 | record(:create_view, args) 8 | end 9 | ruby2_keywords :create_view if respond_to?(:ruby2_keywords, true) 10 | 11 | def drop_view(*args) 12 | record(:drop_view, args) 13 | end 14 | ruby2_keywords :drop_view if respond_to?(:ruby2_keywords, true) 15 | 16 | def update_view(*args) 17 | record(:update_view, args) 18 | end 19 | ruby2_keywords :update_view if respond_to?(:ruby2_keywords, true) 20 | 21 | def replace_view(*args) 22 | record(:replace_view, args) 23 | end 24 | ruby2_keywords :replace_view if respond_to?(:ruby2_keywords, true) 25 | 26 | def invert_create_view(args) 27 | drop_view_args = StatementArguments.new(args).remove_version.to_a 28 | [:drop_view, drop_view_args] 29 | end 30 | 31 | def invert_drop_view(args) 32 | perform_scenic_inversion(:create_view, args) 33 | end 34 | 35 | def invert_update_view(args) 36 | perform_scenic_inversion(:update_view, args) 37 | end 38 | 39 | def invert_replace_view(args) 40 | perform_scenic_inversion(:replace_view, args) 41 | end 42 | 43 | private 44 | 45 | def perform_scenic_inversion(method, args) 46 | scenic_args = StatementArguments.new(args) 47 | 48 | if scenic_args.revert_to_version.nil? 49 | message = "#{method} is reversible only if given a revert_to_version" 50 | raise ActiveRecord::IrreversibleMigration, message 51 | end 52 | 53 | [method, scenic_args.invert_version.to_a] 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/scenic/command_recorder/statement_arguments.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | module CommandRecorder 3 | # @api private 4 | class StatementArguments 5 | def initialize(args) 6 | @args = args.freeze 7 | end 8 | 9 | def view 10 | @args[0] 11 | end 12 | 13 | def version 14 | options[:version] 15 | end 16 | 17 | def revert_to_version 18 | options[:revert_to_version] 19 | end 20 | 21 | def invert_version 22 | StatementArguments.new([view, options_for_revert]) 23 | end 24 | 25 | def remove_version 26 | StatementArguments.new([view, options_without_version]) 27 | end 28 | 29 | def to_a 30 | @args.to_a.dup.delete_if(&:empty?) 31 | end 32 | 33 | private 34 | 35 | def options 36 | @options ||= @args[1] || {} 37 | end 38 | 39 | def keyword_hash(hash) 40 | if Hash.respond_to? :ruby2_keywords_hash 41 | Hash.ruby2_keywords_hash(hash) 42 | else 43 | hash 44 | end 45 | end 46 | 47 | def options_for_revert 48 | opts = options.clone.tap do |revert_options| 49 | revert_options[:version] = revert_to_version 50 | revert_options.delete(:revert_to_version) 51 | end 52 | 53 | keyword_hash(opts) 54 | end 55 | 56 | def options_without_version 57 | keyword_hash(options.except(:version)) 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/scenic/configuration.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | class Configuration 3 | # The Scenic database adapter instance to use when executing SQL. 4 | # 5 | # Defaults to an instance of {Adapters::Postgres} 6 | # @return Scenic adapter 7 | attr_accessor :database 8 | 9 | def initialize 10 | @database = Scenic::Adapters::Postgres.new 11 | end 12 | end 13 | 14 | # @return [Scenic::Configuration] Scenic's current configuration 15 | def self.configuration 16 | @configuration ||= Configuration.new 17 | end 18 | 19 | # Set Scenic's configuration 20 | # 21 | # @param config [Scenic::Configuration] 22 | def self.configuration=(config) 23 | @configuration = config 24 | end 25 | 26 | # Modify Scenic's current configuration 27 | # 28 | # @yieldparam [Scenic::Configuration] config current Scenic config 29 | # ``` 30 | # Scenic.configure do |config| 31 | # config.database = Scenic::Adapters::Postgres.new 32 | # end 33 | # ``` 34 | def self.configure 35 | yield configuration 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/scenic/definition.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | # @api private 3 | class Definition 4 | def initialize(name, version) 5 | @name = name.to_s 6 | @version = version.to_i 7 | end 8 | 9 | def to_sql 10 | File.read(full_path).tap do |content| 11 | if content.empty? 12 | raise "Define view query in #{path} before migrating." 13 | end 14 | end 15 | end 16 | 17 | def full_path 18 | Rails.root.join(path) 19 | end 20 | 21 | def path 22 | File.join("db", "views", filename) 23 | end 24 | 25 | def version 26 | @version.to_s.rjust(2, "0") 27 | end 28 | 29 | private 30 | 31 | attr_reader :name 32 | 33 | def filename 34 | "#{UnaffixedName.for(name).tr(".", "_")}_v#{version}.sql" 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/scenic/index.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | # The in-memory representation of a database index. 3 | # 4 | # **This object is used internally by adapters and the schema dumper and is 5 | # not intended to be used by application code. It is documented here for 6 | # use by adapter gems.** 7 | # 8 | # @api extension 9 | class Index 10 | # The name of the object that has the index 11 | # @return [String] 12 | attr_reader :object_name 13 | 14 | # The name of the index 15 | # @return [String] 16 | attr_reader :index_name 17 | 18 | # The SQL statement that defines the index 19 | # @return [String] 20 | # 21 | # @example 22 | # "CREATE INDEX index_users_on_email ON users USING btree (email)" 23 | attr_reader :definition 24 | 25 | # Returns a new instance of Index 26 | # 27 | # @param object_name [String] The name of the object that has the index 28 | # @param index_name [String] The name of the index 29 | # @param definition [String] The SQL statements that defined the index 30 | def initialize(object_name:, index_name:, definition:) 31 | @object_name = object_name 32 | @index_name = index_name 33 | @definition = definition 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/scenic/railtie.rb: -------------------------------------------------------------------------------- 1 | require "rails/railtie" 2 | 3 | module Scenic 4 | # Automatically initializes Scenic in the context of a Rails application when 5 | # ActiveRecord is loaded. 6 | # 7 | # @see Scenic.load 8 | class Railtie < Rails::Railtie 9 | initializer "scenic.load" do 10 | ActiveSupport.on_load :active_record do 11 | Scenic.load 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/scenic/schema_dumper.rb: -------------------------------------------------------------------------------- 1 | require "rails" 2 | 3 | module Scenic 4 | # @api private 5 | module SchemaDumper 6 | def tables(stream) 7 | super 8 | views(stream) 9 | end 10 | 11 | def views(stream) 12 | if dumpable_views_in_database.any? 13 | stream.puts 14 | end 15 | 16 | dumpable_views_in_database.each do |view| 17 | stream.puts(view.to_schema) 18 | indexes(view.name, stream) 19 | end 20 | end 21 | 22 | private 23 | 24 | def dumpable_views_in_database 25 | @dumpable_views_in_database ||= Scenic.database.views.reject do |view| 26 | ignored?(view.name) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/scenic/statements.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | # Methods that are made available in migrations for managing Scenic views. 3 | module Statements 4 | # Create a new database view. 5 | # 6 | # @param name [String, Symbol] The name of the database view. 7 | # @param version [Fixnum] The version number of the view, used to find the 8 | # definition file in `db/views`. This defaults to `1` if not provided. 9 | # @param sql_definition [String] The SQL query for the view schema. An error 10 | # will be raised if `sql_definition` and `version` are both set, 11 | # as they are mutually exclusive. 12 | # @param materialized [Boolean, Hash] Set to a truthy value to create a 13 | # materialized view. Hash 14 | # @option materialized [Boolean] :no_data (false) Set to true to create 15 | # materialized view without running the associated query. You will need 16 | # to perform a non-concurrent refresh to populate with data. 17 | # @return The database response from executing the create statement. 18 | # 19 | # @example Create from `db/views/searches_v02.sql` 20 | # create_view(:searches, version: 2) 21 | # 22 | # @example Create from provided SQL string 23 | # create_view(:active_users, sql_definition: <<-SQL) 24 | # SELECT * FROM users WHERE users.active = 't' 25 | # SQL 26 | # 27 | def create_view(name, version: nil, sql_definition: nil, materialized: false) 28 | if version.present? && sql_definition.present? 29 | raise( 30 | ArgumentError, 31 | "sql_definition and version cannot both be set" 32 | ) 33 | end 34 | 35 | if version.blank? && sql_definition.blank? 36 | version = 1 37 | end 38 | 39 | sql_definition ||= definition(name, version) 40 | 41 | if materialized 42 | options = materialized_options(materialized) 43 | 44 | Scenic.database.create_materialized_view( 45 | name, 46 | sql_definition, 47 | no_data: options[:no_data] 48 | ) 49 | else 50 | Scenic.database.create_view(name, sql_definition) 51 | end 52 | end 53 | 54 | # Drop a database view by name. 55 | # 56 | # @param name [String, Symbol] The name of the database view. 57 | # @param revert_to_version [Fixnum] Used to reverse the `drop_view` command 58 | # on `rake db:rollback`. The provided version will be passed as the 59 | # `version` argument to {#create_view}. 60 | # @param materialized [Boolean] Set to true if dropping a meterialized view. 61 | # defaults to false. 62 | # @return The database response from executing the drop statement. 63 | # 64 | # @example Drop a view, rolling back to version 3 on rollback 65 | # drop_view(:users_who_recently_logged_in, revert_to_version: 3) 66 | # 67 | def drop_view(name, revert_to_version: nil, materialized: false) 68 | if materialized 69 | Scenic.database.drop_materialized_view(name) 70 | else 71 | Scenic.database.drop_view(name) 72 | end 73 | end 74 | 75 | # Update a database view to a new version. 76 | # 77 | # The existing view is dropped and recreated using the supplied `version` 78 | # parameter. 79 | # 80 | # @param name [String, Symbol] The name of the database view. 81 | # @param version [Fixnum] The version number of the view. 82 | # @param sql_definition [String] The SQL query for the view schema. An error 83 | # will be raised if `sql_definition` and `version` are both set, 84 | # as they are mutually exclusive. 85 | # @param revert_to_version [Fixnum] The version number to rollback to on 86 | # `rake db rollback` 87 | # @param materialized [Boolean, Hash] True or a Hash if updating a 88 | # materialized view. 89 | # @option materialized [Boolean] :no_data (false) Set to true to update 90 | # a materialized view without loading data. You will need to perform a 91 | # refresh to populate with data. Cannot be combined with the :side_by_side 92 | # option. 93 | # @option materialized [Boolean] :side_by_side (false) Set to true to update 94 | # update a materialized view using our side-by-side strategy, which will 95 | # limit the time the view is locked at the cost of increasing disk usage. 96 | # The view is initially updated with a temporary name and atomically 97 | # swapped once it is successfully created with data. Cannot be combined 98 | # with the :no_data option. 99 | # @return The database response from executing the create statement. 100 | # 101 | # @example 102 | # update_view :engagement_reports, version: 3, revert_to_version: 2 103 | # update_view :comments, version: 2, revert_to_version: 1, materialized: { side_by_side: true } 104 | def update_view(name, version: nil, sql_definition: nil, revert_to_version: nil, materialized: false) 105 | if version.blank? && sql_definition.blank? 106 | raise( 107 | ArgumentError, 108 | "sql_definition or version must be specified" 109 | ) 110 | end 111 | 112 | if version.present? && sql_definition.present? 113 | raise( 114 | ArgumentError, 115 | "sql_definition and version cannot both be set" 116 | ) 117 | end 118 | 119 | sql_definition ||= definition(name, version) 120 | 121 | if materialized 122 | options = materialized_options(materialized) 123 | 124 | if options[:no_data] && options[:side_by_side] 125 | raise( 126 | ArgumentError, 127 | "no_data and side_by_side options cannot be combined" 128 | ) 129 | end 130 | 131 | if options[:side_by_side] && !transaction_open? 132 | raise "a transaction is required to perform a side-by-side update" 133 | end 134 | 135 | Scenic.database.update_materialized_view( 136 | name, 137 | sql_definition, 138 | no_data: options[:no_data], 139 | side_by_side: options[:side_by_side] 140 | ) 141 | else 142 | Scenic.database.update_view(name, sql_definition) 143 | end 144 | end 145 | 146 | # Update a database view to a new version using `CREATE OR REPLACE VIEW`. 147 | # 148 | # The existing view is replaced using the supplied `version` 149 | # parameter. 150 | # 151 | # Does not work with materialized views due to lack of database support. 152 | # 153 | # @param name [String, Symbol] The name of the database view. 154 | # @param version [Fixnum] The version number of the view. 155 | # @param revert_to_version [Fixnum] The version number to rollback to on 156 | # `rake db rollback` 157 | # @return The database response from executing the create statement. 158 | # 159 | # @example 160 | # replace_view :engagement_reports, version: 3, revert_to_version: 2 161 | # 162 | def replace_view(name, version: nil, revert_to_version: nil, materialized: false) 163 | if version.blank? 164 | raise ArgumentError, "version is required" 165 | end 166 | 167 | if materialized 168 | raise ArgumentError, "Cannot replace materialized views" 169 | end 170 | 171 | sql_definition = definition(name, version) 172 | 173 | Scenic.database.replace_view(name, sql_definition) 174 | end 175 | 176 | private 177 | 178 | def definition(name, version) 179 | Scenic::Definition.new(name, version).to_sql 180 | end 181 | 182 | def materialized_options(materialized) 183 | if materialized.is_a? Hash 184 | { 185 | no_data: materialized.fetch(:no_data, false), 186 | side_by_side: materialized.fetch(:side_by_side, false) 187 | } 188 | else 189 | { 190 | no_data: false, 191 | side_by_side: false 192 | } 193 | end 194 | end 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /lib/scenic/unaffixed_name.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | # The name of a view or table according to rails. 3 | # 4 | # This removes any table name prefix or suffix that is configured via 5 | # ActiveRecord. This allows, for example, the SchemaDumper to dump a view with 6 | # its unaffixed name, consistent with how rails handles table dumping. 7 | class UnaffixedName 8 | # Gets the unaffixed name for the provided string 9 | # @return [String] 10 | # 11 | # @param name [String] The (potentially) affixed view name 12 | def self.for(name) 13 | new(name, config: ActiveRecord::Base).call 14 | end 15 | 16 | def initialize(name, config:) 17 | @name = name 18 | @config = config 19 | end 20 | 21 | def call 22 | prefix = Regexp.escape(config.table_name_prefix) 23 | suffix = Regexp.escape(config.table_name_suffix) 24 | name.sub(/\A#{prefix}(.+)#{suffix}\z/, "\\1") 25 | end 26 | 27 | private 28 | 29 | attr_reader :name, :config 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/scenic/version.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | VERSION = "1.8.0".freeze 3 | end 4 | -------------------------------------------------------------------------------- /lib/scenic/view.rb: -------------------------------------------------------------------------------- 1 | module Scenic 2 | # The in-memory representation of a view definition. 3 | # 4 | # **This object is used internally by adapters and the schema dumper and is 5 | # not intended to be used by application code. It is documented here for 6 | # use by adapter gems.** 7 | # 8 | # @api extension 9 | class View 10 | # The name of the view 11 | # @return [String] 12 | attr_reader :name 13 | 14 | # The SQL schema for the query that defines the view 15 | # @return [String] 16 | # 17 | # @example 18 | # "SELECT name, email FROM users UNION SELECT name, email FROM contacts" 19 | attr_reader :definition 20 | 21 | # True if the view is materialized 22 | # @return [Boolean] 23 | attr_reader :materialized 24 | 25 | # Returns a new instance of View. 26 | # 27 | # @param name [String] The name of the view. 28 | # @param definition [String] The SQL for the query that defines the view. 29 | # @param materialized [Boolean] `true` if the view is materialized. 30 | def initialize(name:, definition:, materialized:) 31 | @name = name 32 | @definition = definition 33 | @materialized = materialized 34 | end 35 | 36 | # @api private 37 | def ==(other) 38 | name == other.name && 39 | definition == other.definition && 40 | materialized == other.materialized 41 | end 42 | 43 | # @api private 44 | def to_schema 45 | materialized_option = materialized ? "materialized: true, " : "" 46 | 47 | <<-DEFINITION 48 | create_view #{UnaffixedName.for(name).inspect}, #{materialized_option}sql_definition: <<-\SQL 49 | #{escaped_definition.indent(2)} 50 | SQL 51 | DEFINITION 52 | end 53 | 54 | def escaped_definition 55 | definition.gsub("\\", "\\\\\\") 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /scenic.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "scenic/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "scenic" 7 | spec.version = Scenic::VERSION 8 | spec.authors = ["Derek Prior", "Caleb Hearth"] 9 | spec.email = ["derekprior@gmail.com", "caleb@calebhearth.com"] 10 | spec.summary = "Support for database views in Rails migrations" 11 | spec.description = <<-DESCRIPTION 12 | Adds methods to ActiveRecord::Migration to create and manage database views 13 | in Rails 14 | DESCRIPTION 15 | spec.homepage = "https://github.com/scenic-views/scenic" 16 | spec.license = "MIT" 17 | 18 | spec.files = `git ls-files -z`.split("\x0") 19 | spec.require_paths = ["lib"] 20 | 21 | spec.metadata = { 22 | "funding-uri" => "https://github.com/scenic-views/scenic" 23 | } 24 | 25 | spec.add_development_dependency "bundler", ">= 1.5" 26 | spec.add_development_dependency "database_cleaner" 27 | spec.add_development_dependency "rake" 28 | spec.add_development_dependency "rspec", ">= 3.3" 29 | spec.add_development_dependency "pg" 30 | spec.add_development_dependency "pry" 31 | spec.add_development_dependency "ammeter", ">= 1.1.3" 32 | spec.add_development_dependency "yard" 33 | spec.add_development_dependency "redcarpet" 34 | spec.add_development_dependency "standard" 35 | 36 | spec.add_dependency "activerecord", ">= 4.0.0" 37 | spec.add_dependency "railties", ">= 4.0.0" 38 | 39 | spec.required_ruby_version = ">= 2.3.0" 40 | end 41 | -------------------------------------------------------------------------------- /spec/acceptance/user_manages_views_spec.rb: -------------------------------------------------------------------------------- 1 | require "acceptance_helper" 2 | require "English" 3 | 4 | describe "User manages views" do 5 | it "handles simple views" do 6 | successfully "rails generate scenic:model search_result" 7 | write_definition "search_results_v01", "SELECT 'needle'::text AS term" 8 | 9 | successfully "rake db:migrate" 10 | verify_result "SearchResult.take.term", "needle" 11 | 12 | successfully "rails generate scenic:view search_results" 13 | verify_identical_view_definitions "search_results_v01", "search_results_v02" 14 | 15 | write_definition "search_results_v02", "SELECT 'haystack'::text AS term" 16 | successfully "rake db:migrate" 17 | 18 | successfully "rake db:reset" 19 | verify_result "SearchResult.take.term", "haystack" 20 | 21 | successfully "rake db:rollback" 22 | successfully "rake db:rollback" 23 | successfully "rails destroy scenic:model search_result" 24 | end 25 | 26 | it "handles materialized views" do 27 | successfully "rails generate scenic:model child --materialized" 28 | write_definition "children_v01", "SELECT 'Owen'::text AS name, 5 AS age" 29 | 30 | successfully "rake db:migrate" 31 | verify_result "Child.take.name", "Owen" 32 | 33 | add_index "children", "name" 34 | add_index "children", "age" 35 | 36 | successfully "rails runner 'Child.refresh'" 37 | 38 | successfully "rails generate scenic:view child --materialized" 39 | verify_identical_view_definitions "children_v01", "children_v02" 40 | 41 | write_definition "children_v02", "SELECT 'Elliot'::text AS name" 42 | successfully "rake db:migrate" 43 | 44 | successfully "rake db:reset" 45 | verify_result "Child.take.name", "Elliot" 46 | verify_schema_contains 'add_index "children"' 47 | 48 | successfully "rails generate scenic:view child --materialized --side-by-side" 49 | verify_identical_view_definitions "children_v02", "children_v03" 50 | 51 | write_definition "children_v03", "SELECT 'Juniper'::text AS name" 52 | successfully "rake db:migrate" 53 | 54 | successfully "rake db:reset" 55 | verify_result "Child.take.name", "Juniper" 56 | verify_schema_contains 'add_index "children"' 57 | 58 | successfully "rake db:rollback" 59 | successfully "rake db:rollback" 60 | successfully "rake db:rollback" 61 | successfully "rails destroy scenic:model child" 62 | end 63 | 64 | it "handles plural view names gracefully during generation" do 65 | successfully "rails generate scenic:model search_results --materialized" 66 | successfully "rails destroy scenic:model search_results --materialized" 67 | end 68 | 69 | def successfully(command) 70 | `RAILS_ENV=test #{command}` 71 | expect($CHILD_STATUS.exitstatus).to eq(0), "'#{command}' was unsuccessful" 72 | end 73 | 74 | def write_definition(file, contents) 75 | File.open("db/views/#{file}.sql", File::WRONLY) do |definition| 76 | definition.truncate(0) 77 | definition.write(contents) 78 | end 79 | end 80 | 81 | def verify_result(command, expected_output) 82 | successfully %{rails runner "#{command} == '#{expected_output}' || exit(1)"} 83 | end 84 | 85 | def verify_identical_view_definitions(def_a, def_b) 86 | successfully "cmp db/views/#{def_a}.sql db/views/#{def_b}.sql" 87 | end 88 | 89 | def add_index(table, column) 90 | successfully(<<-CMD.strip) 91 | rails runner 'ActiveRecord::Migration.add_index "#{table}", "#{column}"' 92 | CMD 93 | end 94 | 95 | def verify_schema_contains(statement) 96 | expect(File.readlines("db/schema.rb").grep(/#{statement}/)) 97 | .not_to be_empty, "Schema does not contain '#{statement}'" 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/acceptance_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | 3 | ENV["RAILS_ENV"] = "test" 4 | 5 | RSpec.configure do |config| 6 | config.around(:each) do |example| 7 | Dir.chdir("spec/dummy") do 8 | example.run 9 | end 10 | end 11 | 12 | config.before(:suite) do 13 | Dir.chdir("spec/dummy") do 14 | system <<-CMD 15 | git init 1>/dev/null && 16 | git add -A && 17 | git commit --no-gpg-sign --message 'initial' 1>/dev/null 18 | CMD 19 | end 20 | end 21 | 22 | config.after(:suite) do 23 | Dir.chdir("spec/dummy") do 24 | system <<-CMD 25 | echo && 26 | rake db:environment:set db:drop db:create && 27 | git add -A && 28 | git reset --hard HEAD 1>/dev/null && 29 | rm -rf .git/ 1>/dev/null 30 | CMD 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/dummy/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-journal 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/*.log 16 | /tmp 17 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path("../config/application", __FILE__) 5 | 6 | Rails.application.load_tasks 7 | 8 | unless Rake::Task.task_defined?("db:environment:set") 9 | desc "dummy task for rails versions where this task does not exist" 10 | task "db:environment:set" do 11 | # no op 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | if Rails::VERSION::STRING >= "5.0.0" 2 | class ApplicationRecord < ActiveRecord::Base 3 | self.abstract_class = true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __FILE__) 3 | load Gem.bin_path("bundler", "bundle") 4 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../../config/application", __FILE__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path("../config/environment", __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path("../boot", __FILE__) 2 | 3 | # Pick the frameworks you want: 4 | require "active_record/railtie" 5 | 6 | Bundler.require(*Rails.groups) 7 | require "scenic" 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | config.cache_classes = true 12 | config.eager_load = false 13 | config.active_support.deprecation = :stderr 14 | 15 | if config.active_support.respond_to?(:to_time_preserves_timezone) 16 | config.active_support.to_time_preserves_timezone = :zone 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../../Gemfile", __FILE__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../../lib", __FILE__) 6 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | development: &default 2 | adapter: postgresql 3 | database: dummy_development 4 | encoding: unicode 5 | host: localhost 6 | pool: 5 7 | <% if ENV.fetch("GITHUB_ACTIONS", false) || ENV.fetch("CODESPACES", false) %> 8 | username: <%= ENV.fetch("POSTGRES_USER") %> 9 | password: <%= ENV.fetch("POSTGRES_PASSWORD") %> 10 | <% end %> 11 | 12 | test: 13 | <<: *default 14 | database: dummy_test 15 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path("../application", __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scenic-views/scenic/3766dd57c1fc5ac418f5af67be7c6ad5d1c0e074/spec/dummy/db/migrate/.keep -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20220112154220_add_pg_stat_statements_extension.rb: -------------------------------------------------------------------------------- 1 | class AddPgStatStatementsExtension < ActiveRecord::Migration[6.1] 2 | def change 3 | enable_extension "pg_stat_statements" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2022_01_12_154220) do 14 | # These are extensions that must be enabled in order to support this database 15 | enable_extension "pg_stat_statements" 16 | enable_extension "plpgsql" 17 | end 18 | -------------------------------------------------------------------------------- /spec/dummy/db/views/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scenic-views/scenic/3766dd57c1fc5ac418f5af67be7c6ad5d1c0e074/spec/dummy/db/views/.keep -------------------------------------------------------------------------------- /spec/generators/scenic/model/model_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "generators/scenic/model/model_generator" 3 | 4 | module Scenic::Generators 5 | describe ModelGenerator, :generator do 6 | before do 7 | allow(ViewGenerator).to receive(:new) 8 | .and_return( 9 | instance_double("Scenic::Generators::ViewGenerator").as_null_object 10 | ) 11 | end 12 | 13 | it "invokes the view generator" do 14 | run_generator ["current_customer"] 15 | 16 | expect(ViewGenerator).to have_received(:new) 17 | end 18 | 19 | it "creates a migration to create the view" do 20 | run_generator ["current_customer"] 21 | model_definition = file("app/models/current_customer.rb") 22 | expect(model_definition).to exist 23 | expect(model_definition).to have_correct_syntax 24 | expect(model_definition).not_to contain("self.refresh") 25 | expect(model_definition).to have_correct_syntax 26 | end 27 | 28 | it "adds a refresh method to materialized models" do 29 | run_generator ["active_user", "--materialized"] 30 | model_definition = file("app/models/active_user.rb") 31 | 32 | expect(model_definition).to contain("self.refresh") 33 | expect(model_definition).to have_correct_syntax 34 | end 35 | 36 | it "adds a populated? method to materialized models" do 37 | run_generator ["active_user", "--materialized"] 38 | model_definition = file("app/models/active_user.rb") 39 | 40 | expect(model_definition).to contain("self.populated?") 41 | expect(model_definition).to have_correct_syntax 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/generators/scenic/view/view_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "generators/scenic/view/view_generator" 3 | 4 | describe Scenic::Generators::ViewGenerator, :generator do 5 | it "creates view definition and migration files" do 6 | migration = file("db/migrate/create_searches.rb") 7 | view_definition = file("db/views/searches_v01.sql") 8 | 9 | run_generator ["search"] 10 | 11 | expect(migration).to be_a_migration 12 | expect(view_definition).to exist 13 | end 14 | 15 | it "updates an existing view" do 16 | with_view_definition("searches", 1, "hello") do 17 | migration = file("db/migrate/update_searches_to_version_2.rb") 18 | view_definition = file("db/views/searches_v02.sql") 19 | allow(Dir).to receive(:entries).and_return(["searches_v01.sql"]) 20 | 21 | run_generator ["search"] 22 | 23 | expect(migration).to be_a_migration 24 | expect(view_definition).to exist 25 | end 26 | end 27 | 28 | it "adds 'materialized: true' to the migration if view is materialized" do 29 | with_view_definition("aired_episodes", 1, "hello") do 30 | allow(Dir).to receive(:entries).and_return(["aired_episodes_v01.sql"]) 31 | 32 | run_generator ["aired_episode", "--materialized"] 33 | migration = migration_file( 34 | "db/migrate/update_aired_episodes_to_version_2.rb" 35 | ) 36 | expect(migration).to contain "materialized: true" 37 | end 38 | end 39 | 40 | it "sets the no_data option when updating a materialized view" do 41 | with_view_definition("aired_episodes", 1, "hello") do 42 | allow(Dir).to receive(:entries).and_return(["aired_episodes_v01.sql"]) 43 | 44 | run_generator ["aired_episode", "--materialized", "--no-data"] 45 | migration = migration_file( 46 | "db/migrate/update_aired_episodes_to_version_2.rb" 47 | ) 48 | expect(migration).to contain "materialized: { no_data: true }" 49 | expect(migration).not_to contain "side_by_side" 50 | end 51 | end 52 | 53 | it "sets the side-by-side option when updating a materialized view" do 54 | with_view_definition("aired_episodes", 1, "hello") do 55 | allow(Dir).to receive(:entries).and_return(["aired_episodes_v01.sql"]) 56 | 57 | run_generator ["aired_episode", "--materialized", "--side-by-side"] 58 | migration = migration_file( 59 | "db/migrate/update_aired_episodes_to_version_2.rb" 60 | ) 61 | expect(migration).to contain "materialized: { side_by_side: true }" 62 | expect(migration).not_to contain "no_data" 63 | end 64 | end 65 | 66 | it "uses 'replace_view' instead of 'update_view' if replace flag is set" do 67 | with_view_definition("aired_episodes", 1, "hello") do 68 | allow(Dir).to receive(:entries).and_return(["aired_episodes_v01.sql"]) 69 | 70 | run_generator ["aired_episode", "--replace"] 71 | migration = migration_file( 72 | "db/migrate/update_aired_episodes_to_version_2.rb" 73 | ) 74 | expect(migration).to contain "replace_view" 75 | end 76 | end 77 | 78 | context "for views created in a schema other than 'public'" do 79 | it "creates a view definition" do 80 | view_definition = file("db/views/non_public_searches_v01.sql") 81 | 82 | run_generator ["non_public.search"] 83 | 84 | expect(view_definition).to exist 85 | end 86 | 87 | it "creates a migration file" do 88 | run_generator ["non_public.search"] 89 | 90 | migration = migration_file("db/migrate/create_non_public_searches.rb") 91 | expect(migration).to contain(/class CreateNonPublicSearches/) 92 | expect(migration).to contain(/create_view "non_public.searches"/) 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /spec/integration/revert_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "Reverting scenic schema statements", :db do 4 | around do |example| 5 | with_view_definition :greetings, 1, "SELECT text 'hola' AS greeting" do 6 | example.run 7 | end 8 | end 9 | 10 | it "reverts dropped view to specified version" do 11 | run_migration(migration_for_create, :up) 12 | run_migration(migration_for_drop, :up) 13 | run_migration(migration_for_drop, :down) 14 | 15 | expect { execute("SELECT * from greetings") } 16 | .not_to raise_error 17 | end 18 | 19 | it "reverts updated view to specified version" do 20 | with_view_definition :greetings, 2, "SELECT text 'good day' AS greeting" do 21 | run_migration(migration_for_create, :up) 22 | run_migration(migration_for_update, :up) 23 | run_migration(migration_for_update, :down) 24 | 25 | greeting = execute("SELECT * from greetings")[0]["greeting"] 26 | 27 | expect(greeting).to eq "hola" 28 | end 29 | end 30 | 31 | def migration_for_create 32 | Class.new(migration_class) do 33 | def change 34 | create_view :greetings 35 | end 36 | end 37 | end 38 | 39 | def migration_for_drop 40 | Class.new(migration_class) do 41 | def change 42 | drop_view :greetings, revert_to_version: 1 43 | end 44 | end 45 | end 46 | 47 | def migration_for_update 48 | Class.new(migration_class) do 49 | def change 50 | update_view :greetings, version: 2, revert_to_version: 1 51 | end 52 | end 53 | end 54 | 55 | def migration_class 56 | if Rails::VERSION::MAJOR >= 5 57 | ::ActiveRecord::Migration[5.0] 58 | else 59 | ::ActiveRecord::Migration 60 | end 61 | end 62 | 63 | def run_migration(migration, directions) 64 | silence_stream($stdout) do 65 | Array.wrap(directions).each do |direction| 66 | migration.migrate(direction) 67 | end 68 | end 69 | end 70 | 71 | def execute(sql) 72 | ActiveRecord::Base.connection.execute(sql) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /spec/scenic/adapters/postgres/connection_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Scenic 4 | module Adapters 5 | describe Postgres::Connection do 6 | describe "supports_materialized_views?" do 7 | context "supports_materialized_views? was defined on connection" do 8 | it "uses the previously defined version" do 9 | base_response = double("response from base connection") 10 | base_connection = double( 11 | "Connection", 12 | supports_materialized_views?: base_response 13 | ) 14 | 15 | connection = Postgres::Connection.new(base_connection) 16 | 17 | expect(connection.supports_materialized_views?).to be base_response 18 | end 19 | end 20 | 21 | context "supports_materialized_views? is not already defined" do 22 | it "is true if postgres version is at least than 9.3.0" do 23 | base_connection = double("Connection", postgresql_version: 90300) 24 | 25 | connection = Postgres::Connection.new(base_connection) 26 | 27 | expect(connection.supports_materialized_views?).to be true 28 | end 29 | 30 | it "is false if postgres version is less than 9.3.0" do 31 | base_connection = double("Connection", postgresql_version: 90299) 32 | 33 | connection = Postgres::Connection.new(base_connection) 34 | 35 | expect(connection.supports_materialized_views?).to be false 36 | end 37 | end 38 | end 39 | 40 | describe "#postgresql_version" do 41 | it "uses the public method on the provided connection if defined" do 42 | base_connection = Class.new do 43 | def postgresql_version 44 | 123 45 | end 46 | end 47 | 48 | connection = Postgres::Connection.new(base_connection.new) 49 | 50 | expect(connection.postgresql_version).to eq 123 51 | end 52 | 53 | it "uses the protected method if the underlying method is not public" do 54 | base_connection = Class.new do 55 | protected 56 | 57 | def postgresql_version 58 | 123 59 | end 60 | end 61 | 62 | connection = Postgres::Connection.new(base_connection.new) 63 | 64 | expect(connection.postgresql_version).to eq 123 65 | end 66 | end 67 | 68 | describe "#supports_concurrent_refresh" do 69 | it "is true if postgres version is at least 9.4.0" do 70 | base_connection = double("Connection", postgresql_version: 90400) 71 | 72 | connection = Postgres::Connection.new(base_connection) 73 | 74 | expect(connection.supports_concurrent_refreshes?).to be true 75 | end 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/scenic/adapters/postgres/index_creation_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Scenic 4 | module Adapters 5 | describe Postgres::IndexCreation, :db do 6 | it "successfully recreates applicable indexes" do 7 | create_materialized_view("hi", "SELECT 'hi' AS greeting") 8 | speaker = DummySpeaker.new 9 | 10 | index = Scenic::Index.new( 11 | object_name: "hi", 12 | index_name: "hi_greeting_idx", 13 | definition: "CREATE INDEX hi_greeting_idx ON hi (greeting)" 14 | ) 15 | 16 | Postgres::IndexCreation 17 | .new(connection: ActiveRecord::Base.connection, speaker: speaker) 18 | .try_create([index]) 19 | 20 | expect(indexes_for("hi")).not_to be_empty 21 | expect(speaker.messages).to include(/index 'hi_greeting_idx' .* has been created/) 22 | end 23 | 24 | it "skips indexes that are not applicable" do 25 | create_materialized_view("hi", "SELECT 'hi' AS greeting") 26 | speaker = DummySpeaker.new 27 | index = Scenic::Index.new( 28 | object_name: "hi", 29 | index_name: "hi_person_idx", 30 | definition: "CREATE INDEX hi_person_idx ON hi (person)" 31 | ) 32 | 33 | Postgres::IndexCreation 34 | .new(connection: ActiveRecord::Base.connection, speaker: speaker) 35 | .try_create([index]) 36 | 37 | expect(indexes_for("hi")).to be_empty 38 | expect(speaker.messages).to include(/index 'hi_person_idx' .* has been dropped/) 39 | end 40 | end 41 | 42 | class DummySpeaker 43 | attr_reader :messages 44 | 45 | def initialize 46 | @messages = [] 47 | end 48 | 49 | def say(message, bool = false) 50 | @messages << message 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/scenic/adapters/postgres/index_migration_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Scenic 4 | module Adapters 5 | describe Postgres::IndexMigration, :db, :silence do 6 | it "moves indexes from the old view to the new view" do 7 | create_materialized_view("hi", "SELECT 'hi' AS greeting") 8 | create_materialized_view("hi_temp", "SELECT 'hi' AS greeting") 9 | add_index(:hi, :greeting, name: "hi_greeting_idx") 10 | 11 | Postgres::IndexMigration 12 | .new(connection: ActiveRecord::Base.connection) 13 | .migrate(from: "hi", to: "hi_temp") 14 | indexes_for_original = indexes_for("hi") 15 | indexes_for_temporary = indexes_for("hi_temp") 16 | 17 | expect(indexes_for_original.length).to eq 1 18 | expect(indexes_for_original.first.index_name).not_to eq "hi_greeting_idx" 19 | expect(indexes_for_temporary.length).to eq 1 20 | expect(indexes_for_temporary.first.index_name).to eq "hi_greeting_idx" 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/scenic/adapters/postgres/refresh_dependencies_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Scenic 4 | module Adapters 5 | describe Postgres::RefreshDependencies, :db do 6 | context "view has dependencies" do 7 | let(:adapter) { Postgres.new } 8 | 9 | before do 10 | adapter.create_materialized_view( 11 | "first", 12 | "SELECT text 'hi' AS greeting" 13 | ) 14 | adapter.create_materialized_view( 15 | "second", 16 | "SELECT * FROM first" 17 | ) 18 | adapter.create_materialized_view( 19 | "third", 20 | "SELECT * FROM first UNION SELECT * FROM second" 21 | ) 22 | adapter.create_materialized_view( 23 | "fourth_1", 24 | "SELECT * FROM third" 25 | ) 26 | adapter.create_materialized_view( 27 | "x_fourth", 28 | "SELECT * FROM fourth_1" 29 | ) 30 | adapter.create_materialized_view( 31 | "fourth", 32 | "SELECT * FROM fourth_1 UNION SELECT * FROM x_fourth" 33 | ) 34 | 35 | expect(adapter).to receive(:refresh_materialized_view) 36 | .with("public.first", concurrently: true).ordered 37 | expect(adapter).to receive(:refresh_materialized_view) 38 | .with("public.second", concurrently: true).ordered 39 | expect(adapter).to receive(:refresh_materialized_view) 40 | .with("public.third", concurrently: true).ordered 41 | expect(adapter).to receive(:refresh_materialized_view) 42 | .with("public.fourth_1", concurrently: true).ordered 43 | expect(adapter).to receive(:refresh_materialized_view) 44 | .with("public.x_fourth", concurrently: true).ordered 45 | end 46 | 47 | it "refreshes in the right order when called without namespace" do 48 | described_class.call( 49 | :fourth, 50 | adapter, 51 | ActiveRecord::Base.connection, 52 | concurrently: true 53 | ) 54 | end 55 | 56 | it "refreshes in the right order when called with namespace" do 57 | described_class.call( 58 | "public.fourth", 59 | adapter, 60 | ActiveRecord::Base.connection, 61 | concurrently: true 62 | ) 63 | end 64 | end 65 | 66 | context "view has no dependencies" do 67 | it "does not raise an error" do 68 | adapter = Postgres.new 69 | 70 | adapter.create_materialized_view( 71 | "first", 72 | "SELECT text 'hi' AS greeting" 73 | ) 74 | 75 | expect { 76 | described_class.call(:first, adapter, ActiveRecord::Base.connection) 77 | }.not_to raise_error 78 | end 79 | end 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/scenic/adapters/postgres/side_by_side_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Scenic 4 | module Adapters 5 | describe Postgres::SideBySide, :db, :silence do 6 | it "updates the materialized view to the new version" do 7 | adapter = Postgres.new 8 | create_materialized_view("hi", "SELECT 'hi' AS greeting") 9 | add_index(:hi, :greeting, name: "hi_greeting_idx") 10 | new_definition = "SELECT 'hola' AS greeting" 11 | 12 | Postgres::SideBySide 13 | .new(adapter: adapter, name: "hi", definition: new_definition) 14 | .update 15 | result = ar_connection.execute("SELECT * FROM hi").first["greeting"] 16 | indexes = indexes_for("hi") 17 | 18 | expect(result).to eq "hola" 19 | expect(indexes.length).to eq 1 20 | expect(indexes.first.index_name).to eq "hi_greeting_idx" 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/scenic/adapters/postgres/temporary_name_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Scenic 4 | module Adapters 5 | describe Postgres::TemporaryName do 6 | it "generates a temporary name based on a SHA1 hash of the original" do 7 | name = "my_materialized_view" 8 | 9 | temporary_name = Postgres::TemporaryName.new(name).to_s 10 | 11 | expect(temporary_name).to match(/_scenic_sbs_[0-9a-f]{40}/) 12 | end 13 | 14 | it "does not overflow the 63 character limit for object names" do 15 | name = "long_view_name_" * 10 16 | 17 | temporary_name = Postgres::TemporaryName.new(name).to_s 18 | 19 | expect(temporary_name.length).to eq 52 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/scenic/adapters/postgres/views_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Scenic 4 | module Adapters 5 | describe Postgres::Views, :db do 6 | it "returns scenic view objects for plain old views" do 7 | connection = ActiveRecord::Base.connection 8 | connection.execute <<-SQL 9 | CREATE VIEW children AS SELECT text 'Elliot' AS name 10 | SQL 11 | 12 | views = Postgres::Views.new(connection).all 13 | first = views.first 14 | 15 | expect(views.size).to eq 1 16 | expect(first.name).to eq "children" 17 | expect(first.materialized).to be false 18 | expect(first.definition).to eq "SELECT 'Elliot'::text AS name;" 19 | end 20 | 21 | it "returns scenic view objects for materialized views" do 22 | connection = ActiveRecord::Base.connection 23 | connection.execute <<-SQL 24 | CREATE MATERIALIZED VIEW children AS SELECT text 'Owen' AS name 25 | SQL 26 | 27 | views = Postgres::Views.new(connection).all 28 | first = views.first 29 | 30 | expect(views.size).to eq 1 31 | expect(first.name).to eq "children" 32 | expect(first.materialized).to be true 33 | expect(first.definition).to eq "SELECT 'Owen'::text AS name;" 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/scenic/adapters/postgres_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Scenic 4 | module Adapters 5 | describe Postgres, :db do 6 | describe "#create_view" do 7 | it "successfully creates a view" do 8 | adapter = Postgres.new 9 | 10 | adapter.create_view("greetings", "SELECT text 'hi' AS greeting") 11 | 12 | expect(adapter.views.map(&:name)).to include("greetings") 13 | end 14 | end 15 | 16 | describe "#create_materialized_view" do 17 | it "successfully creates a materialized view" do 18 | adapter = Postgres.new 19 | 20 | adapter.create_materialized_view( 21 | "greetings", 22 | "SELECT text 'hi' AS greeting" 23 | ) 24 | 25 | view = adapter.views.first 26 | expect(view.name).to eq("greetings") 27 | expect(view.materialized).to eq true 28 | end 29 | 30 | it "handles semicolon in definition when using `with no data`" do 31 | adapter = Postgres.new 32 | 33 | adapter.create_materialized_view( 34 | "greetings", 35 | "SELECT text 'hi' AS greeting; \n", 36 | no_data: true 37 | ) 38 | 39 | view = adapter.views.first 40 | expect(view.name).to eq("greetings") 41 | expect(view.materialized).to eq true 42 | end 43 | 44 | it "raises an exception if the version of PostgreSQL is too old" do 45 | connection = double("Connection", supports_materialized_views?: false) 46 | connectable = double("Connectable", connection: connection) 47 | adapter = Postgres.new(connectable) 48 | err = Scenic::Adapters::Postgres::MaterializedViewsNotSupportedError 49 | 50 | expect { adapter.create_materialized_view("greetings", "select 1") } 51 | .to raise_error err 52 | end 53 | end 54 | 55 | describe "#replace_view" do 56 | it "successfully replaces a view" do 57 | adapter = Postgres.new 58 | 59 | adapter.create_view("greetings", "SELECT text 'hi' AS greeting") 60 | 61 | view = adapter.views.first.definition 62 | expect(view).to eql "SELECT 'hi'::text AS greeting;" 63 | 64 | adapter.replace_view("greetings", "SELECT text 'hello' AS greeting") 65 | 66 | view = adapter.views.first.definition 67 | expect(view).to eql "SELECT 'hello'::text AS greeting;" 68 | end 69 | end 70 | 71 | describe "#drop_view" do 72 | it "successfully drops a view" do 73 | adapter = Postgres.new 74 | 75 | adapter.create_view("greetings", "SELECT text 'hi' AS greeting") 76 | adapter.drop_view("greetings") 77 | 78 | expect(adapter.views.map(&:name)).not_to include("greetings") 79 | end 80 | end 81 | 82 | describe "#drop_materialized_view" do 83 | it "successfully drops a materialized view" do 84 | adapter = Postgres.new 85 | 86 | adapter.create_materialized_view( 87 | "greetings", 88 | "SELECT text 'hi' AS greeting" 89 | ) 90 | adapter.drop_materialized_view("greetings") 91 | 92 | expect(adapter.views.map(&:name)).not_to include("greetings") 93 | end 94 | 95 | it "raises an exception if the version of PostgreSQL is too old" do 96 | connection = double("Connection", supports_materialized_views?: false) 97 | connectable = double("Connectable", connection: connection) 98 | adapter = Postgres.new(connectable) 99 | err = Scenic::Adapters::Postgres::MaterializedViewsNotSupportedError 100 | 101 | expect { adapter.drop_materialized_view("greetings") } 102 | .to raise_error err 103 | end 104 | end 105 | 106 | describe "#refresh_materialized_view" do 107 | it "raises an exception if the version of PostgreSQL is too old" do 108 | connection = double("Connection", supports_materialized_views?: false) 109 | connectable = double("Connectable", connection: connection) 110 | adapter = Postgres.new(connectable) 111 | err = Scenic::Adapters::Postgres::MaterializedViewsNotSupportedError 112 | 113 | expect { adapter.refresh_materialized_view(:tests) } 114 | .to raise_error err 115 | end 116 | 117 | it "can refresh the views dependencies first" do 118 | connection = double("Connection").as_null_object 119 | connectable = double("Connectable", connection: connection) 120 | adapter = Postgres.new(connectable) 121 | expect(Scenic::Adapters::Postgres::RefreshDependencies) 122 | .to receive(:call) 123 | .with(:tests, adapter, connection, concurrently: true) 124 | 125 | adapter.refresh_materialized_view( 126 | :tests, 127 | cascade: true, 128 | concurrently: true 129 | ) 130 | end 131 | 132 | context "refreshing concurrently" do 133 | it "raises descriptive error if concurrent refresh is not possible" do 134 | adapter = Postgres.new 135 | adapter.create_materialized_view(:tests, "SELECT text 'hi' as text") 136 | 137 | expect { 138 | adapter.refresh_materialized_view(:tests, concurrently: true) 139 | }.to raise_error(/Create a unique index with no WHERE clause/) 140 | end 141 | 142 | it "raises an exception if the version of PostgreSQL is too old" do 143 | connection = double("Connection", postgresql_version: 90300) 144 | connectable = double("Connectable", connection: connection) 145 | adapter = Postgres.new(connectable) 146 | e = Scenic::Adapters::Postgres::ConcurrentRefreshesNotSupportedError 147 | 148 | expect { 149 | adapter.refresh_materialized_view(:tests, concurrently: true) 150 | }.to raise_error e 151 | end 152 | 153 | it "falls back to non-concurrent refresh if not populated" do 154 | adapter = Postgres.new 155 | adapter.create_materialized_view(:testing, "SELECT unnest('{1, 2}'::int[])", no_data: true) 156 | 157 | expect { adapter.refresh_materialized_view(:testing, concurrently: true) } 158 | .not_to raise_error 159 | end 160 | end 161 | end 162 | 163 | describe "#views" do 164 | it "returns the views defined on this connection" do 165 | adapter = Postgres.new 166 | 167 | ActiveRecord::Base.connection.execute <<-SQL 168 | CREATE VIEW parents AS SELECT text 'Joe' AS name 169 | SQL 170 | 171 | ActiveRecord::Base.connection.execute <<-SQL 172 | CREATE VIEW children AS SELECT text 'Owen' AS name 173 | SQL 174 | 175 | ActiveRecord::Base.connection.execute <<-SQL 176 | CREATE MATERIALIZED VIEW people AS 177 | SELECT name FROM parents UNION SELECT name FROM children 178 | SQL 179 | 180 | ActiveRecord::Base.connection.execute <<-SQL 181 | CREATE VIEW people_with_names AS 182 | SELECT name FROM people 183 | WHERE name IS NOT NULL 184 | SQL 185 | 186 | expect(adapter.views.map(&:name)).to eq [ 187 | "children", 188 | "parents", 189 | "people", 190 | "people_with_names" 191 | ] 192 | end 193 | 194 | context "with views in non public schemas" do 195 | it "returns also the non public views" do 196 | adapter = Postgres.new 197 | 198 | ActiveRecord::Base.connection.execute <<-SQL 199 | CREATE VIEW parents AS SELECT text 'Joe' AS name 200 | SQL 201 | 202 | ActiveRecord::Base.connection.execute <<-SQL 203 | CREATE SCHEMA scenic; 204 | CREATE VIEW scenic.more_parents AS SELECT text 'Maarten' AS name; 205 | SET search_path TO scenic, public; 206 | SQL 207 | 208 | expect(adapter.views.map(&:name)).to eq [ 209 | "parents", 210 | "scenic.more_parents" 211 | ] 212 | end 213 | end 214 | end 215 | 216 | describe "#populated?" do 217 | it "returns false if a materialized view is not populated" do 218 | adapter = Postgres.new 219 | 220 | ActiveRecord::Base.connection.execute <<-SQL 221 | CREATE MATERIALIZED VIEW greetings AS 222 | SELECT text 'hi' AS greeting 223 | WITH NO DATA 224 | SQL 225 | 226 | expect(adapter.populated?("greetings")).to be false 227 | end 228 | 229 | it "returns true if a materialized view is populated" do 230 | adapter = Postgres.new 231 | 232 | ActiveRecord::Base.connection.execute <<-SQL 233 | CREATE MATERIALIZED VIEW greetings AS 234 | SELECT text 'hi' AS greeting 235 | SQL 236 | 237 | expect(adapter.populated?("greetings")).to be true 238 | end 239 | 240 | it "strips out the schema from table_name" do 241 | adapter = Postgres.new 242 | 243 | ActiveRecord::Base.connection.execute <<-SQL 244 | CREATE MATERIALIZED VIEW greetings AS 245 | SELECT text 'hi' AS greeting 246 | WITH NO DATA 247 | SQL 248 | 249 | expect(adapter.populated?("public.greetings")).to be false 250 | end 251 | 252 | it "raises an exception if the version of PostgreSQL is too old" do 253 | connection = double("Connection", supports_materialized_views?: false) 254 | connectable = double("Connectable", connection: connection) 255 | adapter = Postgres.new(connectable) 256 | err = Scenic::Adapters::Postgres::MaterializedViewsNotSupportedError 257 | 258 | expect { adapter.populated?("greetings") }.to raise_error err 259 | end 260 | end 261 | 262 | describe "#update_materialized_view" do 263 | it "updates the definition of a materialized view in place" do 264 | adapter = Postgres.new 265 | create_materialized_view("hi", "SELECT 'hi' AS greeting") 266 | new_definition = "SELECT 'hello' AS greeting" 267 | 268 | adapter.update_materialized_view("hi", new_definition) 269 | result = adapter.connection.execute("SELECT * FROM hi").first["greeting"] 270 | 271 | expect(result).to eq "hello" 272 | end 273 | 274 | it "updates the definition of a materialized view side by side", :silence do 275 | adapter = Postgres.new 276 | create_materialized_view("hi", "SELECT 'hi' AS greeting") 277 | new_definition = "SELECT 'hello' AS greeting" 278 | 279 | adapter.update_materialized_view("hi", new_definition, side_by_side: true) 280 | result = adapter.connection.execute("SELECT * FROM hi").first["greeting"] 281 | 282 | expect(result).to eq "hello" 283 | end 284 | 285 | it "raises an exception if the version of PostgreSQL is too old" do 286 | connection = double("Connection", supports_materialized_views?: false) 287 | connectable = double("Connectable", connection: connection) 288 | adapter = Postgres.new(connectable) 289 | 290 | expect { adapter.create_materialized_view("greetings", "select 1") } 291 | .to raise_error Postgres::MaterializedViewsNotSupportedError 292 | end 293 | end 294 | end 295 | end 296 | end 297 | -------------------------------------------------------------------------------- /spec/scenic/command_recorder/statement_arguments_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Scenic::CommandRecorder 4 | describe StatementArguments do 5 | describe "#view" do 6 | it "is the view name" do 7 | raw_args = [:spaceships, {foo: :bar}] 8 | args = StatementArguments.new(raw_args) 9 | 10 | expect(args.view).to eq :spaceships 11 | end 12 | end 13 | 14 | describe "#revert_to_version" do 15 | it "is the revert_to_version from the keyword arguments" do 16 | raw_args = [:spaceships, {revert_to_version: 42}] 17 | args = StatementArguments.new(raw_args) 18 | 19 | expect(args.revert_to_version).to eq 42 20 | end 21 | 22 | it "is nil if the revert_to_version was not supplied" do 23 | raw_args = [:spaceships, {foo: :bar}] 24 | args = StatementArguments.new(raw_args) 25 | 26 | expect(args.revert_to_version).to be nil 27 | end 28 | end 29 | 30 | describe "#invert_version" do 31 | it "returns object with version set to revert_to_version" do 32 | raw_args = [:meatballs, {version: 42, revert_to_version: 15}] 33 | 34 | inverted_args = StatementArguments.new(raw_args).invert_version 35 | 36 | expect(inverted_args.version).to eq 15 37 | expect(inverted_args.revert_to_version).to be nil 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/scenic/command_recorder_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Scenic::CommandRecorder do 4 | describe "#create_view" do 5 | it "records the created view" do 6 | recorder.create_view :greetings 7 | 8 | expect(recorder.commands).to eq [[:create_view, [:greetings], nil]] 9 | end 10 | 11 | it "reverts to drop_view when not passed a version" do 12 | recorder.revert { recorder.create_view :greetings } 13 | 14 | expect(recorder.commands).to eq [[:drop_view, [:greetings]]] 15 | end 16 | 17 | it "reverts to drop_view when passed a version" do 18 | recorder.revert { recorder.create_view :greetings, version: 2 } 19 | 20 | expect(recorder.commands).to eq [[:drop_view, [:greetings]]] 21 | end 22 | 23 | it "reverts materialized views appropriately" do 24 | recorder.revert { recorder.create_view :greetings, materialized: true } 25 | 26 | expect(recorder.commands).to eq [ 27 | [:drop_view, [:greetings, materialized: true]] 28 | ] 29 | end 30 | end 31 | 32 | describe "#drop_view" do 33 | it "records the dropped view" do 34 | recorder.drop_view :users 35 | 36 | expect(recorder.commands).to eq [[:drop_view, [:users], nil]] 37 | end 38 | 39 | it "reverts to create_view with specified revert_to_version" do 40 | args = [:users, {revert_to_version: 3}] 41 | revert_args = [:users, {version: 3}] 42 | 43 | recorder.revert { recorder.drop_view(*args) } 44 | 45 | expect(recorder.commands).to eq [[:create_view, revert_args]] 46 | end 47 | 48 | it "raises when reverting without revert_to_version set" do 49 | args = [:users, {another_argument: 1}] 50 | 51 | expect { recorder.revert { recorder.drop_view(*args) } } 52 | .to raise_error(ActiveRecord::IrreversibleMigration) 53 | end 54 | end 55 | 56 | describe "#update_view" do 57 | it "records the updated view" do 58 | args = [:users, {version: 2}] 59 | 60 | recorder.update_view(*args) 61 | 62 | expect(recorder.commands).to eq [[:update_view, args, nil]] 63 | end 64 | 65 | it "reverts to update_view with the specified revert_to_version" do 66 | args = [:users, {version: 2, revert_to_version: 1}] 67 | revert_args = [:users, {version: 1}] 68 | 69 | recorder.revert { recorder.update_view(*args) } 70 | 71 | expect(recorder.commands).to eq [[:update_view, revert_args]] 72 | end 73 | 74 | it "raises when reverting without revert_to_version set" do 75 | args = [:users, {version: 42, another_argument: 1}] 76 | 77 | expect { recorder.revert { recorder.update_view(*args) } } 78 | .to raise_error(ActiveRecord::IrreversibleMigration) 79 | end 80 | 81 | it "reverts materialized views with no_data option appropriately" do 82 | args = [:users, {version: 2, revert_to_version: 1, materialized: {no_data: true}}] 83 | revert_args = [:users, {version: 1, materialized: {no_data: true}}] 84 | 85 | recorder.revert { recorder.update_view(*args) } 86 | 87 | expect(recorder.commands).to eq [[:update_view, revert_args]] 88 | end 89 | 90 | it "reverts materialized views with side_by_side option appropriately" do 91 | args = [:users, {version: 2, revert_to_version: 1, materialized: {side_by_side: true}}] 92 | revert_args = [:users, {version: 1, materialized: {side_by_side: true}}] 93 | 94 | recorder.revert { recorder.update_view(*args) } 95 | 96 | expect(recorder.commands).to eq [[:update_view, revert_args]] 97 | end 98 | end 99 | 100 | describe "#replace_view" do 101 | it "records the replaced view" do 102 | args = [:users, {version: 2}] 103 | 104 | recorder.replace_view(*args) 105 | 106 | expect(recorder.commands).to eq [[:replace_view, args, nil]] 107 | end 108 | 109 | it "reverts to replace_view with the specified revert_to_version" do 110 | args = [:users, {version: 2, revert_to_version: 1}] 111 | revert_args = [:users, {version: 1}] 112 | 113 | recorder.revert { recorder.replace_view(*args) } 114 | 115 | expect(recorder.commands).to eq [[:replace_view, revert_args]] 116 | end 117 | 118 | it "raises when reverting without revert_to_version set" do 119 | args = [:users, {version: 42, another_argument: 1}] 120 | 121 | expect { recorder.revert { recorder.replace_view(*args) } } 122 | .to raise_error(ActiveRecord::IrreversibleMigration) 123 | end 124 | end 125 | 126 | def recorder 127 | @recorder ||= ActiveRecord::Migration::CommandRecorder.new 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/scenic/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Scenic 4 | describe Configuration do 5 | after { restore_default_config } 6 | 7 | it "defaults the database adapter to postgres" do 8 | expect(Scenic.configuration.database).to be_a Adapters::Postgres 9 | expect(Scenic.database).to be_a Adapters::Postgres 10 | end 11 | 12 | it "allows the database adapter to be set" do 13 | adapter = double("Scenic Adapter") 14 | 15 | Scenic.configure do |config| 16 | config.database = adapter 17 | end 18 | 19 | expect(Scenic.configuration.database).to eq adapter 20 | expect(Scenic.database).to eq adapter 21 | end 22 | 23 | def restore_default_config 24 | Scenic.configuration = Configuration.new 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/scenic/definition_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Scenic 4 | describe Definition do 5 | describe "to_sql" do 6 | it "returns the content of a view definition" do 7 | sql_definition = "SELECT text 'Hi' as greeting" 8 | allow(File).to receive(:read).and_return(sql_definition) 9 | 10 | definition = Definition.new("searches", 1) 11 | 12 | expect(definition.to_sql).to eq sql_definition 13 | end 14 | 15 | it "raises an error if the file is empty" do 16 | allow(File).to receive(:read).and_return("") 17 | 18 | expect do 19 | Definition.new("searches", 1).to_sql 20 | end.to raise_error RuntimeError 21 | end 22 | end 23 | 24 | describe "path" do 25 | it "returns a sql file in db/views with padded version and view name" do 26 | expected = "db/views/searches_v01.sql" 27 | 28 | definition = Definition.new("searches", 1) 29 | 30 | expect(definition.path).to eq expected 31 | end 32 | 33 | it "handles schema qualified view names" do 34 | definition = Definition.new("non_public.searches", 1) 35 | 36 | expect(definition.path).to eq "db/views/non_public_searches_v01.sql" 37 | end 38 | 39 | it "handles active record view prefix and suffixing" do 40 | with_affixed_tables(prefix: "foo_", suffix: "_bar") do 41 | definition = Definition.new("foo_searches_bar", 1) 42 | 43 | expect(definition.path).to eq "db/views/searches_v01.sql" 44 | end 45 | end 46 | end 47 | 48 | describe "full_path" do 49 | it "joins the path with Rails.root" do 50 | definition = Definition.new("searches", 15) 51 | 52 | expect(definition.full_path).to eq Rails.root.join(definition.path) 53 | end 54 | end 55 | 56 | describe "version" do 57 | it "pads the version number with 0" do 58 | definition = Definition.new(:_, 1) 59 | 60 | expect(definition.version).to eq "01" 61 | end 62 | 63 | it "doesn't pad more than 2 characters" do 64 | definition = Definition.new(:_, 15) 65 | 66 | expect(definition.version).to eq "15" 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/scenic/schema_dumper_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | class Search < ActiveRecord::Base; end 4 | 5 | class SearchInAHaystack < ActiveRecord::Base 6 | self.table_name = '"search in a haystack"' 7 | end 8 | 9 | describe Scenic::SchemaDumper, :db do 10 | it "dumps a create_view for a view in the database" do 11 | view_definition = "SELECT 'needle'::text AS haystack" 12 | Search.connection.create_view :searches, sql_definition: view_definition 13 | stream = StringIO.new 14 | 15 | dump_schema(stream) 16 | 17 | output = stream.string 18 | 19 | expect(output).to include 'create_view "searches", sql_definition: <<-SQL' 20 | expect(output).to include view_definition 21 | 22 | Search.connection.drop_view :searches 23 | 24 | silence_stream($stdout) { eval(output) } # standard:disable Security/Eval 25 | 26 | expect(Search.first.haystack).to eq "needle" 27 | end 28 | 29 | it "accurately dumps create view statements with a regular expression" do 30 | view_definition = "SELECT 'needle'::text AS haystack WHERE 'a2z' ~ '\\d+'" 31 | Search.connection.create_view :searches, sql_definition: view_definition 32 | stream = StringIO.new 33 | 34 | dump_schema(stream) 35 | 36 | output = stream.string 37 | expect(output).to include "~ '\\\\d+'::text" 38 | 39 | Search.connection.drop_view :searches 40 | silence_stream($stdout) { eval(output) } # standard:disable Security/Eval 41 | 42 | expect(Search.first.haystack).to eq "needle" 43 | end 44 | 45 | it "dumps a create_view for a materialized view in the database" do 46 | view_definition = "SELECT 'needle'::text AS haystack" 47 | Search.connection.create_view :searches, materialized: true, sql_definition: view_definition 48 | stream = StringIO.new 49 | 50 | dump_schema(stream) 51 | 52 | output = stream.string 53 | 54 | expect(output).to include 'create_view "searches", materialized: true, sql_definition: <<-SQL' 55 | expect(output).to include view_definition 56 | end 57 | 58 | context "with views in non public schemas" do 59 | it "dumps a create_view including namespace for a view in the database" do 60 | view_definition = "SELECT 'needle'::text AS haystack" 61 | Search.connection.execute "CREATE SCHEMA scenic; SET search_path TO scenic, public" 62 | Search.connection.create_view :"scenic.searches", sql_definition: view_definition 63 | stream = StringIO.new 64 | 65 | dump_schema(stream) 66 | 67 | output = stream.string 68 | expect(output).to include 'create_view "scenic.searches",' 69 | 70 | Search.connection.drop_view :"scenic.searches" 71 | end 72 | 73 | it "sorts dependency order when views exist in a non-public schema" do 74 | Search.connection.execute("CREATE SCHEMA IF NOT EXISTS scenic; SET search_path TO public, scenic") 75 | Search.connection.execute("CREATE VIEW scenic.apples AS SELECT 1;") 76 | Search.connection.execute("CREATE VIEW scenic.bananas AS SELECT 2;") 77 | Search.connection.execute("CREATE OR REPLACE VIEW scenic.apples AS SELECT * FROM scenic.bananas;") 78 | stream = StringIO.new 79 | 80 | dump_schema(stream) 81 | views = stream.string.lines.grep(/create_view/).map do |view_line| 82 | view_line.match('create_view "(?.*)"')[:name] 83 | end 84 | expect(views).to eq(%w[scenic.bananas scenic.apples]) 85 | 86 | Search.connection.execute("DROP SCHEMA IF EXISTS scenic CASCADE; SET search_path TO public") 87 | end 88 | end 89 | 90 | it "handles active record table name prefixes and suffixes" do 91 | with_affixed_tables(prefix: "a_", suffix: "_z") do 92 | view_definition = "SELECT 'needle'::text AS haystack" 93 | Search.connection.create_view :a_searches_z, sql_definition: view_definition 94 | stream = StringIO.new 95 | 96 | dump_schema(stream) 97 | 98 | output = stream.string 99 | 100 | expect(output).to include 'create_view "searches"' 101 | end 102 | end 103 | 104 | it "ignores tables internal to Rails" do 105 | view_definition = "SELECT 'needle'::text AS haystack" 106 | Search.connection.create_view :searches, sql_definition: view_definition 107 | stream = StringIO.new 108 | 109 | dump_schema(stream) 110 | 111 | output = stream.string 112 | 113 | expect(output).to include 'create_view "searches"' 114 | expect(output).not_to include "pg_stat_statements_info" 115 | expect(output).not_to include "schema_migrations" 116 | end 117 | 118 | context "with views using unexpected characters in name" do 119 | it "dumps a create_view for a view in the database" do 120 | view_definition = "SELECT 'needle'::text AS haystack" 121 | Search.connection.create_view '"search in a haystack"', sql_definition: view_definition 122 | stream = StringIO.new 123 | 124 | dump_schema(stream) 125 | 126 | output = stream.string 127 | expect(output).to include 'create_view "\"search in a haystack\"",' 128 | expect(output).to include view_definition 129 | 130 | Search.connection.drop_view :'"search in a haystack"' 131 | 132 | silence_stream($stdout) { eval(output) } # standard:disable Security/Eval 133 | 134 | expect(SearchInAHaystack.take.haystack).to eq "needle" 135 | end 136 | end 137 | 138 | context "with views using unexpected characters, name including namespace" do 139 | it "dumps a create_view for a view in the database" do 140 | view_definition = "SELECT 'needle'::text AS haystack" 141 | Search.connection.execute( 142 | "CREATE SCHEMA scenic; SET search_path TO scenic, public" 143 | ) 144 | Search.connection.create_view 'scenic."search in a haystack"', 145 | sql_definition: view_definition 146 | stream = StringIO.new 147 | 148 | dump_schema(stream) 149 | 150 | output = stream.string 151 | expect(output).to include 'create_view "scenic.\"search in a haystack\"",' 152 | expect(output).to include view_definition 153 | 154 | Search.connection.drop_view :'scenic."search in a haystack"' 155 | 156 | case ActiveRecord.gem_version 157 | when Gem::Requirement.new(">= 7.1") 158 | Search.connection.drop_schema "scenic" 159 | end 160 | 161 | silence_stream($stdout) { eval(output) } # standard:disable Security/Eval 162 | 163 | expect(SearchInAHaystack.take.haystack).to eq "needle" 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /spec/scenic/statements_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | module Scenic 4 | describe Scenic::Statements do 5 | before do 6 | adapter = instance_double("Scenic::Adapters::Postgres").as_null_object 7 | allow(Scenic).to receive(:database).and_return(adapter) 8 | end 9 | 10 | describe "create_view" do 11 | it "creates a view from a file" do 12 | version = 15 13 | definition_stub = instance_double("Definition", to_sql: "foo") 14 | allow(Definition).to receive(:new) 15 | .with(:views, version) 16 | .and_return(definition_stub) 17 | 18 | connection.create_view :views, version: version 19 | 20 | expect(Scenic.database).to have_received(:create_view) 21 | .with(:views, definition_stub.to_sql) 22 | end 23 | 24 | it "creates a view from a text definition" do 25 | sql_definition = "a defintion" 26 | 27 | connection.create_view(:views, sql_definition: sql_definition) 28 | 29 | expect(Scenic.database).to have_received(:create_view) 30 | .with(:views, sql_definition) 31 | end 32 | 33 | it "creates version 1 of the view if neither version nor sql_defintion are provided" do 34 | version = 1 35 | definition_stub = instance_double("Definition", to_sql: "foo") 36 | allow(Definition).to receive(:new) 37 | .with(:views, version) 38 | .and_return(definition_stub) 39 | 40 | connection.create_view :views 41 | 42 | expect(Scenic.database).to have_received(:create_view) 43 | .with(:views, definition_stub.to_sql) 44 | end 45 | 46 | it "raises an error if both version and sql_defintion are provided" do 47 | expect do 48 | connection.create_view :foo, version: 1, sql_definition: "a defintion" 49 | end.to raise_error ArgumentError 50 | end 51 | end 52 | 53 | describe "create_view :materialized" do 54 | it "sends the create_materialized_view message" do 55 | definition = instance_double("Scenic::Definition", to_sql: "definition") 56 | allow(Definition).to receive(:new).and_return(definition) 57 | 58 | connection.create_view(:views, version: 1, materialized: true) 59 | 60 | expect(Scenic.database).to have_received(:create_materialized_view) 61 | .with(:views, definition.to_sql, no_data: false) 62 | end 63 | end 64 | 65 | describe "create_view :materialized with :no_data" do 66 | it "sends the create_materialized_view message" do 67 | definition = instance_double("Scenic::Definition", to_sql: "definition") 68 | allow(Definition).to receive(:new).and_return(definition) 69 | 70 | connection.create_view( 71 | :views, 72 | version: 1, 73 | materialized: {no_data: true} 74 | ) 75 | 76 | expect(Scenic.database).to have_received(:create_materialized_view) 77 | .with(:views, definition.to_sql, no_data: true) 78 | end 79 | end 80 | 81 | describe "drop_view" do 82 | it "removes a view from the database" do 83 | connection.drop_view :name 84 | 85 | expect(Scenic.database).to have_received(:drop_view).with(:name) 86 | end 87 | end 88 | 89 | describe "drop_view :materialized" do 90 | it "removes a materialized view from the database" do 91 | connection.drop_view :name, materialized: true 92 | 93 | expect(Scenic.database).to have_received(:drop_materialized_view) 94 | end 95 | end 96 | 97 | describe "update_view" do 98 | it "updates the view in the database" do 99 | definition = instance_double("Definition", to_sql: "definition") 100 | allow(Definition).to receive(:new) 101 | .with(:name, 3) 102 | .and_return(definition) 103 | 104 | connection.update_view(:name, version: 3) 105 | 106 | expect(Scenic.database).to have_received(:update_view) 107 | .with(:name, definition.to_sql) 108 | end 109 | 110 | it "updates a view from a text definition" do 111 | sql_definition = "a defintion" 112 | 113 | connection.update_view(:name, sql_definition: sql_definition) 114 | 115 | expect(Scenic.database).to have_received(:update_view) 116 | .with(:name, sql_definition) 117 | end 118 | 119 | it "updates the materialized view in the database" do 120 | definition = instance_double("Definition", to_sql: "definition") 121 | allow(Definition).to receive(:new) 122 | .with(:name, 3) 123 | .and_return(definition) 124 | 125 | connection.update_view(:name, version: 3, materialized: true) 126 | 127 | expect(Scenic.database).to have_received(:update_materialized_view) 128 | .with(:name, definition.to_sql, no_data: false, side_by_side: false) 129 | end 130 | 131 | it "updates the materialized view in the database with NO DATA" do 132 | definition = instance_double("Definition", to_sql: "definition") 133 | allow(Definition).to receive(:new) 134 | .with(:name, 3) 135 | .and_return(definition) 136 | 137 | connection.update_view( 138 | :name, 139 | version: 3, 140 | materialized: {no_data: true} 141 | ) 142 | 143 | expect(Scenic.database).to have_received(:update_materialized_view) 144 | .with(:name, definition.to_sql, no_data: true, side_by_side: false) 145 | end 146 | 147 | it "updates the materialized view with side-by-side mode" do 148 | definition = instance_double("Definition", to_sql: "definition") 149 | allow(Definition).to receive(:new) 150 | .with(:name, 3) 151 | .and_return(definition) 152 | 153 | connection.update_view( 154 | :name, 155 | version: 3, 156 | materialized: {side_by_side: true} 157 | ) 158 | 159 | expect(Scenic.database).to have_received(:update_materialized_view) 160 | .with(:name, definition.to_sql, no_data: false, side_by_side: true) 161 | end 162 | 163 | it "raises an error if not supplied a version or sql_defintion" do 164 | expect { connection.update_view :views }.to raise_error( 165 | ArgumentError, 166 | /sql_definition or version must be specified/ 167 | ) 168 | end 169 | 170 | it "raises an error if both version and sql_defintion are provided" do 171 | expect do 172 | connection.update_view( 173 | :views, 174 | version: 1, 175 | sql_definition: "a defintion" 176 | ) 177 | end.to raise_error ArgumentError, /cannot both be set/ 178 | end 179 | 180 | it "raises an error is no_data and side_by_side are both set" do 181 | definition = instance_double("Definition", to_sql: "definition") 182 | allow(Definition).to receive(:new) 183 | .with(:name, 3) 184 | .and_return(definition) 185 | 186 | expect do 187 | connection.update_view( 188 | :name, 189 | version: 3, 190 | materialized: {no_data: true, side_by_side: true} 191 | ) 192 | end.to raise_error ArgumentError, /cannot be combined/ 193 | end 194 | 195 | it "raises an error if not in a transaction" do 196 | definition = instance_double("Definition", to_sql: "definition") 197 | allow(Definition).to receive(:new) 198 | .with(:name, 3) 199 | .and_return(definition) 200 | 201 | expect do 202 | connection(transactions_enabled: false).update_view( 203 | :name, 204 | version: 3, 205 | materialized: {side_by_side: true} 206 | ) 207 | end.to raise_error RuntimeError, /transaction is required/ 208 | end 209 | end 210 | 211 | describe "replace_view" do 212 | it "replaces the view in the database" do 213 | definition = instance_double("Definition", to_sql: "definition") 214 | allow(Definition).to receive(:new) 215 | .with(:name, 3) 216 | .and_return(definition) 217 | 218 | connection.replace_view(:name, version: 3) 219 | 220 | expect(Scenic.database).to have_received(:replace_view) 221 | .with(:name, definition.to_sql) 222 | end 223 | 224 | it "fails to replace the materialized view in the database" do 225 | definition = instance_double("Definition", to_sql: "definition") 226 | allow(Definition).to receive(:new) 227 | .with(:name, 3) 228 | .and_return(definition) 229 | 230 | expect do 231 | connection.replace_view(:name, version: 3, materialized: true) 232 | end.to raise_error(ArgumentError, /Cannot replace materialized views/) 233 | end 234 | 235 | it "raises an error if not supplied a version" do 236 | expect { connection.replace_view :views } 237 | .to raise_error(ArgumentError, /version is required/) 238 | end 239 | end 240 | 241 | def connection(transactions_enabled: true) 242 | DummyConnection.new(transactions_enabled: transactions_enabled) 243 | end 244 | end 245 | 246 | class DummyConnection 247 | include Statements 248 | 249 | def initialize(transactions_enabled:) 250 | @transactions_enabled = transactions_enabled 251 | end 252 | 253 | def transaction_open? 254 | @transactions_enabled 255 | end 256 | end 257 | end 258 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | ENV["RAILS_ENV"] = "test" 2 | require "database_cleaner" 3 | 4 | require File.expand_path("dummy/config/environment", __dir__) 5 | 6 | Dir.glob("#{__dir__}/support/**/*.rb").each { |f| require f } 7 | 8 | RSpec.configure do |config| 9 | config.order = "random" 10 | config.include DatabaseSchemaHelpers 11 | config.include ViewDefinitionHelpers 12 | config.include RailsConfigurationHelpers 13 | DatabaseCleaner.strategy = :transaction 14 | 15 | config.around(:each, db: true) do |example| 16 | case ActiveRecord.gem_version 17 | when Gem::Requirement.new(">= 7.2") 18 | ActiveRecord::SchemaMigration 19 | .new(ActiveRecord::Tasks::DatabaseTasks.migration_connection_pool) 20 | .create_table 21 | when Gem::Requirement.new("~> 7.1.0") 22 | ActiveRecord::SchemaMigration 23 | .new(ActiveRecord::Tasks::DatabaseTasks.migration_connection) 24 | .create_table 25 | when Gem::Requirement.new("< 7.1") 26 | ActiveRecord::SchemaMigration.create_table 27 | end 28 | 29 | DatabaseCleaner.start 30 | example.run 31 | DatabaseCleaner.clean 32 | end 33 | 34 | config.before(:each, silence: true) do |example| 35 | allow_any_instance_of(ActiveRecord::Migration).to receive(:say) 36 | end 37 | 38 | if defined? ActiveSupport::Testing::Stream 39 | config.include ActiveSupport::Testing::Stream 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/support/database_schema_helpers.rb: -------------------------------------------------------------------------------- 1 | module DatabaseSchemaHelpers 2 | def dump_schema(stream) 3 | case ActiveRecord.gem_version 4 | when Gem::Requirement.new(">= 7.2") 5 | ActiveRecord::SchemaDumper.dump(Search.connection_pool, stream) 6 | else 7 | ActiveRecord::SchemaDumper.dump(Search.connection, stream) 8 | end 9 | end 10 | 11 | def ar_connection 12 | ActiveRecord::Base.connection 13 | end 14 | 15 | def create_materialized_view(name, sql) 16 | ar_connection.execute("CREATE MATERIALIZED VIEW #{name} AS #{sql}") 17 | end 18 | 19 | def add_index(view, columns, name: nil) 20 | ar_connection.add_index(view, columns, name: name) 21 | end 22 | 23 | def indexes_for(view_name) 24 | Scenic::Adapters::Postgres::Indexes 25 | .new(connection: ar_connection) 26 | .on(view_name) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/support/generator_spec_setup.rb: -------------------------------------------------------------------------------- 1 | require "rspec/rails" 2 | require "ammeter/rspec/generator/example" 3 | require "ammeter/rspec/generator/matchers" 4 | require "ammeter/init" 5 | 6 | RSpec.configure do |config| 7 | config.before(:example, :generator) do 8 | fake_rails_root = File.expand_path("../../tmp", __dir__) 9 | allow(Rails).to receive(:root).and_return(Pathname.new(fake_rails_root)) 10 | 11 | destination fake_rails_root 12 | prepare_destination 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/support/rails_configuration_helpers.rb: -------------------------------------------------------------------------------- 1 | module RailsConfigurationHelpers 2 | def with_affixed_tables(prefix: "", suffix: "") 3 | ActiveRecord::Base.table_name_prefix = prefix 4 | ActiveRecord::Base.table_name_suffix = suffix 5 | yield 6 | ensure 7 | ActiveRecord::Base.table_name_prefix = "" 8 | ActiveRecord::Base.table_name_suffix = "" 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/view_definition_helpers.rb: -------------------------------------------------------------------------------- 1 | module ViewDefinitionHelpers 2 | def with_view_definition(name, version, schema) 3 | definition = Scenic::Definition.new(name, version) 4 | FileUtils.mkdir_p(File.dirname(definition.full_path)) 5 | File.write(definition.full_path, schema) 6 | yield 7 | ensure 8 | FileUtils.rm_f(definition.full_path) 9 | end 10 | end 11 | --------------------------------------------------------------------------------