├── .github └── workflows │ ├── rubocop.yml │ ├── test-with-mysql.yml │ ├── test-with-postgresql.yml │ └── test-with-sqlite.yml ├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── .travis.yml ├── .vimrc ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── activerecord-cte.gemspec ├── bin ├── console ├── setup └── test ├── docker-compose.yml ├── lib └── activerecord │ ├── cte.rb │ └── cte │ ├── core_ext.rb │ └── version.rb └── test ├── activerecord └── cte_test.rb ├── database.yml ├── fixtures └── posts.yml ├── models └── post.rb └── test_helper.rb /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: Rubocop 2 | on: [pull_request] 3 | jobs: 4 | Rubocop: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Check out repository code 8 | uses: actions/checkout@v2 9 | - name: Setup Ruby 10 | uses: ruby/setup-ruby@v1 11 | - run: bundle install 12 | - run: bundle exec rubocop 13 | -------------------------------------------------------------------------------- /.github/workflows/test-with-mysql.yml: -------------------------------------------------------------------------------- 1 | name: MySql 2 | on: [pull_request] 3 | jobs: 4 | Test-With-Mysql: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | active_record: [6.1.7.2, 6.0.6, 5.2.8.1] 10 | ruby: ['3.0', 3.1, 3.2] 11 | exclude: 12 | - active_record: 5.2.8.1 13 | ruby: '3.0' 14 | - active_record: 5.2.8.1 15 | ruby: 3.1 16 | - active_record: 5.2.8.1 17 | ruby: 3.2 18 | env: 19 | ACTIVE_RECORD_VERSION: ${{ matrix.active_record }} 20 | DATABASE_ADAPTER: mysql 21 | INSTALL_MYSQL_GEM: true 22 | RAILS_ENV: test 23 | steps: 24 | - name: Check out repository code 25 | uses: actions/checkout@v2 26 | - name: Start mysql 27 | run: sudo service mysql start 28 | - name: Set up Ruby 29 | uses: ruby/setup-ruby@v1 30 | with: 31 | ruby-version: ${{ matrix.ruby }} 32 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 33 | - name: Run tests 34 | run: bundle exec rake test 35 | -------------------------------------------------------------------------------- /.github/workflows/test-with-postgresql.yml: -------------------------------------------------------------------------------- 1 | name: PostgreSQL 2 | on: [pull_request] 3 | jobs: 4 | Test-With-PostgreSQL: 5 | runs-on: ubuntu-latest 6 | container: ruby:${{ matrix.ruby }} 7 | strategy: 8 | fail-fast: false 9 | matrix: 10 | active_record: [6.1.7.2, 6.0.6, 5.2.8.1] 11 | ruby: ['3.0', 3.1, 3.2] 12 | exclude: 13 | - active_record: 5.2.8.1 14 | ruby: '3.0' 15 | - active_record: 5.2.8.1 16 | ruby: 3.1 17 | - active_record: 5.2.8.1 18 | ruby: 3.2 19 | env: 20 | ACTIVE_RECORD_VERSION: ${{ matrix.active_record }} 21 | DATABASE_ADAPTER: postgresql 22 | INSTALL_PG_GEM: true 23 | RAILS_ENV: test 24 | services: 25 | postgres: 26 | image: postgres 27 | env: 28 | POSTGRES_PASSWORD: postgres 29 | options: >- 30 | --health-cmd pg_isready 31 | --health-interval 10s 32 | --health-timeout 5s 33 | --health-retries 5 34 | ports: 35 | - 5432:5432 36 | steps: 37 | - name: Check out repository code 38 | uses: actions/checkout@v2 39 | - name: Update bundler 40 | run: gem update bundler # Use the latest bundler 41 | - name: Bundle dependencies 42 | run: bundle install 43 | - name: Run tests 44 | run: bundle exec rake test 45 | -------------------------------------------------------------------------------- /.github/workflows/test-with-sqlite.yml: -------------------------------------------------------------------------------- 1 | name: SQLite 2 | on: [pull_request] 3 | jobs: 4 | Test-With-SQLite: 5 | runs-on: ubuntu-latest 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | active_record: [6.1.7.2, 6.0.6, 5.2.8.1] 10 | # Due to https://github.com/actions/runner/issues/849, we have to use quotes for '3.0' 11 | ruby: ['3.0', 3.1, 3.2] 12 | exclude: 13 | - active_record: 5.2.8.1 14 | ruby: '3.0' 15 | - active_record: 5.2.8.1 16 | ruby: 3.1 17 | - active_record: 5.2.8.1 18 | ruby: 3.2 19 | 20 | env: 21 | ACTIVE_RECORD_VERSION: ${{ matrix.active_record }} 22 | DATABASE_ADAPTER: sqlite3 23 | RAILS_ENV: test 24 | steps: 25 | - name: Check out repository code 26 | uses: actions/checkout@v2 27 | - name: Set up Ruby 28 | uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{ matrix.ruby }} 31 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 32 | - name: Run tests 33 | run: bundle exec rake test 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | .env 10 | .DS_Store 11 | Gemfile.lock 12 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-minitest 3 | - rubocop-performance 4 | - rubocop-rake 5 | 6 | AllCops: 7 | NewCops: enable 8 | TargetRubyVersion: 2.7 9 | 10 | Gemspec/RequiredRubyVersion: 11 | Enabled: false 12 | 13 | Layout/LineLength: 14 | AllowHeredoc: true 15 | AllowURI: true 16 | IgnoreCopDirectives: true 17 | Max: 120 18 | Exclude: 19 | - "activerecord-cte.gemspec" 20 | - "test/**/*" 21 | 22 | Layout/MultilineMethodCallIndentation: 23 | EnforcedStyle: indented 24 | 25 | Metrics/AbcSize: 26 | Exclude: 27 | - "test/**/*_test.rb" 28 | 29 | Metrics/ClassLength: 30 | Exclude: 31 | - "test/**/*_test.rb" 32 | 33 | Metrics/CyclomaticComplexity: 34 | Max: 7 35 | 36 | Metrics/MethodLength: 37 | Exclude: 38 | - "test/**/*_test.rb" 39 | 40 | Minitest/MultipleAssertions: 41 | Max: 5 42 | 43 | Style/ClassAndModuleChildren: 44 | Enabled: false 45 | 46 | Style/Documentation: 47 | Enabled: false 48 | 49 | Style/HashEachMethods: 50 | Enabled: true 51 | 52 | Style/HashTransformKeys: 53 | Enabled: true 54 | 55 | Style/HashTransformValues: 56 | Enabled: true 57 | 58 | Style/StringLiterals: 59 | EnforcedStyle: double_quotes 60 | 61 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.1.2 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | sudo: false 3 | language: ruby 4 | cache: bundler 5 | rvm: 6 | - 2.6.3 7 | before_install: gem install bundler -v 2.0.2 8 | -------------------------------------------------------------------------------- /.vimrc: -------------------------------------------------------------------------------- 1 | set colorcolumn=120 2 | -------------------------------------------------------------------------------- /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, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | 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 vladocingel@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further 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], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.1 2 | 3 | ENV APP_HOME /activerecord_cte 4 | RUN mkdir $APP_HOME 5 | WORKDIR $APP_HOME 6 | 7 | ENV RAILS_ENV test 8 | ENV INSTALL_MYSQL_GEM true 9 | ENV INSTALL_PG_GEM true 10 | ENV MYSQL_HOST mysql 11 | 12 | # Cache the bundle install 13 | COPY Gemfile* $APP_HOME/ 14 | COPY lib/activerecord/cte/version.rb $APP_HOME/lib/activerecord/cte/version.rb 15 | COPY *.gemspec $APP_HOME/ 16 | RUN gem install bundler 17 | RUN bundle install 18 | 19 | ADD . $APP_HOME 20 | 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in activerecord-cte.gemspec 6 | gemspec 7 | 8 | ACTIVE_RECORD_VERSION = ENV.fetch("ACTIVE_RECORD_VERSION", "6.1.7.2") 9 | 10 | gem "activerecord", ACTIVE_RECORD_VERSION 11 | 12 | gem "mysql2" if ENV["INSTALL_MYSQL_GEM"] 13 | gem "pg" if ENV["INSTALL_PG_GEM"] 14 | 15 | gem "sqlite3", "1.7.3" 16 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Vlado Cingel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActiveRecord::Cte 2 | 3 | ![Rubocop](https://github.com/vlado/activerecord-cte/actions/workflows/rubocop.yml/badge.svg) 4 | ![MySQL](https://github.com/vlado/activerecord-cte/actions/workflows/test-with-mysql.yml/badge.svg) 5 | ![PostgreSQL](https://github.com/vlado/activerecord-cte/actions/workflows/test-with-postgresql.yml/badge.svg) 6 | ![SQLite](https://github.com/vlado/activerecord-cte/actions/workflows/test-with-sqlite.yml/badge.svg) 7 | 8 | Adds [Common Table Expression](https://en.wikipedia.org/wiki/Hierarchical_and_recursive_queries_in_SQL#Common_table_expression) support to ActiveRecord (Rails). 9 | 10 | It adds `.with` query method and makes it super easy to build and chain complex CTE queries. Let's explain it using simple example. 11 | 12 | ```ruby 13 | Post.with( 14 | posts_with_comments: Post.where("comments_count > ?", 0), 15 | posts_with_tags: Post.where("tags_count > ?", 0) 16 | ) 17 | ``` 18 | 19 | Will return `ActiveRecord::Relation` and will generate SQL like this. 20 | 21 | ```SQL 22 | WITH posts_with_comments AS ( 23 | SELECT * FROM posts WHERE (comments_count > 0) 24 | ), posts_with_tags AS ( 25 | SELECT * FROM posts WHERE (tags_count > 0) 26 | ) 27 | SELECT * FROM posts 28 | ``` 29 | 30 | **Please note that this creates the expressions but is not using them yet. See [Taking it further](#taking-it-further) for more info.** 31 | 32 | Without this gem you would need to use `Arel` directly. 33 | 34 | ```ruby 35 | post_with_comments_table = Arel::Table.new(:posts_with_comments) 36 | post_with_comments_expression = Post.arel_table.where(posts_with_comments_table[:comments_count].gt(0)) 37 | post_with_tags_table = Arel::Table.new(:posts_with_tags) 38 | post_with_tags_expression = Post.arel_table.where(posts_with_tags_table[:tags_count].gt(0)) 39 | 40 | Post.all.arel.with([ 41 | Arel::Node::As.new(posts_with_comments_table, posts_with_comments_expression), 42 | Arel::Node::As.new(posts_with_tags_table, posts_with_tags_expression) 43 | ]) 44 | ``` 45 | 46 | Instead of Arel you could also pass raw SQL string but either way you will NOT get `ActiveRecord::Relation` and 47 | you will not be able to chain them further, cache them easily, call `count` and other aggregates on them, ... 48 | 49 | ## Installation 50 | 51 | Add this line to your application's Gemfile: 52 | 53 | ```ruby 54 | gem "activerecord-cte" 55 | ``` 56 | 57 | And then execute: 58 | 59 | $ bundle 60 | 61 | Or install it yourself as: 62 | 63 | $ gem install activerecord-cte 64 | 65 | ## Usage 66 | 67 | ### Hash arguments 68 | 69 | Easiest way to build the `WITH` query is to pass the `Hash` where keys are used as names of the tables and values are used to 70 | generate the SQL. You can pass `ActiveRecord::Relation`, `String` or `Arel::Nodes::As` node. 71 | 72 | ```ruby 73 | Post.with( 74 | posts_with_comments: Post.where("comments_count > ?", 0), 75 | posts_with_tags: "SELECT * FROM posts WHERE tags_count > 0" 76 | ) 77 | # WITH posts_with_comments AS ( 78 | # SELECT * FROM posts WHERE (comments_count > 0) 79 | # ), posts_with_tags AS ( 80 | # SELECT * FROM posts WHERE (tags_count > 0) 81 | # ) 82 | # SELECT * FROM posts 83 | ``` 84 | 85 | ### SQL string 86 | 87 | You can also pass complete CTE as a single SQL string 88 | 89 | ```ruby 90 | Post.with("posts_with_tags AS (SELECT * FROM posts WHERE tags_count > 0)") 91 | # WITH posts_with_tags AS ( 92 | # SELECT * FROM posts WHERE (tags_count > 0) 93 | # ) 94 | # SELECT * FROM posts 95 | ``` 96 | 97 | ### Arel Nodes 98 | 99 | If you already have `Arel::Node::As` node you can just pass it as is 100 | 101 | ```ruby 102 | posts_table = Arel::Table.new(:posts) 103 | cte_table = Arel::Table.new(:posts_with_tags) 104 | cte_select = posts_table.project(Arel.star).where(posts_table[:tags_count].gt(100)) 105 | as = Arel::Nodes::As.new(cte_table, cte_select) 106 | 107 | Post.with(as) 108 | # WITH posts_with_tags AS ( 109 | # SELECT * FROM posts WHERE (tags_count > 0) 110 | # ) 111 | # SELECT * FROM posts 112 | ``` 113 | 114 | You can also pass array of Arel Nodes 115 | 116 | ```ruby 117 | posts_table = Arel::Table.new(:posts) 118 | 119 | with_tags_table = Arel::Table.new(:posts_with_tags) 120 | with_tags_select = posts_table.project(Arel.star).where(posts_table[:tags_count].gt(100)) 121 | as_posts_with_tags = Arel::Nodes::As.new(with_tags_table, with_tags_select) 122 | 123 | with_comments_table = Arel::Table.new(:posts_with_comments) 124 | with_comments_select = posts_table.project(Arel.star).where(posts_table[:comments_count].gt(100)) 125 | as_posts_with_comments = Arel::Nodes::As.new(with_comments_table, with_comments_select) 126 | 127 | Post.with([as_posts_with_tags, as_posts_with_comments]) 128 | # WITH posts_with_comments AS ( 129 | # SELECT * FROM posts WHERE (comments_count > 0) 130 | # ), posts_with_tags AS ( 131 | # SELECT * FROM posts WHERE (tags_count > 0) 132 | # ) 133 | # SELECT * FROM posts 134 | ``` 135 | 136 | ### Taking it further 137 | 138 | As you probably noticed from the examples above `.with` is only a half of the equation. Once we have CTE results we also need to do the select on them somehow. 139 | 140 | You can write custom `FROM` that will alias your CTE table to the table ActiveRecord expects by default (`Post -> posts`) for example. 141 | 142 | ```ruby 143 | Post 144 | .with(posts_with_tags: "SELECT * FROM posts WHERE tags_count > 0") 145 | .from("posts_with_tags AS posts") 146 | # WITH posts_with_tags AS ( 147 | # SELECT * FROM posts WHERE (tags_count > 0) 148 | # ) 149 | # SELECT * FROM posts_with_tags AS posts 150 | 151 | Post 152 | .with(posts_with_tags: "SELECT * FROM posts WHERE tags_count > 0") 153 | .from("posts_with_tags AS posts") 154 | .count 155 | 156 | # WITH posts_with_tags AS ( 157 | # SELECT * FROM posts WHERE (tags_count > 0) 158 | # ) 159 | # SELECT COUNT(*) FROM posts_with_tags AS posts 160 | ``` 161 | 162 | Another option would be to use join 163 | 164 | ```ruby 165 | Post 166 | .with(posts_with_tags: "SELECT * FROM posts WHERE tags_count > 0") 167 | .joins("JOIN posts_with_tags ON posts_with_tags.id = posts.id") 168 | # WITH posts_with_tags AS ( 169 | # SELECT * FROM posts WHERE (tags_count > 0) 170 | # ) 171 | # SELECT * FROM posts JOIN posts_with_tags ON posts_with_tags.id = posts.id 172 | ``` 173 | 174 | There are other options also but that heavily depends on your use case and is out of scope of this README :) 175 | 176 | ### Recursive CTE 177 | 178 | Recursive queries are also supported `Post.with(:recursive, popular_posts: "... union to get popular posts ...")`. 179 | 180 | ```ruby 181 | posts = Arel::Table.new(:posts) 182 | top_posts = Arel::Table.new(:top_posts) 183 | 184 | anchor_term = posts.project(posts[:id]).where(posts[:comments_count].gt(1)) 185 | recursive_term = posts.project(posts[:id]).join(top_posts).on(posts[:id].eq(top_posts[:id])) 186 | 187 | Post.with(:recursive, top_posts: anchor_term.union(recursive_term)).from("top_posts AS posts") 188 | # WITH RECURSIVE "popular_posts" AS ( 189 | # SELECT "posts"."id" FROM "posts" WHERE "posts"."comments_count" > 0 UNION SELECT "posts"."id" FROM "posts" INNER JOIN "popular_posts" ON "posts"."id" = "popular_posts"."id" ) SELECT "posts".* FROM popular_posts AS posts 190 | ``` 191 | 192 | ## Issues 193 | 194 | Please note that `update_all` and `delete_all` methods are not implemented and will not work as expected. I tried to implement them and was succesfull 195 | but the "monkey patching" level was so high that I decided not to keep the implementation. 196 | 197 | If my [Pull Request](https://github.com/rails/rails/pull/37944) gets merged adding them to Rails direcly will be easy and since I did not need them yet 198 | I decided to wait a bit :) 199 | 200 | ## Development 201 | 202 | ### Setup 203 | 204 | After checking out the repo, run `bin/setup` to install dependencies. 205 | 206 | ### Running Rubocop 207 | 208 | ``` 209 | bundle exec rubocop 210 | ``` 211 | 212 | ### Running tests 213 | 214 | To run the tests using SQLite adapter and latest version on Rails run 215 | 216 | ``` 217 | bundle exec rake test 218 | ``` 219 | 220 | GitHub Actions will run the test matrix with multiple ActiveRecord versions and database adapters. You can also run the matrix locally with 221 | 222 | ``` 223 | bundle exec rake test:matrix 224 | ``` 225 | 226 | This will build Docker image with all dependencies and run all tests in it. See `bin/test` for more info. 227 | 228 | ### Console 229 | 230 | You can run `bin/console` for an interactive prompt that will allow you to experiment. 231 | 232 | ### Other 233 | 234 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 235 | 236 | ## Contributing 237 | 238 | Bug reports and pull requests are welcome on GitHub at https://github.com/vlado/activerecord-cte. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 239 | 240 | ## License 241 | 242 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 243 | 244 | ## Code of Conduct 245 | 246 | Everyone interacting in the Activerecord::Cte project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/vlado/activerecord-cte/blob/master/CODE_OF_CONDUCT.md). 247 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList["test/**/*_test.rb"] 10 | end 11 | 12 | task default: :test 13 | 14 | namespace :test do 15 | desc "Will run the tests in all db adapters - AR version combinations" 16 | task :matrix do 17 | require "English" 18 | system("docker-compose build && docker-compose run lib bin/test") 19 | exit($CHILD_STATUS.exitstatus) unless $CHILD_STATUS.success? 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /activerecord-cte.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "activerecord/cte/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "activerecord-cte" 9 | spec.version = Activerecord::Cte::VERSION 10 | spec.authors = ["Vlado Cingel"] 11 | spec.email = ["vladocingel@gmail.com"] 12 | 13 | spec.summary = "Brings Common Table Expressions support to ActiveRecord and makes it super easy to build and chain complex CTE queries" 14 | spec.description = spec.summary 15 | spec.homepage = "https://github.com/vlado/activerecord-cte" 16 | spec.license = "MIT" 17 | 18 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 19 | 20 | spec.metadata["homepage_uri"] = spec.homepage 21 | spec.metadata["source_code_uri"] = "https://github.com/vlado/activerecord-cte" 22 | 23 | spec.required_ruby_version = ">= 1.9.2" 24 | # Specify which files should be added to the gem when it is released. 25 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 26 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 27 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 28 | end 29 | spec.bindir = "exe" 30 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 31 | spec.require_paths = ["lib"] 32 | 33 | spec.add_dependency "activerecord" 34 | 35 | spec.add_development_dependency "bundler", "~> 2.0" 36 | spec.add_development_dependency "minitest", "~> 5.0" 37 | spec.add_development_dependency "rake", "~> 13.0.1" 38 | spec.add_development_dependency "rubocop", "~> 1.17.0" 39 | spec.add_development_dependency "rubocop-minitest", "~> 0.13.0" 40 | spec.add_development_dependency "rubocop-performance", "~> 1.11.3" 41 | spec.add_development_dependency "rubocop-rake", "~> 0.5.1" 42 | spec.add_development_dependency "sqlite3" 43 | end 44 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "activerecord/cte" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "English" 5 | require "yaml" 6 | 7 | active_record_versions = %w[6.1.7.2 6.0.6] 8 | database_adapters = %w[mysql postgresql sqlite3] 9 | 10 | class Matrix 11 | def initialize(active_record_versions, database_adapters) 12 | @active_record_versions = active_record_versions 13 | @database_adapters = database_adapters 14 | @exit_status_code = 0 15 | end 16 | 17 | def run 18 | original_ar_version = `bundle show activerecord`.split("-").last.strip 19 | @active_record_versions.each do |ar_version| 20 | run_with_active_record_version(ar_version) 21 | end 22 | puts "----> Reverting back to original ActiveRecord version (#{original_ar_version})" 23 | cmd("ACTIVE_RECORD_VERSION=#{original_ar_version} bundle update") 24 | 25 | exit(@exit_status_code) unless @exit_status_code.zero? 26 | end 27 | 28 | private 29 | 30 | def cmd(cmd) 31 | system(cmd) 32 | @exit_status_code = $CHILD_STATUS.exitstatus unless $CHILD_STATUS.success? 33 | end 34 | 35 | def run_with_active_record_version(ar_version) 36 | puts "----> Switching ActiveRecord to version #{ar_version}" 37 | cmd("ACTIVE_RECORD_VERSION=#{ar_version} bundle update") 38 | 39 | @database_adapters.each do |adapter| 40 | puts "----> Running tests with ActiveRecord #{ar_version} and #{adapter} adapter" 41 | cmd("DATABASE_ADAPTER=#{adapter} ACTIVE_RECORD_VERSION=#{ar_version} bundle exec rake test") 42 | end 43 | end 44 | end 45 | 46 | Matrix.new(active_record_versions.flatten.uniq, database_adapters.uniq).run 47 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | lib: 5 | build: . 6 | links: 7 | - mysql 8 | - postgres 9 | volumes: 10 | - ".:/activerecord_cte" 11 | 12 | mysql: 13 | image: mysql:8.0 14 | command: mysqld --default-authentication-plugin=mysql_native_password --skip-mysqlx 15 | restart: always 16 | environment: 17 | MYSQL_DATABASE: activerecord_cte_test 18 | MYSQL_USER: root 19 | MYSQL_PASSWORD: root 20 | MYSQL_ROOT_PASSWORD: root 21 | ports: 22 | - 3306:3306 23 | expose: 24 | - 3306 25 | 26 | postgres: 27 | image: postgres:12 28 | restart: always 29 | environment: 30 | POSTGRES_DB: activerecord_cte_test 31 | POSTGRES_USER: postgres 32 | POSTGRES_PASSWORD: postgres 33 | ports: 34 | - 5432:5432 35 | expose: 36 | - 5432 37 | 38 | -------------------------------------------------------------------------------- /lib/activerecord/cte.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support" 4 | require "activerecord/cte/version" 5 | 6 | module Activerecord 7 | module Cte 8 | class Error < StandardError; end 9 | # Your code goes here... 10 | end 11 | end 12 | 13 | ActiveSupport.on_load(:active_record) do 14 | require "activerecord/cte/core_ext" 15 | end 16 | -------------------------------------------------------------------------------- /lib/activerecord/cte/core_ext.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module Querying 5 | delegate :with, to: :all 6 | end 7 | 8 | module WithMerger 9 | def merge 10 | super 11 | merge_withs 12 | relation 13 | end 14 | 15 | private 16 | 17 | def merge_withs 18 | relation.recursive_with = true if other.recursive_with? 19 | other_values = other.with_values.reject { |value| relation.with_values.include?(value) } 20 | relation.with!(*other_values) if other_values.any? 21 | end 22 | end 23 | 24 | class Relation 25 | class Merger 26 | prepend WithMerger 27 | end 28 | 29 | def with(opts, *rest) 30 | spawn.with!(opts, *rest) 31 | end 32 | 33 | def with!(opts, *rest) 34 | if opts == :recursive 35 | self.recursive_with = true 36 | self.with_values += rest 37 | else 38 | self.with_values += [opts] + rest 39 | end 40 | self 41 | end 42 | 43 | def with_values 44 | @values[:with] || [] 45 | end 46 | 47 | def with_values=(values) 48 | raise ImmutableRelation if @loaded 49 | 50 | @values[:with] = values 51 | end 52 | 53 | def recursive_with? 54 | @values[:recursive_with] 55 | end 56 | 57 | def recursive_with=(value) 58 | raise ImmutableRelation if @loaded 59 | 60 | @values[:recursive_with] = value 61 | end 62 | 63 | private 64 | 65 | def build_arel(*args) 66 | arel = super 67 | build_with(arel) if @values[:with] 68 | arel 69 | end 70 | 71 | def build_with(arel) # rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity 72 | return if with_values.empty? 73 | 74 | with_statements = with_values.map do |with_value| 75 | case with_value 76 | when String then Arel::Nodes::SqlLiteral.new(with_value) 77 | when Arel::Nodes::As then with_value 78 | when Hash then build_with_value_from_hash(with_value) 79 | when Array then build_with_value_from_array(with_value) 80 | else 81 | raise ArgumentError, "Unsupported argument type: #{with_value} #{with_value.class}" 82 | end 83 | end 84 | 85 | recursive_with? ? arel.with(:recursive, with_statements) : arel.with(with_statements) 86 | end 87 | 88 | def build_with_value_from_array(array) 89 | unless array.map(&:class).uniq == [Arel::Nodes::As] 90 | raise ArgumentError, "Unsupported argument type: #{array} #{array.class}" 91 | end 92 | 93 | array 94 | end 95 | 96 | def build_with_value_from_hash(hash) # rubocop:disable Metrics/MethodLength 97 | hash.map do |name, value| 98 | table = Arel::Table.new(name) 99 | expression = case value 100 | when String then Arel::Nodes::SqlLiteral.new("(#{value})") 101 | when ActiveRecord::Relation then value.arel 102 | when Arel::SelectManager, Arel::Nodes::Union, Arel::Nodes::UnionAll then value 103 | else 104 | raise ArgumentError, "Unsupported argument type: #{value} #{value.class}" 105 | end 106 | Arel::Nodes::As.new(table, expression) 107 | end 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /lib/activerecord/cte/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Activerecord 4 | module Cte 5 | VERSION = "0.4.0" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/activerecord/cte_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | require "models/post" 6 | 7 | class Activerecord::CteTest < ActiveSupport::TestCase 8 | fixtures :posts 9 | 10 | def test_with_when_hash_is_passed_as_an_argument 11 | popular_posts = Post.where("views_count > 100") 12 | popular_posts_from_cte = Post.with(popular_posts: popular_posts).from("popular_posts AS posts") 13 | assert popular_posts.any? 14 | assert_equal popular_posts.to_a, popular_posts_from_cte 15 | end 16 | 17 | def test_with_when_string_is_passed_as_an_argument 18 | # Guard can be removed when new version that includes https://github.com/rails/rails/pull/42563 is released and configured in test matrix 19 | return if ActiveRecord.version == Gem::Version.create("6.1.7.2") 20 | 21 | popular_posts = Post.where("views_count > 100") 22 | popular_posts_from_cte = Post.with("popular_posts AS (SELECT * FROM posts WHERE views_count > 100)").from("popular_posts AS posts") 23 | assert popular_posts.any? 24 | assert_equal popular_posts.to_a, popular_posts_from_cte 25 | end 26 | 27 | def test_with_when_arel_as_node_is_passed_as_an_argument 28 | popular_posts = Post.where("views_count > 100") 29 | 30 | posts_table = Arel::Table.new(:posts) 31 | cte_table = Arel::Table.new(:popular_posts) 32 | cte_select = posts_table.project(Arel.star).where(posts_table[:views_count].gt(100)) 33 | as = Arel::Nodes::As.new(cte_table, cte_select) 34 | 35 | popular_posts_from_cte = Post.with(as).from("popular_posts AS posts") 36 | 37 | assert popular_posts.any? 38 | assert_equal popular_posts.to_a, popular_posts_from_cte 39 | end 40 | 41 | def test_with_when_array_of_arel_node_as_is_passed_as_an_argument 42 | popular_archived_posts = Post.where("views_count > 100").where(archived: true) 43 | 44 | posts_table = Arel::Table.new(:posts) 45 | first_cte_table = Arel::Table.new(:popular_posts) 46 | first_cte_select = posts_table.project(Arel.star).where(posts_table[:views_count].gt(100)) 47 | first_as = Arel::Nodes::As.new(first_cte_table, first_cte_select) 48 | second_cte_table = Arel::Table.new(:popular_archived_posts) 49 | second_cte_select = first_cte_table.project(Arel.star).where(first_cte_table[:archived].eq(true)) 50 | second_as = Arel::Nodes::As.new(second_cte_table, second_cte_select) 51 | 52 | popular_archived_posts_from_cte = Post.with([first_as, second_as]).from("popular_archived_posts AS posts") 53 | 54 | assert popular_archived_posts.any? 55 | assert_equal popular_archived_posts.to_a, popular_archived_posts_from_cte 56 | end 57 | 58 | def test_with_when_hash_with_multiple_elements_of_different_type_is_passed_as_an_argument 59 | popular_archived_posts_written_in_german = Post.where("views_count > 100").where(archived: true, language: :de) 60 | posts_table = Arel::Table.new(:posts) 61 | cte_options = { 62 | popular_posts: posts_table.project(Arel.star).where(posts_table[:views_count].gt(100)), 63 | popular_posts_written_in_german: "SELECT * FROM popular_posts WHERE language = 'de'", 64 | popular_archived_posts_written_in_german: Post.where(archived: true).from("popular_posts_written_in_german AS posts") 65 | } 66 | popular_archived_posts_written_in_german_from_cte = Post.with(cte_options).from("popular_archived_posts_written_in_german AS posts") 67 | assert popular_archived_posts_written_in_german_from_cte.any? 68 | assert_equal popular_archived_posts_written_in_german.to_a, popular_archived_posts_written_in_german_from_cte 69 | end 70 | 71 | def test_multiple_with_calls 72 | popular_archived_posts = Post.where("views_count > 100").where(archived: true) 73 | popular_archived_posts_from_cte = Post 74 | .with(archived_posts: Post.where(archived: true)) 75 | .with(popular_archived_posts: "SELECT * FROM archived_posts WHERE views_count > 100") 76 | .from("popular_archived_posts AS posts") 77 | assert popular_archived_posts_from_cte.any? 78 | assert_equal popular_archived_posts.to_a, popular_archived_posts_from_cte 79 | end 80 | 81 | def test_multiple_with_calls_randomly_callled 82 | popular_archived_posts = Post.where("views_count > 100").where(archived: true) 83 | popular_archived_posts_from_cte = Post 84 | .with(archived_posts: Post.where(archived: true)) 85 | .from("popular_archived_posts AS posts") 86 | .with(popular_archived_posts: "SELECT * FROM archived_posts WHERE views_count > 100") 87 | assert popular_archived_posts.any? 88 | assert_equal popular_archived_posts.to_a, popular_archived_posts_from_cte 89 | end 90 | 91 | def test_recursive_with_call 92 | posts = Arel::Table.new(:posts) 93 | popular_posts = Arel::Table.new(:popular_posts) 94 | anchor_term = posts.project(posts[:id]).where(posts[:views_count].gt(100)) 95 | recursive_term = posts.project(posts[:id]).join(popular_posts).on(posts[:id].eq(popular_posts[:id])) 96 | 97 | recursive_rel = Post.with(:recursive, popular_posts: anchor_term.union(recursive_term)).from("popular_posts AS posts") 98 | assert_equal Post.select(:id).where("views_count > 100").to_a, recursive_rel 99 | end 100 | 101 | def test_recursive_with_call_union_all 102 | posts = Arel::Table.new(:posts) 103 | popular_posts = Arel::Table.new(:popular_posts) 104 | anchor_term = posts.project(posts[:id]).where(posts[:views_count].gt(100)) 105 | recursive_term = posts.project(posts[:id]).join(popular_posts).on(posts[:id].eq(popular_posts[:id])) 106 | 107 | recursive_rel = Post.with(:recursive, popular_posts: anchor_term.union(:all, recursive_term)).from("popular_posts AS posts") 108 | assert_includes recursive_rel.to_sql, "UNION ALL" 109 | end 110 | 111 | def test_recursive_is_preserved_on_multiple_with_calls 112 | posts = Arel::Table.new(:posts) 113 | popular_posts = Arel::Table.new(:popular_posts) 114 | anchor_term = posts.project(posts[:id], posts[:archived]).where(posts[:views_count].gt(100)) 115 | recursive_term = posts.project(posts[:id], posts[:archived]).join(popular_posts).on(posts[:id].eq(popular_posts[:id])) 116 | 117 | recursive_rel = Post.with(:recursive, popular_posts: anchor_term.union(recursive_term)).from("popular_posts AS posts") 118 | 119 | assert_equal Post.select(:id).where("views_count > 100").to_a, recursive_rel 120 | assert_equal Post.select(:id).where("views_count > 100").where(archived: true).to_a, recursive_rel.where(archived: true) 121 | end 122 | 123 | def test_multiple_with_calls_with_recursive_and_non_recursive_queries 124 | posts = Arel::Table.new(:posts) 125 | popular_posts = Arel::Table.new(:popular_posts) 126 | anchor_term = posts.project(posts[:id]).where(posts[:views_count].gt(100)) 127 | recursive_term = posts.project(posts[:id]).join(popular_posts).on(posts[:id].eq(popular_posts[:id])) 128 | 129 | archived_popular_posts = Post 130 | .with(archived_posts: Post.where(archived: true)) 131 | .with(:recursive, popular_posts: anchor_term.union(recursive_term)) 132 | .from("popular_posts AS posts") 133 | .joins("INNER JOIN archived_posts ON archived_posts.id = posts.id") 134 | 135 | assert archived_popular_posts.to_sql.start_with?("WITH RECURSIVE ") 136 | assert_equal posts(:two, :three).pluck(:id).sort, archived_popular_posts.to_a.pluck(:id).sort 137 | end 138 | 139 | def test_recursive_with_query_called_as_non_recursive 140 | # Recursive queries works in SQLite without RECURSIVE 141 | return if ActiveRecord::Base.connection.adapter_name == "SQLite" 142 | 143 | posts = Arel::Table.new(:posts) 144 | popular_posts = Arel::Table.new(:popular_posts) 145 | anchor_term = posts.project(posts[:id]).where(posts[:views_count].gt(100)) 146 | recursive_term = posts.project(posts[:id]).join(popular_posts).on(posts[:id].eq(popular_posts[:id])) 147 | 148 | non_recursive_rel = Post.with(popular_posts: anchor_term.union(recursive_term)).from("popular_posts AS posts") 149 | assert_raise ActiveRecord::StatementInvalid do 150 | non_recursive_rel.load 151 | end 152 | end 153 | 154 | def test_count_after_with_call 155 | posts_count = Post.all.count 156 | popular_posts_count = Post.where("views_count > 100").count 157 | assert posts_count > popular_posts_count 158 | assert popular_posts_count.positive? 159 | 160 | with_relation = Post.with(popular_posts: Post.where("views_count > 100")) 161 | assert_equal posts_count, with_relation.count 162 | assert_equal popular_posts_count, with_relation.from("popular_posts AS posts").count 163 | assert_equal popular_posts_count, with_relation.joins("JOIN popular_posts ON popular_posts.id = posts.id").count 164 | end 165 | 166 | def test_with_when_called_from_active_record_scope 167 | popular_posts = Post.where("views_count > 100") 168 | assert_equal popular_posts.to_a, Post.popular_posts 169 | end 170 | 171 | def test_with_when_invalid_params_are_passed 172 | assert_raise(ArgumentError) { Post.with.load } 173 | assert_raise(ArgumentError) { Post.with([{ popular_posts: Post.where("views_count > 100") }]).load } 174 | assert_raise(ArgumentError) { Post.with(popular_posts: nil).load } 175 | assert_raise(ArgumentError) { Post.with(popular_posts: [Post.where("views_count > 100")]).load } 176 | end 177 | 178 | def test_with_when_merging_relations 179 | most_popular = Post.with(most_popular: Post.where("views_count >= 100").select("id as post_id")).joins("join most_popular on most_popular.post_id = posts.id") 180 | least_popular = Post.with(least_popular: Post.where("views_count <= 400").select("id as post_id")).joins("join least_popular on least_popular.post_id = posts.id") 181 | merged = most_popular.merge(least_popular) 182 | 183 | assert_equal(1, merged.size) 184 | assert_equal(123, merged[0].views_count) 185 | end 186 | 187 | def test_with_when_merging_relations_with_identical_with_names_and_identical_queries 188 | most_popular1 = Post.with(most_popular: Post.where("views_count >= 100")) 189 | most_popular2 = Post.with(most_popular: Post.where("views_count >= 100")) 190 | 191 | merged = most_popular1.merge(most_popular2).from("most_popular as posts") 192 | 193 | assert_equal posts(:two, :three, :four).sort, merged.sort 194 | end 195 | 196 | def test_with_when_merging_relations_with_a_mixture_of_strings_and_relations 197 | most_popular1 = Post.with(most_popular: Post.where(views_count: 456)) 198 | most_popular2 = Post.with(most_popular: Post.where("views_count = 456")) 199 | 200 | merged = most_popular1.merge(most_popular2) 201 | 202 | assert_raise ActiveRecord::StatementInvalid do 203 | merged.load 204 | end 205 | end 206 | 207 | def test_with_when_merging_relations_with_identical_with_names_and_different_queries 208 | most_popular1 = Post.with(most_popular: Post.where("views_count >= 100")) 209 | most_popular2 = Post.with(most_popular: Post.where("views_count <= 100")) 210 | 211 | merged = most_popular1.merge(most_popular2) 212 | 213 | assert_raise ActiveRecord::StatementInvalid do 214 | merged.load 215 | end 216 | end 217 | 218 | def test_with_when_merging_relations_with_recursive_and_non_recursive_queries 219 | non_recursive_rel = Post.with(archived_posts: Post.where(archived: true)) 220 | 221 | posts = Arel::Table.new(:posts) 222 | popular_posts = Arel::Table.new(:popular_posts) 223 | anchor_term = posts.project(posts[:id]).where(posts[:views_count].gt(100)) 224 | recursive_term = posts.project(posts[:id]).join(popular_posts).on(posts[:id].eq(popular_posts[:id])) 225 | recursive_rel = Post.with(:recursive, popular_posts: anchor_term.union(recursive_term)) 226 | 227 | merged_rel = non_recursive_rel 228 | .merge(recursive_rel) 229 | .from("popular_posts AS posts") 230 | .joins("INNER JOIN archived_posts ON archived_posts.id = posts.id") 231 | 232 | assert merged_rel.to_sql.start_with?("WITH RECURSIVE ") 233 | assert_equal posts(:two, :three).pluck(:id).sort, merged_rel.to_a.pluck(:id).sort 234 | end 235 | 236 | def test_update_all_works_as_expected 237 | Post.with(most_popular: Post.where("views_count >= 100")).update_all(views_count: 123) 238 | assert_equal [123], Post.pluck(Arel.sql("DISTINCT views_count")) 239 | end 240 | 241 | def test_delete_all_works_as_expected 242 | Post.with(most_popular: Post.where("views_count >= 100")).delete_all 243 | assert_equal 0, Post.count 244 | end 245 | end 246 | -------------------------------------------------------------------------------- /test/database.yml: -------------------------------------------------------------------------------- 1 | mysql: 2 | adapter: mysql2 3 | database: activerecord_cte_test 4 | username: root 5 | password: root 6 | host: <%= ENV.fetch("MYSQL_HOST", "localhost") %> 7 | port: 3306 8 | 9 | postgresql: 10 | adapter: postgresql 11 | encoding: unicode 12 | database: activerecord_cte_test 13 | username: postgres 14 | password: postgres 15 | host: postgres 16 | 17 | sqlite3: 18 | adapter: sqlite3 19 | database: tmp/activerecord_cte_test.db 20 | -------------------------------------------------------------------------------- /test/fixtures/posts.yml: -------------------------------------------------------------------------------- 1 | one: 2 | archived: false 3 | views_count: 90 4 | language: en 5 | 6 | two: 7 | archived: true 8 | views_count: 123 9 | language: en 10 | 11 | three: 12 | archived: true 13 | views_count: 456 14 | language: de 15 | 16 | four: 17 | archived: false 18 | views_count: 500 19 | language: de 20 | -------------------------------------------------------------------------------- /test/models/post.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Post < ActiveRecord::Base 4 | scope :popular_posts, -> { with(popular_posts: where("views_count > 100")).from("popular_posts AS posts") } 5 | end 6 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | 5 | ENV["RAILS_ENV"] = "test" 6 | 7 | require "active_record" 8 | require "active_record/fixtures" 9 | require "active_support/test_case" 10 | 11 | require "activerecord/cte" 12 | 13 | require "active_support/testing/autorun" 14 | 15 | # Suppress keyword parameters warnings for ActiveRecord < 6.0.3 16 | # Otherwise test output is flooded with warnings like: 17 | # warning: Using the last argument as keyword parameters is deprecated; maybe ** should be added to the call 18 | if Warning.respond_to?("[]=") && ENV["ACTIVE_RECORD_VERSION"] && ENV["ACTIVE_RECORD_VERSION"] < "6.0.3" 19 | Warning[:deprecated] = false 20 | end 21 | 22 | adapter = ENV.fetch("DATABASE_ADAPTER", "sqlite3") 23 | db_config = YAML.safe_load(ERB.new(File.read("test/database.yml")).result)[adapter] 24 | 25 | ActiveRecord::Base.configurations = { "test" => db_config } # Key must be string for older AR versions 26 | ActiveRecord::Tasks::DatabaseTasks.create(db_config) if %w[postgresql mysql].include?(adapter) 27 | ActiveRecord::Base.establish_connection(:test) 28 | 29 | ActiveSupport.on_load(:active_support_test_case) do 30 | include ActiveRecord::TestFixtures 31 | self.fixture_path = "test/fixtures/" 32 | 33 | ActiveSupport::TestCase.test_order = :random 34 | end 35 | 36 | ActiveRecord::Schema.define do 37 | create_table :posts, force: true do |t| 38 | t.boolean :archived, default: false 39 | t.integer :views_count 40 | t.string :language, default: :en 41 | t.timestamps 42 | end 43 | end 44 | --------------------------------------------------------------------------------