├── .standard.yml ├── spec ├── internal │ ├── log │ │ └── .gitignore │ ├── config │ │ └── database.yml │ ├── app │ │ └── models │ │ │ └── track.rb │ └── db │ │ └── schema.rb ├── support │ └── migrations_helper.rb ├── active_record │ ├── enum_validator_spec.rb │ ├── migrations_spec.rb │ └── postgres_enum_spec.rb └── spec_helper.rb ├── .rspec ├── Rakefile ├── lefthook-local.dip_example.yml ├── lib ├── activerecord │ └── postgres_enum.rb └── active_record │ ├── postgres_enum │ ├── version.rb │ ├── column.rb │ ├── column_methods.rb │ ├── schema_statements.rb │ ├── command_recorder.rb │ ├── schema_dumper.rb │ ├── enum_validator.rb │ └── postgresql_adapter.rb │ └── postgres_enum.rb ├── bin ├── setup ├── appraisal └── console ├── lefthook.yml ├── .gitignore ├── Gemfile ├── Appraisals ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ └── ruby.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── docker-compose.yml ├── dip.yml ├── LICENSE.txt ├── activerecord-postgres_enum.gemspec ├── CODE_OF_CONDUCT.md └── README.md /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 2.7 2 | -------------------------------------------------------------------------------- /spec/internal/log/.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format progress 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | -------------------------------------------------------------------------------- /lefthook-local.dip_example.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | commands: 3 | rubocop: 4 | runner: dip {cmd} 5 | -------------------------------------------------------------------------------- /lib/activerecord/postgres_enum.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record/postgres_enum" 4 | -------------------------------------------------------------------------------- /spec/internal/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: postgresql 3 | url: <%= ENV.fetch("DATABASE_URL") %> 4 | database: postgres_enum 5 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/active_record/postgres_enum/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module PostgresEnum 5 | VERSION = "2.1.0" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/internal/app/models/track.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Track < ActiveRecord::Base 4 | validates_enum :mood, allow_nil: true, message: "Invalid enum value" 5 | end 6 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | commands: 3 | rubocop: 4 | tags: backend 5 | glob: "**/*.rb" 6 | runner: bundle exec standardrb --fix {staged_files} && git add {staged_files} 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /gemfiles/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .pry_history 11 | .rspec_status 12 | Gemfile.lock 13 | lefthook-local.yml 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 6 | 7 | # Specify your gem's dependencies in activerecord-postgres_enum.gemspec 8 | gemspec 9 | -------------------------------------------------------------------------------- /lib/active_record/postgres_enum/column.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module PostgresEnum 5 | module Column 6 | def enum? 7 | type == :enum 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/internal/db/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define do 4 | create_enum("moods", %w[happy great been_better]) 5 | 6 | create_table :tracks do |t| 7 | t.enum "mood", enum_type: :moods 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if RUBY_VERSION < "3" 4 | appraise "rails-5" do 5 | gem "rails", "~> 5.2" 6 | end 7 | end 8 | 9 | appraise "rails-6" do 10 | gem "rails", "~> 6.0" 11 | end 12 | 13 | appraise "rails-7" do 14 | gem "rails", "~> 7.0" 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/migrations_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MigrationsHelper 4 | def build_migration(&block) 5 | Class.new((Rails::VERSION::MAJOR < 5) ? ActiveRecord::Migration : ActiveRecord::Migration::Current) do 6 | define_method(:change, &block) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/active_record/postgres_enum/column_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module PostgresEnum 5 | module ColumnMethods 6 | # Enables `t.enum :my_field, enum_type: :my_enum_name` on migrations 7 | def enum(name, enum_type:, **options) 8 | column(name, enum_type, **options) 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/active_record/postgres_enum/schema_statements.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module PostgresEnum 5 | module SchemaStatements 6 | def type_to_sql(type, enum_type: nil, **kwargs) 7 | if type.to_s == "enum" 8 | enum_type 9 | else 10 | super 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /bin/appraisal: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # Remove this wrapper after the fix will be merged 5 | # https://github.com/thoughtbot/appraisal/issues/186 6 | 7 | require 'rubygems' 8 | require 'bundler/setup' 9 | require 'set' 10 | require 'appraisal' 11 | require 'appraisal/cli' 12 | 13 | begin 14 | Appraisal::CLI.start 15 | rescue Appraisal::AppraisalsNotFound => e 16 | puts e.message 17 | exit 127 18 | end 19 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "activerecord/postgres_enum" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /spec/active_record/enum_validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ActiveRecord::PostgresEnum::EnumValidator do 4 | subject { Track.new } 5 | 6 | it "rejects wrong values" do 7 | subject.update(mood: "calm") 8 | 9 | expect(subject.errors).to be_key(:mood) 10 | expect(subject.errors[:mood].first).to eq "Invalid enum value" 11 | end 12 | 13 | it "accepts nil value" do 14 | expect(subject.update(mood: nil)).to be true 15 | end 16 | 17 | it "accepts valid value" do 18 | expect(subject.update(mood: "happy")).to be true 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Context 2 | 3 | 6 | 7 | ## Related tickets 8 | 9 | 10 | 11 | # What's inside 12 | 13 | 18 | - [x] A 19 | - [ ] B 20 | - ... 21 | 22 | # Checklist: 23 | 24 | - [ ] I have added tests 25 | - [ ] I have made corresponding changes to the documentation 26 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/setup" 4 | require "activerecord/postgres_enum" 5 | require "pry-byebug" 6 | 7 | require "combustion" 8 | Combustion.initialize! :active_record 9 | 10 | require "rspec/rails" 11 | require "support/migrations_helper" 12 | 13 | RSpec.configure do |config| 14 | config.include MigrationsHelper 15 | 16 | # Enable flags like --only-failures and --next-failure 17 | config.example_status_persistence_file_path = ".rspec_status" 18 | 19 | # Disable RSpec exposing methods globally on `Module` and `main` 20 | config.disable_monkey_patching! 21 | 22 | config.expect_with :rspec do |c| 23 | c.syntax = :expect 24 | end 25 | 26 | config.use_transactional_fixtures = true 27 | end 28 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: ["**"] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | ruby: ["2.7", "3.0", "3.1", "3.2", "3.3"] 16 | env: 17 | RUBY_IMAGE: ${{ matrix.ruby }} 18 | name: Ruby ${{ matrix.ruby }} 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | - name: Install dependencies 26 | run: | 27 | gem install dip 28 | dip provision 29 | - name: Run tests 30 | run: dip appraisal rspec 31 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ruby: 3 | image: ruby:${RUBY_IMAGE:-2.7} 4 | environment: 5 | - HISTFILE=/app/tmp/.bash_history 6 | - BUNDLE_PATH=/bundle 7 | - BUNDLE_CONFIG=/app/.bundle/config 8 | - DATABASE_URL=postgres://postgres:@postgres:5432 9 | command: bash 10 | working_dir: /app 11 | volumes: 12 | - .:/app:cached 13 | - bundler_data:/bundle 14 | tmpfs: 15 | - /tmp 16 | depends_on: 17 | postgres: 18 | condition: service_healthy 19 | 20 | postgres: 21 | image: postgres:13 22 | environment: 23 | POSTGRES_HOST_AUTH_METHOD: trust 24 | ports: 25 | - 5432 26 | healthcheck: 27 | test: pg_isready -U postgres -h 127.0.0.1 28 | interval: 10s 29 | 30 | volumes: 31 | bundler_data: 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Provide dip.yml config 16 | 2. Provide docker-compose.yml 17 | 3. Run command 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Context (please complete the following information):** 27 | - OS: [e.g. Ubuntu 18.04.3] 28 | - Version [e.g. binary 4.3.2] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /lib/active_record/postgres_enum/command_recorder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module PostgresEnum 5 | module CommandRecorder 6 | def create_enum(name, values, _opts = nil) 7 | record(:create_enum, [name, values]) 8 | end 9 | 10 | def invert_create_enum(args) 11 | [:drop_enum, args.first] 12 | end 13 | 14 | def rename_enum(name, new_name) 15 | record(:rename_enum, [name, new_name]) 16 | end 17 | 18 | def invert_rename_enum(args) 19 | [:rename_enum, args.reverse] 20 | end 21 | 22 | def rename_enum_value(name, existing_value, new_value) 23 | record(:rename_enum_value, [name, existing_value, new_value]) 24 | end 25 | 26 | def invert_rename_enum_value(args) 27 | [:rename_enum_value, [args.first] + args.last(2).reverse] 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /dip.yml: -------------------------------------------------------------------------------- 1 | version: '7' 2 | 3 | compose: 4 | files: 5 | - docker-compose.yml 6 | 7 | interaction: 8 | bash: 9 | description: Open the Bash shell in app's container 10 | service: ruby 11 | command: /bin/bash 12 | 13 | pry: 14 | description: Open Pry console 15 | service: ruby 16 | command: ./bin/console 17 | 18 | bundle: 19 | description: Run Bundler commands 20 | service: ruby 21 | command: bundle 22 | 23 | appraisal: 24 | description: Run Appraisal commands 25 | service: ruby 26 | command: bundle exec ./bin/appraisal 27 | 28 | rspec: 29 | description: Run Rspec commands 30 | service: ruby 31 | command: bundle exec rspec 32 | 33 | standardrb: 34 | description: Run Standard linter 35 | service: ruby 36 | command: bundle exec standardrb 37 | 38 | provision: 39 | - cp -f lefthook-local.dip_example.yml lefthook-local.yml 40 | - dip compose down --volumes 41 | - rm -f Gemfile.lock gemfiles/* 42 | - dip bundle install 43 | - dip appraisal install 44 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Michael Merkushin 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 | -------------------------------------------------------------------------------- /lib/active_record/postgres_enum/schema_dumper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module PostgresEnum 5 | # provide support for writing out the 'create_enum' calls in schema.rb 6 | module SchemaDumper 7 | unless ActiveRecord::PostgresEnum.rails_7? 8 | def tables(stream) 9 | types(stream) 10 | 11 | super 12 | end 13 | end 14 | 15 | private 16 | 17 | def types(stream) 18 | statements = [] 19 | if @connection.respond_to?(:enum_types) 20 | @connection.enum_types.each do |name, values| 21 | values = values.map { |v| " #{v.inspect}," }.join("\n") 22 | statements << " create_enum #{name.inspect}, [\n#{values}\n ], force: :cascade" 23 | end 24 | 25 | # Check if there any enum types to dump - otherwise don't output 26 | # anything to prevent empty lines in schema.rb 27 | if statements.any? 28 | stream.puts statements.join("\n\n") 29 | stream.puts 30 | end 31 | end 32 | end 33 | 34 | unless ActiveRecord::PostgresEnum.rails_7? 35 | def prepare_column_options(column) 36 | spec = super 37 | spec[:enum_type] ||= "\"#{column.sql_type}\"" if column.enum? 38 | spec 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/active_record/postgres_enum/enum_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module PostgresEnum 5 | class EnumValidator < ActiveModel::EachValidator 6 | def self.enum_values(attr_id, type, connection) 7 | @enums ||= {} 8 | return @enums[attr_id] if @enums.key?(attr_id) 9 | 10 | @enums[attr_id] ||= connection.enum_types.fetch(type.to_sym) do 11 | raise "Enum `#{type}` not found in a database #{connection}" 12 | end 13 | end 14 | 15 | def validate_each(object, attribute, value) 16 | enum_type = enum_type(object, attribute) 17 | attr_id = "#{object.class.name}##{attribute}" 18 | 19 | return if self.class.enum_values(attr_id, enum_type, object.class.connection).include?(value) 20 | 21 | object.errors.add( 22 | attribute, 23 | options[:message] || :invalid_enum_value, 24 | value: value, 25 | type: enum_type 26 | ) 27 | end 28 | 29 | private 30 | 31 | def enum_type(object, attribute) 32 | object.class.columns_hash[attribute.to_s].sql_type 33 | end 34 | end 35 | end 36 | end 37 | 38 | module ActiveModel 39 | module Validations 40 | module HelperMethods 41 | def validates_enum(*attr_names) 42 | validates_with ActiveRecord::PostgresEnum::EnumValidator, _merge_attributes(attr_names) 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /activerecord-postgres_enum.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "active_record/postgres_enum/version" 6 | 7 | # rubocop:disable Metrics/BlockLength 8 | Gem::Specification.new do |spec| 9 | spec.name = "activerecord-postgres_enum" 10 | spec.version = ActiveRecord::PostgresEnum::VERSION 11 | spec.authors = ["Michael Merkushin"] 12 | spec.email = ["merkushin.m.s@gmail.com"] 13 | 14 | spec.summary = "Integrate PostgreSQL's enum data type into ActiveRecord's schema and migrations." 15 | spec.homepage = "https://github.com/bibendi/activerecord-postgres_enum" 16 | spec.license = "MIT" 17 | 18 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 19 | # to allow pushing to a single host or delete this section to allow pushing to any host. 20 | if spec.respond_to?(:metadata) 21 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 22 | else 23 | raise "RubyGems 2.0 or newer is required to protect against " \ 24 | "public gem pushes." 25 | end 26 | 27 | # Specify which files should be added to the gem when it is released. 28 | spec.files = Dir.glob("lib/**/*") + %w[LICENSE.txt README.md] 29 | spec.bindir = "exe" 30 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 31 | spec.require_paths = ["lib"] 32 | spec.required_ruby_version = ">= 2.7" 33 | 34 | spec.add_runtime_dependency "activerecord", ">= 5.2" 35 | spec.add_runtime_dependency "pg" 36 | 37 | spec.add_development_dependency "appraisal", "~> 2.2" 38 | spec.add_development_dependency "bundler", ">= 1.16" 39 | spec.add_development_dependency "combustion", "~> 1.1" 40 | spec.add_development_dependency "pry-byebug", "~> 3" 41 | spec.add_development_dependency "rake", "~> 13.0" 42 | spec.add_development_dependency "rspec-rails", "~> 5.0" 43 | spec.add_development_dependency "standard", "~> 1.1" 44 | end 45 | # rubocop:enable Metrics/BlockLength 46 | -------------------------------------------------------------------------------- /lib/active_record/postgres_enum.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | require "active_record/schema_dumper" 5 | require "active_record/connection_adapters/postgresql/schema_statements" 6 | require "active_record/connection_adapters/postgresql_adapter" 7 | require "active_support/lazy_load_hooks" 8 | 9 | module ActiveRecord 10 | module PostgresEnum 11 | def self.rails_7? 12 | ActiveRecord::VERSION::MAJOR == 7 13 | end 14 | 15 | def self.rails_5? 16 | ActiveRecord::VERSION::MAJOR == 5 17 | end 18 | end 19 | end 20 | 21 | require "active_record/postgres_enum/version" 22 | require "active_record/postgres_enum/postgresql_adapter" 23 | require "active_record/postgres_enum/schema_dumper" 24 | require "active_record/postgres_enum/schema_statements" 25 | require "active_record/postgres_enum/column" 26 | require "active_record/postgres_enum/column_methods" 27 | require "active_record/postgres_enum/command_recorder" 28 | require "active_record/postgres_enum/enum_validator" 29 | 30 | ActiveSupport.on_load(:active_record) do 31 | ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.prepend ActiveRecord::PostgresEnum::PostgreSQLAdapter 32 | 33 | ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaDumper.prepend( 34 | ActiveRecord::PostgresEnum::SchemaDumper 35 | ) 36 | 37 | ActiveRecord::Migration::CommandRecorder.prepend ActiveRecord::PostgresEnum::CommandRecorder 38 | 39 | unless ActiveRecord::PostgresEnum.rails_7? 40 | ::ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:enum] = {} 41 | 42 | if ActiveRecord::PostgresEnum.rails_5? 43 | ActiveRecord::ConnectionAdapters::PostgreSQLColumn.prepend( 44 | ActiveRecord::PostgresEnum::Column 45 | ) 46 | else 47 | ActiveRecord::ConnectionAdapters::PostgreSQL::Column.prepend( 48 | ActiveRecord::PostgresEnum::Column 49 | ) 50 | end 51 | 52 | ActiveRecord::ConnectionAdapters::PostgreSQL::TableDefinition.include( 53 | ActiveRecord::PostgresEnum::ColumnMethods 54 | ) 55 | 56 | ActiveRecord::ConnectionAdapters::PostgreSQL::Table.include( 57 | ActiveRecord::PostgresEnum::ColumnMethods 58 | ) 59 | 60 | ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaStatements.prepend( 61 | ActiveRecord::PostgresEnum::SchemaStatements 62 | ) 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/active_record/migrations_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe ActiveRecord::PostgresEnum::CommandRecorder do 6 | let(:connection) { ActiveRecord::Base.connection } 7 | 8 | it "reverts create_enum with no options" do 9 | migration = build_migration { create_enum :genre, %w[drama comedy] } 10 | 11 | migration.migrate(:up) 12 | 13 | expect(connection.enum_types[:genre]).to eq %w[drama comedy] 14 | 15 | migration.migrate(:down) 16 | 17 | expect(connection.enum_types[:genre]).to be_nil 18 | end 19 | 20 | it "reverts create_enum with options" do 21 | migration = build_migration { create_enum :genre, %w[drama comedy], force: true, if_not_exists: true } 22 | 23 | migration.migrate(:up) 24 | 25 | expect(connection.enum_types[:genre]).to eq %w[drama comedy] 26 | 27 | migration.migrate(:down) 28 | 29 | expect(connection.enum_types[:genre]).to be_nil 30 | end 31 | 32 | it "reverts rename_enum" do 33 | build_migration { create_enum :genre, %w[drama comedy] }.migrate(:up) 34 | 35 | migration = build_migration { rename_enum :genre, :style } 36 | 37 | migration.migrate(:up) 38 | 39 | expect(connection.enum_types[:genre]).to be_nil 40 | expect(connection.enum_types[:style]).to eq %w[drama comedy] 41 | 42 | migration.migrate(:down) 43 | 44 | expect(connection.enum_types[:style]).to be_nil 45 | expect(connection.enum_types[:genre]).to eq %w[drama comedy] 46 | end 47 | 48 | it "reverts rename_enum_value" do 49 | build_migration { create_enum :genre, %w[drama comedy] }.migrate(:up) 50 | 51 | migration = build_migration { rename_enum_value :genre, :drama, :thriller } 52 | 53 | migration.migrate(:up) 54 | 55 | expect(connection.enum_types[:genre]).to eq %w[thriller comedy] 56 | 57 | migration.migrate(:down) 58 | 59 | expect(connection.enum_types[:genre]).to eq %w[drama comedy] 60 | end 61 | 62 | it "reverts add_column" do 63 | build_migration { create_enum :genre, %w[drama comedy] }.migrate(:up) 64 | 65 | migration = build_migration { add_column :tracks, :genre, :genre } 66 | 67 | migration.migrate(:up) 68 | 69 | col = connection.columns(:tracks).find { |c| c.name == "genre" } 70 | expect(col).not_to be nil 71 | expect(col.sql_type).to eq "genre" 72 | 73 | migration.migrate(:down) 74 | 75 | col = connection.columns(:tracks).find { |c| c.name == "genre" } 76 | expect(col).to be nil 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/active_record/postgres_enum/postgresql_adapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module PostgresEnum 5 | module PostgreSQLAdapter 6 | DEFINED_ENUMS_QUERY = <<~SQL 7 | SELECT 8 | t.OID, 9 | t.typname, 10 | t.typtype, 11 | array_to_string(array_agg(e.enumlabel ORDER BY e.enumsortorder), '\t\t', '') as enumlabels 12 | FROM pg_type t 13 | INNER JOIN pg_namespace n ON n.oid = t.typnamespace 14 | LEFT JOIN pg_enum e ON e.enumtypid = t.oid 15 | WHERE t.typtype = 'e' 16 | AND n.nspname = ANY(current_schemas(true)) 17 | GROUP BY t.OID, t.typname, t.typtype 18 | ORDER BY t.typname 19 | SQL 20 | 21 | def enum_types 22 | select_all(DEFINED_ENUMS_QUERY).each_with_object({}) do |row, memo| 23 | memo[row["typname"].to_sym] = row["enumlabels"].split("\t\t") 24 | end 25 | end 26 | 27 | def create_enum(name, values, force: false, if_not_exists: nil) 28 | return if if_not_exists && enum_types.include?(name.to_sym) 29 | 30 | drop_enum(name, cascade: force == :cascade, if_exists: true) if force 31 | 32 | values = values.map { |v| quote v } 33 | execute "CREATE TYPE #{name} AS ENUM (#{values.join(", ")})" 34 | end 35 | 36 | def drop_enum(name, cascade: nil, if_exists: nil) 37 | if_exists_statement = "IF EXISTS" if if_exists 38 | cascade_statement = "CASCADE" if cascade 39 | 40 | sql = "DROP TYPE #{if_exists_statement} #{name} #{cascade_statement}" 41 | execute sql 42 | end 43 | 44 | def rename_enum(name, new_name) 45 | execute "ALTER TYPE #{name} RENAME TO #{new_name}" 46 | end 47 | 48 | def add_enum_value(name, value, after: nil, before: nil, if_not_exists: nil) 49 | if_not_exists_statement = "IF NOT EXISTS" if if_not_exists 50 | 51 | sql = "ALTER TYPE #{name} ADD VALUE #{if_not_exists_statement} #{quote value}" 52 | if after 53 | sql += " AFTER #{quote after}" 54 | elsif before 55 | sql += " BEFORE #{quote before}" 56 | end 57 | execute sql 58 | end 59 | 60 | def remove_enum_value(name, value) 61 | sql = %{ 62 | DELETE FROM pg_enum 63 | WHERE enumlabel = #{quote value} 64 | AND enumtypid IN (SELECT t.oid 65 | FROM pg_type t 66 | INNER JOIN pg_namespace n ON n.oid = t.typnamespace 67 | WHERE t.typname = '#{name}' 68 | AND n.nspname = ANY(current_schemas(true))) 69 | } 70 | execute sql 71 | end 72 | 73 | def rename_enum_value(name, existing_value, new_value) 74 | raise "Renaming enum values is only supported in PostgreSQL 10.0+" unless rename_enum_value_supported? 75 | 76 | execute "ALTER TYPE #{name} RENAME VALUE #{quote existing_value} TO #{quote new_value}" 77 | end 78 | 79 | def rename_enum_value_supported? 80 | ActiveRecord::Base.connection.send(:postgresql_version) >= 100_000 81 | end 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /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 merkushin.m.s@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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/activerecord-postgres_enum.svg)](https://badge.fury.io/rb/activerecord-postgres_enum) 2 | [![Build Status](https://github.com/bibendi/activerecord-postgres_enum/workflows/Ruby/badge.svg?branch=master)](https://github.com/bibendi/activerecord-postgres_enum/actions?query=branch%3Amaster) 3 | 4 | # ActiveRecord::PostgresEnum 5 | 6 | Adds migration and schema.rb support to PostgreSQL enum data types. 7 | 8 | 9 | Sponsored by Evil Martians 10 | 11 | ## Installation 12 | 13 | Add this line to your application's Gemfile: 14 | 15 | ```ruby 16 | gem 'activerecord-postgres_enum' 17 | ``` 18 | 19 | And then execute: 20 | 21 | $ bundle 22 | 23 | Or install it yourself as: 24 | 25 | $ gem install activerecord-postgres_enum 26 | 27 | ## Usage 28 | 29 | ### Migrations 30 | 31 | ```ruby 32 | create_enum :mood, %w(happy great been_better) 33 | 34 | create_table :person do 35 | t.enum :person_mood, enum_type: :mood 36 | end 37 | ``` 38 | 39 | Running the above will create a table :person, with a column :person_mood of type :mood. This will also be saved on schema.rb so that `rake db:schema:load` works as expected. 40 | 41 | To drop an existing enum: 42 | 43 | ```ruby 44 | drop_enum :mood 45 | ``` 46 | 47 | To rename an existing enum: 48 | 49 | ```ruby 50 | rename_enum :mood, :emotions 51 | ``` 52 | 53 | To add a value into existing enum: 54 | 55 | ```ruby 56 | add_enum_value :mood, "pensive" 57 | ``` 58 | 59 | To remove a value from existing enum: 60 | 61 | > :warning: Make sure that value is not used anywhere in the database. 62 | 63 | ```ruby 64 | remove_enum_value :mood, "pensive" 65 | ``` 66 | 67 | To add a new enum column to an existing table: 68 | 69 | ```ruby 70 | def change 71 | create_enum :product_type, %w[one-off subscription] 72 | 73 | add_column :products, :type, :product_type 74 | end 75 | ``` 76 | 77 | To rename a value: 78 | 79 | ```ruby 80 | rename_enum_value :mood, "pensive", "wistful" 81 | ``` 82 | 83 | **NB:** To stop Postgres complaining about adding enum values inside a transaction, use [`disable_ddl_transaction!`](https://api.rubyonrails.org/classes/ActiveRecord/Migration.html#method-c-disable_ddl_transaction-21) in your migration. 84 | 85 | 86 | ## Development 87 | 88 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 89 | 90 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 91 | 92 | ## Contributing 93 | 94 | Bug reports and pull requests are welcome on GitHub at https://github.com/bibendi/activerecord-postgres_enum. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 95 | 96 | ## License 97 | 98 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 99 | 100 | ## Code of Conduct 101 | 102 | Everyone interacting in the Activerecord::PostgresEnum project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/bibendi/activerecord-postgres_enum/blob/master/CODE_OF_CONDUCT.md). 103 | -------------------------------------------------------------------------------- /spec/active_record/postgres_enum_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ActiveRecord::PostgresEnum do 4 | it "has a version number" do 5 | expect(ActiveRecord::PostgresEnum::VERSION).not_to be nil 6 | end 7 | 8 | it "has created table for tracks from schema.rb" do 9 | expect(Track.create(mood: "happy")).to be_truthy 10 | end 11 | 12 | describe "adapter" do 13 | let(:connection) { ActiveRecord::Base.connection } 14 | 15 | before do 16 | connection.create_enum(:foo, %w[a1 a2]) 17 | end 18 | 19 | it "creates an enum" do 20 | expect(connection.enum_types[:foo]).to eq %w[a1 a2] 21 | end 22 | 23 | it "creates an enum if not exists" do 24 | expect { connection.create_enum(:foo, %w[a1 a2], if_not_exists: true) }.not_to raise_error 25 | end 26 | 27 | it "does not change the enum options if it exists" do 28 | expect { connection.create_enum(:foo, %w[b1 b2], if_not_exists: true) }.not_to raise_error 29 | expect(connection.enum_types[:foo]).to eq %w[a1 a2] 30 | end 31 | 32 | it "fails create an existing enum" do 33 | expect { connection.create_enum(:foo, %w[a1 a2]) }.to raise_error StandardError 34 | end 35 | 36 | context "it forces the creation of an enum" do 37 | it "recreates an enum with current set of values" do 38 | expect(connection.enum_types[:foo]).to eq %w[a1 a2] 39 | expect { connection.create_enum(:foo, %w[b1 b2], force: :cascade) }.not_to raise_error 40 | expect(connection.enum_types[:foo]).to eq %w[b1 b2] 41 | end 42 | 43 | it "does not error out if forcing creation of an enum" do 44 | expect(connection.enum_types[:bar]).to be_nil 45 | expect { connection.create_enum(:bar, %w[a1 a2], force: :cascade) }.not_to raise_error 46 | expect(connection.enum_types[:bar]).to eq %w[a1 a2] 47 | end 48 | 49 | context "cascading" do 50 | before do 51 | connection.create_table :test_tbl_for_cascade do |t| 52 | t.enum :baz, enum_type: :foo 53 | end 54 | end 55 | 56 | it "fails to force create an enum if not cascading" do 57 | expect { connection.create_enum(:foo, %w[a1 a2], force: true) }.to raise_error StandardError 58 | end 59 | 60 | it "force creates an enum if cascading" do 61 | expect { connection.create_enum(:foo, %w[a1 a2], force: :cascade) }.not_to raise_error 62 | end 63 | end 64 | end 65 | 66 | it "drops an enum" do 67 | expect { connection.drop_enum(:foo) }.to_not raise_error 68 | expect(connection.enum_types[:foo]).to be_nil 69 | end 70 | 71 | it "drops an enum if exists" do 72 | expect { connection.drop_enum(:some_unknown_type, if_exists: true) }.to_not raise_error 73 | end 74 | 75 | it "fails drop a non existing enum" do 76 | expect { connection.drop_enum(:some_unknown_type) }.to raise_error StandardError 77 | end 78 | 79 | context "drops an enum with cascade" do 80 | before do 81 | connection.create_table :test_tbl_for_cascade do |t| 82 | t.enum :baz, enum_type: :foo 83 | end 84 | end 85 | 86 | it "fails drop an enum with cascade" do 87 | expect { connection.drop_enum(:foo) }.to raise_error StandardError 88 | end 89 | 90 | it "drops an enum with cascade" do 91 | expect { connection.drop_enum(:foo, cascade: true) }.to_not raise_error 92 | expect(connection.columns("test_tbl_for_cascade").map(&:name)).to_not include("baz") 93 | end 94 | end 95 | 96 | it "renames an enum" do 97 | expect { connection.rename_enum(:foo, :bar) }.to_not raise_error 98 | expect(connection.enum_types[:bar]).to eq %w[a1 a2] 99 | end 100 | 101 | it "adds an enum value" do 102 | expect { connection.add_enum_value(:foo, "a3") }.to_not raise_error 103 | expect(connection.enum_types[:foo]).to eq %w[a1 a2 a3] 104 | end 105 | 106 | it "adds an enum value if not exists" do 107 | expect { connection.add_enum_value(:foo, "a1", if_not_exists: true) }.to_not raise_error 108 | end 109 | 110 | it "fails to add an enum value if exists" do 111 | expect { connection.add_enum_value(:foo, "a1", if_not_exists: false) }.to raise_error StandardError 112 | expect { connection.add_enum_value(:foo, "a2") }.to raise_error StandardError 113 | end 114 | 115 | it "adds an enum value after a given value" do 116 | expect { connection.add_enum_value(:foo, "a3", after: "a1") }.to_not raise_error 117 | expect(connection.enum_types[:foo]).to eq %w[a1 a3 a2] 118 | end 119 | 120 | it "adds an enum value before a given value" do 121 | expect { connection.add_enum_value(:foo, "a3", before: "a1") }.to_not raise_error 122 | expect(connection.enum_types[:foo]).to eq %w[a3 a1 a2] 123 | end 124 | 125 | it "adds an enum value with a space" do 126 | expect { connection.add_enum_value(:foo, "a 3") }.to_not raise_error 127 | expect(connection.enum_types[:foo]).to eq ["a1", "a2", "a 3"] 128 | end 129 | 130 | it "adds an enum value with a comma" do 131 | expect { connection.add_enum_value(:foo, "a,3") }.to_not raise_error 132 | expect(connection.enum_types[:foo]).to eq ["a1", "a2", "a,3"] 133 | end 134 | 135 | it "adds an enum value with a single quote" do 136 | expect { connection.add_enum_value(:foo, "a'3") }.to_not raise_error 137 | expect(connection.enum_types[:foo]).to eq ["a1", "a2", "a'3"] 138 | end 139 | 140 | it "adds an enum value with a double quote" do 141 | expect { connection.add_enum_value(:foo, 'a"3') }.to_not raise_error 142 | expect(connection.enum_types[:foo]).to eq ["a1", "a2", 'a"3'] 143 | end 144 | 145 | it "removes an enum value" do 146 | expect { connection.remove_enum_value(:foo, "a1") }.to_not raise_error 147 | expect(connection.enum_types[:foo]).to eq %w[a2] 148 | end 149 | 150 | it "removes an enum value with a space" do 151 | expect { connection.add_enum_value(:foo, "a 3") }.to_not raise_error 152 | expect { connection.remove_enum_value(:foo, "a 3") }.to_not raise_error 153 | expect(connection.enum_types[:foo]).to eq %w[a1 a2] 154 | end 155 | 156 | it "removes an enum value with a comma" do 157 | expect { connection.add_enum_value(:foo, "a,3") }.to_not raise_error 158 | expect { connection.remove_enum_value(:foo, "a,3") }.to_not raise_error 159 | expect(connection.enum_types[:foo]).to eq %w[a1 a2] 160 | end 161 | 162 | it "removes an enum value with a single quote" do 163 | expect { connection.add_enum_value(:foo, "a'3") }.to_not raise_error 164 | expect { connection.remove_enum_value(:foo, "a'3") }.to_not raise_error 165 | expect(connection.enum_types[:foo]).to eq %w[a1 a2] 166 | end 167 | 168 | it "removes an enum value with a double quote" do 169 | expect { connection.add_enum_value(:foo, "a\"3") }.to_not raise_error 170 | expect { connection.remove_enum_value(:foo, "a\"3") }.to_not raise_error 171 | expect(connection.enum_types[:foo]).to eq %w[a1 a2] 172 | end 173 | 174 | it "renames an enum value" do 175 | expect { connection.rename_enum_value(:foo, "a2", "b2") }.to_not raise_error 176 | expect(connection.enum_types[:foo]).to eq %w[a1 b2] 177 | end 178 | 179 | it "creates table with enum column" do 180 | expect do 181 | connection.create_table :albums do |t| 182 | t.enum :bar, enum_type: :foo 183 | end 184 | end.to_not raise_error 185 | 186 | col = connection.columns(:albums).find { |c| c.name == "bar" } 187 | expect(col).not_to be nil 188 | expect(col.type).to eq :enum 189 | expect(col.sql_type).to eq "foo" 190 | end 191 | 192 | it "adds an enum value to an existing table" do 193 | expect { connection.add_column(:tracks, :bar, :foo) }.to_not raise_error 194 | 195 | col = connection.columns(:tracks).find { |c| c.name == "bar" } 196 | expect(col).not_to be nil 197 | expect(col.type).to eq :enum 198 | expect(col.sql_type).to eq "foo" 199 | end 200 | 201 | context "only affects the selected schema" do 202 | before do 203 | @default_schema = connection.schema_search_path 204 | 205 | connection.execute "CREATE SCHEMA IF NOT EXISTS myschema" 206 | connection.schema_search_path = "myschema" 207 | 208 | expect { connection.create_enum(:foo, %w[a1 a2]) }.to_not raise_error 209 | end 210 | 211 | after do 212 | connection.execute "DROP SCHEMA myschema CASCADE" 213 | connection.schema_search_path = @default_schema 214 | 215 | expect(connection.enum_types[:foo]).to eq %w[a1 a2] 216 | end 217 | 218 | it "drops only the separate enum" do 219 | expect { connection.drop_enum(:foo) }.to_not raise_error 220 | expect(connection.enum_types[:foo]).to be_nil 221 | end 222 | 223 | it "renames only the separate enum" do 224 | expect { connection.rename_enum(:foo, :bar) }.to_not raise_error 225 | expect(connection.enum_types[:bar]).to eq %w[a1 a2] 226 | end 227 | 228 | it "adds an enum value to the separate enum only" do 229 | expect { connection.add_enum_value(:foo, "a3") }.to_not raise_error 230 | expect(connection.enum_types[:foo]).to eq %w[a1 a2 a3] 231 | end 232 | 233 | it "removes an enum value from the separate enum only" do 234 | expect { connection.remove_enum_value(:foo, "a2") }.to_not raise_error 235 | expect(connection.enum_types[:foo]).to eq %w[a1] 236 | end 237 | 238 | it "renames an enum value in the separate enum only" do 239 | expect { connection.rename_enum_value(:foo, "a2", "b2") }.to_not raise_error 240 | expect(connection.enum_types[:foo]).to eq %w[a1 b2] 241 | end 242 | end 243 | end 244 | end 245 | --------------------------------------------------------------------------------