├── .bundle └── config ├── .circleci └── config.yml ├── .gitignore ├── .pryrc ├── .rspec ├── Appraisals ├── CHANGES.md ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── de ├── docker-compose.yml ├── gemfiles ├── .bundle │ └── config ├── ar_7.0.gemfile ├── ar_7.1.gemfile ├── ar_7.2.gemfile └── ar_8.0.gemfile ├── lib ├── pg_party.rb └── pg_party │ ├── adapter │ ├── abstract_methods.rb │ └── postgresql_methods.rb │ ├── adapter_decorator.rb │ ├── cache.rb │ ├── config.rb │ ├── hacks │ └── postgresql_database_tasks.rb │ ├── model │ ├── hash_methods.rb │ ├── list_methods.rb │ ├── methods.rb │ ├── range_methods.rb │ └── shared_methods.rb │ ├── model_decorator.rb │ ├── model_injector.rb │ └── version.rb ├── pg_party.gemspec └── spec ├── adapter ├── abstract_methods_spec.rb └── postgresql_methods_spec.rb ├── cache_spec.rb ├── config_spec.rb ├── dummy ├── app │ └── models │ │ ├── application_record.rb │ │ ├── bigint_boolean_list.rb │ │ ├── bigint_custom_id_int_list.rb │ │ ├── bigint_custom_id_int_range.rb │ │ ├── bigint_date_range.rb │ │ ├── bigint_date_range_no_partition.rb │ │ ├── bigint_int_list_date_range_subpartition.rb │ │ ├── bigint_month_range.rb │ │ ├── no_pk_substring_list.rb │ │ ├── uuid_string_list.rb │ │ └── uuid_string_range.rb ├── config │ └── database.yml ├── db │ └── schema.rb └── log │ └── .gitignore ├── integration ├── migration_hash_and_index_spec.rb ├── migration_spec.rb ├── model │ ├── bigint_boolean_list_spec.rb │ ├── bigint_custom_id_int_list_spec.rb │ ├── bigint_custom_id_int_range_spec.rb │ ├── bigint_date_range_no_partition_spec.rb │ ├── bigint_date_range_spec.rb │ ├── bigint_int_list_date_range_subpartition_spec.rb │ ├── bigint_month_range_spec.rb │ ├── no_pk_substring_list_spec.rb │ ├── uuid_string_list_spec.rb │ └── uuid_string_range_spec.rb ├── structure_dump_spec.rb └── threading_spec.rb ├── model ├── hash_methods_spec.rb ├── list_methods_spec.rb ├── methods_spec.rb ├── range_methods_spec.rb └── shared_methods_spec.rb ├── model_injector_spec.rb ├── pg_party_spec.rb ├── spec_helper.rb └── support ├── db.rake ├── heredoc_matcher.rb ├── pg_dump_helper.rb └── uuid_matcher.rb /.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | shared_config: &shared_config 2 | machine: 3 | image: default 4 | 5 | steps: 6 | - checkout 7 | 8 | - run: 9 | name: Prepare and start containers 10 | command: docker-compose up -d 11 | 12 | - run: 13 | name: Install global gems 14 | command: bin/de bundle 15 | 16 | - run: 17 | name: Install appraisal gems 18 | command: bin/de appraisal 19 | 20 | - run: 21 | name: Notify CC that tests are about to run 22 | command: | 23 | set +e 24 | [ "$UPLOAD_COVERAGE" = "true" ] && 25 | bin/de cc-reporter before-build 26 | /bin/true 27 | 28 | - run: 29 | name: Run tests 30 | command: bin/de appraisal rake ci 31 | 32 | - store_test_results: 33 | path: spec/results 34 | 35 | - store_artifacts: 36 | path: coverage 37 | 38 | - run: 39 | name: Attempting to upload coverage report 40 | when: always 41 | command: | 42 | set +e 43 | [ "$UPLOAD_COVERAGE" = "true" ] && 44 | [ -d "coverage" ] && 45 | bin/de cc-reporter after-build -t simplecov 46 | /bin/true 47 | 48 | version: 2 49 | jobs: 50 | build-ruby-3.2-pg-13: 51 | <<: *shared_config 52 | 53 | environment: 54 | CONTAINER_RUBY_VERSION: "3.2" 55 | CONTAINER_PG_VERSION: 13 56 | 57 | build-ruby-3.2-pg-14: 58 | <<: *shared_config 59 | 60 | environment: 61 | CONTAINER_RUBY_VERSION: "3.2" 62 | CONTAINER_PG_VERSION: 14 63 | 64 | build-ruby-3.2-pg-15: 65 | <<: *shared_config 66 | 67 | environment: 68 | CONTAINER_RUBY_VERSION: "3.2" 69 | CONTAINER_PG_VERSION: 15 70 | 71 | build-ruby-3.2-pg-16: 72 | <<: *shared_config 73 | 74 | environment: 75 | CONTAINER_RUBY_VERSION: "3.2" 76 | CONTAINER_PG_VERSION: 15 77 | 78 | build-ruby-3.2-pg-17: 79 | <<: *shared_config 80 | 81 | environment: 82 | CONTAINER_RUBY_VERSION: "3.2" 83 | CONTAINER_PG_VERSION: 17 84 | 85 | build-ruby-latest-pg-13: 86 | <<: *shared_config 87 | 88 | environment: 89 | CONTAINER_RUBY_VERSION: latest 90 | CONTAINER_PG_VERSION: 13 91 | 92 | build-ruby-latest-pg-14: 93 | <<: *shared_config 94 | 95 | environment: 96 | CONTAINER_RUBY_VERSION: latest 97 | CONTAINER_PG_VERSION: 14 98 | 99 | build-ruby-latest-pg-15: 100 | <<: *shared_config 101 | 102 | environment: 103 | CONTAINER_RUBY_VERSION: latest 104 | CONTAINER_PG_VERSION: 15 105 | 106 | build-ruby-latest-pg-16: 107 | <<: *shared_config 108 | 109 | environment: 110 | CONTAINER_RUBY_VERSION: latest 111 | CONTAINER_PG_VERSION: 16 112 | 113 | build-ruby-latest-pg-17: 114 | <<: *shared_config 115 | 116 | environment: 117 | CONTAINER_RUBY_VERSION: latest 118 | CONTAINER_PG_VERSION: 17 119 | UPLOAD_COVERAGE: true 120 | 121 | workflows: 122 | version: 2 123 | build_matrix: 124 | jobs: 125 | - build-ruby-3.2-pg-13 126 | - build-ruby-3.2-pg-14 127 | - build-ruby-3.2-pg-15 128 | - build-ruby-3.2-pg-16 129 | - build-ruby-3.2-pg-17 130 | - build-ruby-latest-pg-13 131 | - build-ruby-latest-pg-14 132 | - build-ruby-latest-pg-15 133 | - build-ruby-latest-pg-16 134 | - build-ruby-latest-pg-17 135 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.yardoc 2 | /Gemfile.lock 3 | /gemfiles/*.lock 4 | /gemfiles/bin 5 | /_yardoc/ 6 | /coverage/ 7 | /doc/ 8 | /pkg/ 9 | /spec/results/ 10 | /spec/dummy/db/structure.sql 11 | spec/dummy/tmp/ 12 | /tmp/ 13 | /.ruby-version 14 | /*.gem 15 | 16 | # rspec failure tracking 17 | .rspec_status 18 | -------------------------------------------------------------------------------- /.pryrc: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if defined?(PryByebug) 4 | Pry.commands.alias_command 'c', 'continue' 5 | Pry.commands.alias_command 's', 'step' 6 | Pry.commands.alias_command 'n', 'next' 7 | Pry.commands.alias_command 'f', 'finish' 8 | end 9 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format progress 2 | --color 3 | --order rand 4 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise "ar-7.0" do 4 | gem "activerecord", "~> 7.0.0" 5 | end 6 | 7 | appraise "ar-7.1" do 8 | gem "activerecord", "~> 7.1.0" 9 | end 10 | 11 | appraise "ar-7.2" do 12 | gem "activerecord", "~> 7.2.0" 13 | end 14 | 15 | appraise "ar-8.0" do 16 | gem "activerecord", "~> 8.0.0" 17 | end 18 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 1.4.0 - Postgres 11+ feature support and enhancements 2 | #### Full Postgres 11 feature support has been added with complete backward compatibility 3 | * When starting a fresh project with Postgres 11 or higher, you can disable template tables and enable primary key constraints on partitioned tables: 4 | * `config.create_template_tables` now governs whether template tables are created by default (defaults true). 5 | * `config.create_with_primary_key` passed down primary key options to CREATE TABLE, including support for composite primary keys 6 | * `create_hash_partition`, `create_hash_partition_of`, and `attach_hash_partition` methods provide support for Hash partitions 7 | * `create_default_partition_of` and `attach_default_partition` allows adding of default partitions to range and list partitioned tables 8 | #### Full support for Subpartitioning 9 | * `create_x_partition_of` methods now support `partition_type` and `partition_key`, which may be supplied to create 10 | a partitioned child table. 11 | * Use `create_x_partition_of` with the child table name to add a partition to your subpartition 12 | * Template tables are supported in that nested subpartitions will inherit from the top-level ancestor's template table, if found 13 | * The `partitions` command now accepts an `include_subpartitions:` option which defaults to false for backward compatibility 14 | * Use `config.include_subpartitions_in_partition_list = true` to override the default 15 | #### `add_index_on_all_partitions` 16 | * Use this new adapter method in migrations to add an index on all partitions and subpartitions automatically 17 | * This method supports `algorithm: :concurrently` to perform uptime operations, so even when using Postgres 11+ it is needed to avoid table locks. 18 | * If you have many partitions, use the optional `in_threads:` option to parallelize index creation via the `parallel` gem 19 | #### Minor enhancements 20 | * Added adapter methods `partitions_for_table_name`, `parent_for_table_name`, and `table_partitioned?` to assist automating 21 | partition management, especially where subpartitions are involved 22 | -------------------------------------------------------------------------------- /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 krage.ryan@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 | ARG CONTAINER_RUBY_VERSION 2 | FROM ruby:$CONTAINER_RUBY_VERSION 3 | 4 | ARG CONTAINER_PG_VERSION 5 | 6 | RUN export DEBIAN_CODENAME=$(cat /etc/os-release | grep "VERSION=" | cut -d "(" -f2 | cut -d ")" -f1) && \ 7 | echo "deb http://apt.postgresql.org/pub/repos/apt/ $DEBIAN_CODENAME-pgdg main" >> /etc/apt/sources.list.d/pgdg.list && \ 8 | wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \ 9 | apt-get update && \ 10 | apt-get install -y --fix-missing --no-install-recommends \ 11 | less \ 12 | postgresql-client-$CONTAINER_PG_VERSION && \ 13 | rm -rf /var/lib/apt/lists/* 14 | 15 | RUN curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > /usr/local/bin/cc-reporter && \ 16 | chmod +x /usr/local/bin/cc-reporter 17 | 18 | RUN mkdir /code 19 | 20 | WORKDIR /code 21 | 22 | COPY . /code 23 | 24 | RUN gem install rubygems-bundler && \ 25 | bundle install && \ 26 | gem regenerate_binstubs 27 | 28 | RUN rm -rf * 29 | 30 | ENV PATH "/code/bin:$PATH" 31 | 32 | CMD /bin/sleep infinity 33 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0.0" 6 | gem "pg", "~> 1.1" 7 | 8 | gemspec 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Ryan Krage 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 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | RSpec::Core::RakeTask.new(:ci) do |t| 9 | ENV["CODE_COVERAGE"] = "true" 10 | 11 | gemfile = File.basename(ENV.fetch("BUNDLE_GEMFILE", ""), ".gemfile") 12 | 13 | if gemfile.empty? || gemfile == "Gemfile" 14 | output_prefix = "default" 15 | else 16 | output_prefix = gemfile 17 | end 18 | 19 | t.rspec_opts = [ 20 | "--format progress", 21 | "--format RspecJunitFormatter", 22 | "--no-color", 23 | "-o spec/results/#{output_prefix}_rspec.xml" 24 | ] 25 | end 26 | 27 | task :default => :spec 28 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # frozen_string_literal: true 4 | 5 | ENV["RAILS_ENV"] ||= "development" 6 | 7 | require "bundler/setup" 8 | require "combustion" 9 | 10 | Combustion.path = "spec/dummy" 11 | Combustion.initialize! :active_record 12 | 13 | require "pry-byebug" 14 | Pry.start 15 | -------------------------------------------------------------------------------- /bin/de: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | docker-compose exec code "$@" 3 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.3" 2 | services: 3 | postgres: 4 | image: postgres:${CONTAINER_PG_VERSION:-16} 5 | environment: 6 | - POSTGRES_PASSWORD=postgres 7 | code: 8 | build: 9 | context: . 10 | args: 11 | - CONTAINER_PG_VERSION=${CONTAINER_PG_VERSION:-16} 12 | - CONTAINER_RUBY_VERSION=${CONTAINER_RUBY_VERSION:-3} 13 | image: pg_party 14 | environment: 15 | - CC_TEST_REPORTER_ID 16 | links: 17 | - postgres 18 | volumes: 19 | - .:/code 20 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | -------------------------------------------------------------------------------- /gemfiles/ar_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.0.0" 6 | gem "pg", "~> 1.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/ar_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.1.0" 6 | gem "pg", "~> 1.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/ar_7.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 7.2.0" 6 | gem "pg", "~> 1.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /gemfiles/ar_8.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 8.0.0" 6 | gem "pg", "~> 1.1" 7 | 8 | gemspec path: "../" 9 | -------------------------------------------------------------------------------- /lib/pg_party.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pg_party/version" 4 | require "pg_party/config" 5 | require "pg_party/cache" 6 | require "active_support" 7 | 8 | module PgParty 9 | @config = Config.new 10 | @cache = Cache.new 11 | 12 | class << self 13 | attr_reader :config, :cache 14 | 15 | def configure(&blk) 16 | blk.call(config) 17 | end 18 | 19 | def reset 20 | @config = Config.new 21 | @cache = Cache.new 22 | end 23 | end 24 | end 25 | 26 | ActiveSupport.on_load(:active_record) do 27 | require "pg_party/model/methods" 28 | 29 | extend PgParty::Model::Methods 30 | 31 | require "pg_party/adapter/abstract_methods" 32 | 33 | ActiveRecord::ConnectionAdapters::AbstractAdapter.include( 34 | PgParty::Adapter::AbstractMethods 35 | ) 36 | 37 | require "active_record/tasks/postgresql_database_tasks" 38 | require "pg_party/hacks/postgresql_database_tasks" 39 | 40 | ActiveRecord::Tasks::PostgreSQLDatabaseTasks.prepend( 41 | PgParty::Hacks::PostgreSQLDatabaseTasks 42 | ) 43 | 44 | begin 45 | require "active_record/connection_adapters/postgresql_adapter" 46 | require "pg_party/adapter/postgresql_methods" 47 | 48 | ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.include( 49 | PgParty::Adapter::PostgreSQLMethods 50 | ) 51 | rescue LoadError 52 | # migration methods will not be available 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/pg_party/adapter/abstract_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PgParty 4 | module Adapter 5 | module AbstractMethods 6 | def create_range_partition(*) 7 | raise "#create_range_partition is not implemented" 8 | end 9 | 10 | def create_list_partition(*) 11 | raise "#create_list_partition is not implemented" 12 | end 13 | 14 | def create_hash_partition(*) 15 | raise "#create_hash_partition is not implemented" 16 | end 17 | 18 | def create_range_partition_of(*) 19 | raise "#create_range_partition_of is not implemented" 20 | end 21 | 22 | def create_list_partition_of(*) 23 | raise "#create_list_partition_of is not implemented" 24 | end 25 | 26 | def create_hash_partition_of(*) 27 | raise "#create_hash_partition_of is not implemented" 28 | end 29 | 30 | def create_default_partition_of(*) 31 | raise "#create_default_partition_of is not implemented" 32 | end 33 | 34 | def create_table_like(*) 35 | raise "#create_table_like is not implemented" 36 | end 37 | 38 | def attach_range_partition(*) 39 | raise "#attach_range_partition is not implemented" 40 | end 41 | 42 | def attach_list_partition(*) 43 | raise "#attach_list_partition is not implemented" 44 | end 45 | 46 | def attach_hash_partition(*) 47 | raise "#attach_hash_partition is not implemented" 48 | end 49 | 50 | def attach_default_partition(*) 51 | raise "#attach_default_partition is not implemented" 52 | end 53 | 54 | def detach_partition(*) 55 | raise "#detach_partition is not implemented" 56 | end 57 | 58 | def parent_for_table_name(*) 59 | raise "#parent_for_table_name is not implemented" 60 | end 61 | 62 | def partitions_for_table_name(*) 63 | raise "#partitions_for_table_name is not implemented" 64 | end 65 | 66 | def add_index_on_all_partitions(*) 67 | raise "#add_index_on_all_partitions is not implemented" 68 | end 69 | 70 | def table_partitioned?(*) 71 | raise "#table_partitioned? is not implemented" 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/pg_party/adapter/postgresql_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pg_party/adapter_decorator" 4 | 5 | module PgParty 6 | module Adapter 7 | module PostgreSQLMethods 8 | ruby2_keywords def create_range_partition(*args, &blk) 9 | PgParty::AdapterDecorator.new(self).create_range_partition(*args, &blk) 10 | end 11 | 12 | ruby2_keywords def create_list_partition(*args, &blk) 13 | PgParty::AdapterDecorator.new(self).create_list_partition(*args, &blk) 14 | end 15 | 16 | ruby2_keywords def create_hash_partition(*args, &blk) 17 | PgParty::AdapterDecorator.new(self).create_hash_partition(*args, &blk) 18 | end 19 | 20 | ruby2_keywords def create_range_partition_of(*args) 21 | PgParty::AdapterDecorator.new(self).create_range_partition_of(*args) 22 | end 23 | 24 | ruby2_keywords def create_list_partition_of(*args) 25 | PgParty::AdapterDecorator.new(self).create_list_partition_of(*args) 26 | end 27 | 28 | ruby2_keywords def create_hash_partition_of(*args) 29 | PgParty::AdapterDecorator.new(self).create_hash_partition_of(*args) 30 | end 31 | 32 | ruby2_keywords def create_default_partition_of(*args) 33 | PgParty::AdapterDecorator.new(self).create_default_partition_of(*args) 34 | end 35 | 36 | ruby2_keywords def create_table_like(*args) 37 | PgParty::AdapterDecorator.new(self).create_table_like(*args) 38 | end 39 | 40 | ruby2_keywords def attach_range_partition(*args) 41 | PgParty::AdapterDecorator.new(self).attach_range_partition(*args) 42 | end 43 | 44 | ruby2_keywords def attach_list_partition(*args) 45 | PgParty::AdapterDecorator.new(self).attach_list_partition(*args) 46 | end 47 | 48 | ruby2_keywords def attach_hash_partition(*args) 49 | PgParty::AdapterDecorator.new(self).attach_hash_partition(*args) 50 | end 51 | 52 | ruby2_keywords def attach_default_partition(*args) 53 | PgParty::AdapterDecorator.new(self).attach_default_partition(*args) 54 | end 55 | 56 | ruby2_keywords def detach_partition(*args) 57 | PgParty::AdapterDecorator.new(self).detach_partition(*args) 58 | end 59 | 60 | ruby2_keywords def partitions_for_table_name(*args) 61 | PgParty::AdapterDecorator.new(self).partitions_for_table_name(*args) 62 | end 63 | 64 | ruby2_keywords def parent_for_table_name(*args) 65 | PgParty::AdapterDecorator.new(self).parent_for_table_name(*args) 66 | end 67 | 68 | ruby2_keywords def add_index_on_all_partitions(*args) 69 | PgParty::AdapterDecorator.new(self).add_index_on_all_partitions(*args) 70 | end 71 | 72 | ruby2_keywords def table_partitioned?(*args) 73 | PgParty::AdapterDecorator.new(self).table_partitioned?(*args) 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/pg_party/adapter_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "digest" 4 | require 'parallel' 5 | 6 | module PgParty 7 | class AdapterDecorator < SimpleDelegator 8 | SUPPORTED_PARTITION_TYPES = %i[range list hash].freeze 9 | 10 | def initialize(adapter) 11 | super(adapter) 12 | 13 | raise "Partitioning only supported in PostgreSQL >= 10.0" unless supports_partitions? 14 | end 15 | 16 | def create_range_partition(table_name, partition_key:, **options, &blk) 17 | create_partition(table_name, :range, partition_key, **options, &blk) 18 | end 19 | 20 | def create_list_partition(table_name, partition_key:, **options, &blk) 21 | create_partition(table_name, :list, partition_key, **options, &blk) 22 | end 23 | 24 | def create_hash_partition(table_name, partition_key:, **options, &blk) 25 | create_partition(table_name, :hash, partition_key, **options, &blk) 26 | end 27 | 28 | def create_range_partition_of(table_name, start_range:, end_range:, **options) 29 | create_partition_of(table_name, range_constraint_clause(start_range, end_range), **options) 30 | end 31 | 32 | def create_list_partition_of(table_name, values:, **options) 33 | create_partition_of(table_name, list_constraint_clause(values), **options) 34 | end 35 | 36 | def create_hash_partition_of(table_name, modulus:, remainder:, **options) 37 | create_partition_of(table_name, hash_constraint_clause(modulus, remainder), **options) 38 | end 39 | 40 | def create_default_partition_of(table_name, **options) 41 | create_partition_of(table_name, nil, default_partition: true, **options) 42 | end 43 | 44 | def create_table_like(table_name, new_table_name, **options) 45 | primary_key = options.fetch(:primary_key) { calculate_primary_key(table_name) } 46 | partition_key = options.fetch(:partition_key, nil) 47 | partition_type = options.fetch(:partition_type, nil) 48 | create_with_pks = options.fetch( 49 | :create_with_primary_key, 50 | PgParty.config.create_with_primary_key 51 | ) 52 | 53 | validate_primary_key(primary_key) unless create_with_pks 54 | if partition_type 55 | validate_supported_partition_type!(partition_type) 56 | raise ArgumentError, '`partition_key` is required when specifying a partition_type' unless partition_key 57 | end 58 | 59 | like_option = if !partition_type || create_with_pks 60 | 'INCLUDING ALL' 61 | else 62 | 'INCLUDING ALL EXCLUDING INDEXES' 63 | end 64 | 65 | execute(<<-SQL) 66 | CREATE TABLE #{quote_table_name(new_table_name)} ( 67 | LIKE #{quote_table_name(table_name)} #{like_option} 68 | ) #{partition_type ? partition_by_clause(partition_type, partition_key) : nil} 69 | SQL 70 | 71 | return if partition_type 72 | return if !primary_key 73 | return if has_primary_key?(new_table_name) 74 | 75 | execute(<<-SQL) 76 | ALTER TABLE #{quote_table_name(new_table_name)} 77 | ADD PRIMARY KEY (#{quote_column_name(primary_key)}) 78 | SQL 79 | end 80 | 81 | def attach_range_partition(parent_table_name, child_table_name, start_range:, end_range:) 82 | attach_partition(parent_table_name, child_table_name, range_constraint_clause(start_range, end_range)) 83 | end 84 | 85 | def attach_list_partition(parent_table_name, child_table_name, values:) 86 | attach_partition(parent_table_name, child_table_name, list_constraint_clause(values)) 87 | end 88 | 89 | def attach_hash_partition(parent_table_name, child_table_name, modulus:, remainder:) 90 | attach_partition(parent_table_name, child_table_name, hash_constraint_clause(modulus, remainder)) 91 | end 92 | 93 | def attach_default_partition(parent_table_name, child_table_name) 94 | execute(<<-SQL) 95 | ALTER TABLE #{quote_table_name(parent_table_name)} 96 | ATTACH PARTITION #{quote_table_name(child_table_name)} 97 | DEFAULT 98 | SQL 99 | 100 | PgParty.cache.clear! 101 | end 102 | 103 | def detach_partition(parent_table_name, child_table_name) 104 | execute(<<-SQL) 105 | ALTER TABLE #{quote_table_name(parent_table_name)} 106 | DETACH PARTITION #{quote_table_name(child_table_name)} 107 | SQL 108 | 109 | PgParty.cache.clear! 110 | end 111 | 112 | def partitions_for_table_name(table_name, include_subpartitions:, _accumulator: []) 113 | select_values(%[ 114 | SELECT pg_inherits.inhrelid::regclass::text 115 | FROM pg_tables 116 | INNER JOIN pg_inherits 117 | ON pg_tables.tablename::regclass = pg_inherits.inhparent::regclass 118 | WHERE pg_tables.schemaname = ANY(current_schemas(false)) AND 119 | pg_tables.tablename = #{quote(table_name)} 120 | ], "SCHEMA").each_with_object(_accumulator) do |partition, acc| 121 | acc << partition 122 | next unless include_subpartitions 123 | 124 | partitions_for_table_name(partition, include_subpartitions: true, _accumulator: acc) 125 | end 126 | end 127 | 128 | def parent_for_table_name(table_name, traverse: false) 129 | parent = select_values(%[ 130 | SELECT pg_inherits.inhparent::regclass::text 131 | FROM pg_tables 132 | INNER JOIN pg_inherits 133 | ON pg_tables.tablename::regclass = pg_inherits.inhrelid::regclass 134 | WHERE pg_tables.schemaname = ANY(current_schemas(false)) AND 135 | pg_tables.tablename = #{quote(table_name)} 136 | ], "SCHEMA").first 137 | return parent if parent.nil? || !traverse 138 | 139 | while (parents_parent = parent_for_table_name(parent)) do 140 | parent = parents_parent 141 | end 142 | 143 | parent 144 | end 145 | 146 | def add_index_on_all_partitions(table_name, column_name, in_threads: nil, **options) 147 | if in_threads && open_transactions > 0 148 | raise ArgumentError, '`in_threads:` cannot be used within a transaction. If running in a migration, use '\ 149 | '`disable_ddl_transaction!` and break out this operation into its own migration.' 150 | end 151 | 152 | index_name, index_type, index_columns, index_options, algorithm, using = extract_index_options( 153 | add_index_options(table_name, column_name, **options) 154 | ) 155 | 156 | # Postgres limits index name to 63 bytes (characters). We will use 8 characters for a `_random_suffix` 157 | # on partitions to ensure no conflicts, leaving 55 chars for the specified index name 158 | raise ArgumentError, 'index name is too long - must be 55 characters or fewer' if index_name.length > 55 159 | 160 | recursive_add_index( 161 | table_name: table_name, 162 | index_name: index_name, 163 | index_type: index_type, 164 | index_columns: index_columns, 165 | index_options: index_options, 166 | algorithm: algorithm, 167 | using: using, 168 | in_threads: in_threads 169 | ) 170 | end 171 | 172 | def table_partitioned?(table_name) 173 | select_values(%[ 174 | SELECT relkind FROM pg_catalog.pg_class AS c 175 | JOIN pg_catalog.pg_namespace AS ns ON c.relnamespace = ns.oid 176 | WHERE relname = #{quote(table_name)} AND nspname = ANY(current_schemas(false)) 177 | ], "SCHEMA").first == 'p' 178 | end 179 | 180 | private 181 | 182 | def create_partition(table_name, type, partition_key, **options, &blk) 183 | modified_options = options.except(:id, :primary_key, :template, :create_with_primary_key) 184 | template = options.fetch(:template, PgParty.config.create_template_tables) 185 | id = options.fetch(:id, :bigserial) 186 | primary_key = options.fetch(:primary_key) { calculate_primary_key(table_name) } 187 | create_with_pks = options.fetch( 188 | :create_with_primary_key, 189 | PgParty.config.create_with_primary_key 190 | ) 191 | 192 | validate_supported_partition_type!(type) 193 | 194 | if create_with_pks 195 | modified_options[:primary_key] = primary_key 196 | modified_options[:id] = id 197 | else 198 | validate_primary_key(primary_key) 199 | modified_options[:id] = false 200 | end 201 | modified_options[:options] = partition_by_clause(type, partition_key) 202 | 203 | migration_or_adapter(blk).create_table(table_name, **modified_options) do |td| 204 | if !modified_options[:id] && id == :uuid 205 | td.column(primary_key, id, null: false, default: "gen_random_uuid()") 206 | elsif !modified_options[:id] && id 207 | td.column(primary_key, id, null: false) 208 | end 209 | 210 | blk&.call(td) 211 | end 212 | 213 | # Rails 4 has a bug where uuid columns are always nullable 214 | migration_or_adapter(blk).change_column_null(table_name, primary_key, false) if !modified_options[:id] && id == :uuid 215 | 216 | return unless template 217 | 218 | create_table_like( 219 | table_name, 220 | template_table_name(table_name), 221 | primary_key: id && primary_key, 222 | create_with_primary_key: create_with_pks 223 | ) 224 | end 225 | 226 | def create_partition_of(table_name, constraint_clause, **options) 227 | child_table_name = options.fetch(:name) { hashed_table_name(table_name, constraint_clause) } 228 | primary_key = options.fetch(:primary_key) { calculate_primary_key(table_name) } 229 | template_table_name = template_table_name(table_name) 230 | 231 | validate_default_partition_support! if options[:default_partition] 232 | 233 | if schema_cache.data_source_exists?(template_table_name) 234 | create_table_like(template_table_name, child_table_name, primary_key: false, 235 | partition_type: options[:partition_type], partition_key: options[:partition_key]) 236 | else 237 | create_table_like(table_name, child_table_name, primary_key: primary_key, 238 | partition_type: options[:partition_type], partition_key: options[:partition_key]) 239 | end 240 | 241 | if options[:default_partition] 242 | attach_default_partition(table_name, child_table_name) 243 | else 244 | attach_partition(table_name, child_table_name, constraint_clause) 245 | end 246 | 247 | child_table_name 248 | end 249 | 250 | def attach_partition(parent_table_name, child_table_name, constraint_clause) 251 | execute(<<-SQL) 252 | ALTER TABLE #{quote_table_name(parent_table_name)} 253 | ATTACH PARTITION #{quote_table_name(child_table_name)} 254 | FOR VALUES #{constraint_clause} 255 | SQL 256 | 257 | PgParty.cache.clear! 258 | end 259 | 260 | def recursive_add_index(table_name:, index_name:, index_type:, index_columns:, index_options:, using:, algorithm:, 261 | in_threads: nil, _parent_index_name: nil, _created_index_names: []) 262 | partitions = partitions_for_table_name(table_name, include_subpartitions: false) 263 | updated_name = _created_index_names.empty? ? index_name : generate_index_name(index_name, table_name) 264 | 265 | # If this is a partitioned table, add index ONLY on this table. 266 | if table_partitioned?(table_name) 267 | add_index_only(table_name, type: index_type, name: updated_name, using: using, columns: index_columns, 268 | options: index_options) 269 | _created_index_names << updated_name 270 | 271 | parallel_map(partitions, in_threads: in_threads) do |partition_name| 272 | recursive_add_index( 273 | table_name: partition_name, 274 | index_name: index_name, 275 | index_type: index_type, 276 | index_columns: index_columns, 277 | index_options: index_options, 278 | using: using, 279 | algorithm: algorithm, 280 | _parent_index_name: updated_name, 281 | _created_index_names: _created_index_names 282 | ) 283 | end 284 | else 285 | _created_index_names << updated_name # Track as created before execution of concurrent index command 286 | add_index_from_options(table_name, name: updated_name, type: index_type, algorithm: algorithm, using: using, 287 | columns: index_columns, options: index_options) 288 | end 289 | 290 | attach_child_index(updated_name, _parent_index_name) if _parent_index_name 291 | 292 | return true if index_valid?(updated_name) 293 | 294 | raise 'index creation failed - an index was marked invalid' 295 | rescue => e 296 | # Clean up any indexes created so this command can be retried later 297 | drop_indices_if_exist(_created_index_names) 298 | raise e 299 | end 300 | 301 | def attach_child_index(child, parent) 302 | return unless postgres_major_version >= 11 303 | 304 | execute "ALTER INDEX #{quote_column_name(parent)} ATTACH PARTITION #{quote_column_name(child)}" 305 | end 306 | 307 | def add_index_only(table_name, type:, name:, using:, columns:, options:) 308 | return unless postgres_major_version >= 11 309 | 310 | execute "CREATE #{type} INDEX #{quote_column_name(name)} ON ONLY "\ 311 | "#{quote_table_name(table_name)} #{using} (#{columns})#{options}" 312 | end 313 | 314 | def add_index_from_options(table_name, name:, type:, algorithm:, using:, columns:, options:) 315 | execute "CREATE #{type} INDEX #{algorithm} #{quote_column_name(name)} ON "\ 316 | "#{quote_table_name(table_name)} #{using} (#{columns})#{options}" 317 | end 318 | 319 | def extract_index_options(add_index_options_result) 320 | # Rails 6.1 changes the result of #add_index_options 321 | index_definition = add_index_options_result.first 322 | return add_index_options_result unless index_definition.is_a?(ActiveRecord::ConnectionAdapters::IndexDefinition) 323 | 324 | index_columns = if index_definition.columns.is_a?(String) 325 | index_definition.columns 326 | else 327 | quoted_columns_for_index(index_definition.columns, index_definition.column_options) 328 | end 329 | 330 | [ 331 | index_definition.name, 332 | index_definition.unique ? 'UNIQUE' : index_definition.type, 333 | index_columns, 334 | index_definition.where ? " WHERE #{index_definition.where}" : nil, 335 | add_index_options_result.second, # algorithm option 336 | index_definition.using ? "USING #{index_definition.using}" : nil 337 | ] 338 | end 339 | 340 | def drop_indices_if_exist(index_names) 341 | index_names.uniq.each { |name| execute "DROP INDEX IF EXISTS #{quote_column_name(name)}" } 342 | end 343 | 344 | def parallel_map(arr, in_threads:) 345 | return [] if arr.empty? 346 | return arr.map { |item| yield(item) } unless in_threads && in_threads > 1 347 | 348 | if ActiveRecord::Base.connection_pool.size <= in_threads 349 | raise ArgumentError, 'in_threads: must be lower than your database connection pool size' 350 | end 351 | 352 | Parallel.map(arr, in_threads: in_threads) do |item| 353 | ActiveRecord::Base.connection_pool.with_connection { yield(item) } 354 | end 355 | end 356 | 357 | # Rails 5.2 now returns boolean literals 358 | # This causes partition creation to fail when the constraint clause includes a boolean 359 | # Might be a PostgreSQL bug, but for now let's revert to the old quoting behavior 360 | def quote(value) 361 | case value 362 | when true then "'t'" 363 | when false then "'f'" 364 | else 365 | __getobj__.quote(value) 366 | end 367 | end 368 | 369 | def has_primary_key?(table_name) 370 | primary_key(table_name).present? 371 | end 372 | 373 | def calculate_primary_key(table_name) 374 | ActiveRecord::Base.get_primary_key(table_name.to_s.singularize).to_sym 375 | end 376 | 377 | def validate_primary_key(key) 378 | raise ArgumentError, "composite primary key not supported" if key.is_a?(Array) 379 | end 380 | 381 | def quote_partition_key(key) 382 | if key.is_a?(Proc) 383 | key.call.to_s # very difficult to determine how to sanitize a complex expression 384 | else 385 | Array.wrap(key).map(&method(:quote_column_name)).join(",") 386 | end 387 | end 388 | 389 | def quote_collection(values) 390 | Array.wrap(values).map(&method(:quote)).join(",") 391 | end 392 | 393 | def template_table_name(table_name) 394 | "#{parent_for_table_name(table_name, traverse: true) || table_name}_template" 395 | end 396 | 397 | def range_constraint_clause(start_range, end_range) 398 | "FROM (#{quote_collection(start_range)}) TO (#{quote_collection(end_range)})" 399 | end 400 | 401 | def hash_constraint_clause(modulus, remainder) 402 | "WITH (MODULUS #{modulus.to_i}, REMAINDER #{remainder.to_i})" 403 | end 404 | 405 | def list_constraint_clause(values) 406 | "IN (#{quote_collection(values.try(:to_a) || values)})" 407 | end 408 | 409 | def partition_by_clause(type, partition_key) 410 | "PARTITION BY #{type.to_s.upcase} (#{quote_partition_key(partition_key)})" 411 | end 412 | 413 | def hashed_table_name(table_name, key) 414 | return "#{table_name}_#{Digest::MD5.hexdigest(key)[0..6]}" if key 415 | 416 | # use _default suffix for default partitions (without a constraint clause) 417 | "#{table_name}_default" 418 | end 419 | 420 | def index_valid?(index_name) 421 | select_values( 422 | "SELECT relname FROM pg_class, pg_index WHERE pg_index.indisvalid = false AND "\ 423 | "pg_index.indexrelid = pg_class.oid AND relname = #{quote(index_name)}", 424 | "SCHEMA" 425 | ).empty? 426 | end 427 | 428 | def generate_index_name(index_name, table_name) 429 | "#{index_name}_#{Digest::MD5.hexdigest(table_name)[0..6]}" 430 | end 431 | 432 | def validate_supported_partition_type!(partition_type) 433 | if (sym = partition_type.to_s.downcase.to_sym) && sym.in?(SUPPORTED_PARTITION_TYPES) 434 | return if sym != :hash || postgres_major_version >= 11 435 | 436 | raise NotImplementedError, 'Hash partitions are only available in Postgres 11 or higher' 437 | end 438 | 439 | raise ArgumentError, "Supported partition types are #{SUPPORTED_PARTITION_TYPES.join(', ')}" 440 | end 441 | 442 | def validate_default_partition_support! 443 | return if postgres_major_version >= 11 444 | 445 | raise NotImplementedError, 'Default partitions are only available in Postgres 11 or higher' 446 | end 447 | 448 | def supports_partitions? 449 | postgres_major_version >= 10 450 | end 451 | 452 | def postgres_major_version 453 | __getobj__.send(:postgresql_version)/10000 454 | end 455 | 456 | def migration_or_adapter(blk) 457 | blk_receiver = blk&.binding&.receiver 458 | blk_receiver.is_a?(ActiveRecord::Migration) ? blk_receiver : self 459 | end 460 | end 461 | end 462 | -------------------------------------------------------------------------------- /lib/pg_party/cache.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "thread" 4 | 5 | module PgParty 6 | class Cache 7 | LOCK = Mutex.new 8 | 9 | def initialize 10 | # automatically initialize a new hash when 11 | # accessing an object id that doesn't exist 12 | @store = Hash.new { |h, k| h[k] = { models: {}, partitions: nil, partitions_with_subpartitions: nil } } 13 | end 14 | 15 | def clear! 16 | LOCK.synchronize { @store.clear } 17 | 18 | nil 19 | end 20 | 21 | def fetch_model(key, child_table, &block) 22 | return block.call unless caching_enabled? 23 | 24 | LOCK.synchronize { fetch_value(@store[key][:models], child_table.to_sym, block) } 25 | end 26 | 27 | def fetch_partitions(key, include_subpartitions, &block) 28 | return block.call unless caching_enabled? 29 | sub_key = include_subpartitions ? :partitions_with_subpartitions : :partitions 30 | 31 | LOCK.synchronize { fetch_value(@store[key], sub_key, block) } 32 | end 33 | 34 | private 35 | 36 | def caching_enabled? 37 | PgParty.config.caching 38 | end 39 | 40 | def fetch_value(subhash, key, block) 41 | entry = subhash[key] 42 | 43 | if entry.nil? || entry.expired? 44 | entry = Entry.new(block.call) 45 | subhash[key] = entry 46 | end 47 | 48 | entry.value 49 | end 50 | 51 | class Entry 52 | attr_reader :value 53 | 54 | def initialize(value) 55 | @value = value 56 | @timestamp = Time.now 57 | end 58 | 59 | def expired? 60 | ttl.positive? && Time.now - @timestamp > ttl 61 | end 62 | 63 | private 64 | 65 | def ttl 66 | PgParty.config.caching_ttl 67 | end 68 | end 69 | 70 | private_constant :Entry 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/pg_party/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PgParty 4 | class Config 5 | attr_accessor \ 6 | :caching, 7 | :caching_ttl, 8 | :schema_exclude_partitions, 9 | :create_template_tables, 10 | :create_with_primary_key, 11 | :include_subpartitions_in_partition_list 12 | 13 | def initialize 14 | @caching = true 15 | @caching_ttl = -1 16 | @schema_exclude_partitions = true 17 | @create_template_tables = true 18 | @create_with_primary_key = false 19 | @include_subpartitions_in_partition_list = false 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/pg_party/hacks/postgresql_database_tasks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PgParty 4 | module Hacks 5 | module PostgreSQLDatabaseTasks 6 | def run_cmd(cmd, args, action) 7 | if action != "dumping" || !PgParty.config.schema_exclude_partitions 8 | return super 9 | end 10 | 11 | partitions = ActiveRecord::Base.connection.select_values(<<-SQL, "SCHEMA") 12 | SELECT 13 | inhrelid::regclass::text 14 | FROM 15 | pg_inherits 16 | JOIN pg_class AS p ON inhparent = p.oid 17 | WHERE p.relkind = 'p' 18 | SQL 19 | 20 | excluded_tables = partitions.flat_map { |table| ["-T", "*.#{table}"] } 21 | 22 | super(cmd, args + excluded_tables, action) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/pg_party/model/hash_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pg_party/model_decorator" 4 | 5 | module PgParty 6 | module Model 7 | module HashMethods 8 | ruby2_keywords def create_partition(*args) 9 | PgParty::ModelDecorator.new(self).create_hash_partition(*args) 10 | end 11 | 12 | ruby2_keywords def partition_key_in(*args) 13 | PgParty::ModelDecorator.new(self).hash_partition_key_in(*args) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/pg_party/model/list_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pg_party/model_decorator" 4 | 5 | module PgParty 6 | module Model 7 | module ListMethods 8 | ruby2_keywords def create_partition(*args) 9 | PgParty::ModelDecorator.new(self).create_list_partition(*args) 10 | end 11 | 12 | ruby2_keywords def create_default_partition(*args) 13 | PgParty::ModelDecorator.new(self).create_default_partition(*args) 14 | end 15 | 16 | ruby2_keywords def partition_key_in(*args) 17 | PgParty::ModelDecorator.new(self).list_partition_key_in(*args) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/pg_party/model/methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pg_party/model_injector" 4 | 5 | module PgParty 6 | module Model 7 | module Methods 8 | def range_partition_by(*key, &blk) 9 | PgParty::ModelInjector.new(self, *key, &blk).inject_range_methods 10 | end 11 | 12 | def list_partition_by(*key, &blk) 13 | PgParty::ModelInjector.new(self, *key, &blk).inject_list_methods 14 | end 15 | 16 | def hash_partition_by(*key, &blk) 17 | PgParty::ModelInjector.new(self, *key, &blk).inject_hash_methods 18 | end 19 | 20 | def partitioned? 21 | try(:partition_key).present? 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/pg_party/model/range_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pg_party/model_decorator" 4 | 5 | module PgParty 6 | module Model 7 | module RangeMethods 8 | ruby2_keywords def create_partition(*args) 9 | PgParty::ModelDecorator.new(self).create_range_partition(*args) 10 | end 11 | 12 | ruby2_keywords def create_default_partition(*args) 13 | PgParty::ModelDecorator.new(self).create_default_partition(*args) 14 | end 15 | 16 | ruby2_keywords def partition_key_in(*args) 17 | PgParty::ModelDecorator.new(self).range_partition_key_in(*args) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/pg_party/model/shared_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pg_party/model_decorator" 4 | 5 | module PgParty 6 | module Model 7 | module SharedMethods 8 | def reset_primary_key 9 | return (@primary_key = base_class.primary_key) if self != base_class 10 | 11 | partitions = partitions(include_subpartitions: PgParty.config.include_subpartitions_in_partition_list) 12 | return (@primary_key = get_primary_key(base_class.name)) if partitions.empty? 13 | 14 | first_partition = partitions.detect { |p| !connection.table_partitioned?(p) } 15 | raise 'No leaf partitions exist for this model. Create a partition to contain your data' unless first_partition 16 | 17 | (@primary_key = in_partition(first_partition).get_primary_key(base_class.name)) 18 | end 19 | 20 | def table_exists? 21 | target_table = partitions.first || table_name 22 | 23 | connection.schema_cache.data_source_exists?(target_table) 24 | end 25 | 26 | def partitions(**args) 27 | PgParty::ModelDecorator.new(self).partitions(**args) 28 | end 29 | 30 | def in_partition(*args) 31 | PgParty::ModelDecorator.new(self).in_partition(*args) 32 | end 33 | 34 | def partition_key_eq(*args) 35 | PgParty::ModelDecorator.new(self).partition_key_eq(*args) 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/pg_party/model_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PgParty 4 | class ModelDecorator < SimpleDelegator 5 | def in_partition(child_table_name) 6 | PgParty.cache.fetch_model(cache_key, child_table_name) do 7 | Class.new(__getobj__) do 8 | self.table_name = child_table_name 9 | 10 | # to avoid argument errors when calling model_name 11 | def self.name 12 | superclass.name 13 | end 14 | 15 | # when returning records from a query, Rails 16 | # allocates objects first, then initializes 17 | def self.allocate 18 | superclass.allocate 19 | end 20 | 21 | # creating and persisting new records from a child partition 22 | # will ultimately insert into the parent partition table 23 | def self.new(*args, &blk) 24 | superclass.new(*args, &blk) 25 | end 26 | 27 | # to avoid unnecessary db lookups 28 | def self.partitions 29 | [] 30 | end 31 | end 32 | end 33 | end 34 | 35 | def partition_key_eq(value) 36 | if complex_partition_key 37 | complex_partition_key_query("(#{partition_key}) = (?)", value) 38 | else 39 | where(partition_key_arel(:eq, value)) 40 | end 41 | end 42 | 43 | def range_partition_key_in(start_range, end_range) 44 | if complex_partition_key 45 | complex_partition_key_query( 46 | "(#{partition_key}) >= (?) AND (#{partition_key}) < (?)", 47 | start_range, 48 | end_range 49 | ) 50 | else 51 | where(partition_key_arel(:gteq, start_range).and(partition_key_arel(:lt, end_range))) 52 | end 53 | end 54 | 55 | def list_partition_key_in(*values) 56 | if complex_partition_key 57 | complex_partition_key_query("(#{partition_key}) IN (?)", values.flatten) 58 | else 59 | where(current_arel_table[partition_key].in(values.flatten)) 60 | end 61 | end 62 | 63 | alias_method :hash_partition_key_in, :list_partition_key_in 64 | 65 | def partitions(include_subpartitions: PgParty.config.include_subpartitions_in_partition_list) 66 | PgParty.cache.fetch_partitions(cache_key, include_subpartitions) do 67 | connection.partitions_for_table_name(table_name, include_subpartitions: include_subpartitions) 68 | end 69 | rescue 70 | [] 71 | end 72 | 73 | def create_range_partition(start_range:, end_range:, **options) 74 | modified_options = options.merge( 75 | start_range: start_range, 76 | end_range: end_range, 77 | primary_key: primary_key, 78 | ) 79 | 80 | create_partition(:create_range_partition_of, table_name, **modified_options) 81 | end 82 | 83 | def create_list_partition(values:, **options) 84 | modified_options = options.merge( 85 | values: values, 86 | primary_key: primary_key, 87 | ) 88 | 89 | create_partition(:create_list_partition_of, table_name, **modified_options) 90 | end 91 | 92 | def create_hash_partition(modulus:, remainder:, **options) 93 | modified_options = options.merge( 94 | modulus: modulus, 95 | remainder: remainder, 96 | primary_key: primary_key, 97 | ) 98 | 99 | create_partition(:create_hash_partition_of, table_name, **modified_options) 100 | end 101 | 102 | def create_default_partition(**options) 103 | modified_options = options.merge( 104 | primary_key: primary_key, 105 | ) 106 | create_partition(:create_default_partition_of, table_name, **modified_options) 107 | end 108 | 109 | private 110 | 111 | def create_partition(migration_method, table_name, **options) 112 | transaction { connection.send(migration_method, table_name, **options) } 113 | end 114 | 115 | def cache_key 116 | __getobj__.object_id 117 | end 118 | 119 | # https://stackoverflow.com/questions/28685149/activerecord-query-with-aliasd-table-names 120 | def current_arel_table 121 | none.arel.source.left.tap do |node| 122 | if [Arel::Table, Arel::Nodes::TableAlias].exclude?(node.class) 123 | raise "could not find arel table in current scope" 124 | end 125 | end 126 | end 127 | 128 | def current_alias 129 | arel_node = current_arel_table 130 | 131 | case arel_node 132 | when Arel::Table 133 | arel_node.name 134 | when Arel::Nodes::TableAlias 135 | arel_node.right 136 | end 137 | end 138 | 139 | def complex_partition_key_query(clause, *interpolated_values) 140 | subquery = unscoped 141 | .select("*") 142 | .where(clause, *interpolated_values) 143 | 144 | from(subquery, current_alias) 145 | end 146 | 147 | def partition_key_arel(meth, values) 148 | partition_key_array = Array.wrap(partition_key) 149 | values = Array.wrap(values) 150 | 151 | if partition_key_array.size != values.size 152 | raise "number of provided values does not match the number of partition key columns" 153 | end 154 | 155 | partition_key_array.zip(values).inject(nil) do |obj, (column, value)| 156 | node = current_arel_table[column].send(meth, value) 157 | 158 | if obj.nil? 159 | node 160 | else 161 | obj.and(node) 162 | end 163 | end 164 | end 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/pg_party/model_injector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PgParty 4 | class ModelInjector 5 | def initialize(model, *key, &blk) 6 | @model = model 7 | @key = key.flatten.compact 8 | @key_blk = blk 9 | end 10 | 11 | def inject_range_methods 12 | require "pg_party/model/range_methods" 13 | 14 | inject_methods_for(PgParty::Model::RangeMethods) 15 | end 16 | 17 | def inject_list_methods 18 | require "pg_party/model/list_methods" 19 | 20 | inject_methods_for(PgParty::Model::ListMethods) 21 | end 22 | 23 | def inject_hash_methods 24 | require "pg_party/model/hash_methods" 25 | 26 | inject_methods_for(PgParty::Model::HashMethods) 27 | end 28 | 29 | private 30 | 31 | def inject_methods_for(mod) 32 | require "pg_party/model/shared_methods" 33 | 34 | @model.extend(PgParty::Model::SharedMethods) 35 | @model.extend(mod) 36 | 37 | create_class_attributes 38 | end 39 | 40 | def create_class_attributes 41 | @model.class_attribute( 42 | :partition_key, 43 | :complex_partition_key, 44 | instance_accessor: false, 45 | instance_predicate: false 46 | ) 47 | 48 | if @key_blk 49 | @model.partition_key = @key_blk.call 50 | @model.complex_partition_key = true 51 | else 52 | if @key.size == 1 53 | @model.partition_key = @key.first 54 | else 55 | @model.partition_key = @key 56 | end 57 | 58 | @model.complex_partition_key = false 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/pg_party/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PgParty 4 | VERSION = "1.9.0" 5 | end 6 | -------------------------------------------------------------------------------- /pg_party.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # frozen_string_literal: true 3 | 4 | lib = File.expand_path("../lib", __FILE__) 5 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 6 | require "pg_party/version" 7 | 8 | Gem::Specification.new do |spec| 9 | spec.name = "pg_party" 10 | spec.version = PgParty::VERSION 11 | spec.authors = ["Ryan Krage"] 12 | spec.email = ["krage.ryan@gmail.com"] 13 | 14 | spec.summary = %q{ActiveRecord PostgreSQL Partitioning} 15 | spec.description = %q{Migrations and model helpers for creating and managing PostgreSQL 10 partitions} 16 | spec.homepage = "https://github.com/rkrage/pg_party" 17 | spec.license = "MIT" 18 | 19 | spec.required_ruby_version = ">= 3.0.0" 20 | 21 | spec.files = Dir["LICENSE.txt", "README.md", "lib/**/*"] 22 | 23 | spec.require_paths = ["lib"] 24 | 25 | spec.add_runtime_dependency "activerecord", ">= 7.0", "< 8.1" 26 | spec.add_runtime_dependency "parallel", "~> 1.0" 27 | 28 | spec.add_development_dependency "appraisal", "~> 2.2" 29 | spec.add_development_dependency "byebug", "~> 11.0" 30 | spec.add_development_dependency "combustion", "~> 1.3" 31 | spec.add_development_dependency "nokogiri", ">= 1.10.4", "< 2.0" 32 | spec.add_development_dependency "pry-byebug", "~> 3.7" 33 | spec.add_development_dependency "rake", "~> 12.3" 34 | spec.add_development_dependency "rspec-its", "~> 1.3" 35 | spec.add_development_dependency "rspec-rails", "~> 6.0" 36 | spec.add_development_dependency "rspec_junit_formatter", "~> 0.4" 37 | spec.add_development_dependency "simplecov", "~> 0.21" 38 | spec.add_development_dependency "timecop", "~> 0.9" 39 | spec.add_development_dependency "psych", "~> 3.3" # psych 4 ships with ruby 3.1 and breaks a lot of things 40 | end 41 | -------------------------------------------------------------------------------- /spec/adapter/abstract_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PgParty::Adapter::AbstractMethods do 6 | let(:adapter_class) do 7 | Class.new do 8 | include PgParty::Adapter::AbstractMethods 9 | end 10 | end 11 | 12 | subject(:adapter) { adapter_class.new } 13 | 14 | describe "#create_range_partition" do 15 | subject { adapter.create_range_partition("args") } 16 | 17 | it "raises not implemented error" do 18 | expect { subject }.to raise_error(RuntimeError, "#create_range_partition is not implemented") 19 | end 20 | end 21 | 22 | describe "#create_list_partition" do 23 | subject { adapter.create_list_partition("args") } 24 | 25 | it "raises not implemented error" do 26 | expect { subject }.to raise_error(RuntimeError, "#create_list_partition is not implemented") 27 | end 28 | end 29 | 30 | describe '#create_hash_partition' do 31 | subject { adapter.create_hash_partition("args") } 32 | 33 | it "raises not implemented error" do 34 | expect { subject }.to raise_error(RuntimeError, "#create_hash_partition is not implemented") 35 | end 36 | end 37 | 38 | describe "#create_range_partition_of" do 39 | subject { adapter.create_range_partition_of("args") } 40 | 41 | it "raises not implemented error" do 42 | expect { subject }.to raise_error(RuntimeError, "#create_range_partition_of is not implemented") 43 | end 44 | end 45 | 46 | describe "#create_list_partition_of" do 47 | subject { adapter.create_list_partition_of("args") } 48 | 49 | it "raises not implemented error" do 50 | expect { subject }.to raise_error(RuntimeError, "#create_list_partition_of is not implemented") 51 | end 52 | end 53 | 54 | describe "#create_hash_partition_of" do 55 | subject { adapter.create_hash_partition_of("args") } 56 | 57 | it "raises not implemented error" do 58 | expect { subject }.to raise_error(RuntimeError, "#create_hash_partition_of is not implemented") 59 | end 60 | end 61 | 62 | describe "#create_default_partition_of" do 63 | subject { adapter.create_default_partition_of("args") } 64 | 65 | it "raises not implemented error" do 66 | expect { subject }.to raise_error(RuntimeError, "#create_default_partition_of is not implemented") 67 | end 68 | end 69 | 70 | describe "#create_table_like" do 71 | subject { adapter.create_table_like("args") } 72 | 73 | it "raises not implemented error" do 74 | expect { subject }.to raise_error(RuntimeError, "#create_table_like is not implemented") 75 | end 76 | end 77 | 78 | describe "#attach_range_partition" do 79 | subject { adapter.attach_range_partition("args") } 80 | 81 | it "raises not implemented error" do 82 | expect { subject }.to raise_error(RuntimeError, "#attach_range_partition is not implemented") 83 | end 84 | end 85 | 86 | describe "#attach_list_partition" do 87 | subject { adapter.attach_list_partition("args") } 88 | 89 | it "raises not implemented error" do 90 | expect { subject }.to raise_error(RuntimeError, "#attach_list_partition is not implemented") 91 | end 92 | end 93 | 94 | describe "#attach_hash_partition" do 95 | subject { adapter.attach_hash_partition("args") } 96 | 97 | it "raises not implemented error" do 98 | expect { subject }.to raise_error(RuntimeError, "#attach_hash_partition is not implemented") 99 | end 100 | end 101 | 102 | describe "#attach_default_partition" do 103 | subject { adapter.attach_default_partition("args") } 104 | 105 | it "raises not implemented error" do 106 | expect { subject }.to raise_error(RuntimeError, "#attach_default_partition is not implemented") 107 | end 108 | end 109 | 110 | describe "#detach_partition" do 111 | subject { adapter.detach_partition("args") } 112 | 113 | it "raises not implemented error" do 114 | expect { subject }.to raise_error(RuntimeError, "#detach_partition is not implemented") 115 | end 116 | end 117 | 118 | describe "#parent_for_table_name" do 119 | subject { adapter.parent_for_table_name("args") } 120 | 121 | it "raises not implemented error" do 122 | expect { subject }.to raise_error(RuntimeError, "#parent_for_table_name is not implemented") 123 | end 124 | end 125 | 126 | describe "#partitions_for_table_name" do 127 | subject { adapter.partitions_for_table_name("args") } 128 | 129 | it "raises not implemented error" do 130 | expect { subject }.to raise_error(RuntimeError, "#partitions_for_table_name is not implemented") 131 | end 132 | end 133 | 134 | describe "#add_index_on_all_partitions" do 135 | subject { adapter.add_index_on_all_partitions("args") } 136 | 137 | it "raises not implemented error" do 138 | expect { subject }.to raise_error(RuntimeError, "#add_index_on_all_partitions is not implemented") 139 | end 140 | end 141 | 142 | describe "#table_partitioned?" do 143 | subject { adapter.table_partitioned?("args") } 144 | 145 | it "raises not implemented error" do 146 | expect { subject }.to raise_error(RuntimeError, "#table_partitioned? is not implemented") 147 | end 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /spec/adapter/postgresql_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PgParty::Adapter::PostgreSQLMethods do 6 | let(:decorator) { instance_double(PgParty::AdapterDecorator) } 7 | let(:adapter_class) do 8 | Class.new do 9 | include PgParty::Adapter::PostgreSQLMethods 10 | end 11 | end 12 | 13 | before do 14 | allow(PgParty::AdapterDecorator).to receive(:new).with(adapter).and_return(decorator) 15 | end 16 | 17 | subject(:adapter) { adapter_class.new } 18 | 19 | describe "#create_range_partition" do 20 | subject { adapter.create_range_partition(:parent, partition_key: :id) } 21 | 22 | it "delegates to decorator" do 23 | expect(decorator).to receive(:create_range_partition).with(:parent, partition_key: :id) 24 | subject 25 | end 26 | end 27 | 28 | describe "#create_list_partition" do 29 | subject { adapter.create_list_partition(:parent, partition_key: :id) } 30 | 31 | it "delegates to decorator" do 32 | expect(decorator).to receive(:create_list_partition).with(:parent, partition_key: :id) 33 | subject 34 | end 35 | end 36 | 37 | describe "#create_hash_partition" do 38 | subject { adapter.create_hash_partition(:parent, partition_key: :id) } 39 | 40 | it "delegates to decorator" do 41 | expect(decorator).to receive(:create_hash_partition).with(:parent, partition_key: :id) 42 | subject 43 | end 44 | end 45 | 46 | describe "#create_range_partition_of" do 47 | subject { adapter.create_range_partition_of(:parent, start_range: 1, end_range: 10) } 48 | 49 | it "delegates to decorator" do 50 | expect(decorator).to receive(:create_range_partition_of).with(:parent, start_range: 1, end_range: 10) 51 | subject 52 | end 53 | end 54 | 55 | describe "#create_list_partition_of" do 56 | subject { adapter.create_list_partition_of(:parent, values: [1, 2, 3]) } 57 | 58 | it "delegates to decorator" do 59 | expect(decorator).to receive(:create_list_partition_of).with(:parent, values: [1, 2, 3]) 60 | subject 61 | end 62 | end 63 | 64 | describe "#create_hash_partition_of" do 65 | subject { adapter.create_hash_partition_of(:parent, modulus: 2, remainder: 0) } 66 | 67 | it "delegates to decorator" do 68 | expect(decorator).to receive(:create_hash_partition_of).with(:parent, modulus: 2, remainder: 0) 69 | subject 70 | end 71 | end 72 | 73 | describe "#create_default_partition_of" do 74 | subject { adapter.create_default_partition_of(:parent) } 75 | 76 | it "delegates to decorator" do 77 | expect(decorator).to receive(:create_default_partition_of).with(:parent) 78 | subject 79 | end 80 | end 81 | 82 | describe "#create_table_like" do 83 | subject { adapter.create_table_like(:table_a, :table_b) } 84 | 85 | it "delegates to decorator" do 86 | expect(decorator).to receive(:create_table_like).with(:table_a, :table_b) 87 | subject 88 | end 89 | end 90 | 91 | describe "#attach_range_partition" do 92 | subject { adapter.attach_range_partition(:parent, :child, start_range: 1, end_range: 10) } 93 | 94 | it "delegates to decorator" do 95 | expect(decorator).to receive(:attach_range_partition).with(:parent, :child, start_range: 1, end_range: 10) 96 | subject 97 | end 98 | end 99 | 100 | describe "#attach_list_partition" do 101 | subject { adapter.attach_list_partition(:parent, :child, values: [1, 2, 3]) } 102 | 103 | it "delegates to decorator" do 104 | expect(decorator).to receive(:attach_list_partition).with(:parent, :child, values: [1, 2, 3]) 105 | subject 106 | end 107 | end 108 | 109 | describe "#attach_hash_partition" do 110 | subject { adapter.attach_hash_partition(:parent, :child, modulus: 2, remainder: 0) } 111 | 112 | it "delegates to decorator" do 113 | expect(decorator).to receive(:attach_hash_partition).with(:parent, :child, modulus: 2, remainder: 0) 114 | subject 115 | end 116 | end 117 | 118 | describe "#attach_default_partition" do 119 | subject { adapter.attach_default_partition(:parent, :child) } 120 | 121 | it "delegates to decorator" do 122 | expect(decorator).to receive(:attach_default_partition).with(:parent, :child) 123 | subject 124 | end 125 | end 126 | 127 | describe "#detach_partition" do 128 | subject { adapter.detach_partition(:parent, :child) } 129 | 130 | it "delegates to decorator" do 131 | expect(decorator).to receive(:detach_partition).with(:parent, :child) 132 | subject 133 | end 134 | end 135 | 136 | describe "#parent_for_table_name" do 137 | subject { adapter.parent_for_table_name(:table_name, traverse: true) } 138 | 139 | it "delegates to decorator" do 140 | expect(decorator).to receive(:parent_for_table_name).with(:table_name, traverse: true) 141 | subject 142 | end 143 | end 144 | 145 | describe "#partitions_for_table_name" do 146 | subject { adapter.partitions_for_table_name(:table_name, include_subpartitions: true) } 147 | 148 | it "delegates to decorator" do 149 | expect(decorator).to receive(:partitions_for_table_name).with(:table_name, include_subpartitions: true) 150 | subject 151 | end 152 | end 153 | 154 | describe "#add_index_on_all_partitions" do 155 | subject { adapter.add_index_on_all_partitions(:table_name, [:columns], unique: true, in_threads: 2) } 156 | 157 | it "delegates to decorator" do 158 | expect(decorator).to receive(:add_index_on_all_partitions) 159 | .with(:table_name, [:columns], unique: true, in_threads: 2) 160 | subject 161 | end 162 | end 163 | 164 | describe "#table_partitioned?" do 165 | subject { adapter.table_partitioned?(:table_name) } 166 | 167 | it "delegates to decorator" do 168 | expect(decorator).to receive(:table_partitioned?) 169 | .with(:table_name) 170 | subject 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /spec/cache_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PgParty::Cache do 6 | let(:block) { ->{ :new_value } } 7 | 8 | subject(:cache) { described_class.new } 9 | subject(:fetch_model) { cache.fetch_model(12345678901, :child, &block) } 10 | subject(:fetch_partitions) { cache.fetch_partitions(12345678901, false, &block) } 11 | 12 | describe ".clear!" do 13 | before do 14 | cache.fetch_partitions(12345678901, false) { :old_value } 15 | cache.fetch_model(12345678901, :child) { :old_value } 16 | end 17 | 18 | subject { cache.clear! } 19 | 20 | it { is_expected.to be_nil } 21 | 22 | it "clears cached partitions" do 23 | subject 24 | expect(fetch_partitions).to eq(:new_value) 25 | end 26 | 27 | it "clears cached models" do 28 | subject 29 | expect(fetch_model).to eq(:new_value) 30 | end 31 | end 32 | 33 | describe ".fetch_model" do 34 | subject { fetch_model } 35 | 36 | context "when key does not exist" do 37 | it { is_expected.to eq(:new_value) } 38 | 39 | it "executes block" do 40 | expect(block).to receive(:call).and_call_original 41 | subject 42 | end 43 | end 44 | 45 | context "when key exists" do 46 | before do 47 | cache.fetch_model(12345678901, :child) { :old_value } 48 | end 49 | 50 | it { is_expected.to eq(:old_value) } 51 | 52 | it "does not execute block" do 53 | expect(block).to_not receive(:call) 54 | subject 55 | end 56 | end 57 | 58 | context "when caching disabled" do 59 | before do 60 | PgParty.config.caching = false 61 | cache.fetch_model(12345678901, :child) { :old_value } 62 | end 63 | 64 | it { is_expected.to eq(:new_value) } 65 | 66 | it "executes block" do 67 | expect(block).to receive(:call).and_call_original 68 | subject 69 | end 70 | end 71 | 72 | context "when TTL expires" do 73 | around do |example| 74 | PgParty.config.caching_ttl = 60 75 | cache.fetch_model(12345678901, :child) { :old_value } 76 | Timecop.freeze(Time.now + 61, &example) 77 | end 78 | 79 | it { is_expected.to eq(:new_value) } 80 | 81 | it "executes block" do 82 | expect(block).to receive(:call).and_call_original 83 | subject 84 | end 85 | end 86 | end 87 | 88 | describe ".fetch_partitions" do 89 | subject { fetch_partitions } 90 | 91 | context "when key does not exist" do 92 | it { is_expected.to eq(:new_value) } 93 | 94 | it "executes block" do 95 | expect(block).to receive(:call).and_call_original 96 | subject 97 | end 98 | end 99 | 100 | context "when key exists for include_sub_partitions = false" do 101 | before do 102 | cache.fetch_partitions(12345678901, false) { :old_value } 103 | end 104 | 105 | it { is_expected.to eq(:old_value) } 106 | 107 | it "does not execute block" do 108 | expect(block).to_not receive(:call) 109 | subject 110 | end 111 | 112 | it 'does not cache value for include_sub_partitions = true' do 113 | expect(cache.fetch_partitions(12345678901, true, &block)).to be :new_value 114 | end 115 | end 116 | 117 | context 'when key exists for include_sub_partitions = true' do 118 | before do 119 | cache.fetch_partitions(12345678901, true) { :old_value } 120 | end 121 | 122 | it { is_expected.to eq(:new_value) } 123 | 124 | it "does execute block" do 125 | expect(block).to receive(:call) 126 | subject 127 | end 128 | 129 | it 'caches value for include_sub_partitions = true' do 130 | expect(cache.fetch_partitions(12345678901, true, &block)).to be :old_value 131 | end 132 | end 133 | 134 | context "when caching disabled" do 135 | before do 136 | PgParty.config.caching = false 137 | cache.fetch_partitions(12345678901, false) { :old_value } 138 | end 139 | 140 | it { is_expected.to eq(:new_value) } 141 | 142 | it "executes block" do 143 | expect(block).to receive(:call).and_call_original 144 | subject 145 | end 146 | end 147 | 148 | context "when TTL expires" do 149 | around do |example| 150 | PgParty.config.caching_ttl = 60 151 | cache.fetch_partitions(12345678901, false) { :old_value } 152 | Timecop.freeze(Time.now + 61, &example) 153 | end 154 | 155 | it { is_expected.to eq(:new_value) } 156 | 157 | it "executes block" do 158 | expect(block).to receive(:call).and_call_original 159 | subject 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PgParty::Config do 6 | let(:instance) { described_class.new } 7 | 8 | describe "#caching" do 9 | subject { instance.caching } 10 | 11 | context "when defaulted" do 12 | it { is_expected.to eq(true) } 13 | end 14 | 15 | context "when overridden" do 16 | before { instance.caching = false } 17 | it { is_expected.to eq(false) } 18 | end 19 | end 20 | 21 | describe "#caching_ttl" do 22 | subject { instance.caching_ttl } 23 | 24 | context "when defaulted" do 25 | it { is_expected.to eq(-1) } 26 | end 27 | 28 | context "when overridden" do 29 | before { instance.caching_ttl = 60 } 30 | it { is_expected.to eq(60) } 31 | end 32 | end 33 | 34 | describe "#schema_exclude_partitions" do 35 | subject { instance.schema_exclude_partitions } 36 | 37 | context "when defaulted" do 38 | it { is_expected.to eq(true) } 39 | end 40 | 41 | context "when overridden" do 42 | before { instance.schema_exclude_partitions = false } 43 | it { is_expected.to eq(false) } 44 | end 45 | end 46 | 47 | describe "#create_template_tables" do 48 | subject { instance.create_template_tables } 49 | 50 | context "when defaulted" do 51 | it { is_expected.to eq(true) } 52 | end 53 | 54 | context "when overridden" do 55 | before { instance.create_template_tables = false } 56 | it { is_expected.to eq(false) } 57 | end 58 | end 59 | 60 | describe "#create_with_primary_key" do 61 | subject { instance.create_with_primary_key } 62 | 63 | context "when defaulted" do 64 | it { is_expected.to eq(false) } 65 | end 66 | 67 | context "when overridden" do 68 | before { instance.create_with_primary_key = true } 69 | it { is_expected.to eq(true) } 70 | end 71 | end 72 | 73 | describe "#include_subpartitions_in_partition_list" do 74 | subject { instance.include_subpartitions_in_partition_list } 75 | 76 | context "when defaulted" do 77 | it { is_expected.to eq(false) } 78 | end 79 | 80 | context "when overridden" do 81 | before { instance.include_subpartitions_in_partition_list = true } 82 | it { is_expected.to eq(true) } 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/bigint_boolean_list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BigintBooleanList < ApplicationRecord 4 | list_partition_by :some_bool 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/bigint_custom_id_int_list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BigintCustomIdIntList < ApplicationRecord 4 | list_partition_by :some_int 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/bigint_custom_id_int_range.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BigintCustomIdIntRange < ApplicationRecord 4 | range_partition_by :some_int, :some_other_int 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/bigint_date_range.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BigintDateRange < ApplicationRecord 4 | range_partition_by { "(created_at::date)" } 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/bigint_date_range_no_partition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BigintDateRangeNoPartition < ApplicationRecord 4 | range_partition_by { "(created_at::date)" } 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/bigint_int_list_date_range_subpartition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BigintIntListDateRangeSubpartition < ApplicationRecord 4 | list_partition_by :id 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/bigint_month_range.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BigintMonthRange < ApplicationRecord 4 | range_partition_by { "EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at)" } 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/no_pk_substring_list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class NoPkSubstringList < ApplicationRecord 4 | list_partition_by { "LEFT(some_string, 1)" } 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/uuid_string_list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UuidStringList < ApplicationRecord 4 | list_partition_by :some_string 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/uuid_string_range.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UuidStringRange < ApplicationRecord 4 | range_partition_by :some_string 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | test: &test 2 | database: pg_party_<%= Rails.env %> 3 | adapter: postgresql 4 | encoding: unicode 5 | username: postgres 6 | password: postgres 7 | host: postgres 8 | schema_search_path: e9651f34 9 | port: 5432 10 | pool: 50 11 | 12 | development: 13 | <<: *test 14 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define do 4 | execute("CREATE SCHEMA e9651f34") 5 | 6 | enable_extension "uuid-ossp" 7 | enable_extension "pgcrypto" 8 | 9 | create_range_partition :bigint_date_ranges, partition_key: ->{ "(created_at::date)" } do |t| 10 | t.timestamps null: false, precision: nil 11 | end 12 | 13 | create_range_partition_of \ 14 | :bigint_date_ranges, 15 | name: :bigint_date_ranges_a, 16 | start_range: Date.today, 17 | end_range: Date.tomorrow 18 | 19 | create_range_partition_of \ 20 | :bigint_date_ranges, 21 | name: :bigint_date_ranges_b, 22 | start_range: Date.tomorrow, 23 | end_range: Date.tomorrow + 1 24 | 25 | create_range_partition :bigint_month_ranges, partition_key: ->{ "EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at)" } do |t| 26 | t.timestamps null: false, precision: nil 27 | t.integer :some_indexed_column 28 | end 29 | 30 | add_index :bigint_month_ranges_template, :some_indexed_column 31 | 32 | add_index \ 33 | :bigint_month_ranges_template, 34 | "EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at)", 35 | name: :bigint_month_ranges_created_at_month 36 | 37 | create_range_partition_of \ 38 | :bigint_month_ranges, 39 | name: :bigint_month_ranges_a, 40 | start_range: [Date.today.year, Date.today.month], 41 | end_range: [(Date.today + 1.month).year, (Date.today + 1.month).month] 42 | 43 | create_range_partition_of \ 44 | :bigint_month_ranges, 45 | name: :bigint_month_ranges_b, 46 | start_range: [(Date.today + 1.month).year, (Date.today + 1.month).month], 47 | end_range: [(Date.today + 2.months).year, (Date.today + 2.months).month] 48 | 49 | create_range_partition :bigint_custom_id_int_ranges, primary_key: :some_id, partition_key: [:some_int, :some_other_int] do |t| 50 | t.integer :some_int, null: false 51 | t.integer :some_other_int, null: false 52 | end 53 | 54 | create_range_partition_of \ 55 | :bigint_custom_id_int_ranges, 56 | name: :bigint_custom_id_int_ranges_a, 57 | start_range: [0, 0], 58 | end_range: [10, 10] 59 | 60 | create_range_partition_of \ 61 | :bigint_custom_id_int_ranges, 62 | name: :bigint_custom_id_int_ranges_b, 63 | start_range: [10, 10], 64 | end_range: [20, 20] 65 | 66 | create_range_partition :uuid_string_ranges, id: :uuid, partition_key: :some_string do |t| 67 | t.text :some_string, null: false 68 | end 69 | 70 | create_range_partition_of \ 71 | :uuid_string_ranges, 72 | name: :uuid_string_ranges_a, 73 | start_range: "a", 74 | end_range: "l" 75 | 76 | create_range_partition_of \ 77 | :uuid_string_ranges, 78 | name: :uuid_string_ranges_b, 79 | start_range: "l", 80 | end_range: "z" 81 | 82 | create_list_partition :bigint_boolean_lists, partition_key: :some_bool, template: false do |t| 83 | t.boolean :some_bool, default: true, null: false 84 | end 85 | 86 | create_list_partition_of \ 87 | :bigint_boolean_lists, 88 | name: :bigint_boolean_lists_a, 89 | values: true 90 | 91 | create_list_partition_of \ 92 | :bigint_boolean_lists, 93 | name: :bigint_boolean_lists_b, 94 | values: false 95 | 96 | create_list_partition :bigint_custom_id_int_lists, primary_key: :some_id, partition_key: :some_int do |t| 97 | t.integer :some_int, null: false 98 | end 99 | 100 | create_list_partition_of \ 101 | :bigint_custom_id_int_lists, 102 | name: :bigint_custom_id_int_lists_a, 103 | values: [1, 2] 104 | 105 | create_list_partition_of \ 106 | :bigint_custom_id_int_lists, 107 | name: :bigint_custom_id_int_lists_b, 108 | values: [3, 4] 109 | 110 | create_list_partition :bigint_int_list_date_range_subpartitions, partition_key: :id do |t| 111 | t.timestamps null: false, precision: nil 112 | end 113 | 114 | create_list_partition_of \ 115 | :bigint_int_list_date_range_subpartitions, 116 | name: :bigint_int_list_date_range_subpartitions_a, 117 | values: [1, 2], 118 | partition_type: :range, 119 | partition_key: :created_at 120 | 121 | create_range_partition_of \ 122 | :bigint_int_list_date_range_subpartitions_a, 123 | name: :bigint_int_list_date_range_subpartitions_a_1, 124 | start_range: Time.now - 1.day, 125 | end_range: Time.now + 10.days 126 | 127 | create_list_partition_of \ 128 | :bigint_int_list_date_range_subpartitions, 129 | name: :bigint_int_list_date_range_subpartitions_b, 130 | values: [3, 4] 131 | 132 | create_list_partition :uuid_string_lists, id: :uuid, partition_key: :some_string do |t| 133 | t.text :some_string, null: false 134 | end 135 | 136 | create_list_partition_of \ 137 | :uuid_string_lists, 138 | name: :uuid_string_lists_a, 139 | values: ["a", "b"] 140 | 141 | create_list_partition_of \ 142 | :uuid_string_lists, 143 | name: :uuid_string_lists_b, 144 | values: ["c", "d"] 145 | 146 | create_list_partition :no_pk_substring_lists, id: false, partition_key: ->{ "LEFT(some_string, 1)" } do |t| 147 | t.text :some_string, null: false 148 | end 149 | 150 | create_list_partition_of \ 151 | :no_pk_substring_lists, 152 | name: :no_pk_substring_lists_a, 153 | values: ["a", "b"] 154 | 155 | create_list_partition_of \ 156 | :no_pk_substring_lists, 157 | name: :no_pk_substring_lists_b, 158 | values: ["c", "d"] 159 | 160 | create_range_partition :bigint_date_range_no_partitions, partition_key: ->{ "(created_at::date)" } do |t| 161 | t.timestamps null: false, precision: nil 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/dummy/log/.gitignore: -------------------------------------------------------------------------------- 1 | *.log -------------------------------------------------------------------------------- /spec/integration/migration_hash_and_index_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe ActiveRecord::ConnectionAdapters::PostgreSQLAdapter do 6 | let(:table_name) { "t1_#{SecureRandom.hex(6)}" } 7 | let(:child_table_name) { "t2_#{SecureRandom.hex(6)}" } 8 | let(:sibling_table_name) { "t3_#{SecureRandom.hex(6)}" } 9 | let(:grandchild_table_name) { "t4_#{SecureRandom.hex(6)}" } 10 | let(:table_like_name) { "t_#{SecureRandom.hex(6)}" } 11 | let(:template_table_name) { "#{table_name}_template" } 12 | let(:current_date) { Date.current } 13 | let(:start_range) { current_date } 14 | let(:end_range) { current_date + 1.month } 15 | let(:index_prefix) { "i_#{SecureRandom.hex(6)}" } 16 | let(:uuid_values) { [SecureRandom.uuid, SecureRandom.uuid] } 17 | let(:create_with_primary_key) { false } 18 | 19 | before do 20 | ActiveRecord::Base.primary_key_prefix_type = :table_name_with_underscore 21 | end 22 | 23 | after do 24 | ActiveRecord::Base.primary_key_prefix_type = nil 25 | 26 | adapter.execute("DROP TABLE IF EXISTS #{table_name} CASCADE") 27 | adapter.execute("DROP TABLE IF EXISTS #{child_table_name} CASCADE") 28 | adapter.execute("DROP TABLE IF EXISTS #{sibling_table_name} CASCADE") 29 | adapter.execute("DROP TABLE IF EXISTS #{grandchild_table_name} CASCADE") 30 | adapter.execute("DROP TABLE IF EXISTS #{table_like_name} CASCADE") 31 | adapter.execute("DROP TABLE IF EXISTS #{template_table_name} CASCADE") 32 | end 33 | 34 | subject(:adapter) { ActiveRecord::Base.connection } 35 | 36 | subject(:create_hash_partition) do 37 | adapter.create_hash_partition( 38 | table_name, 39 | partition_key: "#{table_name}_id", 40 | create_with_primary_key: create_with_primary_key, 41 | id: :serial 42 | ) do |t| 43 | t.timestamps null: false, precision: nil 44 | end 45 | end 46 | 47 | subject(:create_hash_partition_of) do 48 | create_hash_partition 49 | 50 | adapter.create_hash_partition_of( 51 | table_name, 52 | name: child_table_name, 53 | modulus: 2, 54 | remainder: 0 55 | ) 56 | end 57 | 58 | subject(:create_default_partition_of) do 59 | create_hash_partition 60 | 61 | adapter.create_default_partition_of( 62 | table_name, 63 | name: child_table_name 64 | ) 65 | end 66 | 67 | subject(:create_hash_table_like) do 68 | create_hash_partition_of 69 | 70 | adapter.create_table_like(child_table_name, table_like_name) 71 | end 72 | 73 | subject(:attach_hash_partition) do 74 | create_hash_partition 75 | 76 | adapter.execute("CREATE TABLE #{child_table_name} (LIKE #{table_name})") 77 | 78 | adapter.attach_hash_partition( 79 | table_name, 80 | child_table_name, 81 | modulus: 2, 82 | remainder: 1 83 | ) 84 | end 85 | 86 | subject(:attach_default_partition) do 87 | adapter.create_list_partition( 88 | table_name, 89 | partition_key: "#{table_name}_id", 90 | id: :serial 91 | ) 92 | 93 | adapter.execute("CREATE TABLE #{child_table_name} (LIKE #{table_name})") 94 | 95 | adapter.attach_default_partition( 96 | table_name, 97 | child_table_name 98 | ) 99 | end 100 | 101 | subject(:create_range_partition_of_subpartitioned_by_list) do 102 | adapter.create_range_partition( 103 | table_name, 104 | partition_key: ->{ "(created_at::date)" }, 105 | primary_key: :custom_id, 106 | id: :uuid 107 | ) do |t| 108 | t.timestamps null: false, precision: nil 109 | end 110 | 111 | adapter.create_range_partition_of( 112 | table_name, 113 | name: child_table_name, 114 | primary_key: :custom_id, 115 | start_range: start_range, 116 | end_range: end_range, 117 | partition_type: :list, 118 | partition_key: :custom_id 119 | ) 120 | 121 | adapter.create_list_partition_of( 122 | child_table_name, 123 | name: grandchild_table_name, 124 | values: uuid_values 125 | ) 126 | 127 | adapter.create_range_partition_of( 128 | table_name, 129 | name: sibling_table_name, 130 | primary_key: :custom_id, 131 | start_range: end_range, 132 | end_range: end_range + 1.month 133 | ) 134 | end 135 | 136 | subject(:add_index_on_all_partitions) do 137 | create_range_partition_of_subpartitioned_by_list 138 | 139 | adapter.add_index_on_all_partitions table_name, :updated_at, name: index_prefix, using: :hash, 140 | algorithm: :concurrently, 141 | where: "created_at > '#{current_date.to_time.iso8601}'" 142 | end 143 | 144 | describe "#create_hash_partition" do 145 | let(:create_table_sql) do 146 | <<-SQL 147 | CREATE TABLE #{table_name} ( 148 | #{table_name}_id integer NOT NULL, 149 | created_at timestamp without time zone NOT NULL, 150 | updated_at timestamp without time zone NOT NULL 151 | ) PARTITION BY HASH (#{table_name}_id); 152 | SQL 153 | end 154 | 155 | let(:incrementing_id_sql) do 156 | <<-SQL 157 | ALTER TABLE ONLY #{table_name} 158 | ALTER COLUMN #{table_name}_id 159 | SET DEFAULT nextval('#{table_name}_#{table_name}_id_seq'::regclass); 160 | SQL 161 | end 162 | 163 | let(:primary_key_sql) do 164 | <<-SQL 165 | ALTER TABLE ONLY #{table_name} 166 | ADD CONSTRAINT #{table_name}_pkey PRIMARY KEY (#{table_name}_id); 167 | SQL 168 | end 169 | 170 | subject do 171 | create_hash_partition 172 | PgDumpHelper.dump_table_structure(table_name) 173 | end 174 | 175 | it { is_expected.to include_heredoc(create_table_sql) } 176 | it { is_expected.to include_heredoc(incrementing_id_sql) } 177 | it { is_expected.not_to include_heredoc(primary_key_sql) } 178 | 179 | describe "template table" do 180 | let(:create_table_sql) do 181 | <<-SQL 182 | CREATE TABLE #{template_table_name} ( 183 | #{table_name}_id integer DEFAULT nextval('#{table_name}_#{table_name}_id_seq'::regclass) NOT NULL, 184 | created_at timestamp without time zone NOT NULL, 185 | updated_at timestamp without time zone NOT NULL 186 | ); 187 | SQL 188 | end 189 | 190 | let(:primary_key_sql) do 191 | <<-SQL 192 | ALTER TABLE ONLY #{template_table_name} 193 | ADD CONSTRAINT #{template_table_name}_pkey PRIMARY KEY (#{table_name}_id); 194 | SQL 195 | end 196 | 197 | subject do 198 | create_hash_partition 199 | PgDumpHelper.dump_table_structure(template_table_name) 200 | end 201 | 202 | it { is_expected.to include_heredoc(create_table_sql) } 203 | it { is_expected.to include_heredoc(primary_key_sql) } 204 | 205 | context 'when config.create_template_tables = false' do 206 | before { PgParty.config.create_template_tables = false } 207 | after { PgParty.config.create_template_tables = true } 208 | 209 | it { is_expected.not_to include_heredoc(create_table_sql) } 210 | it { is_expected.not_to include_heredoc(primary_key_sql) } 211 | end 212 | end 213 | 214 | context 'when config.create_with_primary_key = true' do 215 | before { PgParty.config.create_with_primary_key = true } 216 | after { PgParty.config.create_with_primary_key = false } 217 | 218 | context 'when create_with_primary_key: false argument is provided' do 219 | it { is_expected.to include_heredoc(create_table_sql) } 220 | it { is_expected.to include_heredoc(incrementing_id_sql) } 221 | it { is_expected.not_to include_heredoc(primary_key_sql) } 222 | end 223 | 224 | context 'when create_with_primary_key: argument is not provided' do 225 | subject do 226 | adapter.create_hash_partition( 227 | table_name, 228 | partition_key: "#{table_name}_id", 229 | id: :serial 230 | ) do |t| 231 | t.timestamps null: false, precision: nil 232 | end 233 | 234 | PgDumpHelper.dump_table_structure(table_name) 235 | end 236 | 237 | it { is_expected.to include_heredoc(create_table_sql) } 238 | it { is_expected.to include_heredoc(incrementing_id_sql) } 239 | it { is_expected.to include_heredoc(primary_key_sql) } 240 | end 241 | end 242 | 243 | context 'when create_with_primary_key: true argument is provided' do 244 | let(:create_with_primary_key) { true } 245 | 246 | it { is_expected.to include_heredoc(create_table_sql) } 247 | it { is_expected.to include_heredoc(incrementing_id_sql) } 248 | it { is_expected.to include_heredoc(primary_key_sql) } 249 | end 250 | end 251 | 252 | describe '#create_hash_partition_of' do 253 | let(:create_table_sql) do 254 | <<-SQL 255 | CREATE TABLE #{child_table_name} ( 256 | #{table_name}_id integer DEFAULT 257 | nextval('#{table_name}_#{table_name}_id_seq'::regclass) NOT NULL, 258 | created_at timestamp without time zone NOT NULL, 259 | updated_at timestamp without time zone NOT NULL 260 | ); 261 | SQL 262 | end 263 | 264 | let(:attach_table_sql) do 265 | <<-SQL 266 | ALTER TABLE ONLY #{table_name} 267 | ATTACH PARTITION #{child_table_name} 268 | FOR VALUES WITH (modulus 2, remainder 0); 269 | SQL 270 | end 271 | 272 | let(:primary_key_sql) do 273 | <<-SQL 274 | ALTER TABLE ONLY #{child_table_name} 275 | ADD CONSTRAINT #{child_table_name}_pkey 276 | PRIMARY KEY (#{table_name}_id); 277 | SQL 278 | end 279 | 280 | subject do 281 | create_hash_partition_of 282 | PgDumpHelper.dump_table_structure(child_table_name) 283 | end 284 | 285 | it { is_expected.to include_heredoc(create_table_sql) } 286 | it { is_expected.to include_heredoc(attach_table_sql) } 287 | it { is_expected.to include_heredoc(primary_key_sql) } 288 | 289 | context 'when config.create_with_primary_key = true' do 290 | before { PgParty.config.create_with_primary_key = true } 291 | after { PgParty.config.create_with_primary_key = false } 292 | 293 | it { is_expected.to include_heredoc(create_table_sql) } 294 | it { is_expected.to include_heredoc(attach_table_sql) } 295 | it { is_expected.to include_heredoc(primary_key_sql) } 296 | 297 | context 'when config.create_template_tables = false' do 298 | before { PgParty.config.create_template_tables = false } 299 | after { PgParty.config.create_template_tables = true } 300 | 301 | it { is_expected.to include_heredoc(create_table_sql) } 302 | it { is_expected.to include_heredoc(attach_table_sql) } 303 | it { is_expected.to include_heredoc(primary_key_sql) } 304 | end 305 | end 306 | end 307 | 308 | describe "#create_table_like" do 309 | context "hash partition" do 310 | let(:create_table_sql) do 311 | <<-SQL 312 | CREATE TABLE #{table_like_name} ( 313 | #{table_name}_id integer DEFAULT nextval('#{table_name}_#{table_name}_id_seq'::regclass) NOT NULL, 314 | created_at timestamp without time zone NOT NULL, 315 | updated_at timestamp without time zone NOT NULL 316 | ); 317 | SQL 318 | end 319 | 320 | let(:primary_key_sql) do 321 | <<-SQL 322 | ALTER TABLE ONLY #{table_like_name} 323 | ADD CONSTRAINT #{table_like_name}_pkey 324 | PRIMARY KEY (#{table_name}_id); 325 | SQL 326 | end 327 | 328 | subject do 329 | create_hash_table_like 330 | PgDumpHelper.dump_table_structure(table_like_name) 331 | end 332 | 333 | it { is_expected.to include_heredoc(create_table_sql) } 334 | it { is_expected.to include_heredoc(primary_key_sql) } 335 | end 336 | end 337 | 338 | describe "#attach_hash_partition" do 339 | let(:attach_table_sql) do 340 | <<-SQL 341 | ALTER TABLE ONLY #{table_name} 342 | ATTACH PARTITION #{child_table_name} 343 | FOR VALUES WITH (modulus 2, remainder 1); 344 | SQL 345 | end 346 | 347 | subject do 348 | attach_hash_partition 349 | PgDumpHelper.dump_table_structure(child_table_name) 350 | end 351 | 352 | it { is_expected.to include_heredoc(attach_table_sql) } 353 | end 354 | 355 | describe "#attach_default_partition" do 356 | let(:attach_table_sql) do 357 | <<-SQL 358 | ALTER TABLE ONLY #{table_name} 359 | ATTACH PARTITION #{child_table_name} 360 | DEFAULT; 361 | SQL 362 | end 363 | 364 | subject do 365 | attach_default_partition 366 | PgDumpHelper.dump_table_structure(child_table_name) 367 | end 368 | 369 | it { is_expected.to include_heredoc(attach_table_sql) } 370 | end 371 | 372 | describe "#add_index_on_all_partitions" do 373 | let(:grandchild_index_sql) do 374 | <<-SQL 375 | CREATE INDEX #{index_prefix}_#{Digest::MD5.hexdigest(grandchild_table_name)[0..6]} 376 | ON #{grandchild_table_name} USING hash (updated_at) 377 | WHERE (created_at > '#{current_date} 00:00:00'::timestamp without time zone) 378 | SQL 379 | end 380 | let(:sibling_index_sql) do 381 | <<-SQL 382 | CREATE INDEX #{index_prefix}_#{Digest::MD5.hexdigest(sibling_table_name)[0..6]} 383 | ON #{sibling_table_name} USING hash (updated_at) 384 | WHERE (created_at > '#{current_date} 00:00:00'::timestamp without time zone) 385 | SQL 386 | end 387 | 388 | before { allow(adapter).to receive(:execute).and_call_original } 389 | 390 | subject do 391 | add_index_on_all_partitions 392 | PgDumpHelper.dump_indices 393 | end 394 | 395 | it { is_expected.to include_heredoc(sibling_index_sql) } 396 | it { is_expected.to include_heredoc(grandchild_index_sql) } 397 | 398 | it 'creates the indices using CONCURRENTLY directive because `algorthim: :concurrently` args are present' do 399 | subject 400 | expect(adapter).to have_received(:execute).with( 401 | "CREATE INDEX CONCURRENTLY \"#{index_prefix}_#{Digest::MD5.hexdigest(grandchild_table_name)[0..6]}\" "\ 402 | "ON \"#{grandchild_table_name}\" USING hash (\"updated_at\") "\ 403 | "WHERE created_at > '#{current_date.to_time.iso8601}'" 404 | ) 405 | expect(adapter).to have_received(:execute).with( 406 | "CREATE INDEX CONCURRENTLY \"#{index_prefix}_#{Digest::MD5.hexdigest(sibling_table_name)[0..6]}\" "\ 407 | "ON \"#{sibling_table_name}\" USING hash (\"updated_at\") "\ 408 | "WHERE created_at > '#{current_date.to_time.iso8601}'" 409 | ) 410 | end 411 | 412 | it 'creates indices, non-concurrently, on partitioned tables using ON ONLY directive' do 413 | subject 414 | expect(adapter).to have_received(:execute).with( 415 | "CREATE INDEX \"#{index_prefix}\" "\ 416 | "ON ONLY \"#{table_name}\" USING hash (\"updated_at\") "\ 417 | "WHERE created_at > '#{current_date.to_time.iso8601}'" 418 | ) 419 | expect(adapter).to have_received(:execute).with( 420 | "CREATE INDEX \"#{index_prefix}_#{Digest::MD5.hexdigest(child_table_name)[0..6]}\" "\ 421 | "ON ONLY \"#{child_table_name}\" USING hash (\"updated_at\") "\ 422 | "WHERE created_at > '#{current_date.to_time.iso8601}'" 423 | ) 424 | end 425 | 426 | it 'attaches the partitioned indices to the correct parent table indices' do 427 | subject 428 | expect(adapter).to have_received(:execute).with( 429 | "ALTER INDEX \"#{index_prefix}\" ATTACH PARTITION "\ 430 | "\"#{index_prefix}_#{Digest::MD5.hexdigest(child_table_name)[0..6]}\"" 431 | ) 432 | expect(adapter).to have_received(:execute).with( 433 | "ALTER INDEX \"#{index_prefix}\" ATTACH PARTITION "\ 434 | "\"#{index_prefix}_#{Digest::MD5.hexdigest(sibling_table_name)[0..6]}\"" 435 | ) 436 | expect(adapter).to have_received(:execute).with( 437 | "ALTER INDEX \"#{index_prefix}_#{Digest::MD5.hexdigest(child_table_name)[0..6]}\" ATTACH PARTITION "\ 438 | "\"#{index_prefix}_#{Digest::MD5.hexdigest(grandchild_table_name)[0..6]}\"" 439 | ) 440 | end 441 | 442 | context 'when an index is not valid at the end of the operation' do 443 | let(:index_dump) { PgDumpHelper.dump_indices } 444 | 445 | before do 446 | # Simulate failure to attach a child index 447 | allow(adapter).to receive(:execute).with( 448 | "ALTER INDEX \"#{index_prefix}\" ATTACH PARTITION "\ 449 | "\"#{index_prefix}_#{Digest::MD5.hexdigest(sibling_table_name)[0..6]}\"" 450 | ) 451 | end 452 | 453 | it 'raises error, after dropping any indices created in the operation' do 454 | expect { add_index_on_all_partitions }.to raise_error 'index creation failed - an index was marked invalid' 455 | expect(index_dump).not_to include_heredoc(sibling_index_sql) 456 | expect(index_dump).not_to include_heredoc(grandchild_index_sql) 457 | expect(adapter).to have_received(:execute).with( 458 | %[DROP INDEX IF EXISTS "#{index_prefix}"] 459 | ) 460 | expect(adapter).to have_received(:execute).with( 461 | %[DROP INDEX IF EXISTS "#{index_prefix}_#{Digest::MD5.hexdigest(sibling_table_name)[0..6]}"] 462 | ) 463 | expect(adapter).to have_received(:execute).with( 464 | %[DROP INDEX IF EXISTS "#{index_prefix}_#{Digest::MD5.hexdigest(child_table_name)[0..6]}"] 465 | ) 466 | expect(adapter).to have_received(:execute).with( 467 | %[DROP INDEX IF EXISTS "#{index_prefix}_#{Digest::MD5.hexdigest(grandchild_table_name)[0..6]}"] 468 | ) 469 | end 470 | end 471 | end 472 | end 473 | -------------------------------------------------------------------------------- /spec/integration/migration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe ActiveRecord::ConnectionAdapters::PostgreSQLAdapter do 6 | let(:table_name) { "t_#{SecureRandom.hex(6)}" } 7 | let(:child_table_name) { "t_#{SecureRandom.hex(6)}" } 8 | let(:sibling_table_name) { "t_#{SecureRandom.hex(6)}" } 9 | let(:grandchild_table_name) { "t_#{SecureRandom.hex(6)}" } 10 | let(:table_like_name) { "t_#{SecureRandom.hex(6)}" } 11 | let(:template_table_name) { "#{table_name}_template" } 12 | let(:current_date) { Date.current } 13 | let(:start_range) { current_date } 14 | let(:end_range) { current_date + 1.month } 15 | let(:values) { (1..3) } 16 | let(:uuid_values) { [SecureRandom.uuid, SecureRandom.uuid] } 17 | let(:timestamps_block) { ->(t) { t.timestamps null: false, precision: nil } } 18 | let(:index_prefix) { "i_#{SecureRandom.hex(6)}" } 19 | let(:index_threads) { nil } 20 | 21 | before do 22 | ActiveRecord::Base.primary_key_prefix_type = :table_name_with_underscore 23 | end 24 | 25 | after do 26 | ActiveRecord::Base.primary_key_prefix_type = nil 27 | 28 | adapter.execute("DROP TABLE IF EXISTS #{table_name} CASCADE") 29 | adapter.execute("DROP TABLE IF EXISTS #{child_table_name} CASCADE") 30 | adapter.execute("DROP TABLE IF EXISTS #{sibling_table_name} CASCADE") 31 | adapter.execute("DROP TABLE IF EXISTS #{grandchild_table_name} CASCADE") 32 | adapter.execute("DROP TABLE IF EXISTS #{table_like_name} CASCADE") 33 | adapter.execute("DROP TABLE IF EXISTS #{template_table_name} CASCADE") 34 | end 35 | 36 | subject(:adapter) { ActiveRecord::Base.connection } 37 | 38 | subject(:create_range_partition) do 39 | adapter.create_range_partition( 40 | table_name, 41 | partition_key: ->{ "(created_at::date)" }, 42 | primary_key: :custom_id, 43 | id: :uuid, 44 | template: false, 45 | ×tamps_block 46 | ) 47 | end 48 | 49 | subject(:create_list_partition) do 50 | adapter.create_list_partition( 51 | table_name, 52 | partition_key: "#{table_name}_id", 53 | id: :serial 54 | ) 55 | end 56 | 57 | subject(:create_range_partition_of) do 58 | create_range_partition 59 | 60 | adapter.create_range_partition_of( 61 | table_name, 62 | name: child_table_name, 63 | primary_key: :custom_id, 64 | start_range: start_range, 65 | end_range: end_range 66 | ) 67 | end 68 | 69 | subject(:create_range_partition_of_subpartitioned_by_list) do 70 | adapter.create_range_partition( 71 | table_name, 72 | partition_key: ->{ "(created_at::date)" }, 73 | primary_key: :custom_id, 74 | id: :uuid, 75 | ×tamps_block 76 | ) 77 | 78 | adapter.create_range_partition_of( 79 | table_name, 80 | name: child_table_name, 81 | primary_key: :custom_id, 82 | start_range: start_range, 83 | end_range: end_range, 84 | partition_type: :list, 85 | partition_key: :custom_id 86 | ) 87 | 88 | adapter.create_list_partition_of( 89 | child_table_name, 90 | name: grandchild_table_name, 91 | values: uuid_values 92 | ) 93 | 94 | adapter.create_range_partition_of( 95 | table_name, 96 | name: sibling_table_name, 97 | primary_key: :custom_id, 98 | start_range: end_range, 99 | end_range: end_range + 1.month 100 | ) 101 | end 102 | 103 | subject(:create_list_partition_of) do 104 | create_list_partition 105 | 106 | adapter.create_list_partition_of( 107 | table_name, 108 | name: child_table_name, 109 | values: values 110 | ) 111 | end 112 | 113 | subject(:create_range_table_like) do 114 | create_range_partition_of 115 | 116 | adapter.create_table_like(child_table_name, table_like_name) 117 | end 118 | 119 | subject(:create_list_table_like) do 120 | create_list_partition_of 121 | 122 | adapter.create_table_like(child_table_name, table_like_name) 123 | end 124 | 125 | subject(:attach_range_partition) do 126 | create_range_partition 127 | 128 | adapter.execute("CREATE TABLE #{child_table_name} (LIKE #{table_name})") 129 | 130 | adapter.attach_range_partition( 131 | table_name, 132 | child_table_name, 133 | start_range: start_range, 134 | end_range: end_range 135 | ) 136 | end 137 | 138 | subject(:attach_list_partition) do 139 | create_list_partition 140 | 141 | adapter.execute("CREATE TABLE #{child_table_name} (LIKE #{table_name})") 142 | 143 | adapter.attach_list_partition( 144 | table_name, 145 | child_table_name, 146 | values: values 147 | ) 148 | end 149 | 150 | subject(:detach_partition) do 151 | create_range_partition 152 | create_range_partition_of 153 | 154 | adapter.detach_partition(table_name, child_table_name) 155 | end 156 | 157 | subject(:add_index_on_all_partitions) do 158 | create_range_partition_of_subpartitioned_by_list 159 | 160 | adapter.add_index_on_all_partitions table_name, :updated_at, name: index_prefix, using: :hash, 161 | in_threads: index_threads, algorithm: :concurrently, 162 | where: "created_at > '#{current_date.to_time.iso8601}'" 163 | end 164 | 165 | describe "#create_range_partition" do 166 | let(:create_table_sql) do 167 | <<-SQL 168 | CREATE TABLE #{table_name} ( 169 | custom_id uuid DEFAULT gen_random_uuid() NOT NULL, 170 | created_at timestamp without time zone NOT NULL, 171 | updated_at timestamp without time zone NOT NULL 172 | ) PARTITION BY RANGE (((created_at)::date)); 173 | SQL 174 | end 175 | 176 | subject do 177 | create_range_partition 178 | PgDumpHelper.dump_table_structure(table_name) 179 | end 180 | 181 | it { is_expected.to include_heredoc(create_table_sql) } 182 | it { is_expected.to_not include("SET DEFAULT nextval") } 183 | 184 | describe "template table" do 185 | subject do 186 | create_range_partition 187 | PgDumpHelper.dump_table_structure(template_table_name) 188 | end 189 | 190 | it { is_expected.to be_empty } 191 | end 192 | end 193 | 194 | describe "#create_list_partition" do 195 | let(:create_table_sql) do 196 | <<-SQL 197 | CREATE TABLE #{table_name} ( 198 | #{table_name}_id integer NOT NULL 199 | ) PARTITION BY LIST (#{table_name}_id); 200 | SQL 201 | end 202 | 203 | let(:incrementing_id_sql) do 204 | <<-SQL 205 | ALTER TABLE ONLY #{table_name} 206 | ALTER COLUMN #{table_name}_id 207 | SET DEFAULT nextval('#{table_name}_#{table_name}_id_seq'::regclass); 208 | SQL 209 | end 210 | 211 | subject do 212 | create_list_partition 213 | PgDumpHelper.dump_table_structure(table_name) 214 | end 215 | 216 | it { is_expected.to include_heredoc(create_table_sql) } 217 | it { is_expected.to include_heredoc(incrementing_id_sql) } 218 | 219 | describe "template table" do 220 | let(:create_table_sql) do 221 | <<-SQL 222 | CREATE TABLE #{template_table_name} ( 223 | #{table_name}_id integer DEFAULT nextval('#{table_name}_#{table_name}_id_seq'::regclass) NOT NULL 224 | ); 225 | SQL 226 | end 227 | 228 | let(:primary_key_sql) do 229 | <<-SQL 230 | ALTER TABLE ONLY #{template_table_name} 231 | ADD CONSTRAINT #{template_table_name}_pkey PRIMARY KEY (#{table_name}_id); 232 | SQL 233 | end 234 | 235 | subject do 236 | create_list_partition 237 | PgDumpHelper.dump_table_structure(template_table_name) 238 | end 239 | 240 | it { is_expected.to include_heredoc(create_table_sql) } 241 | it { is_expected.to include_heredoc(primary_key_sql) } 242 | 243 | context 'when config.create_template_tables = false' do 244 | before { PgParty.config.create_template_tables = false } 245 | after { PgParty.config.create_template_tables = true } 246 | 247 | it { is_expected.not_to include_heredoc(create_table_sql) } 248 | it { is_expected.not_to include_heredoc(primary_key_sql) } 249 | end 250 | end 251 | end 252 | 253 | describe "#create_range_partition_of" do 254 | let(:create_table_sql) do 255 | <<-SQL 256 | CREATE TABLE #{child_table_name} ( 257 | custom_id uuid DEFAULT gen_random_uuid() NOT NULL, 258 | created_at timestamp without time zone NOT NULL, 259 | updated_at timestamp without time zone NOT NULL 260 | ); 261 | SQL 262 | end 263 | 264 | let(:attach_table_sql) do 265 | <<-SQL 266 | ALTER TABLE ONLY #{table_name} 267 | ATTACH PARTITION #{child_table_name} 268 | FOR VALUES FROM ('#{start_range}') TO ('#{end_range}'); 269 | SQL 270 | end 271 | 272 | let(:primary_key_sql) do 273 | <<-SQL 274 | ALTER TABLE ONLY #{child_table_name} 275 | ADD CONSTRAINT #{child_table_name}_pkey 276 | PRIMARY KEY (custom_id); 277 | SQL 278 | end 279 | 280 | subject do 281 | create_range_partition_of 282 | PgDumpHelper.dump_table_structure(child_table_name) 283 | end 284 | 285 | it { is_expected.to include_heredoc(create_table_sql) } 286 | it { is_expected.to include_heredoc(attach_table_sql) } 287 | it { is_expected.to include_heredoc(primary_key_sql) } 288 | 289 | context 'when subpartitioning' do 290 | let(:create_table_sql) do 291 | <<-SQL 292 | CREATE TABLE #{child_table_name} ( 293 | custom_id uuid DEFAULT gen_random_uuid() NOT NULL, 294 | created_at timestamp without time zone NOT NULL, 295 | updated_at timestamp without time zone NOT NULL 296 | ) 297 | PARTITION BY LIST (custom_id); 298 | SQL 299 | end 300 | 301 | subject do 302 | create_range_partition_of_subpartitioned_by_list 303 | PgDumpHelper.dump_table_structure(child_table_name) 304 | end 305 | 306 | it { is_expected.to include_heredoc(create_table_sql) } 307 | it { is_expected.to include_heredoc(attach_table_sql) } 308 | it { is_expected.not_to include_heredoc(primary_key_sql) } 309 | end 310 | 311 | context 'when an unsupported partition_type: is specified' do 312 | subject(:create_range_partition_of) do 313 | create_range_partition 314 | 315 | adapter.create_range_partition_of( 316 | table_name, 317 | name: child_table_name, 318 | partition_type: :something_invalid, 319 | partition_key: :custom_id, 320 | start_range: start_range, 321 | end_range: end_range, 322 | ) 323 | end 324 | 325 | it 'raises ArgumentError' do 326 | expect { subject }.to raise_error ArgumentError, 'Supported partition types are range, list, hash' 327 | end 328 | end 329 | 330 | context 'when partition_type: is specified but not partition_key:' do 331 | subject(:create_range_partition_of) do 332 | create_range_partition 333 | 334 | adapter.create_range_partition_of( 335 | table_name, 336 | name: child_table_name, 337 | partition_type: :list, 338 | start_range: start_range, 339 | end_range: end_range, 340 | ) 341 | end 342 | 343 | it 'raises ArgumentError' do 344 | expect { subject }.to raise_error ArgumentError, '`partition_key` is required when specifying a partition_type' 345 | end 346 | end 347 | end 348 | 349 | describe "#create_list_partition_of" do 350 | let(:create_table_sql) do 351 | <<-SQL 352 | CREATE TABLE #{child_table_name} ( 353 | #{table_name}_id integer DEFAULT nextval('#{table_name}_#{table_name}_id_seq'::regclass) NOT NULL 354 | ); 355 | SQL 356 | end 357 | 358 | let(:attach_table_sql) do 359 | <<-SQL 360 | ALTER TABLE ONLY #{table_name} 361 | ATTACH PARTITION #{child_table_name} 362 | FOR VALUES IN (1, 2, 3); 363 | SQL 364 | end 365 | 366 | let(:primary_key_sql) do 367 | <<-SQL 368 | ALTER TABLE ONLY #{child_table_name} 369 | ADD CONSTRAINT #{child_table_name}_pkey 370 | PRIMARY KEY (#{table_name}_id); 371 | SQL 372 | end 373 | 374 | subject do 375 | create_list_partition_of 376 | PgDumpHelper.dump_table_structure(child_table_name) 377 | end 378 | 379 | it { is_expected.to include_heredoc(create_table_sql) } 380 | it { is_expected.to include_heredoc(attach_table_sql) } 381 | it { is_expected.to include_heredoc(primary_key_sql) } 382 | end 383 | 384 | describe "#create_table_like" do 385 | context "range partition" do 386 | let(:create_table_sql) do 387 | <<-SQL 388 | CREATE TABLE #{table_like_name} ( 389 | custom_id uuid DEFAULT gen_random_uuid() NOT NULL, 390 | created_at timestamp without time zone NOT NULL, 391 | updated_at timestamp without time zone NOT NULL 392 | ); 393 | SQL 394 | end 395 | 396 | let(:primary_key_sql) do 397 | <<-SQL 398 | ALTER TABLE ONLY #{table_like_name} 399 | ADD CONSTRAINT #{table_like_name}_pkey 400 | PRIMARY KEY (custom_id); 401 | SQL 402 | end 403 | 404 | subject do 405 | create_range_table_like 406 | PgDumpHelper.dump_table_structure(table_like_name) 407 | end 408 | 409 | it { is_expected.to include_heredoc(create_table_sql) } 410 | it { is_expected.to include_heredoc(primary_key_sql) } 411 | end 412 | 413 | context "list partition" do 414 | let(:create_table_sql) do 415 | <<-SQL 416 | CREATE TABLE #{table_like_name} ( 417 | #{table_name}_id integer DEFAULT nextval('#{table_name}_#{table_name}_id_seq'::regclass) NOT NULL 418 | ); 419 | SQL 420 | end 421 | 422 | let(:primary_key_sql) do 423 | <<-SQL 424 | ALTER TABLE ONLY #{table_like_name} 425 | ADD CONSTRAINT #{table_like_name}_pkey 426 | PRIMARY KEY (#{table_name}_id); 427 | SQL 428 | end 429 | 430 | subject do 431 | create_list_table_like 432 | PgDumpHelper.dump_table_structure(table_like_name) 433 | end 434 | 435 | it { is_expected.to include_heredoc(create_table_sql) } 436 | it { is_expected.to include_heredoc(primary_key_sql) } 437 | end 438 | end 439 | 440 | describe "#attach_range_partition" do 441 | let(:attach_table_sql) do 442 | <<-SQL 443 | ALTER TABLE ONLY #{table_name} 444 | ATTACH PARTITION #{child_table_name} 445 | FOR VALUES FROM ('#{start_range}') TO ('#{end_range}'); 446 | SQL 447 | end 448 | 449 | subject do 450 | attach_range_partition 451 | PgDumpHelper.dump_table_structure(child_table_name) 452 | end 453 | 454 | it { is_expected.to include_heredoc(attach_table_sql) } 455 | end 456 | 457 | describe "#attach_list_partition" do 458 | let(:attach_table_sql) do 459 | <<-SQL 460 | ALTER TABLE ONLY #{table_name} 461 | ATTACH PARTITION #{child_table_name} 462 | FOR VALUES IN (1, 2, 3); 463 | SQL 464 | end 465 | 466 | subject do 467 | attach_list_partition 468 | PgDumpHelper.dump_table_structure(child_table_name) 469 | end 470 | 471 | it { is_expected.to include_heredoc(attach_table_sql) } 472 | end 473 | 474 | describe "#detach_partition" do 475 | let(:create_table_sql) do 476 | <<-SQL 477 | CREATE TABLE #{child_table_name} ( 478 | custom_id uuid DEFAULT gen_random_uuid() NOT NULL, 479 | created_at timestamp without time zone NOT NULL, 480 | updated_at timestamp without time zone NOT NULL 481 | ); 482 | SQL 483 | end 484 | 485 | subject do 486 | detach_partition 487 | PgDumpHelper.dump_table_structure(child_table_name) 488 | end 489 | 490 | it { is_expected.to include_heredoc(create_table_sql) } 491 | it { is_expected.to_not include("ATTACH") } 492 | end 493 | 494 | describe '#parent_for_table_name' do 495 | let(:traverse) { false } 496 | 497 | before { create_range_partition_of_subpartitioned_by_list } 498 | 499 | it 'fetches the parent of the given table' do 500 | expect(adapter.parent_for_table_name(grandchild_table_name)).to eq child_table_name 501 | expect(adapter.parent_for_table_name(child_table_name)).to eq table_name 502 | expect(adapter.parent_for_table_name(table_name)).to be_nil 503 | end 504 | 505 | context 'when traverse: true argument is specified' do 506 | it 'returns top-level ancestor' do 507 | expect(adapter.parent_for_table_name(grandchild_table_name, traverse: true)).to eq table_name 508 | expect(adapter.parent_for_table_name(child_table_name, traverse: true)).to eq table_name 509 | expect(adapter.parent_for_table_name(table_name, traverse: true)).to be_nil 510 | end 511 | end 512 | end 513 | 514 | describe '#partitions_for_table_name' do 515 | let(:traverse) { false } 516 | 517 | before do 518 | create_range_partition_of_subpartitioned_by_list 519 | end 520 | 521 | context 'when include_subpartitions: false' do 522 | it 'fetches the partitions of the table specified' do 523 | expect(adapter.partitions_for_table_name(table_name, include_subpartitions: false)).to eq( 524 | [child_table_name, sibling_table_name] 525 | ) 526 | expect(adapter.partitions_for_table_name(child_table_name, include_subpartitions: false)).to eq( 527 | [grandchild_table_name] 528 | ) 529 | expect(adapter.partitions_for_table_name(grandchild_table_name, include_subpartitions: false)).to be_empty 530 | end 531 | end 532 | 533 | context 'when include_subpartitions: true' do 534 | it 'fetches all partitions and subpartitions of the table specified' do 535 | expect(adapter.partitions_for_table_name(table_name, include_subpartitions: true)).to eq( 536 | [child_table_name, grandchild_table_name, sibling_table_name] 537 | ) 538 | expect(adapter.partitions_for_table_name(child_table_name, include_subpartitions: true)).to eq( 539 | [grandchild_table_name] 540 | ) 541 | expect(adapter.partitions_for_table_name(grandchild_table_name, include_subpartitions: true)).to be_empty 542 | end 543 | end 544 | end 545 | 546 | describe "#add_index_on_all_partitions" do 547 | let(:grandchild_index_sql) do 548 | <<-SQL 549 | CREATE INDEX #{index_prefix}_#{Digest::MD5.hexdigest(grandchild_table_name)[0..6]} 550 | ON #{grandchild_table_name} USING hash (updated_at) 551 | WHERE (created_at > '#{current_date} 00:00:00'::timestamp without time zone) 552 | SQL 553 | end 554 | let(:sibling_index_sql) do 555 | <<-SQL 556 | CREATE INDEX #{index_prefix}_#{Digest::MD5.hexdigest(sibling_table_name)[0..6]} 557 | ON #{sibling_table_name} USING hash (updated_at) 558 | WHERE (created_at > '#{current_date} 00:00:00'::timestamp without time zone) 559 | SQL 560 | end 561 | 562 | before { allow(adapter).to receive(:execute).and_call_original } 563 | 564 | subject do 565 | add_index_on_all_partitions 566 | PgDumpHelper.dump_indices 567 | end 568 | 569 | it { is_expected.to include_heredoc(sibling_index_sql) } 570 | it { is_expected.to include_heredoc(grandchild_index_sql) } 571 | 572 | it 'creates the indices using CONCURRENTLY directive because `algorthim: :concurrently` args are present' do 573 | subject 574 | expect(adapter).to have_received(:execute).with( 575 | "CREATE INDEX CONCURRENTLY \"#{index_prefix}_#{Digest::MD5.hexdigest(grandchild_table_name)[0..6]}\" "\ 576 | "ON \"#{grandchild_table_name}\" USING hash (\"updated_at\") "\ 577 | "WHERE created_at > '#{current_date.to_time.iso8601}'" 578 | ) 579 | expect(adapter).to have_received(:execute).with( 580 | "CREATE INDEX CONCURRENTLY \"#{index_prefix}_#{Digest::MD5.hexdigest(sibling_table_name)[0..6]}\" "\ 581 | "ON \"#{sibling_table_name}\" USING hash (\"updated_at\") "\ 582 | "WHERE created_at > '#{current_date.to_time.iso8601}'" 583 | ) 584 | end 585 | 586 | context 'when unique: true index option is used' do 587 | subject(:add_index_on_all_partitions) do 588 | create_list_partition_of 589 | 590 | adapter.add_index_on_all_partitions table_name, "#{table_name}_id", name: index_prefix, 591 | in_threads: index_threads, algorithm: :concurrently, unique: true 592 | end 593 | 594 | it 'creates a unique index' do 595 | subject 596 | expect(adapter).to have_received(:execute).with( 597 | "CREATE UNIQUE INDEX CONCURRENTLY \"#{index_prefix}_#{Digest::MD5.hexdigest(child_table_name)[0..6]}\" "\ 598 | "ON \"#{child_table_name}\" (\"#{table_name}_id\")" 599 | ) 600 | end 601 | end 602 | 603 | context 'when in_threads: is provided' do 604 | let(:index_threads) { ActiveRecord::Base.connection_pool.size - 1 } 605 | 606 | before do 607 | allow(Parallel).to receive(:map).with([child_table_name, sibling_table_name], in_threads: index_threads) 608 | .and_yield(child_table_name).and_yield(sibling_table_name) 609 | end 610 | 611 | it 'calls through Parallel.map' do 612 | subject 613 | expect(Parallel).to have_received(:map) 614 | .with([child_table_name, sibling_table_name], in_threads: index_threads) 615 | end 616 | 617 | it { is_expected.to include_heredoc(sibling_index_sql) } 618 | it { is_expected.to include_heredoc(grandchild_index_sql) } 619 | 620 | context 'when in a transaction' do 621 | it 'raises ArgumentError' do 622 | ActiveRecord::Base.transaction do 623 | expect { subject }.to raise_error(ArgumentError, 624 | '`in_threads:` cannot be used within a transaction. If running in a migration, use '\ 625 | '`disable_ddl_transaction!` and break out this operation into its own migration.' 626 | ) 627 | end 628 | end 629 | end 630 | 631 | context 'when in_threads is equal to or greater than connection pool size' do 632 | let(:index_threads) { ActiveRecord::Base.connection_pool.size } 633 | 634 | it 'raises ArgumentError' do 635 | expect { subject }.to raise_error(ArgumentError, 636 | 'in_threads: must be lower than your database connection pool size' 637 | ) 638 | end 639 | end 640 | context 'when index name has more than 55 characters' do 641 | let(:index_prefix) { SecureRandom.hex(28) } 642 | 643 | it 'raises ArgumentError' do 644 | expect { subject }.to raise_error(ArgumentError, 645 | 'index name is too long - must be 55 characters or fewer') 646 | end 647 | end 648 | end 649 | end 650 | 651 | describe '#table_partitioned?' do 652 | before { create_range_partition_of_subpartitioned_by_list } 653 | 654 | it 'returns true for partitioned tables; false for partitions themselves' do 655 | expect(adapter.table_partitioned?(table_name)).to be true 656 | expect(adapter.table_partitioned?(child_table_name)).to be true 657 | expect(adapter.table_partitioned?(sibling_table_name)).to be false 658 | expect(adapter.table_partitioned?(grandchild_table_name)).to be false 659 | end 660 | end 661 | end 662 | -------------------------------------------------------------------------------- /spec/integration/model/bigint_boolean_list_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe BigintBooleanList do 6 | let(:connection) { described_class.connection } 7 | let(:schema_cache) { connection.schema_cache } 8 | let(:table_name) { described_class.table_name } 9 | 10 | describe ".primary_key" do 11 | subject { described_class.primary_key } 12 | 13 | it { is_expected.to eq("id") } 14 | end 15 | 16 | describe ".create" do 17 | let(:some_bool) { true } 18 | 19 | subject { described_class.create!(some_bool: some_bool) } 20 | 21 | context "when partition key in list" do 22 | its(:id) { is_expected.to be_an(Integer) } 23 | its(:some_bool) { is_expected.to eq(some_bool) } 24 | end 25 | end 26 | 27 | describe ".partitions" do 28 | subject { described_class.partitions } 29 | 30 | context "when query successful" do 31 | it { is_expected.to contain_exactly("#{table_name}_a", "#{table_name}_b") } 32 | end 33 | 34 | context "when an error occurs" do 35 | before { allow(PgParty.cache).to receive(:fetch_partitions).and_raise("boom") } 36 | 37 | it { is_expected.to eq([]) } 38 | end 39 | end 40 | 41 | describe ".create_partition" do 42 | let(:values) { true } 43 | let(:child_table_name) { "#{table_name}_c" } 44 | 45 | subject(:create_partition) { described_class.create_partition(values: values, name: child_table_name) } 46 | subject(:child_table_exists) { schema_cache.data_source_exists?(child_table_name) } 47 | 48 | context "when values overlap" do 49 | it "raises error and cleans up intermediate table" do 50 | expect { create_partition }.to raise_error(ActiveRecord::StatementInvalid, /PG::InvalidObjectDefinition/) 51 | expect(child_table_exists).to eq(false) 52 | end 53 | end 54 | end 55 | 56 | describe ".in_partition" do 57 | let(:child_table_name) { "#{table_name}_a" } 58 | 59 | subject { described_class.in_partition(child_table_name) } 60 | 61 | its(:table_name) { is_expected.to eq(child_table_name) } 62 | its(:name) { is_expected.to eq(described_class.name) } 63 | its(:new) { is_expected.to be_an_instance_of(described_class) } 64 | its(:allocate) { is_expected.to be_an_instance_of(described_class) } 65 | 66 | describe "query methods" do 67 | let!(:record_one) { described_class.create!(some_bool: true) } 68 | let!(:record_two) { described_class.create!(some_bool: true) } 69 | let!(:record_three) { described_class.create!(some_bool: false) } 70 | 71 | describe ".all" do 72 | subject { described_class.in_partition(child_table_name).all } 73 | 74 | it { is_expected.to contain_exactly(record_one, record_two) } 75 | end 76 | 77 | describe ".where" do 78 | subject { described_class.in_partition(child_table_name).where(id: record_one.id) } 79 | 80 | it { is_expected.to contain_exactly(record_one) } 81 | end 82 | end 83 | end 84 | 85 | describe ".partition_key_in" do 86 | let(:values) { true } 87 | 88 | let!(:record_one) { described_class.create!(some_bool: true) } 89 | let!(:record_two) { described_class.create!(some_bool: true) } 90 | let!(:record_three) { described_class.create!(some_bool: false) } 91 | 92 | subject { described_class.partition_key_in(values) } 93 | 94 | context "when spanning a single partition" do 95 | it { is_expected.to contain_exactly(record_one, record_two) } 96 | end 97 | 98 | context "when spanning multiple partitions" do 99 | let(:values) { [true, false] } 100 | 101 | it { is_expected.to contain_exactly(record_one, record_two, record_three) } 102 | end 103 | 104 | context "when chaining methods" do 105 | let(:values) { false } 106 | 107 | subject { described_class.partition_key_in(values).where(some_bool: false) } 108 | 109 | it { is_expected.to contain_exactly(record_three) } 110 | end 111 | end 112 | 113 | describe ".partition_key_eq" do 114 | let(:partition_key) { true } 115 | 116 | let!(:record_one) { described_class.create!(some_bool: true) } 117 | let!(:record_two) { described_class.create!(some_bool: false) } 118 | 119 | subject { described_class.partition_key_eq(partition_key) } 120 | 121 | context "when partition key in first partition" do 122 | it { is_expected.to contain_exactly(record_one) } 123 | end 124 | 125 | context "when partition key in second partition" do 126 | let(:partition_key) { false } 127 | 128 | it { is_expected.to contain_exactly(record_two) } 129 | end 130 | 131 | context "when chaining methods" do 132 | subject do 133 | described_class 134 | .in_partition("#{table_name}_b") 135 | .unscoped 136 | .partition_key_eq(partition_key) 137 | end 138 | 139 | it { is_expected.to be_empty } 140 | end 141 | 142 | context "when table is aliased" do 143 | subject do 144 | described_class 145 | .select("*") 146 | .from(described_class.arel_table.alias) 147 | .partition_key_eq(partition_key) 148 | end 149 | 150 | it { is_expected.to contain_exactly(record_one) } 151 | end 152 | 153 | context "when table alias not resolvable" do 154 | subject do 155 | described_class 156 | .select("*") 157 | .from("garbage") 158 | .partition_key_eq(partition_key) 159 | end 160 | 161 | it { expect { subject }.to raise_error("could not find arel table in current scope") } 162 | end 163 | end 164 | end 165 | -------------------------------------------------------------------------------- /spec/integration/model/bigint_custom_id_int_list_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe BigintCustomIdIntList do 6 | let(:connection) { described_class.connection } 7 | let(:schema_cache) { connection.schema_cache } 8 | let(:table_name) { described_class.table_name } 9 | 10 | describe ".primary_key" do 11 | subject { described_class.primary_key } 12 | 13 | it { is_expected.to eq("some_id") } 14 | end 15 | 16 | describe ".create" do 17 | let(:some_int) { 1 } 18 | 19 | subject { described_class.create!(some_int: some_int) } 20 | 21 | context "when partition key in list" do 22 | its(:id) { is_expected.to be_a(Integer) } 23 | its(:some_int) { is_expected.to eq(some_int) } 24 | end 25 | 26 | context "when partition key outside list" do 27 | let(:some_int) { 5 } 28 | 29 | it "raises error" do 30 | expect { subject }.to raise_error(ActiveRecord::StatementInvalid, /PG::CheckViolation/) 31 | end 32 | end 33 | end 34 | 35 | describe ".partitions" do 36 | subject { described_class.partitions } 37 | 38 | context "when query successful" do 39 | it { is_expected.to contain_exactly("#{table_name}_a", "#{table_name}_b") } 40 | end 41 | 42 | context "when an error occurs" do 43 | before { allow(PgParty.cache).to receive(:fetch_partitions).and_raise("boom") } 44 | 45 | it { is_expected.to eq([]) } 46 | end 47 | end 48 | 49 | describe ".create_partition" do 50 | let(:values) { [5, 6] } 51 | let(:child_table_name) { "#{table_name}_c" } 52 | 53 | subject(:create_partition) { described_class.create_partition(values: values, name: child_table_name) } 54 | subject(:partitions) { described_class.partitions } 55 | subject(:child_table_exists) { schema_cache.data_source_exists?(child_table_name) } 56 | 57 | before do 58 | schema_cache.clear! 59 | described_class.partitions 60 | end 61 | 62 | after { connection.drop_table(child_table_name) if child_table_exists } 63 | 64 | context "when values do not overlap" do 65 | it "returns table name and adds it to partition list" do 66 | expect(create_partition).to eq(child_table_name) 67 | 68 | expect(partitions).to contain_exactly( 69 | "#{table_name}_a", 70 | "#{table_name}_b", 71 | "#{table_name}_c" 72 | ) 73 | end 74 | end 75 | 76 | context "when name not provided" do 77 | let(:child_table_name) { create_partition } 78 | 79 | subject(:create_partition) { described_class.create_partition(values: values) } 80 | 81 | it "returns table name and adds it to partition list" do 82 | expect(create_partition).to match(/^#{table_name}_\w{7}$/) 83 | 84 | expect(partitions).to contain_exactly( 85 | "#{table_name}_a", 86 | "#{table_name}_b", 87 | child_table_name, 88 | ) 89 | end 90 | end 91 | 92 | context "when values overlap" do 93 | let(:values) { [2, 3] } 94 | 95 | it "raises error and cleans up intermediate table" do 96 | expect { create_partition }.to raise_error(ActiveRecord::StatementInvalid, /PG::InvalidObjectDefinition/) 97 | expect(child_table_exists).to eq(false) 98 | end 99 | end 100 | end 101 | 102 | describe ".in_partition" do 103 | let(:child_table_name) { "#{table_name}_a" } 104 | 105 | subject { described_class.in_partition(child_table_name) } 106 | 107 | its(:table_name) { is_expected.to eq(child_table_name) } 108 | its(:name) { is_expected.to eq(described_class.name) } 109 | its(:new) { is_expected.to be_an_instance_of(described_class) } 110 | its(:allocate) { is_expected.to be_an_instance_of(described_class) } 111 | 112 | describe "query methods" do 113 | let!(:record_one) { described_class.create!(some_int: 1) } 114 | let!(:record_two) { described_class.create!(some_int: 2) } 115 | let!(:record_three) { described_class.create!(some_int: 4) } 116 | 117 | describe ".all" do 118 | subject { described_class.in_partition(child_table_name).all } 119 | 120 | it { is_expected.to contain_exactly(record_one, record_two) } 121 | end 122 | 123 | describe ".where" do 124 | subject { described_class.in_partition(child_table_name).where(some_id: record_one.some_id) } 125 | 126 | it { is_expected.to contain_exactly(record_one) } 127 | end 128 | end 129 | end 130 | 131 | describe ".partition_key_in" do 132 | let(:values) { [1, 2] } 133 | 134 | let!(:record_one) { described_class.create!(some_int: 1) } 135 | let!(:record_two) { described_class.create!(some_int: 2) } 136 | let!(:record_three) { described_class.create!(some_int: 4) } 137 | 138 | subject { described_class.partition_key_in(values) } 139 | 140 | context "when spanning a single partition" do 141 | it { is_expected.to contain_exactly(record_one, record_two) } 142 | end 143 | 144 | context "when spanning multiple partitions" do 145 | let(:values) { [1, 2, 3, 4] } 146 | 147 | it { is_expected.to contain_exactly(record_one, record_two, record_three) } 148 | end 149 | 150 | context "when chaining methods" do 151 | subject { described_class.partition_key_in(values).where(some_int: 1) } 152 | 153 | it { is_expected.to contain_exactly(record_one) } 154 | end 155 | end 156 | 157 | describe ".partition_key_eq" do 158 | let(:partition_key) { 1 } 159 | 160 | let!(:record_one) { described_class.create!(some_int: 1) } 161 | let!(:record_two) { described_class.create!(some_int: 3) } 162 | 163 | subject { described_class.partition_key_eq(partition_key) } 164 | 165 | context "when partition key in first partition" do 166 | it { is_expected.to contain_exactly(record_one) } 167 | end 168 | 169 | context "when partition key in second partition" do 170 | let(:partition_key) { 3 } 171 | 172 | it { is_expected.to contain_exactly(record_two) } 173 | end 174 | 175 | context "when chaining methods" do 176 | subject do 177 | described_class 178 | .in_partition("#{table_name}_b") 179 | .unscoped 180 | .partition_key_eq(partition_key) 181 | end 182 | 183 | it { is_expected.to be_empty } 184 | end 185 | 186 | context "when table is aliased" do 187 | subject do 188 | described_class 189 | .select("*") 190 | .from(described_class.arel_table.alias) 191 | .partition_key_eq(partition_key) 192 | end 193 | 194 | it { is_expected.to contain_exactly(record_one) } 195 | end 196 | 197 | context "when table alias not resolvable" do 198 | subject do 199 | described_class 200 | .select("*") 201 | .from("garbage") 202 | .partition_key_eq(partition_key) 203 | end 204 | 205 | it { expect { subject }.to raise_error("could not find arel table in current scope") } 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /spec/integration/model/bigint_custom_id_int_range_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe BigintCustomIdIntRange do 6 | let(:connection) { described_class.connection } 7 | let(:schema_cache) { connection.schema_cache } 8 | let(:table_name) { described_class.table_name } 9 | 10 | describe ".primary_key" do 11 | subject { described_class.primary_key } 12 | 13 | it { is_expected.to eq("some_id") } 14 | end 15 | 16 | describe ".create" do 17 | let(:some_int) { 1 } 18 | let(:some_other_int) { 9 } 19 | 20 | subject do 21 | described_class.create!( 22 | some_int: some_int, 23 | some_other_int: some_other_int, 24 | ) 25 | end 26 | 27 | context "when partition key in range" do 28 | its(:id) { is_expected.to be_a(Integer) } 29 | its(:some_int) { is_expected.to eq(some_int) } 30 | its(:some_other_int) { is_expected.to eq(some_other_int) } 31 | end 32 | 33 | context "when partition key outside range" do 34 | let(:some_int) { 20 } 35 | let(:some_other_int) { 20 } 36 | 37 | it "raises error" do 38 | expect { subject }.to raise_error(ActiveRecord::StatementInvalid, /PG::CheckViolation/) 39 | end 40 | end 41 | end 42 | 43 | describe ".partitions" do 44 | subject { described_class.partitions } 45 | 46 | context "when query successful" do 47 | it { is_expected.to contain_exactly("#{table_name}_a", "#{table_name}_b") } 48 | end 49 | 50 | context "when an error occurs" do 51 | before { allow(PgParty.cache).to receive(:fetch_partitions).and_raise("boom") } 52 | 53 | it { is_expected.to eq([]) } 54 | end 55 | end 56 | 57 | describe ".create_partition" do 58 | let(:start_range) { [20, 20] } 59 | let(:end_range) { [30, 30] } 60 | let(:child_table_name) { "#{table_name}_c" } 61 | 62 | subject(:create_partition) do 63 | described_class.create_partition( 64 | start_range: start_range, 65 | end_range: end_range, 66 | name: child_table_name 67 | ) 68 | end 69 | 70 | subject(:partitions) { described_class.partitions } 71 | subject(:child_table_exists) { schema_cache.data_source_exists?(child_table_name) } 72 | 73 | before do 74 | schema_cache.clear! 75 | described_class.partitions 76 | end 77 | 78 | after { connection.drop_table(child_table_name) if child_table_exists } 79 | 80 | context "when ranges do not overlap" do 81 | it "returns table name and adds it to partition list" do 82 | expect(create_partition).to eq(child_table_name) 83 | 84 | expect(partitions).to contain_exactly( 85 | "#{table_name}_a", 86 | "#{table_name}_b", 87 | "#{table_name}_c" 88 | ) 89 | end 90 | end 91 | 92 | context "when name not provided" do 93 | let(:child_table_name) { create_partition } 94 | 95 | subject(:create_partition) do 96 | described_class.create_partition( 97 | start_range: start_range, 98 | end_range: end_range, 99 | ) 100 | end 101 | 102 | it "returns table name and adds it to partition list" do 103 | expect(create_partition).to match(/^#{table_name}_\w{7}$/) 104 | 105 | expect(partitions).to contain_exactly( 106 | "#{table_name}_a", 107 | "#{table_name}_b", 108 | child_table_name, 109 | ) 110 | end 111 | end 112 | 113 | context "when ranges overlap" do 114 | let(:start_range) { [19, 19] } 115 | 116 | it "raises error and cleans up intermediate table" do 117 | expect { create_partition }.to raise_error(ActiveRecord::StatementInvalid, /PG::InvalidObjectDefinition/) 118 | expect(child_table_exists).to eq(false) 119 | end 120 | end 121 | end 122 | 123 | describe ".in_partition" do 124 | let(:child_table_name) { "#{table_name}_a" } 125 | 126 | subject { described_class.in_partition(child_table_name) } 127 | 128 | its(:table_name) { is_expected.to eq(child_table_name) } 129 | its(:name) { is_expected.to eq(described_class.name) } 130 | its(:new) { is_expected.to be_an_instance_of(described_class) } 131 | its(:allocate) { is_expected.to be_an_instance_of(described_class) } 132 | 133 | describe "query methods" do 134 | let!(:record_one) { described_class.create!(some_int: 0, some_other_int: 0) } 135 | let!(:record_two) { described_class.create!(some_int: 9, some_other_int: 9) } 136 | let!(:record_three) { described_class.create!(some_int: 19, some_other_int: 19) } 137 | 138 | describe ".all" do 139 | subject { described_class.in_partition(child_table_name).all } 140 | 141 | it { is_expected.to contain_exactly(record_one, record_two) } 142 | end 143 | 144 | describe ".where" do 145 | subject { described_class.in_partition(child_table_name).where(some_id: record_one.some_id) } 146 | 147 | it { is_expected.to contain_exactly(record_one) } 148 | end 149 | end 150 | end 151 | 152 | describe ".partition_key_in" do 153 | let(:start_range) { [0, 0] } 154 | let(:end_range) { [10, 10] } 155 | 156 | let!(:record_one) { described_class.create!(some_int: 0, some_other_int: 0) } 157 | let!(:record_two) { described_class.create!(some_int: 9, some_other_int: 9) } 158 | let!(:record_three) { described_class.create!(some_int: 19, some_other_int: 19) } 159 | 160 | subject { described_class.partition_key_in(start_range, end_range) } 161 | 162 | context "when spanning a single partition" do 163 | it { is_expected.to contain_exactly(record_one, record_two) } 164 | end 165 | 166 | context "when spanning multiple partitions" do 167 | let(:end_range) { [20, 20] } 168 | 169 | it { is_expected.to contain_exactly(record_one, record_two, record_three) } 170 | end 171 | 172 | context "when chaining methods" do 173 | subject { described_class.partition_key_in(start_range, end_range).where(some_int: 0) } 174 | 175 | it { is_expected.to contain_exactly(record_one) } 176 | end 177 | 178 | context "when incorrect number of values provided" do 179 | let(:start_range) { 0 } 180 | 181 | it "raises error" do 182 | expect { subject }.to raise_error(/does not match the number of partition key columns/) 183 | end 184 | end 185 | end 186 | 187 | describe ".partition_key_eq" do 188 | let(:partition_key) { [0, 0] } 189 | 190 | let!(:record_one) { described_class.create!(some_int: 0, some_other_int: 0) } 191 | let!(:record_two) { described_class.create!(some_int: 10, some_other_int: 10) } 192 | 193 | subject { described_class.partition_key_eq(partition_key) } 194 | 195 | context "when partition key in first partition" do 196 | it { is_expected.to contain_exactly(record_one) } 197 | end 198 | 199 | context "when partition key in second partition" do 200 | let(:partition_key) { [10, 10] } 201 | 202 | it { is_expected.to contain_exactly(record_two) } 203 | end 204 | 205 | context "when chaining methods" do 206 | subject do 207 | described_class 208 | .in_partition("#{table_name}_b") 209 | .unscoped 210 | .partition_key_eq(partition_key) 211 | end 212 | 213 | it { is_expected.to be_empty } 214 | end 215 | 216 | context "when table is aliased" do 217 | subject do 218 | described_class 219 | .select("*") 220 | .from(described_class.arel_table.alias) 221 | .partition_key_eq(partition_key) 222 | end 223 | 224 | it { is_expected.to contain_exactly(record_one) } 225 | end 226 | 227 | context "when table alias not resolvable" do 228 | subject do 229 | described_class 230 | .select("*") 231 | .from("garbage") 232 | .partition_key_eq(partition_key) 233 | end 234 | 235 | it { expect { subject }.to raise_error("could not find arel table in current scope") } 236 | end 237 | end 238 | end 239 | -------------------------------------------------------------------------------- /spec/integration/model/bigint_date_range_no_partition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe BigintDateRangeNoPartition do 6 | let(:current_date) { Date.current } 7 | let(:current_time) { Time.current } 8 | let(:connection) { described_class.connection } 9 | let(:schema_cache) { connection.schema_cache } 10 | let(:table_name) { described_class.table_name } 11 | 12 | describe ".primary_key" do 13 | subject { described_class.primary_key } 14 | 15 | it { is_expected.to be_nil } 16 | end 17 | 18 | describe ".create" do 19 | let(:created_at) { current_time } 20 | 21 | subject { described_class.create!(created_at: created_at) } 22 | 23 | it "raises error" do 24 | expect { subject }.to raise_error(ActiveRecord::StatementInvalid, /PG::CheckViolation/) 25 | end 26 | end 27 | 28 | describe ".partitions" do 29 | subject { described_class.partitions } 30 | 31 | it { is_expected.to be_empty } 32 | end 33 | 34 | describe ".create_partition" do 35 | let(:start_range) { current_date } 36 | let(:end_range) { current_date + 1.day } 37 | let(:child_table_name) { "#{table_name}_a" } 38 | 39 | subject(:create_partition) do 40 | described_class.create_partition( 41 | start_range: start_range, 42 | end_range: end_range, 43 | name: child_table_name 44 | ) 45 | end 46 | 47 | subject(:partitions) { described_class.partitions } 48 | 49 | before { described_class.partitions } 50 | after { connection.drop_table(child_table_name) } 51 | 52 | it "returns table name and adds it to partition list" do 53 | expect(create_partition).to eq(child_table_name) 54 | 55 | expect(partitions).to contain_exactly("#{table_name}_a") 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/integration/model/bigint_date_range_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe BigintDateRange do 6 | let(:current_date) { Date.current } 7 | let(:current_time) { Time.current } 8 | let(:connection) { described_class.connection } 9 | let(:schema_cache) { connection.schema_cache } 10 | let(:table_name) { described_class.table_name } 11 | 12 | describe ".primary_key" do 13 | subject { described_class.primary_key } 14 | 15 | it { is_expected.to eq("id") } 16 | end 17 | 18 | describe ".create" do 19 | let(:created_at) { current_time } 20 | 21 | subject { described_class.create!(created_at: created_at) } 22 | 23 | context "when partition key in range" do 24 | its(:id) { is_expected.to be_an(Integer) } 25 | its(:created_at) { is_expected.to eq(created_at) } 26 | end 27 | 28 | context "when partition key outside range" do 29 | let(:created_at) { current_time - 10.days } 30 | 31 | it "raises error" do 32 | expect { subject }.to raise_error(ActiveRecord::StatementInvalid, /PG::CheckViolation/) 33 | end 34 | end 35 | end 36 | 37 | describe ".partitions" do 38 | subject { described_class.partitions } 39 | 40 | context "when query successful" do 41 | it { is_expected.to contain_exactly("#{table_name}_a", "#{table_name}_b") } 42 | end 43 | 44 | context "when an error occurs" do 45 | before { allow(PgParty.cache).to receive(:fetch_partitions).and_raise("boom") } 46 | 47 | it { is_expected.to eq([]) } 48 | end 49 | end 50 | 51 | describe ".create_partition" do 52 | let(:start_range) { current_date + 2.days } 53 | let(:end_range) { current_date + 3.days } 54 | let(:child_table_name) { "#{table_name}_c" } 55 | 56 | subject(:create_partition) do 57 | described_class.create_partition( 58 | start_range: start_range, 59 | end_range: end_range, 60 | name: child_table_name 61 | ) 62 | end 63 | 64 | subject(:partitions) { described_class.partitions } 65 | subject(:child_table_exists) { schema_cache.data_source_exists?(child_table_name) } 66 | 67 | before do 68 | schema_cache.clear! 69 | described_class.partitions 70 | end 71 | 72 | after { connection.drop_table(child_table_name) if child_table_exists } 73 | 74 | context "when ranges do not overlap" do 75 | it "returns table name and adds it to partition list" do 76 | expect(create_partition).to eq(child_table_name) 77 | 78 | expect(partitions).to contain_exactly( 79 | "#{table_name}_a", 80 | "#{table_name}_b", 81 | "#{table_name}_c" 82 | ) 83 | end 84 | end 85 | 86 | context "when name not provided" do 87 | let(:child_table_name) { create_partition } 88 | 89 | subject(:create_partition) do 90 | described_class.create_partition( 91 | start_range: start_range, 92 | end_range: end_range, 93 | ) 94 | end 95 | 96 | it "returns table name and adds it to partition list" do 97 | expect(create_partition).to match(/^#{table_name}_\w{7}$/) 98 | 99 | expect(partitions).to contain_exactly( 100 | "#{table_name}_a", 101 | "#{table_name}_b", 102 | child_table_name, 103 | ) 104 | end 105 | end 106 | 107 | context "when ranges overlap" do 108 | let(:start_range) { current_date } 109 | 110 | it "raises error and cleans up intermediate table" do 111 | expect { create_partition }.to raise_error(ActiveRecord::StatementInvalid, /PG::InvalidObjectDefinition/) 112 | expect(child_table_exists).to eq(false) 113 | end 114 | end 115 | end 116 | 117 | describe ".in_partition" do 118 | let(:child_table_name) { "#{table_name}_a" } 119 | 120 | subject { described_class.in_partition(child_table_name) } 121 | 122 | its(:table_name) { is_expected.to eq(child_table_name) } 123 | its(:name) { is_expected.to eq(described_class.name) } 124 | its(:new) { is_expected.to be_an_instance_of(described_class) } 125 | its(:allocate) { is_expected.to be_an_instance_of(described_class) } 126 | 127 | describe "query methods" do 128 | let!(:record_one) { described_class.create!(created_at: current_time) } 129 | let!(:record_two) { described_class.create!(created_at: current_time + 1.minute) } 130 | let!(:record_three) { described_class.create!(created_at: current_time + 1.day) } 131 | 132 | describe ".all" do 133 | subject { described_class.in_partition(child_table_name).all } 134 | 135 | it { is_expected.to contain_exactly(record_one, record_two) } 136 | end 137 | 138 | describe ".where" do 139 | subject { described_class.in_partition(child_table_name).where(id: record_one.id) } 140 | 141 | it { is_expected.to contain_exactly(record_one) } 142 | end 143 | end 144 | end 145 | 146 | describe ".partition_key_in" do 147 | let(:start_range) { current_date } 148 | let(:end_range) { current_date + 1.day } 149 | 150 | let!(:record_one) { described_class.create!(created_at: current_time) } 151 | let!(:record_two) { described_class.create!(created_at: current_time + 1.minute) } 152 | let!(:record_three) { described_class.create!(created_at: current_time + 1.day) } 153 | 154 | subject { described_class.partition_key_in(start_range, end_range) } 155 | 156 | context "when spanning a single partition" do 157 | it { is_expected.to contain_exactly(record_one, record_two) } 158 | end 159 | 160 | context "when spanning multiple partitions" do 161 | let(:end_range) { current_date + 2.days } 162 | 163 | it { is_expected.to contain_exactly(record_one, record_two, record_three) } 164 | end 165 | 166 | context "when chaining methods" do 167 | subject { described_class.partition_key_in(start_range, end_range).where(id: record_one.id) } 168 | 169 | it { is_expected.to contain_exactly(record_one) } 170 | end 171 | end 172 | 173 | describe ".partition_key_eq" do 174 | let(:partition_key) { current_date } 175 | 176 | let!(:record_one) { described_class.create!(created_at: current_time) } 177 | let!(:record_two) { described_class.create!(created_at: current_time + 1.minute) } 178 | let!(:record_three) { described_class.create!(created_at: current_time + 1.day) } 179 | 180 | subject { described_class.partition_key_eq(partition_key) } 181 | 182 | context "when partition key in first partition" do 183 | it { is_expected.to contain_exactly(record_one, record_two) } 184 | end 185 | 186 | context "when partition key in second partition" do 187 | let(:partition_key) { current_date + 1.day } 188 | 189 | it { is_expected.to contain_exactly(record_three) } 190 | end 191 | 192 | context "when chaining methods" do 193 | subject do 194 | described_class 195 | .in_partition("#{table_name}_b") 196 | .unscoped 197 | .partition_key_eq(partition_key) 198 | end 199 | 200 | it { is_expected.to be_empty } 201 | end 202 | 203 | context "when table is aliased" do 204 | subject do 205 | described_class 206 | .select("*") 207 | .from(described_class.arel_table.alias) 208 | .partition_key_eq(partition_key) 209 | end 210 | 211 | it { is_expected.to contain_exactly(record_one, record_two) } 212 | end 213 | 214 | context "when table alias not resolvable" do 215 | subject do 216 | described_class 217 | .select("*") 218 | .from("garbage") 219 | .partition_key_eq(partition_key) 220 | end 221 | 222 | it { expect { subject }.to raise_error("could not find arel table in current scope") } 223 | end 224 | end 225 | end 226 | -------------------------------------------------------------------------------- /spec/integration/model/bigint_int_list_date_range_subpartition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe BigintIntListDateRangeSubpartition do 6 | let(:current_date) { Date.current } 7 | let(:current_time) { Time.current } 8 | let(:connection) { described_class.connection } 9 | let(:schema_cache) { connection.schema_cache } 10 | let(:table_name) { described_class.table_name } 11 | 12 | describe ".primary_key" do 13 | subject { described_class.primary_key } 14 | 15 | it { is_expected.to eq("id") } 16 | end 17 | 18 | describe ".create" do 19 | let(:created_at) { current_time } 20 | let(:id) { 1 } 21 | 22 | subject { described_class.create!(id: id, created_at: created_at) } 23 | 24 | context "when partition key in list" do 25 | its(:id) { is_expected.to be_a(Integer) } 26 | its(:id) { is_expected.to eq(id) } 27 | its(:created_at) { is_expected.to eq(created_at) } 28 | end 29 | 30 | context "when partition key outside list" do 31 | let(:id) { 5 } 32 | 33 | it "raises error" do 34 | expect { subject }.to raise_error(ActiveRecord::StatementInvalid, /PG::CheckViolation/) 35 | end 36 | end 37 | 38 | context "when subpartition key outside range" do 39 | let(:created_at) { current_time - 10.days } 40 | 41 | it "raises error" do 42 | expect { subject }.to raise_error(ActiveRecord::StatementInvalid, /PG::CheckViolation/) 43 | end 44 | end 45 | end 46 | 47 | describe ".partitions" do 48 | subject { described_class.partitions } 49 | 50 | context "when query successful" do 51 | it { is_expected.to contain_exactly("#{table_name}_a", "#{table_name}_b") } 52 | end 53 | 54 | context "when an error occurs" do 55 | before { allow(PgParty.cache).to receive(:fetch_partitions).and_raise("boom") } 56 | 57 | it { is_expected.to eq([]) } 58 | end 59 | 60 | context 'include_subpartitions: true' do 61 | subject { described_class.partitions(include_subpartitions: true) } 62 | 63 | it { is_expected.to contain_exactly("#{table_name}_a", "#{table_name}_a_1", "#{table_name}_b") } 64 | end 65 | 66 | context 'config.include_subpartitions_in_partition_list = true' do 67 | before { PgParty.config.include_subpartitions_in_partition_list = true } 68 | after { PgParty.config.include_subpartitions_in_partition_list = false } 69 | 70 | it { is_expected.to contain_exactly("#{table_name}_a", "#{table_name}_a_1", "#{table_name}_b") } 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/integration/model/bigint_month_range_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe BigintMonthRange do 6 | let(:current_date) { Date.current } 7 | let(:current_time) { Time.current } 8 | let(:connection) { described_class.connection } 9 | let(:schema_cache) { connection.schema_cache } 10 | let(:table_name) { described_class.table_name } 11 | 12 | describe ".primary_key" do 13 | subject { described_class.primary_key } 14 | 15 | it { is_expected.to eq("id") } 16 | end 17 | 18 | describe ".create" do 19 | let(:created_at) { current_time } 20 | 21 | subject { described_class.create!(created_at: created_at) } 22 | 23 | context "when partition key in range" do 24 | its(:id) { is_expected.to be_an(Integer) } 25 | its(:created_at) { is_expected.to eq(created_at) } 26 | end 27 | 28 | context "when partition key outside range" do 29 | let(:created_at) { current_time - 1.month } 30 | 31 | it "raises error" do 32 | expect { subject }.to raise_error(ActiveRecord::StatementInvalid, /PG::CheckViolation/) 33 | end 34 | end 35 | end 36 | 37 | describe ".partitions" do 38 | subject { described_class.partitions } 39 | 40 | context "when query successful" do 41 | it { is_expected.to contain_exactly("#{table_name}_a", "#{table_name}_b") } 42 | end 43 | 44 | context "when an error occurs" do 45 | before { allow(PgParty.cache).to receive(:fetch_partitions).and_raise("boom") } 46 | 47 | it { is_expected.to eq([]) } 48 | end 49 | end 50 | 51 | describe ".create_partition" do 52 | let(:start_date) { current_date + 2.months } 53 | let(:end_date) { current_date + 3.months } 54 | let(:start_range) { [start_date.year, start_date.month] } 55 | let(:end_range) { [end_date.year, end_date.month] } 56 | let(:child_table_name) { "#{table_name}_c" } 57 | 58 | subject(:create_partition) do 59 | described_class.create_partition( 60 | start_range: start_range, 61 | end_range: end_range, 62 | name: child_table_name 63 | ) 64 | end 65 | 66 | subject(:partitions) { described_class.partitions } 67 | subject(:child_table_exists) { schema_cache.data_source_exists?(child_table_name) } 68 | 69 | before do 70 | schema_cache.clear! 71 | described_class.partitions 72 | end 73 | 74 | after { connection.drop_table(child_table_name) if child_table_exists } 75 | 76 | context "when ranges do not overlap" do 77 | it "returns table name and adds it to partition list" do 78 | expect(create_partition).to eq(child_table_name) 79 | 80 | expect(partitions).to contain_exactly( 81 | "#{table_name}_a", 82 | "#{table_name}_b", 83 | "#{table_name}_c" 84 | ) 85 | end 86 | end 87 | 88 | context "when name not provided" do 89 | let(:child_table_name) { create_partition } 90 | 91 | subject(:create_partition) do 92 | described_class.create_partition( 93 | start_range: start_range, 94 | end_range: end_range, 95 | ) 96 | end 97 | 98 | it "returns table name and adds it to partition list" do 99 | expect(create_partition).to match(/^#{table_name}_\w{7}$/) 100 | 101 | expect(partitions).to contain_exactly( 102 | "#{table_name}_a", 103 | "#{table_name}_b", 104 | child_table_name, 105 | ) 106 | end 107 | end 108 | 109 | context "when ranges overlap" do 110 | let(:start_date) { current_date - 1.month } 111 | 112 | it "raises error and cleans up intermediate table" do 113 | expect { create_partition }.to raise_error(ActiveRecord::StatementInvalid, /PG::InvalidObjectDefinition/) 114 | expect(child_table_exists).to eq(false) 115 | end 116 | end 117 | end 118 | 119 | describe ".in_partition" do 120 | let(:child_table_name) { "#{table_name}_a" } 121 | 122 | subject { described_class.in_partition(child_table_name) } 123 | 124 | its(:table_name) { is_expected.to eq(child_table_name) } 125 | its(:name) { is_expected.to eq(described_class.name) } 126 | its(:new) { is_expected.to be_an_instance_of(described_class) } 127 | its(:allocate) { is_expected.to be_an_instance_of(described_class) } 128 | 129 | describe "query methods" do 130 | let!(:record_one) { described_class.create!(created_at: current_time) } 131 | let!(:record_two) { described_class.create!(created_at: current_time.end_of_month) } 132 | let!(:record_three) { described_class.create!(created_at: (current_time + 1.month).end_of_month) } 133 | 134 | describe ".all" do 135 | subject { described_class.in_partition(child_table_name).all } 136 | 137 | it { is_expected.to contain_exactly(record_one, record_two) } 138 | end 139 | 140 | describe ".where" do 141 | subject { described_class.in_partition(child_table_name).where(id: record_one.id) } 142 | 143 | it { is_expected.to contain_exactly(record_one) } 144 | end 145 | end 146 | end 147 | 148 | describe ".partition_key_in" do 149 | let(:start_date) { current_date } 150 | let(:end_date) { current_date + 1.month } 151 | let(:start_range) { [start_date.year, start_date.month] } 152 | let(:end_range) { [end_date.year, end_date.month] } 153 | 154 | let!(:record_one) { described_class.create!(created_at: current_time) } 155 | let!(:record_two) { described_class.create!(created_at: current_time.end_of_month) } 156 | let!(:record_three) { described_class.create!(created_at: (current_time + 1.month).end_of_month) } 157 | 158 | subject { described_class.partition_key_in(start_range, end_range) } 159 | 160 | context "when spanning a single partition" do 161 | it { is_expected.to contain_exactly(record_one, record_two) } 162 | end 163 | 164 | context "when spanning multiple partitions" do 165 | let(:end_date) { current_date + 2.months } 166 | 167 | it { is_expected.to contain_exactly(record_one, record_two, record_three) } 168 | end 169 | 170 | context "when chaining methods" do 171 | subject { described_class.partition_key_in(start_range, end_range).where(id: record_one.id) } 172 | 173 | it { is_expected.to contain_exactly(record_one) } 174 | end 175 | end 176 | 177 | describe ".partition_key_eq" do 178 | let(:partition_date) { current_date } 179 | let(:partition_key) { [partition_date.year, partition_date.month] } 180 | 181 | let!(:record_one) { described_class.create!(created_at: current_time) } 182 | let!(:record_two) { described_class.create!(created_at: current_time.end_of_month) } 183 | let!(:record_three) { described_class.create!(created_at: (current_time + 1.month).end_of_month) } 184 | 185 | subject { described_class.partition_key_eq(partition_key) } 186 | 187 | context "when partition key in first partition" do 188 | it { is_expected.to contain_exactly(record_one, record_two) } 189 | end 190 | 191 | context "when partition key in second partition" do 192 | let(:partition_date) { current_date + 1.month } 193 | 194 | it { is_expected.to contain_exactly(record_three) } 195 | end 196 | 197 | context "when chaining methods" do 198 | subject do 199 | described_class 200 | .in_partition("#{table_name}_b") 201 | .unscoped 202 | .partition_key_eq(partition_key) 203 | end 204 | 205 | it { is_expected.to be_empty } 206 | end 207 | 208 | context "when table is aliased" do 209 | subject do 210 | described_class 211 | .select("*") 212 | .from(described_class.arel_table.alias) 213 | .partition_key_eq(partition_key) 214 | end 215 | 216 | it { is_expected.to contain_exactly(record_one, record_two) } 217 | end 218 | 219 | context "when table alias not resolvable" do 220 | subject do 221 | described_class 222 | .select("*") 223 | .from("garbage") 224 | .partition_key_eq(partition_key) 225 | end 226 | 227 | it { expect { subject }.to raise_error("could not find arel table in current scope") } 228 | end 229 | end 230 | end 231 | -------------------------------------------------------------------------------- /spec/integration/model/no_pk_substring_list_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe NoPkSubstringList do 6 | let(:connection) { described_class.connection } 7 | let(:schema_cache) { connection.schema_cache } 8 | let(:table_name) { described_class.table_name } 9 | 10 | describe ".primary_key" do 11 | subject { described_class.primary_key } 12 | 13 | it { is_expected.to be_nil } 14 | end 15 | 16 | describe ".create" do 17 | let(:some_string) { "a_foo" } 18 | 19 | subject { described_class.create!(some_string: some_string) } 20 | 21 | context "when partition key in range" do 22 | its(:some_string) { is_expected.to eq(some_string) } 23 | end 24 | 25 | context "when partition key outside range" do 26 | let(:some_string) { "e_foo" } 27 | 28 | it "raises error" do 29 | expect { subject }.to raise_error(ActiveRecord::StatementInvalid, /PG::CheckViolation/) 30 | end 31 | end 32 | end 33 | 34 | describe ".partitions" do 35 | subject { described_class.partitions } 36 | 37 | context "when query successful" do 38 | it { is_expected.to contain_exactly("#{table_name}_a", "#{table_name}_b") } 39 | end 40 | 41 | context "when an error occurs" do 42 | before { allow(PgParty.cache).to receive(:fetch_partitions).and_raise("boom") } 43 | 44 | it { is_expected.to eq([]) } 45 | end 46 | end 47 | 48 | describe ".create_partition" do 49 | let(:values) { ["e", "f"] } 50 | let(:child_table_name) { "#{table_name}_c" } 51 | 52 | subject(:create_partition) { described_class.create_partition(values: values, name: child_table_name) } 53 | subject(:partitions) { described_class.partitions } 54 | subject(:child_table_exists) { schema_cache.data_source_exists?(child_table_name) } 55 | 56 | before do 57 | schema_cache.clear! 58 | described_class.partitions 59 | end 60 | 61 | after { connection.drop_table(child_table_name) if child_table_exists } 62 | 63 | context "when ranges do not overlap" do 64 | it "returns table name and adds it to partition list" do 65 | expect(create_partition).to eq(child_table_name) 66 | 67 | expect(partitions).to contain_exactly( 68 | "#{table_name}_a", 69 | "#{table_name}_b", 70 | "#{table_name}_c" 71 | ) 72 | end 73 | end 74 | 75 | context "when name not provided" do 76 | let(:child_table_name) { create_partition } 77 | 78 | subject(:create_partition) { described_class.create_partition(values: values) } 79 | 80 | it "returns table name and adds it to partition list" do 81 | expect(create_partition).to match(/^#{table_name}_\w{7}$/) 82 | 83 | expect(partitions).to contain_exactly( 84 | "#{table_name}_a", 85 | "#{table_name}_b", 86 | child_table_name, 87 | ) 88 | end 89 | end 90 | 91 | context "when ranges overlap" do 92 | let(:values) { ["b", "c"] } 93 | 94 | it "raises error and cleans up intermediate table" do 95 | expect { create_partition }.to raise_error(ActiveRecord::StatementInvalid, /PG::InvalidObjectDefinition/) 96 | expect(child_table_exists).to eq(false) 97 | end 98 | end 99 | end 100 | 101 | describe ".in_partition" do 102 | let(:child_table_name) { "#{table_name}_a" } 103 | 104 | subject { described_class.in_partition(child_table_name) } 105 | 106 | its(:table_name) { is_expected.to eq(child_table_name) } 107 | its(:name) { is_expected.to eq(described_class.name) } 108 | its(:new) { is_expected.to be_an_instance_of(described_class) } 109 | its(:allocate) { is_expected.to be_an_instance_of(described_class) } 110 | 111 | describe "query methods" do 112 | let!(:record_one) { described_class.create!(some_string: "a_foo") } 113 | let!(:record_two) { described_class.create!(some_string: "b_foo") } 114 | let!(:record_three) { described_class.create!(some_string: "c_foo") } 115 | 116 | describe ".all" do 117 | subject { described_class.in_partition(child_table_name).all } 118 | 119 | it do 120 | is_expected.to contain_exactly( 121 | an_object_having_attributes(some_string: "a_foo"), 122 | an_object_having_attributes(some_string: "b_foo"), 123 | ) 124 | end 125 | end 126 | 127 | describe ".where" do 128 | subject { described_class.in_partition(child_table_name).where(some_string: "a_foo") } 129 | 130 | it do 131 | is_expected.to contain_exactly( 132 | an_object_having_attributes(some_string: "a_foo") 133 | ) 134 | end 135 | end 136 | end 137 | end 138 | 139 | describe ".partition_key_in" do 140 | let(:values) { ["a", "b"] } 141 | 142 | let!(:record_one) { described_class.create!(some_string: "a_foo") } 143 | let!(:record_two) { described_class.create!(some_string: "b_foo") } 144 | let!(:record_three) { described_class.create!(some_string: "c_foo") } 145 | 146 | subject { described_class.partition_key_in(values) } 147 | 148 | context "when spanning a single partition" do 149 | it do 150 | is_expected.to contain_exactly( 151 | an_object_having_attributes(some_string: "a_foo"), 152 | an_object_having_attributes(some_string: "b_foo"), 153 | ) 154 | end 155 | end 156 | 157 | context "when spanning multiple partitions" do 158 | let(:values) { ["a", "b", "c", "d"] } 159 | 160 | it do 161 | is_expected.to contain_exactly( 162 | an_object_having_attributes(some_string: "a_foo"), 163 | an_object_having_attributes(some_string: "b_foo"), 164 | an_object_having_attributes(some_string: "c_foo"), 165 | ) 166 | end 167 | end 168 | 169 | context "when chaining methods" do 170 | subject { described_class.partition_key_in(values).where(some_string: "a_foo") } 171 | 172 | it do 173 | is_expected.to contain_exactly( 174 | an_object_having_attributes(some_string: "a_foo") 175 | ) 176 | end 177 | end 178 | end 179 | 180 | describe ".partition_key_eq" do 181 | let(:partition_key) { "a" } 182 | 183 | let!(:record_one) { described_class.create!(some_string: "a_foo") } 184 | let!(:record_two) { described_class.create!(some_string: "c_foo") } 185 | 186 | subject { described_class.partition_key_eq(partition_key) } 187 | 188 | context "when partition key in first partition" do 189 | it do 190 | is_expected.to contain_exactly( 191 | an_object_having_attributes(some_string: "a_foo") 192 | ) 193 | end 194 | end 195 | 196 | context "when partition key in second partition" do 197 | let(:partition_key) { "c" } 198 | 199 | it do 200 | is_expected.to contain_exactly( 201 | an_object_having_attributes(some_string: "c_foo") 202 | ) 203 | end 204 | end 205 | 206 | context "when chaining methods" do 207 | subject do 208 | described_class 209 | .in_partition("#{table_name}_b") 210 | .unscoped 211 | .partition_key_eq(partition_key) 212 | end 213 | 214 | it { is_expected.to be_empty } 215 | end 216 | 217 | context "when partition key in first partition and table is aliased" do 218 | subject do 219 | described_class 220 | .select("*") 221 | .from(described_class.arel_table.alias) 222 | .partition_key_eq(partition_key) 223 | end 224 | 225 | it do 226 | is_expected.to contain_exactly( 227 | an_object_having_attributes(some_string: "a_foo") 228 | ) 229 | end 230 | end 231 | 232 | context "when table alias not resolvable" do 233 | subject do 234 | described_class 235 | .select("*") 236 | .from("garbage") 237 | .partition_key_eq(partition_key) 238 | end 239 | 240 | it { expect { subject }.to raise_error("could not find arel table in current scope") } 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /spec/integration/model/uuid_string_list_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe UuidStringList do 6 | let(:connection) { described_class.connection } 7 | let(:schema_cache) { connection.schema_cache } 8 | let(:table_name) { described_class.table_name } 9 | 10 | describe ".primary_key" do 11 | subject { described_class.primary_key } 12 | 13 | it { is_expected.to eq("id") } 14 | end 15 | 16 | describe ".create" do 17 | let(:some_string) { "a" } 18 | 19 | subject { described_class.create!(some_string: some_string) } 20 | 21 | context "when partition key in list" do 22 | its(:id) { is_expected.to be_a_uuid } 23 | its(:some_string) { is_expected.to eq(some_string) } 24 | end 25 | 26 | context "when partition key outside list" do 27 | let(:some_string) { "e" } 28 | 29 | it "raises error" do 30 | expect { subject }.to raise_error(ActiveRecord::StatementInvalid, /PG::CheckViolation/) 31 | end 32 | end 33 | end 34 | 35 | describe ".partitions" do 36 | subject { described_class.partitions } 37 | 38 | context "when query successful" do 39 | it { is_expected.to contain_exactly("#{table_name}_a", "#{table_name}_b") } 40 | end 41 | 42 | context "when an error occurs" do 43 | before { allow(PgParty.cache).to receive(:fetch_partitions).and_raise("boom") } 44 | 45 | it { is_expected.to eq([]) } 46 | end 47 | end 48 | 49 | describe ".create_partition" do 50 | let(:values) { ["e", "f"] } 51 | let(:child_table_name) { "#{table_name}_c" } 52 | 53 | subject(:create_partition) { described_class.create_partition(values: values, name: child_table_name) } 54 | subject(:partitions) { described_class.partitions } 55 | subject(:child_table_exists) { schema_cache.data_source_exists?(child_table_name) } 56 | 57 | before do 58 | schema_cache.clear! 59 | described_class.partitions 60 | end 61 | 62 | after { connection.drop_table(child_table_name) if child_table_exists } 63 | 64 | context "when values do not overlap" do 65 | it "returns table name and adds it to partition list" do 66 | expect(create_partition).to eq(child_table_name) 67 | 68 | expect(partitions).to contain_exactly( 69 | "#{table_name}_a", 70 | "#{table_name}_b", 71 | "#{table_name}_c" 72 | ) 73 | end 74 | end 75 | 76 | context "when name not provided" do 77 | let(:child_table_name) { create_partition } 78 | 79 | subject(:create_partition) { described_class.create_partition(values: values) } 80 | 81 | it "returns table name and adds it to partition list" do 82 | expect(create_partition).to match(/^#{table_name}_\w{7}$/) 83 | 84 | expect(partitions).to contain_exactly( 85 | "#{table_name}_a", 86 | "#{table_name}_b", 87 | child_table_name, 88 | ) 89 | end 90 | end 91 | 92 | context "when values overlap" do 93 | let(:values) { ["b", "c"] } 94 | 95 | it "raises error and cleans up intermediate table" do 96 | expect { create_partition }.to raise_error(ActiveRecord::StatementInvalid, /PG::InvalidObjectDefinition/) 97 | expect(child_table_exists).to eq(false) 98 | end 99 | end 100 | end 101 | 102 | describe ".in_partition" do 103 | let(:child_table_name) { "#{table_name}_a" } 104 | 105 | subject { described_class.in_partition(child_table_name) } 106 | 107 | its(:table_name) { is_expected.to eq(child_table_name) } 108 | its(:name) { is_expected.to eq(described_class.name) } 109 | its(:new) { is_expected.to be_an_instance_of(described_class) } 110 | its(:allocate) { is_expected.to be_an_instance_of(described_class) } 111 | 112 | describe "query methods" do 113 | let!(:record_one) { described_class.create!(some_string: "a") } 114 | let!(:record_two) { described_class.create!(some_string: "b") } 115 | let!(:record_three) { described_class.create!(some_string: "d") } 116 | 117 | describe ".all" do 118 | subject { described_class.in_partition(child_table_name).all } 119 | 120 | it { is_expected.to contain_exactly(record_one, record_two) } 121 | end 122 | 123 | describe ".where" do 124 | subject { described_class.in_partition(child_table_name).where(id: record_one.id) } 125 | 126 | it { is_expected.to contain_exactly(record_one) } 127 | end 128 | end 129 | end 130 | 131 | describe ".partition_key_in" do 132 | let(:values) { ["a", "b"] } 133 | 134 | let!(:record_one) { described_class.create!(some_string: "a") } 135 | let!(:record_two) { described_class.create!(some_string: "b") } 136 | let!(:record_three) { described_class.create!(some_string: "d") } 137 | 138 | subject { described_class.partition_key_in(values) } 139 | 140 | context "when spanning a single partition" do 141 | it { is_expected.to contain_exactly(record_one, record_two) } 142 | end 143 | 144 | context "when spanning multiple partitions" do 145 | let(:values) { ["a", "b", "c", "d"] } 146 | 147 | it { is_expected.to contain_exactly(record_one, record_two, record_three) } 148 | end 149 | 150 | context "when chaining methods" do 151 | subject { described_class.partition_key_in(values).where(some_string: "a") } 152 | 153 | it { is_expected.to contain_exactly(record_one) } 154 | end 155 | end 156 | 157 | describe ".partition_key_eq" do 158 | let(:partition_key) { "a" } 159 | 160 | let!(:record_one) { described_class.create!(some_string: "a") } 161 | let!(:record_two) { described_class.create!(some_string: "c") } 162 | 163 | subject { described_class.partition_key_eq(partition_key) } 164 | 165 | context "when partition key in first partition" do 166 | it { is_expected.to contain_exactly(record_one) } 167 | end 168 | 169 | context "when partition key in second partition" do 170 | let(:partition_key) { "c" } 171 | 172 | it { is_expected.to contain_exactly(record_two) } 173 | end 174 | 175 | context "when chaining methods" do 176 | subject do 177 | described_class 178 | .in_partition("#{table_name}_b") 179 | .unscoped 180 | .partition_key_eq(partition_key) 181 | end 182 | 183 | it { is_expected.to be_empty } 184 | end 185 | 186 | context "when table is aliased" do 187 | subject do 188 | described_class 189 | .select("*") 190 | .from(described_class.arel_table.alias) 191 | .partition_key_eq(partition_key) 192 | end 193 | 194 | it { is_expected.to contain_exactly(record_one) } 195 | end 196 | 197 | context "when table alias not resolvable" do 198 | subject do 199 | described_class 200 | .select("*") 201 | .from("garbage") 202 | .partition_key_eq(partition_key) 203 | end 204 | 205 | it { expect { subject }.to raise_error("could not find arel table in current scope") } 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /spec/integration/model/uuid_string_range_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe UuidStringRange do 6 | let(:connection) { described_class.connection } 7 | let(:schema_cache) { connection.schema_cache } 8 | let(:table_name) { described_class.table_name } 9 | 10 | describe ".primary_key" do 11 | subject { described_class.primary_key } 12 | 13 | it { is_expected.to eq("id") } 14 | end 15 | 16 | describe ".create" do 17 | let(:some_string) { "c" } 18 | 19 | subject { described_class.create!(some_string: some_string) } 20 | 21 | context "when partition key in range" do 22 | its(:id) { is_expected.to be_a_uuid } 23 | its(:some_string) { is_expected.to eq(some_string) } 24 | end 25 | 26 | context "when partition key outside range" do 27 | let(:some_string) { "z" } 28 | 29 | it "raises error" do 30 | expect { subject }.to raise_error(ActiveRecord::StatementInvalid, /PG::CheckViolation/) 31 | end 32 | end 33 | end 34 | 35 | describe ".partitions" do 36 | subject { described_class.partitions } 37 | 38 | context "when query successful" do 39 | it { is_expected.to contain_exactly("#{table_name}_a", "#{table_name}_b") } 40 | end 41 | 42 | context "when an error occurs" do 43 | before { allow(PgParty.cache).to receive(:fetch_partitions).and_raise("boom") } 44 | 45 | it { is_expected.to eq([]) } 46 | end 47 | end 48 | 49 | describe ".create_partition" do 50 | let(:start_range) { "0" } 51 | let(:end_range) { "9" } 52 | let(:child_table_name) { "#{table_name}_c" } 53 | 54 | subject(:create_partition) do 55 | described_class.create_partition( 56 | start_range: start_range, 57 | end_range: end_range, 58 | name: child_table_name 59 | ) 60 | end 61 | 62 | subject(:partitions) { described_class.partitions } 63 | subject(:child_table_exists) { schema_cache.data_source_exists?(child_table_name) } 64 | 65 | before do 66 | schema_cache.clear! 67 | described_class.partitions 68 | end 69 | 70 | after { connection.drop_table(child_table_name) if child_table_exists } 71 | 72 | context "when ranges do not overlap" do 73 | it "returns table name and adds it to partition list" do 74 | expect(create_partition).to eq(child_table_name) 75 | 76 | expect(partitions).to contain_exactly( 77 | "#{table_name}_a", 78 | "#{table_name}_b", 79 | "#{table_name}_c" 80 | ) 81 | end 82 | end 83 | 84 | context "when name not provided" do 85 | let(:child_table_name) { create_partition } 86 | 87 | subject(:create_partition) do 88 | described_class.create_partition( 89 | start_range: start_range, 90 | end_range: end_range, 91 | ) 92 | end 93 | 94 | it "returns table name and adds it to partition list" do 95 | expect(create_partition).to match(/^#{table_name}_\w{7}$/) 96 | 97 | expect(partitions).to contain_exactly( 98 | "#{table_name}_a", 99 | "#{table_name}_b", 100 | child_table_name, 101 | ) 102 | end 103 | end 104 | 105 | context "when ranges overlap" do 106 | let(:end_range) { "b" } 107 | 108 | it "raises error and cleans up intermediate table" do 109 | expect { create_partition }.to raise_error(ActiveRecord::StatementInvalid, /PG::InvalidObjectDefinition/) 110 | expect(child_table_exists).to eq(false) 111 | end 112 | end 113 | end 114 | 115 | describe ".in_partition" do 116 | let(:child_table_name) { "#{table_name}_a" } 117 | 118 | subject { described_class.in_partition(child_table_name) } 119 | 120 | its(:table_name) { is_expected.to eq(child_table_name) } 121 | its(:name) { is_expected.to eq(described_class.name) } 122 | its(:new) { is_expected.to be_an_instance_of(described_class) } 123 | its(:allocate) { is_expected.to be_an_instance_of(described_class) } 124 | 125 | describe "query methods" do 126 | let!(:record_one) { described_class.create!(some_string: "d") } 127 | let!(:record_two) { described_class.create!(some_string: "f") } 128 | let!(:record_three) { described_class.create!(some_string: "x") } 129 | 130 | describe ".all" do 131 | subject { described_class.in_partition(child_table_name).all } 132 | 133 | it { is_expected.to contain_exactly(record_one, record_two) } 134 | end 135 | 136 | describe ".where" do 137 | subject { described_class.in_partition(child_table_name).where(id: record_one.id) } 138 | 139 | it { is_expected.to contain_exactly(record_one) } 140 | end 141 | end 142 | end 143 | 144 | describe ".partition_key_in" do 145 | let(:start_range) { "a" } 146 | let(:end_range) { "l" } 147 | 148 | let!(:record_one) { described_class.create!(some_string: "d") } 149 | let!(:record_two) { described_class.create!(some_string: "f") } 150 | let!(:record_three) { described_class.create!(some_string: "x") } 151 | 152 | subject { described_class.partition_key_in(start_range, end_range) } 153 | 154 | context "when spanning a single partition" do 155 | it { is_expected.to contain_exactly(record_one, record_two) } 156 | end 157 | 158 | context "when spanning multiple partitions" do 159 | let(:end_range) { "z" } 160 | 161 | it { is_expected.to contain_exactly(record_one, record_two, record_three) } 162 | end 163 | 164 | context "when excluding records with a lower bound" do 165 | let(:start_range) { "f" } 166 | let(:end_range) { "z" } 167 | 168 | it { is_expected.to contain_exactly(record_two, record_three) } 169 | end 170 | 171 | context "when chaining methods" do 172 | subject { described_class.partition_key_in(start_range, end_range).where(some_string: "d") } 173 | 174 | it { is_expected.to contain_exactly(record_one) } 175 | end 176 | end 177 | 178 | describe ".partition_key_eq" do 179 | let(:partition_key) { "d" } 180 | 181 | let!(:record_one) { described_class.create!(some_string: "d") } 182 | let!(:record_two) { described_class.create!(some_string: "x") } 183 | 184 | subject { described_class.partition_key_eq(partition_key) } 185 | 186 | context "when partition key in first partition" do 187 | it { is_expected.to contain_exactly(record_one) } 188 | end 189 | 190 | context "when partition key in second partition" do 191 | let(:partition_key) { "x" } 192 | 193 | it { is_expected.to contain_exactly(record_two) } 194 | end 195 | 196 | context "when chaining methods" do 197 | subject do 198 | described_class 199 | .in_partition("#{table_name}_b") 200 | .unscoped 201 | .partition_key_eq(partition_key) 202 | end 203 | 204 | it { is_expected.to be_empty } 205 | end 206 | 207 | context "when table is aliased" do 208 | subject do 209 | described_class 210 | .select("*") 211 | .from(described_class.arel_table.alias) 212 | .partition_key_eq(partition_key) 213 | end 214 | 215 | it { is_expected.to contain_exactly(record_one) } 216 | end 217 | 218 | context "when table alias not resolvable" do 219 | subject do 220 | described_class 221 | .select("*") 222 | .from("garbage") 223 | .partition_key_eq(partition_key) 224 | end 225 | 226 | it { expect { subject }.to raise_error("could not find arel table in current scope") } 227 | end 228 | end 229 | end 230 | -------------------------------------------------------------------------------- /spec/integration/structure_dump_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe "db:structure:dump", :structure_dump do 6 | subject do 7 | if Rails.gem_version < Gem::Version.new("7.0") 8 | ActiveRecord::Base.schema_format = :sql 9 | else 10 | ActiveRecord.schema_format = :sql 11 | end 12 | 13 | Rake::Task["db:schema:dump"].invoke 14 | File.read(File.expand_path("../../dummy/db/structure.sql", __FILE__)) 15 | end 16 | 17 | context "when schema_exclude_partitions is true" do 18 | it { is_expected.to_not include("bigint_date_ranges_a") } 19 | it { is_expected.to include("bigint_date_ranges") } 20 | end 21 | 22 | context "when schema_exclude_partitions is false" do 23 | before { PgParty.config.schema_exclude_partitions = false } 24 | 25 | it { is_expected.to include("bigint_date_ranges_a") } 26 | it { is_expected.to include("bigint_date_ranges") } 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/integration/threading_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "thread" 5 | 6 | RSpec.describe "threading" do 7 | let!(:model) { BigintDateRange } 8 | let!(:table_name) { model.table_name } 9 | let!(:child_table_name) { "#{table_name}_c" } 10 | let!(:current_date) { Date.current } 11 | let!(:current_time) { Time.now } 12 | 13 | before do 14 | allow(PgParty.cache).to receive(:clear!) 15 | PgParty.config.caching_ttl = 5 16 | Timecop.travel(current_date + 12.hours) 17 | end 18 | 19 | describe ".partitions" do 20 | after do 21 | ActiveRecord::Base 22 | .connection 23 | .execute("DROP TABLE IF EXISTS #{child_table_name} CASCADE") 24 | end 25 | 26 | it "eventually detects new partitions" do 27 | threads = 20.times.map do 28 | Thread.new do 29 | partitions = nil 30 | 31 | 6.times do 32 | sleep 1 33 | partitions = model.partitions 34 | end 35 | 36 | if partitions.size == 3 37 | Thread.current[:status] = "success" 38 | else 39 | Thread.current[:status] = "failed" 40 | end 41 | end 42 | end 43 | 44 | # init cache 45 | model.partitions 46 | 47 | model.create_partition( 48 | start_range: current_date + 2.days, 49 | end_range: current_date + 3.days, 50 | name: child_table_name, 51 | ) 52 | 53 | expect(model.partitions.size).to eq(2) 54 | 55 | threads.map(&:join).each do |t| 56 | expect(t[:status]).to eq("success") 57 | end 58 | 59 | expect(model.partitions.size).to eq(3) 60 | end 61 | end 62 | 63 | describe ".in_partition" do 64 | before do 65 | (0..23).each do |i| 66 | model.create!( 67 | created_at: current_time + i.hours, 68 | updated_at: current_time + i.hours, 69 | ) 70 | end 71 | end 72 | 73 | it "concurrently queries data" do 74 | threads = 20.times.map do 75 | Thread.new do 76 | partition_a_data = nil 77 | partition_b_data = nil 78 | 79 | 6.times do 80 | sleep 1 81 | partition_a_data = model.in_partition("#{table_name}_a").all 82 | partition_b_data = model.in_partition("#{table_name}_b").all 83 | end 84 | 85 | if partition_a_data.count == 13 && partition_b_data.count == 13 86 | Thread.current[:status] = "success" 87 | else 88 | Thread.current[:status] = "failed" 89 | end 90 | end 91 | end 92 | 93 | model.create!( 94 | created_at: current_time, 95 | updated_at: current_time, 96 | ) 97 | 98 | model.create!( 99 | created_at: current_time + 12.hours, 100 | updated_at: current_time + 12.hours, 101 | ) 102 | 103 | threads.map(&:join).each do |t| 104 | expect(t[:status]).to eq("success") 105 | end 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/model/hash_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PgParty::Model::HashMethods do 6 | let(:decorator) { instance_double(PgParty::ModelDecorator) } 7 | 8 | before do 9 | allow(PgParty::ModelDecorator).to receive(:new).with(model).and_return(decorator) 10 | end 11 | 12 | subject(:model) do 13 | Class.new do 14 | extend PgParty::Model::HashMethods 15 | end 16 | end 17 | 18 | describe ".create_partition" do 19 | let(:args) do 20 | { 21 | modulus: 2, 22 | remainder: 0, 23 | name: "my_partition" 24 | } 25 | end 26 | 27 | subject { model.create_partition(args) } 28 | 29 | it "delegates to decorator" do 30 | expect(decorator).to receive(:create_hash_partition).with(args) 31 | subject 32 | end 33 | end 34 | 35 | describe ".partition_key_in" do 36 | let(:values) { [2, SecureRandom.uuid] } 37 | 38 | subject { model.partition_key_in(values) } 39 | 40 | it "delegates to decorator" do 41 | expect(decorator).to receive(:hash_partition_key_in).with(values) 42 | subject 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/model/list_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PgParty::Model::ListMethods do 6 | let(:decorator) { instance_double(PgParty::ModelDecorator) } 7 | 8 | before do 9 | allow(PgParty::ModelDecorator).to receive(:new).with(model).and_return(decorator) 10 | end 11 | 12 | subject(:model) do 13 | Class.new do 14 | extend PgParty::Model::ListMethods 15 | end 16 | end 17 | 18 | describe ".create_partition" do 19 | let(:args) do 20 | { 21 | values: Date.current, 22 | name: "my_partition" 23 | } 24 | end 25 | 26 | subject { model.create_partition(args) } 27 | 28 | it "delegates to decorator" do 29 | expect(decorator).to receive(:create_list_partition).with(args) 30 | subject 31 | end 32 | end 33 | 34 | describe ".create_default_partition" do 35 | let(:args) do 36 | { 37 | name: "my_partition" 38 | } 39 | end 40 | subject { model.create_default_partition(args) } 41 | 42 | it "delegates to decorator" do 43 | expect(decorator).to receive(:create_default_partition).with(args) 44 | subject 45 | end 46 | end 47 | 48 | describe ".partition_key_in" do 49 | let(:values) { [Date.current, Date.tomorrow] } 50 | 51 | subject { model.partition_key_in(values) } 52 | 53 | it "delegates to decorator" do 54 | expect(decorator).to receive(:list_partition_key_in).with(values) 55 | subject 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/model/methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PgParty::Model::Methods do 6 | let(:key) { "created_at::date" } 7 | let(:injector) { instance_double(PgParty::ModelInjector) } 8 | 9 | subject(:model) do 10 | Class.new do 11 | extend PgParty::Model::Methods 12 | end 13 | end 14 | 15 | before do 16 | allow(PgParty::ModelInjector).to receive(:new).and_return(injector) 17 | allow(injector).to receive(:inject_range_methods) 18 | allow(injector).to receive(:inject_list_methods) 19 | allow(injector).to receive(:inject_hash_methods) 20 | end 21 | 22 | describe ".range_partition_by" do 23 | context "when partition key provided as argument" do 24 | subject { model.range_partition_by(key) } 25 | 26 | it "initializes injector with model and key" do 27 | expect(PgParty::ModelInjector).to receive(:new).with(model, key) 28 | subject 29 | end 30 | 31 | it "delegates to injector" do 32 | expect(injector).to receive(:inject_range_methods) 33 | subject 34 | end 35 | end 36 | 37 | context "when partition key provided as block" do 38 | let(:key_as_block) { ->{ key } } 39 | 40 | subject { model.range_partition_by(&key_as_block) } 41 | 42 | it "initializes injector with model and block" do 43 | expect(PgParty::ModelInjector).to receive(:new) do |m, &blk| 44 | expect(m).to eq(model) 45 | expect(blk).to eq(blk) 46 | injector 47 | end 48 | 49 | subject 50 | end 51 | 52 | it "delegates to injector" do 53 | expect(injector).to receive(:inject_range_methods) 54 | subject 55 | end 56 | end 57 | end 58 | 59 | describe ".list_partition_by" do 60 | context "when partition key provided as argument" do 61 | subject { model.list_partition_by(key) } 62 | 63 | it "initializes injector with model and key" do 64 | expect(PgParty::ModelInjector).to receive(:new).with(model, key) 65 | subject 66 | end 67 | 68 | it "delegates to injector" do 69 | expect(injector).to receive(:inject_list_methods) 70 | subject 71 | end 72 | end 73 | 74 | context "when partition key provided as block" do 75 | let(:key_as_block) { ->{ key } } 76 | 77 | subject { model.list_partition_by(&key_as_block) } 78 | 79 | it "initializes injector with model and block" do 80 | expect(PgParty::ModelInjector).to receive(:new) do |m, &blk| 81 | expect(m).to eq(model) 82 | expect(blk).to eq(blk) 83 | injector 84 | end 85 | 86 | subject 87 | end 88 | 89 | it "delegates to injector" do 90 | expect(injector).to receive(:inject_list_methods) 91 | subject 92 | end 93 | end 94 | end 95 | 96 | describe ".hash_partition_by" do 97 | context "when partition key provided as argument" do 98 | subject { model.hash_partition_by(key) } 99 | 100 | it "initializes injector with model and key" do 101 | expect(PgParty::ModelInjector).to receive(:new).with(model, key) 102 | subject 103 | end 104 | 105 | it "delegates to injector" do 106 | expect(injector).to receive(:inject_hash_methods) 107 | subject 108 | end 109 | end 110 | 111 | context "when partition key provided as block" do 112 | let(:key_as_block) { ->{ key } } 113 | 114 | subject { model.hash_partition_by(&key_as_block) } 115 | 116 | it "initializes injector with model and block" do 117 | expect(PgParty::ModelInjector).to receive(:new) do |m, &blk| 118 | expect(m).to eq(model) 119 | expect(blk).to eq(blk) 120 | injector 121 | end 122 | 123 | subject 124 | end 125 | 126 | it "delegates to injector" do 127 | expect(injector).to receive(:inject_hash_methods) 128 | subject 129 | end 130 | end 131 | end 132 | 133 | describe ".partitioned?" do 134 | subject { model.partitioned? } 135 | 136 | context "when partition key not defined" do 137 | it { is_expected.to eq(false) } 138 | end 139 | 140 | context "when partition key defined" do 141 | let(:block) { ->{ "blah" } } 142 | 143 | before { model.singleton_class.send(:define_method, :partition_key, &block) } 144 | 145 | it { is_expected.to eq(true) } 146 | end 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /spec/model/range_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PgParty::Model::RangeMethods do 6 | let(:decorator) { instance_double(PgParty::ModelDecorator) } 7 | 8 | before do 9 | allow(PgParty::ModelDecorator).to receive(:new).with(model).and_return(decorator) 10 | end 11 | 12 | subject(:model) do 13 | Class.new do 14 | extend PgParty::Model::RangeMethods 15 | end 16 | end 17 | 18 | describe ".create_partition" do 19 | let(:args) do 20 | { 21 | start_range: Date.current, 22 | end_range: Date.tomorrow, 23 | name: "my_partition" 24 | } 25 | end 26 | 27 | subject { model.create_partition(args) } 28 | 29 | it "delegates to decorator" do 30 | expect(decorator).to receive(:create_range_partition).with(args) 31 | subject 32 | end 33 | end 34 | 35 | describe ".create_default_partition" do 36 | let(:args) do 37 | { 38 | name: "my_partition" 39 | } 40 | end 41 | subject { model.create_default_partition(args) } 42 | 43 | it "delegates to decorator" do 44 | expect(decorator).to receive(:create_default_partition).with(args) 45 | subject 46 | end 47 | end 48 | 49 | describe ".partition_key_in" do 50 | let(:start_range) { Date.current } 51 | let(:end_range) { Date.tomorrow } 52 | 53 | subject { model.partition_key_in(start_range, end_range) } 54 | 55 | it "delegates to decorator" do 56 | expect(decorator).to receive(:range_partition_key_in).with(start_range, end_range) 57 | subject 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/model/shared_methods_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PgParty::Model::SharedMethods do 6 | let(:decorator) { instance_double(PgParty::ModelDecorator) } 7 | 8 | before do 9 | allow(PgParty::ModelDecorator).to receive(:new).with(model).and_return(decorator) 10 | end 11 | 12 | subject(:model) do 13 | Class.new do 14 | extend PgParty::Model::SharedMethods 15 | 16 | def self.base_class 17 | self 18 | end 19 | 20 | def self.get_primary_key(class_name) 21 | nil 22 | end 23 | end 24 | end 25 | 26 | describe ".reset_primary_key" do 27 | subject { model.reset_primary_key } 28 | 29 | context 'when using default config: config.include_subpartitions_in_partition_list = false' do 30 | it do 31 | expect(decorator).to receive(:partitions).with(include_subpartitions: false).and_return([]) 32 | subject 33 | end 34 | end 35 | 36 | context 'config.include_subpartitions_in_partition_list = true' do 37 | before { PgParty.config.include_subpartitions_in_partition_list = true } 38 | 39 | it do 40 | expect(decorator).to receive(:partitions).with(include_subpartitions: true).and_return([]) 41 | subject 42 | end 43 | end 44 | end 45 | 46 | describe ".partitions" do 47 | subject { model.partitions(include_subpartitions: true) } 48 | 49 | it "delegates to decorator" do 50 | expect(decorator).to receive(:partitions).with(include_subpartitions: true) 51 | subject 52 | end 53 | end 54 | 55 | describe ".in_partition" do 56 | let(:partition) { "partition" } 57 | 58 | subject { model.in_partition(partition) } 59 | 60 | it "delegates to decorator" do 61 | expect(decorator).to receive(:in_partition).with(partition) 62 | subject 63 | end 64 | end 65 | 66 | describe ".partition_key_eq" do 67 | let(:value) { Date.current } 68 | 69 | subject { model.partition_key_eq(value) } 70 | 71 | it "delegates to decorator" do 72 | expect(decorator).to receive(:partition_key_eq).with(value) 73 | subject 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/model_injector_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PgParty::ModelInjector do 6 | let(:key) { "created_at" } 7 | let(:model) { Class.new } 8 | let(:injector) { described_class.new(model, *key) } 9 | 10 | subject(:inject_range_methods) { injector.inject_range_methods } 11 | subject(:inject_list_methods) { injector.inject_list_methods } 12 | subject(:inject_hash_methods) { injector.inject_hash_methods } 13 | 14 | before { allow(model).to receive(:extend).and_call_original } 15 | 16 | describe "#inject_range_methods" do 17 | subject { inject_range_methods } 18 | 19 | context "when key is a string" do 20 | it "extends range methods" do 21 | expect(model).to receive(:extend).with(PgParty::Model::RangeMethods) 22 | subject 23 | end 24 | 25 | it "extends shared methods" do 26 | expect(model).to receive(:extend).with(PgParty::Model::SharedMethods) 27 | subject 28 | end 29 | 30 | describe "model" do 31 | subject do 32 | inject_range_methods 33 | model 34 | end 35 | 36 | its(:partition_key) { is_expected.to eq("created_at") } 37 | its(:complex_partition_key) { is_expected.to eq(false) } 38 | end 39 | end 40 | 41 | context "when key is array" do 42 | let(:key) { ["created_at", "updated_at"] } 43 | 44 | it "extends range methods" do 45 | expect(model).to receive(:extend).with(PgParty::Model::RangeMethods) 46 | subject 47 | end 48 | 49 | it "extends shared methods" do 50 | expect(model).to receive(:extend).with(PgParty::Model::SharedMethods) 51 | subject 52 | end 53 | 54 | describe "model" do 55 | subject do 56 | inject_range_methods 57 | model 58 | end 59 | 60 | its(:partition_key) { is_expected.to eq(["created_at", "updated_at"]) } 61 | its(:complex_partition_key) { is_expected.to eq(false) } 62 | end 63 | end 64 | 65 | context "when block is provided" do 66 | let(:blk) { ->{ "created_at::date" } } 67 | 68 | let(:injector) { described_class.new(model, &blk) } 69 | 70 | it "extends range methods" do 71 | expect(model).to receive(:extend).with(PgParty::Model::RangeMethods) 72 | subject 73 | end 74 | 75 | it "extends shared methods" do 76 | expect(model).to receive(:extend).with(PgParty::Model::SharedMethods) 77 | subject 78 | end 79 | 80 | describe "model" do 81 | subject do 82 | inject_range_methods 83 | model 84 | end 85 | 86 | its(:partition_key) { is_expected.to eq("created_at::date") } 87 | its(:complex_partition_key) { is_expected.to eq(true) } 88 | end 89 | end 90 | end 91 | 92 | describe "#inject_list_methods" do 93 | subject { inject_list_methods } 94 | 95 | context "when key is a string" do 96 | it "extends range methods" do 97 | expect(model).to receive(:extend).with(PgParty::Model::ListMethods) 98 | subject 99 | end 100 | 101 | it "extends shared methods" do 102 | expect(model).to receive(:extend).with(PgParty::Model::SharedMethods) 103 | subject 104 | end 105 | 106 | describe "model" do 107 | subject do 108 | inject_list_methods 109 | model 110 | end 111 | 112 | its(:partition_key) { is_expected.to eq("created_at") } 113 | its(:complex_partition_key) { is_expected.to eq(false) } 114 | end 115 | end 116 | 117 | context "when key is array" do 118 | let(:key) { ["created_at", "updated_at"] } 119 | 120 | it "extends list methods" do 121 | expect(model).to receive(:extend).with(PgParty::Model::ListMethods) 122 | subject 123 | end 124 | 125 | it "extends shared methods" do 126 | expect(model).to receive(:extend).with(PgParty::Model::SharedMethods) 127 | subject 128 | end 129 | 130 | describe "model" do 131 | subject do 132 | inject_list_methods 133 | model 134 | end 135 | 136 | its(:partition_key) { is_expected.to eq(["created_at", "updated_at"]) } 137 | its(:complex_partition_key) { is_expected.to eq(false) } 138 | end 139 | end 140 | 141 | context "when block is provided" do 142 | let(:blk) { ->{ "created_at::date" } } 143 | 144 | let(:injector) { described_class.new(model, &blk) } 145 | 146 | it "extends list methods" do 147 | expect(model).to receive(:extend).with(PgParty::Model::ListMethods) 148 | subject 149 | end 150 | 151 | it "extends shared methods" do 152 | expect(model).to receive(:extend).with(PgParty::Model::SharedMethods) 153 | subject 154 | end 155 | 156 | describe "model" do 157 | subject do 158 | inject_list_methods 159 | model 160 | end 161 | 162 | its(:partition_key) { is_expected.to eq("created_at::date") } 163 | its(:complex_partition_key) { is_expected.to eq(true) } 164 | end 165 | end 166 | end 167 | 168 | describe "#inject_hash_methods" do 169 | subject { inject_hash_methods } 170 | 171 | context "when key is a string" do 172 | it "extends hash methods" do 173 | expect(model).to receive(:extend).with(PgParty::Model::HashMethods) 174 | subject 175 | end 176 | 177 | it "extends shared methods" do 178 | expect(model).to receive(:extend).with(PgParty::Model::SharedMethods) 179 | subject 180 | end 181 | 182 | describe "model" do 183 | subject do 184 | inject_hash_methods 185 | model 186 | end 187 | 188 | its(:partition_key) { is_expected.to eq("created_at") } 189 | its(:complex_partition_key) { is_expected.to eq(false) } 190 | end 191 | end 192 | 193 | context "when key is array" do 194 | let(:key) { ["created_at", "updated_at"] } 195 | 196 | it "extends hash methods" do 197 | expect(model).to receive(:extend).with(PgParty::Model::HashMethods) 198 | subject 199 | end 200 | 201 | it "extends shared methods" do 202 | expect(model).to receive(:extend).with(PgParty::Model::SharedMethods) 203 | subject 204 | end 205 | 206 | describe "model" do 207 | subject do 208 | inject_hash_methods 209 | model 210 | end 211 | 212 | its(:partition_key) { is_expected.to eq(["created_at", "updated_at"]) } 213 | its(:complex_partition_key) { is_expected.to eq(false) } 214 | end 215 | end 216 | 217 | context "when block is provided" do 218 | let(:blk) { ->{ "created_at::date" } } 219 | 220 | let(:injector) { described_class.new(model, &blk) } 221 | 222 | it "extends hash methods" do 223 | expect(model).to receive(:extend).with(PgParty::Model::HashMethods) 224 | subject 225 | end 226 | 227 | it "extends shared methods" do 228 | expect(model).to receive(:extend).with(PgParty::Model::SharedMethods) 229 | subject 230 | end 231 | 232 | describe "model" do 233 | subject do 234 | inject_hash_methods 235 | model 236 | end 237 | 238 | its(:partition_key) { is_expected.to eq("created_at::date") } 239 | its(:complex_partition_key) { is_expected.to eq(true) } 240 | end 241 | end 242 | end 243 | end 244 | -------------------------------------------------------------------------------- /spec/pg_party_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PgParty do 6 | describe ".configure" do 7 | subject do 8 | described_class.configure do |c| 9 | c.caching = false 10 | c.caching_ttl = 60 11 | c.schema_exclude_partitions = false 12 | end 13 | 14 | described_class.config 15 | end 16 | 17 | its(:caching) { is_expected.to eq(false) } 18 | its(:caching_ttl) { is_expected.to eq(60) } 19 | its(:schema_exclude_partitions) { is_expected.to eq(false) } 20 | end 21 | 22 | describe ".reset" do 23 | let!(:initial_config) { described_class.config } 24 | let!(:initial_cache) { described_class.cache } 25 | 26 | subject do 27 | described_class.reset 28 | described_class 29 | end 30 | 31 | its(:config) { is_expected.to_not eq(initial_config) } 32 | its(:cache) { is_expected.to_not eq(initial_cache) } 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["RAILS_ENV"] ||= "test" 4 | 5 | require "combustion" 6 | require "timecop" 7 | require "pry-byebug" 8 | require "simplecov" 9 | require "rake" 10 | 11 | if ENV["CODE_COVERAGE"] == "true" 12 | SimpleCov.command_name Rails.gem_version.to_s 13 | 14 | SimpleCov.start do 15 | add_filter "spec" 16 | end 17 | end 18 | 19 | # make sure injected modules are required 20 | require "pg_party/model/shared_methods" 21 | require "pg_party/model/range_methods" 22 | require "pg_party/model/list_methods" 23 | require "pg_party/model/hash_methods" 24 | 25 | Combustion.path = "spec/dummy" 26 | Combustion.initialize! :active_record do 27 | config.eager_load = true 28 | end 29 | 30 | load "support/db.rake" 31 | 32 | require "rspec/rails" 33 | require "rspec/its" 34 | require "support/uuid_matcher" 35 | require "support/heredoc_matcher" 36 | require "support/pg_dump_helper" 37 | 38 | static_time = Date.current + 12.hours 39 | 40 | RSpec.configure do |config| 41 | # Enable flags like --only-failures and --next-failure 42 | config.example_status_persistence_file_path = ".rspec_status" 43 | 44 | # Disable RSpec exposing methods globally on `Module` and `main` 45 | config.disable_monkey_patching! 46 | 47 | config.mock_with :rspec do |c| 48 | c.verify_partial_doubles = true 49 | end 50 | 51 | config.expect_with :rspec do |c| 52 | c.syntax = :expect 53 | end 54 | 55 | config.use_transactional_fixtures = false 56 | 57 | config.around(:each) do |example| 58 | Timecop.freeze(static_time) 59 | PgParty.reset 60 | example.run 61 | ActiveRecord::Tasks::DatabaseTasks.truncate_all 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/support/db.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "erb" 4 | 5 | include ActiveRecord::Tasks 6 | 7 | root_dir = File.expand_path("../../dummy", __FILE__) 8 | 9 | DatabaseTasks.env = ENV["RAILS_ENV"] 10 | DatabaseTasks.root = root_dir 11 | DatabaseTasks.database_configuration = YAML.load(ERB.new(File.read("#{root_dir}/config/database.yml")).result) 12 | DatabaseTasks.db_dir = "#{root_dir}/db" 13 | DatabaseTasks.migrations_paths = "#{root_dir}/db/migrate" 14 | 15 | task :environment do 16 | ActiveRecord::Base.configurations = DatabaseTasks.database_configuration 17 | ActiveRecord::Base.establish_connection DatabaseTasks.env.to_sym 18 | end 19 | 20 | load "active_record/railties/databases.rake" 21 | -------------------------------------------------------------------------------- /spec/support/heredoc_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :match_heredoc do |expected| 4 | match do |actual| 5 | actual.squish == expected.squish 6 | end 7 | end 8 | 9 | RSpec::Matchers.alias_matcher :heredoc_matching , :match_heredoc 10 | 11 | RSpec::Matchers.define :include_heredoc do |expected| 12 | match do |actual| 13 | actual.squish.include?(expected.squish) 14 | end 15 | end 16 | 17 | RSpec::Matchers.alias_matcher :heredoc_including, :include_heredoc 18 | -------------------------------------------------------------------------------- /spec/support/pg_dump_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class PgDumpHelper 4 | def self.dump_table_structure(table_name) 5 | new(table_name: table_name).dump_table_structure 6 | end 7 | 8 | def dump_table_structure 9 | pg_dump.gsub("#{schema_name}.", "") 10 | end 11 | 12 | def self.dump_indices 13 | ActiveRecord::Base.connection.select_values( 14 | "SELECT indexdef FROM pg_indexes WHERE tablename NOT LIKE 'pg%'" 15 | ).join('; ').gsub("#{schema_name}.", "") 16 | end 17 | 18 | private 19 | 20 | def initialize(options) 21 | @table_name = "#{schema_name}.#{options[:table_name]}" 22 | end 23 | 24 | def pg_dump 25 | `#{pg_env_string} pg_dump -s -x -O -d #{config[:database]} -t #{@table_name} 2>/dev/null` 26 | end 27 | 28 | def pg_env_string 29 | env_strings = [] 30 | env_strings << "PGHOST=#{config[:host]}" if config[:host] 31 | env_strings << "PGPORT=#{config[:port]}" if config[:port] 32 | env_strings << "PGPASSWORD=#{config[:password]}" if config[:password] 33 | env_strings << "PGUSER=#{config[:username]}" if config[:username] 34 | env_strings.join(" ") 35 | end 36 | 37 | def self.config 38 | @config ||= ActiveRecord::Base.connection_db_config.as_json["configuration_hash"].symbolize_keys! 39 | end 40 | 41 | def config 42 | self.class.config 43 | end 44 | 45 | def self.schema_name 46 | config[:schema_search_path] 47 | end 48 | 49 | def schema_name 50 | self.class.schema_name 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/support/uuid_matcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :be_a_uuid do 4 | match do |value| 5 | value =~ /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ 6 | end 7 | end 8 | --------------------------------------------------------------------------------