├── lib ├── schema_plus_enums.rb └── schema_plus │ ├── enums │ ├── version.rb │ ├── middleware.rb │ └── active_record.rb │ └── enums.rb ├── .gitignore ├── gemfiles ├── activerecord-6.0 │ ├── Gemfile.base │ └── Gemfile.postgresql ├── activerecord-5.2 │ ├── Gemfile.base │ └── Gemfile.postgresql └── Gemfile.base ├── schema_dev.yml ├── Gemfile ├── Rakefile ├── .simplecov ├── LICENSE.txt ├── schema_plus_enums.gemspec ├── spec ├── spec_helper.rb ├── schema_dumper_spec.rb └── enum_spec.rb ├── .github └── workflows │ └── prs.yml └── README.md /lib/schema_plus_enums.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'schema_plus/enums' 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /tmp 3 | /pkg 4 | /Gemfile.local 5 | /.idea 6 | 7 | *.lock 8 | *.log 9 | *.sqlite3 10 | !gemfiles/**/*.sqlite3 11 | -------------------------------------------------------------------------------- /lib/schema_plus/enums/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SchemaPlus 4 | module Enums 5 | VERSION = "1.0.0" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.0/Gemfile.base: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile) 3 | 4 | gem "activerecord", ">= 6.0", "< 6.1" 5 | -------------------------------------------------------------------------------- /gemfiles/activerecord-5.2/Gemfile.base: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile) 3 | 4 | gem "activerecord", ">= 5.2.0.beta0", "< 5.3" 5 | -------------------------------------------------------------------------------- /schema_dev.yml: -------------------------------------------------------------------------------- 1 | ruby: 2 | - 2.5 3 | - 2.7 4 | - 3.0 5 | activerecord: 6 | - 5.2 7 | - 6.0 8 | db: 9 | - postgresql 10 | dbversions: 11 | postgresql: ['9.6','10', '11', '12'] 12 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.base: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec path: File.expand_path('..', __FILE__) 3 | 4 | File.exist?(gemfile_local = File.expand_path('../Gemfile.local', __FILE__)) and eval File.read(gemfile_local), binding, gemfile_local 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "http://rubygems.org" 4 | 5 | gemspec 6 | 7 | gemfile_local = File.expand_path '../Gemfile.local', __FILE__ 8 | eval File.read(gemfile_local), binding, gemfile_local if File.exist? gemfile_local 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler' 4 | Bundler::GemHelper.install_tasks 5 | 6 | require 'schema_dev/tasks' 7 | 8 | task :default => :spec 9 | 10 | require 'rspec/core/rake_task' 11 | RSpec::Core::RakeTask.new(:spec) 12 | -------------------------------------------------------------------------------- /lib/schema_plus/enums.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'schema_plus/core' 4 | 5 | require_relative 'enums/active_record' 6 | require_relative 'enums/middleware' 7 | require_relative 'enums/version' 8 | 9 | SchemaMonkey.register SchemaPlus::Enums 10 | -------------------------------------------------------------------------------- /gemfiles/activerecord-5.2/Gemfile.postgresql: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "pg" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcpostgresql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord-6.0/Gemfile.postgresql: -------------------------------------------------------------------------------- 1 | base_gemfile = File.expand_path('../Gemfile.base', __FILE__) 2 | eval File.read(base_gemfile), binding, base_gemfile 3 | 4 | platform :ruby do 5 | gem "pg" 6 | end 7 | 8 | platform :jruby do 9 | gem 'activerecord-jdbcpostgresql-adapter' 10 | end 11 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SimpleCov.configure do 4 | enable_coverage :branch 5 | add_filter '/spec/' 6 | 7 | add_group 'Binaries', '/bin/' 8 | add_group 'Libraries', '/lib/' 9 | 10 | if ENV['CI'] 11 | require 'simplecov-lcov' 12 | 13 | SimpleCov::Formatter::LcovFormatter.config do |c| 14 | c.report_with_single_file = true 15 | c.single_report_path = 'coverage/lcov.info' 16 | end 17 | 18 | formatter SimpleCov::Formatter::LcovFormatter 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/schema_plus/enums/middleware.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SchemaPlus::Enums 4 | module Middleware 5 | 6 | module Dumper 7 | module Initial 8 | 9 | module Postgresql 10 | 11 | def after(env) 12 | env.connection.enums.sort_by { |it| it[1] }.each do |schema, name, values| 13 | params = [name.inspect] 14 | params << values.map(&:inspect).join(', ') 15 | params << ":schema => #{schema.inspect}" if schema != 'public' 16 | 17 | env.initial << " create_enum #{params.join(', ')}" 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 ronen barzel 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /schema_plus_enums.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('../lib', __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'schema_plus/enums/version' 6 | 7 | Gem::Specification.new do |gem| 8 | gem.name = "schema_plus_enums" 9 | gem.version = SchemaPlus::Enums::VERSION 10 | gem.authors = ["ronen barzel"] 11 | gem.email = ["ronen@barzel.org"] 12 | gem.summary = %q{Adds support for enum data types in ActiveRecord} 13 | gem.description = %q{Adds support for enum data types in ActiveRecord} 14 | gem.homepage = "https://github.com/SchemaPlus/schema_plus_enums" 15 | gem.license = "MIT" 16 | 17 | gem.files = `git ls-files -z`.split("\x0") 18 | gem.executables = gem.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 20 | gem.require_paths = ["lib"] 21 | 22 | gem.required_ruby_version = '>= 2.5' 23 | 24 | gem.add_dependency 'activerecord', '>= 5.2', '< 6.1' 25 | gem.add_dependency 'schema_plus_core', '~> 3.0.0' 26 | 27 | gem.add_development_dependency 'bundler' 28 | gem.add_development_dependency 'rake', '~> 13.0' 29 | gem.add_development_dependency 'rspec', '~> 3.0' 30 | gem.add_development_dependency 'schema_dev', '~> 4.1' 31 | end 32 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | SimpleCov.start 5 | 6 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 7 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 8 | 9 | require 'rspec' 10 | require 'active_record' 11 | require 'schema_plus_enums' 12 | require 'schema_dev/rspec' 13 | 14 | SchemaDev::Rspec.setup 15 | 16 | Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each { |f| require f } 17 | 18 | RSpec::Matchers.define_negated_matcher :not_include, :include 19 | 20 | RSpec.configure do |config| 21 | config.warnings = true 22 | 23 | config.filter_run_excluding pg_version: lambda { |v| 24 | version = ActiveRecord::Base.connection.select_value("SHOW server_version").match(/(\d+\.\d+)/)[1] 25 | postgresql_version = Gem::Version.new(version) 26 | test = Gem::Requirement.new(v) 27 | !test.satisfied_by?(postgresql_version) 28 | } 29 | 30 | config.after do 31 | ActiveRecord::Base.connection.tap do |c| 32 | c.enums.each do |p, e, _| 33 | c.drop_enum e, schema: p, cascade: true 34 | end 35 | 36 | c.tables.each do |t| 37 | c.drop_table t, cascade: true 38 | end 39 | end 40 | end 41 | end 42 | 43 | SimpleCov.command_name "[ruby #{RUBY_VERSION} - ActiveRecord #{::ActiveRecord::VERSION::STRING} - #{ActiveRecord::Base.connection.adapter_name}]" 44 | -------------------------------------------------------------------------------- /spec/schema_dumper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'stringio' 5 | 6 | describe "Schema dump" do 7 | 8 | context 'with enum', :postgresql => :only do 9 | let(:connection) { ActiveRecord::Base.connection } 10 | 11 | it 'should include enum' do 12 | begin 13 | connection.execute "CREATE TYPE color AS ENUM ('red', 'green', 'blue')" 14 | expect(dump_schema).to match(%r{create_enum "color", "red", "green", "blue"}) 15 | ensure 16 | connection.execute "DROP TYPE color" 17 | end 18 | end 19 | 20 | it 'should list enums alphabetically' do 21 | begin 22 | connection.execute "CREATE TYPE height AS ENUM ('tall', 'medium', 'short')" 23 | connection.execute "CREATE TYPE color AS ENUM ('red', 'green', 'blue')" 24 | expect(dump_schema).to match(%r{create_enum "color", "red", "green", "blue"\s+create_enum "height", "tall", "medium", "short"}m) 25 | ensure 26 | connection.execute "DROP TYPE color" 27 | connection.execute "DROP TYPE height" 28 | end 29 | end 30 | 31 | 32 | it 'should include enum with schema' do 33 | begin 34 | connection.execute "CREATE SCHEMA cmyk; CREATE TYPE cmyk.color AS ENUM ('cyan', 'magenta', 'yellow', 'black')" 35 | expect(dump_schema).to match(%r{create_enum "color", "cyan", "magenta", "yellow", "black", :schema => "cmyk"}) 36 | ensure 37 | connection.execute "DROP SCHEMA cmyk CASCADE" 38 | end 39 | end 40 | end 41 | 42 | protected 43 | 44 | def dump_schema(opts={}) 45 | stream = StringIO.new 46 | ActiveRecord::SchemaDumper.ignore_tables = Array.wrap(opts[:ignore]) || [] 47 | ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream) 48 | stream.string 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /lib/schema_plus/enums/active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SchemaPlus::Enums 4 | module ActiveRecord 5 | module ConnectionAdapters 6 | module PostgresqlAdapter 7 | 8 | def enums 9 | result = query(<<-SQL) 10 | SELECT 11 | N.nspname AS schema_name, 12 | T.typname AS enum_name, 13 | E.enumlabel AS enum_label, 14 | E.enumsortorder AS enum_sort_order 15 | --array_agg(E.enumlabel ORDER BY enumsortorder) AS labels 16 | FROM pg_type T 17 | JOIN pg_enum E ON E.enumtypid = T.oid 18 | JOIN pg_namespace N ON N.oid = T.typnamespace 19 | ORDER BY 1, 2, 4 20 | SQL 21 | 22 | result.reduce([]) do |res, row| 23 | last = res.last 24 | if last && last[0] == row[0] && last[1] == row[1] 25 | last[2] << row[2] 26 | else 27 | res << (row[0..1] << [row[2]]) 28 | end 29 | res 30 | end 31 | end 32 | 33 | def create_enum(name, *values, **options) 34 | list = values.map { |value| escape_enum_value(value) } 35 | 36 | if options[:force] 37 | drop_enum(name, 38 | cascade: options[:force] == :cascade, 39 | if_exists: true, 40 | schema: options[:schema]) 41 | end 42 | 43 | execute "CREATE TYPE #{enum_name(name, options[:schema])} AS ENUM (#{list.join(',')})" 44 | end 45 | 46 | def alter_enum(name, value, **options) 47 | ActiveSupport::Deprecation.warn "alter_enum is deprecated. use add_enum_value instead" 48 | 49 | add_enum_value(name, value, **options) 50 | end 51 | 52 | def add_enum_value(name, value, **options) 53 | sql = +"ALTER TYPE #{enum_name(name, options[:schema])} ADD VALUE " 54 | sql << 'IF NOT EXISTS ' if options[:if_not_exists] 55 | sql << escape_enum_value(value) 56 | sql << case 57 | when options[:before] then " BEFORE #{escape_enum_value(options[:before])}" 58 | when options[:after] then " AFTER #{escape_enum_value(options[:after])}" 59 | else 60 | '' 61 | end 62 | execute sql 63 | end 64 | 65 | def remove_enum_value(name, value, **options) 66 | sql = <<~SQL 67 | DELETE FROM pg_enum 68 | WHERE enumlabel=#{escape_enum_value(value)} 69 | AND enumtypid = ( 70 | SELECT T.oid 71 | FROM pg_type T 72 | JOIN pg_namespace N ON N.oid = T.typnamespace 73 | WHERE T.typname = #{quote name} AND N.nspname = #{quote schema_name(options[:schema])} 74 | ) 75 | SQL 76 | execute sql 77 | end 78 | 79 | def rename_enum_value(name, value, new_value, **options) 80 | raise "Renaming enum values is only supported in PostgreSQL 10.0+" unless rename_enum_value_supported? 81 | 82 | sql = <<~SQL 83 | ALTER TYPE #{enum_name(name, options[:schema])} 84 | RENAME VALUE #{escape_enum_value(value)} 85 | TO #{escape_enum_value(new_value)} 86 | SQL 87 | 88 | execute sql 89 | end 90 | 91 | def rename_enum(name, new_name, **options) 92 | execute "ALTER TYPE #{enum_name(name, options[:schema])} RENAME TO #{new_name}" 93 | end 94 | 95 | def drop_enum(name, **options) 96 | sql = +'DROP TYPE ' 97 | sql << 'IF EXISTS ' if options[:if_exists] 98 | sql << enum_name(name, options[:schema]) 99 | sql << ' CASCADE' if options[:cascade] 100 | 101 | execute sql 102 | end 103 | 104 | private 105 | 106 | def rename_enum_value_supported? 107 | unless defined? @rename_enum_value_supported 108 | version = select_value("SHOW server_version").match(/(\d+\.\d+)/)[1] 109 | @rename_enum_value_supported = Gem::Version.new(version) >= Gem::Version.new('10.0') 110 | end 111 | @rename_enum_value_supported 112 | end 113 | 114 | def schema_name(schema) 115 | schema || 'public' 116 | end 117 | 118 | def enum_name(name, schema) 119 | [schema_name(schema), name].map { |s| 120 | %Q{"#{s}"} 121 | }.join('.') 122 | end 123 | 124 | def escape_enum_value(value) 125 | escaped_value = value.to_s.sub("'", "''") 126 | "'#{escaped_value}'" 127 | end 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /.github/workflows/prs.yml: -------------------------------------------------------------------------------- 1 | # This file was auto-generated by the schema_dev tool, based on the data in 2 | # ./schema_dev.yml 3 | # Please do not edit this file; any changes will be overwritten next time 4 | # schema_dev gets run. 5 | --- 6 | name: CI PR Builds 7 | 'on': 8 | push: 9 | branches: 10 | - master 11 | pull_request: 12 | concurrency: 13 | group: ci-${{ github.ref }} 14 | cancel-in-progress: true 15 | jobs: 16 | test: 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | ruby: 22 | - '2.5' 23 | - '2.7' 24 | - '3.0' 25 | activerecord: 26 | - '5.2' 27 | - '6.0' 28 | db: 29 | - skip 30 | dbversion: 31 | - skip 32 | exclude: 33 | - ruby: '3.0' 34 | activerecord: '5.2' 35 | - db: skip 36 | dbversion: skip 37 | include: 38 | - ruby: '2.5' 39 | activerecord: '5.2' 40 | db: postgresql 41 | dbversion: '9.6' 42 | - ruby: '2.5' 43 | activerecord: '5.2' 44 | db: postgresql 45 | dbversion: '10' 46 | - ruby: '2.5' 47 | activerecord: '5.2' 48 | db: postgresql 49 | dbversion: '11' 50 | - ruby: '2.5' 51 | activerecord: '5.2' 52 | db: postgresql 53 | dbversion: '12' 54 | - ruby: '2.5' 55 | activerecord: '6.0' 56 | db: postgresql 57 | dbversion: '9.6' 58 | - ruby: '2.5' 59 | activerecord: '6.0' 60 | db: postgresql 61 | dbversion: '10' 62 | - ruby: '2.5' 63 | activerecord: '6.0' 64 | db: postgresql 65 | dbversion: '11' 66 | - ruby: '2.5' 67 | activerecord: '6.0' 68 | db: postgresql 69 | dbversion: '12' 70 | - ruby: '2.7' 71 | activerecord: '5.2' 72 | db: postgresql 73 | dbversion: '9.6' 74 | - ruby: '2.7' 75 | activerecord: '5.2' 76 | db: postgresql 77 | dbversion: '10' 78 | - ruby: '2.7' 79 | activerecord: '5.2' 80 | db: postgresql 81 | dbversion: '11' 82 | - ruby: '2.7' 83 | activerecord: '5.2' 84 | db: postgresql 85 | dbversion: '12' 86 | - ruby: '2.7' 87 | activerecord: '6.0' 88 | db: postgresql 89 | dbversion: '9.6' 90 | - ruby: '2.7' 91 | activerecord: '6.0' 92 | db: postgresql 93 | dbversion: '10' 94 | - ruby: '2.7' 95 | activerecord: '6.0' 96 | db: postgresql 97 | dbversion: '11' 98 | - ruby: '2.7' 99 | activerecord: '6.0' 100 | db: postgresql 101 | dbversion: '12' 102 | - ruby: '3.0' 103 | activerecord: '6.0' 104 | db: postgresql 105 | dbversion: '9.6' 106 | - ruby: '3.0' 107 | activerecord: '6.0' 108 | db: postgresql 109 | dbversion: '10' 110 | - ruby: '3.0' 111 | activerecord: '6.0' 112 | db: postgresql 113 | dbversion: '11' 114 | - ruby: '3.0' 115 | activerecord: '6.0' 116 | db: postgresql 117 | dbversion: '12' 118 | env: 119 | BUNDLE_GEMFILE: "${{ github.workspace }}/gemfiles/activerecord-${{ matrix.activerecord }}/Gemfile.${{ matrix.db }}" 120 | POSTGRESQL_DB_HOST: 127.0.0.1 121 | POSTGRESQL_DB_USER: schema_plus_test 122 | POSTGRESQL_DB_PASS: database 123 | steps: 124 | - uses: actions/checkout@v2 125 | - name: Set up Ruby 126 | uses: ruby/setup-ruby@v1 127 | with: 128 | ruby-version: "${{ matrix.ruby }}" 129 | bundler-cache: true 130 | - name: Run bundle update 131 | run: bundle update 132 | - name: Start Postgresql 133 | if: matrix.db == 'postgresql' 134 | run: | 135 | docker run --rm --detach \ 136 | -e POSTGRES_USER=$POSTGRESQL_DB_USER \ 137 | -e POSTGRES_PASSWORD=$POSTGRESQL_DB_PASS \ 138 | -p 5432:5432 \ 139 | --health-cmd "pg_isready -q" \ 140 | --health-interval 5s \ 141 | --health-timeout 5s \ 142 | --health-retries 5 \ 143 | --name database postgres:${{ matrix.dbversion }} 144 | - name: Wait for database to start 145 | if: "(matrix.db == 'postgresql' || matrix.db == 'mysql2')" 146 | run: | 147 | COUNT=0 148 | ATTEMPTS=20 149 | until [[ $COUNT -eq $ATTEMPTS ]]; do 150 | [ "$(docker inspect -f {{.State.Health.Status}} database)" == "healthy" ] && break 151 | echo $(( COUNT++ )) > /dev/null 152 | sleep 2 153 | done 154 | - name: Create testing database 155 | if: "(matrix.db == 'postgresql' || matrix.db == 'mysql2')" 156 | run: bundle exec rake create_ci_database 157 | - name: Run tests 158 | run: bundle exec rake spec 159 | - name: Shutdown database 160 | if: always() && (matrix.db == 'postgresql' || matrix.db == 'mysql2') 161 | run: docker stop database 162 | - name: Coveralls Parallel 163 | if: "${{ !env.ACT }}" 164 | uses: coverallsapp/github-action@master 165 | with: 166 | github-token: "${{ secrets.GITHUB_TOKEN }}" 167 | flag-name: run-${{ matrix.ruby }}-${{ matrix.activerecord }}-${{ matrix.db }}-${{ matrix.dbversion }} 168 | parallel: true 169 | finish: 170 | needs: test 171 | runs-on: ubuntu-latest 172 | steps: 173 | - name: Coveralls Finished 174 | if: "${{ !env.ACT }}" 175 | uses: coverallsapp/github-action@master 176 | with: 177 | github-token: "${{ secrets.GITHUB_TOKEN }}" 178 | parallel-finished: true 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Gem Version](https://badge.fury.io/rb/schema_plus_enums.svg)](http://badge.fury.io/rb/schema_plus_enums) 2 | [![Build Status](https://github.com/SchemaPlus/schema_plus_enums/actions/workflows/pr.yml/badge.svg)](http://github.com/SchemaPlus/schema_plus_enums/actions) 3 | [![Coverage Status](https://coveralls.io/github/SchemaPlus/schema_plus_enums/badge.svg)](https://coveralls.io/github/SchemaPlus/schema_plus_enums) 4 | 5 | # SchemaPlus::Enums 6 | 7 | SchemaPlus::Enums provides support for enum data types in ActiveRecord. Currently the support is limited to defining enum data types, for PostgreSQL only. 8 | 9 | 10 | SchemaPlus::Enums is part of the [SchemaPlus](https://github.com/SchemaPlus/) family of Ruby on Rails ActiveRecord extension gems. 11 | 12 | ## Installation 13 | 14 | 15 | 16 | As usual: 17 | 18 | ```ruby 19 | gem "schema_plus_enums" # in a Gemfile 20 | gem.add_dependency "schema_plus_enums" # in a .gemspec 21 | ``` 22 | 23 | 24 | 25 | ## Compatibility 26 | 27 | SchemaPlus::Enums is tested on: 28 | 29 | 30 | 31 | * ruby **2.5** with activerecord **5.2**, using **postgresql:9.6**, **postgresql:10**, **postgresql:11** or **postgresql:12** 32 | * ruby **2.5** with activerecord **6.0**, using **postgresql:9.6**, **postgresql:10**, **postgresql:11** or **postgresql:12** 33 | * ruby **2.7** with activerecord **5.2**, using **postgresql:9.6**, **postgresql:10**, **postgresql:11** or **postgresql:12** 34 | * ruby **2.7** with activerecord **6.0**, using **postgresql:9.6**, **postgresql:10**, **postgresql:11** or **postgresql:12** 35 | * ruby **3.0** with activerecord **6.0**, using **postgresql:9.6**, **postgresql:10**, **postgresql:11** or **postgresql:12** 36 | 37 | 38 | 39 | ## Usage 40 | 41 | In a migration, 42 | an enum can be created: 43 | 44 | ```ruby 45 | create_enum :color, 'red', 'green', 'blue' # default schema is 'public' 46 | create_enum :color, 'cyan', 'magenta', 'yellow', 'black', schema: 'cmyk' 47 | ``` 48 | 49 | New values can be added 50 | 51 | ```ruby 52 | add_enum_value :color, 'black' 53 | add_enum_value :color, 'red', if_not_exists: true 54 | add_enum_value :color, 'purple', after: 'red' 55 | add_enum_value :color, 'pink', before: 'purple' 56 | add_enum_value :color, 'white', schema: 'cmyk' 57 | ``` 58 | 59 | Values can be dropped 60 | ```ruby 61 | remove_enum_value :color, 'black' 62 | remove_enum_value :color, 'black', schema: 'cmyk' 63 | ``` 64 | 65 | Values can be renamed 66 | ```ruby 67 | rename_enum_value :color, 'red', 'orange' 68 | rename_enum_value :color, 'red', 'orange', schema: 'cmyk' 69 | ``` 70 | 71 | The enum can be renamed 72 | ```ruby 73 | rename_enum :color, :hue 74 | rename_enum :color, :hue, schema: 'cmyk' 75 | ``` 76 | 77 | And can be dropped: 78 | 79 | ```ruby 80 | drop_enum :color 81 | drop_enum :color, schema: 'cmyk' 82 | ``` 83 | 84 | ## Release Notes 85 | 86 | * **1.0.0** - Add AR 6.0, Ruby 3.0, and drop AR < 5.2 and Ruby < 2.5. Also add new functionality 87 | * **0.1.8** - Update dependencies to include AR 5.2. 88 | * **0.1.7** - Update dependencies to include AR 5.1.* Thanks to [@patleb](https://github.com/patleb) 89 | * **0.1.6** - Update dependencies to include AR 5.1. Thanks to [@willsoto](https://github.com/willsoto) 90 | * **0.1.5** - Update dependencies to include AR 5.0. Thanks to [@jimcavoli](https://github.com/jimcavoli) 91 | * **0.1.4** - Missing require 92 | * **0.1.3** - Explicit gem dependencies 93 | * **0.1.2** - Upgrade schema_plus_core dependency 94 | * **0.1.1** - Clean up and sort dumper output. Thanks to [@pik](https://github.com/pik) 95 | * **0.1.0** - Initial release, pulled from schema_plus 1.x 96 | 97 | ## Development & Testing 98 | 99 | Are you interested in contributing to SchemaPlus::Enums? Thanks! Please follow 100 | the standard protocol: fork, feature branch, develop, push, and issue pull 101 | request. 102 | 103 | Some things to know about to help you develop and test: 104 | 105 | 106 | 107 | * **schema_dev**: SchemaPlus::Enums uses [schema_dev](https://github.com/SchemaPlus/schema_dev) to 108 | facilitate running rspec tests on the matrix of ruby, activerecord, and database 109 | versions that the gem supports, both locally and on 110 | [github actions](https://github.com/SchemaPlus/schema_plus_enums/actions) 111 | 112 | To to run rspec locally on the full matrix, do: 113 | 114 | $ schema_dev bundle install 115 | $ schema_dev rspec 116 | 117 | You can also run on just one configuration at a time; For info, see `schema_dev --help` or the [schema_dev](https://github.com/SchemaPlus/schema_dev) README. 118 | 119 | The matrix of configurations is specified in `schema_dev.yml` in 120 | the project root. 121 | 122 | 123 | 124 | 125 | 126 | * **schema_plus_core**: SchemaPlus::Enums uses the SchemaPlus::Core API that 127 | provides middleware callback stacks to make it easy to extend 128 | ActiveRecord's behavior. If that API is missing something you need for 129 | your contribution, please head over to 130 | [schema_plus_core](https://github.com/SchemaPlus/schema_plus_core) and open 131 | an issue or pull request. 132 | 133 | 134 | 135 | 136 | 137 | * **schema_monkey**: SchemaPlus::Enums is implemented as a 138 | [schema_monkey](https://github.com/SchemaPlus/schema_monkey) client, 139 | using [schema_monkey](https://github.com/SchemaPlus/schema_monkey)'s 140 | convention-based protocols for extending ActiveRecord and using middleware stacks. 141 | 142 | 143 | -------------------------------------------------------------------------------- /spec/enum_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | def enum_fields(name, schema = 'public') 6 | sql = <<-SQL 7 | SELECT array_to_string(array_agg(E.enumlabel ORDER BY enumsortorder), ' ') AS "values" 8 | FROM pg_enum E 9 | JOIN pg_type T ON E.enumtypid = T.oid 10 | JOIN pg_namespace N ON N.oid = T.typnamespace 11 | WHERE N.nspname = '#{schema}' AND T.typname = '#{name}' 12 | GROUP BY T.oid; 13 | SQL 14 | 15 | data = ActiveRecord::Base.connection.select_all(sql) 16 | return nil if data.empty? 17 | data[0]['values'].split(' ') 18 | end 19 | 20 | describe 'enum', :postgresql => :only do 21 | before(:all) do 22 | ActiveRecord::Migration.verbose = false 23 | end 24 | 25 | let(:migration) { ActiveRecord::Migration } 26 | 27 | describe 'enums' do 28 | it 'should return all enums' do 29 | begin 30 | migration.execute 'create schema cmyk' 31 | migration.create_enum 'color', 'red', 'green', 'blue' 32 | migration.create_enum 'color', 'cyan', 'magenta', 'yellow', 'black', schema: 'cmyk' 33 | 34 | expect(migration.enums).to match_array [['cmyk', 'color', %w|cyan magenta yellow black|], ['public', 'color', %w|red green blue|]] 35 | ensure 36 | migration.drop_enum 'color' 37 | migration.execute 'drop schema cmyk cascade' 38 | end 39 | end 40 | end 41 | 42 | describe 'create_enum' do 43 | it 'should create enum with given values' do 44 | begin 45 | migration.create_enum 'color', *%w|red green blue| 46 | expect(enum_fields('color')).to eq(%w|red green blue|) 47 | ensure 48 | migration.execute 'DROP TYPE IF EXISTS color' 49 | end 50 | end 51 | 52 | it 'should create enum using symbols' do 53 | begin 54 | migration.create_enum :color, :red, :green, :blue 55 | expect(enum_fields('color')).to eq(%w|red green blue|) 56 | ensure 57 | migration.execute 'DROP TYPE IF EXISTS color' 58 | end 59 | end 60 | 61 | it 'should create enum with schema' do 62 | begin 63 | migration.execute 'CREATE SCHEMA colors' 64 | migration.create_enum 'color', *%|red green blue|, schema: 'colors' 65 | expect(enum_fields('color', 'colors')).to eq(%w|red green blue|) 66 | ensure 67 | migration.execute 'DROP SCHEMA IF EXISTS colors CASCADE' 68 | end 69 | end 70 | 71 | it 'should escape enum value' do 72 | begin 73 | migration.create_enum('names', "O'Neal") 74 | expect(enum_fields('names')).to eq(["O'Neal"]) 75 | ensure 76 | migration.execute "DROP TYPE IF EXISTS names" 77 | end 78 | end 79 | 80 | it 'should escape scheme name and enum name' do 81 | begin 82 | migration.execute 'CREATE SCHEMA "select"' 83 | migration.create_enum 'where', *%|red green blue|, schema: 'select' 84 | expect(enum_fields('where', 'select')).to eq(%w|red green blue|) 85 | ensure 86 | migration.execute 'DROP SCHEMA IF EXISTS "select" CASCADE' 87 | end 88 | end 89 | 90 | context 'when force: true is passed' do 91 | it 'removes the existing enum' do 92 | allow(migration.connection).to receive(:drop_enum) 93 | 94 | migration.create_enum 'color', *%w|red green blue|, force: true 95 | 96 | expect(migration.connection).to have_received(:drop_enum).with( 97 | 'color', { cascade: false, if_exists: true, schema: nil } 98 | ) 99 | 100 | migration.execute 'DROP TYPE IF EXISTS color' 101 | end 102 | end 103 | 104 | context 'when force: :cascade is passed' do 105 | it 'removes the existing enum' do 106 | allow(migration.connection).to receive(:drop_enum) 107 | 108 | migration.create_enum 'color', *%w|red green blue|, force: :cascade 109 | 110 | expect(migration.connection).to have_received(:drop_enum).with( 111 | 'color', { cascade: true, if_exists: true, schema: nil } 112 | ) 113 | 114 | migration.execute 'DROP TYPE IF EXISTS color' 115 | end 116 | end 117 | 118 | context 'when force: :cascade is passed with a schema' do 119 | it 'removes the existing enum' do 120 | allow(migration.connection).to receive(:drop_enum) 121 | 122 | migration.create_enum 'color', *%w|red green blue|, force: :cascade, schema: 'public' 123 | 124 | expect(migration.connection).to have_received(:drop_enum).with( 125 | 'color', { cascade: true, if_exists: true, schema: 'public' } 126 | ) 127 | 128 | migration.execute 'DROP TYPE IF EXISTS color' 129 | end 130 | end 131 | end 132 | 133 | describe 'alter_enum' do 134 | before do 135 | migration.create_enum('color', 'red', 'green', 'blue') 136 | allow(ActiveSupport::Deprecation).to receive(:warn) 137 | allow(migration.connection).to receive(:add_enum_value) 138 | end 139 | after do 140 | migration.execute 'DROP TYPE IF EXISTS color' 141 | end 142 | 143 | it 'calls add_enum_value' do 144 | migration.alter_enum('color', 'magenta') 145 | 146 | expect(migration.connection).to have_received(:add_enum_value) 147 | end 148 | 149 | it 'sends a deprecation warning' do 150 | migration.alter_enum('color', 'magenta') 151 | 152 | expect(ActiveSupport::Deprecation).to have_received(:warn) 153 | end 154 | end 155 | 156 | describe 'add_enum_value' do 157 | before do 158 | migration.create_enum('color', 'red', 'green', 'blue') 159 | end 160 | after do 161 | migration.execute 'DROP TYPE IF EXISTS color' 162 | end 163 | 164 | it 'should add new value after all values' do 165 | migration.add_enum_value('color', 'magenta') 166 | expect(enum_fields('color')).to eq(%w|red green blue magenta|) 167 | end 168 | 169 | it 'should add new value after existed' do 170 | migration.add_enum_value('color', 'magenta', after: 'red') 171 | expect(enum_fields('color')).to eq(%w|red magenta green blue|) 172 | end 173 | 174 | it 'should add new value before existed' do 175 | migration.add_enum_value('color', 'magenta', before: 'green') 176 | expect(enum_fields('color')).to eq(%w|red magenta green blue|) 177 | end 178 | 179 | it 'should add new value within given schema' do 180 | begin 181 | migration.execute 'CREATE SCHEMA colors' 182 | migration.create_enum('color', 'red', schema: 'colors') 183 | migration.add_enum_value('color', 'green', schema: 'colors') 184 | 185 | expect(enum_fields('color', 'colors')).to eq(%w|red green|) 186 | ensure 187 | migration.execute 'DROP SCHEMA colors CASCADE' 188 | end 189 | end 190 | 191 | context 'without if_not_exists: true' do 192 | it 'raises a DB error if the value exists' do 193 | expect { 194 | migration.add_enum_value('color', 'red') 195 | }.to raise_error(ActiveRecord::StatementInvalid) 196 | end 197 | end 198 | 199 | context 'with if_not_exists: true' do 200 | it 'does not raise a DB error if the value exists' do 201 | expect { 202 | migration.add_enum_value('color', 'red', if_not_exists: true) 203 | }.to_not raise_error 204 | end 205 | end 206 | end 207 | 208 | describe 'remove_enum_value' do 209 | before do 210 | migration.create_enum('color', 'red', 'green', 'blue') 211 | end 212 | after do 213 | migration.execute 'DROP TYPE IF EXISTS color' 214 | end 215 | 216 | it 'removes the value' do 217 | expect { 218 | migration.remove_enum_value('color', 'green') 219 | }.to change { 220 | enum_fields('color') 221 | }.to(%w[red blue]) 222 | end 223 | 224 | context 'when the enum is in a schema' do 225 | before do 226 | migration.execute "CREATE SCHEMA colors; CREATE TYPE colors.color AS ENUM ('red', 'magenta', 'blue')" 227 | end 228 | after do 229 | migration.execute "DROP SCHEMA colors CASCADE" 230 | end 231 | 232 | it 'should rename the enum within given name and schema' do 233 | expect { 234 | migration.remove_enum_value('color', 'blue', schema: 'colors') 235 | }.to change { 236 | enum_fields('color', 'colors') 237 | }.to(%w[red magenta]) 238 | end 239 | end 240 | end 241 | 242 | describe 'rename_enum_value' do 243 | before do 244 | migration.create_enum('color', 'red', 'green', 'blue') 245 | end 246 | after do 247 | migration.execute 'DROP TYPE IF EXISTS color' 248 | end 249 | 250 | context 'when postgresql version is >= 10', pg_version: '>= 10.0' do 251 | it 'renames the value' do 252 | expect { 253 | migration.rename_enum_value('color', 'green', 'orange') 254 | }.to change { 255 | enum_fields('color') 256 | }.to(%w[red orange blue]) 257 | end 258 | end 259 | 260 | context 'when postgresql version is < 10', pg_version: '< 10.0' do 261 | it 'raises an error' do 262 | expect { 263 | migration.rename_enum_value('color', 'green', 'orange') 264 | }.to raise_error(/Renaming enum values is only supported/) 265 | end 266 | end 267 | end 268 | 269 | describe 'rename_enum' do 270 | before do 271 | migration.create_enum('color', 'red', 'green', 'blue') 272 | end 273 | after do 274 | migration.execute 'DROP TYPE IF EXISTS color' 275 | migration.execute 'DROP TYPE IF EXISTS shade' 276 | end 277 | 278 | it 'renames the enum' do 279 | expect { 280 | migration.rename_enum('color', 'shade') 281 | }.to change { 282 | migration.enums.map(&:second) 283 | }.from(contain_exactly('color')).to(contain_exactly('shade')) 284 | end 285 | 286 | context 'when the enum is in a schema' do 287 | before do 288 | migration.execute "CREATE SCHEMA colors; CREATE TYPE colors.color AS ENUM ('red', 'blue')" 289 | end 290 | after do 291 | migration.execute "DROP SCHEMA colors CASCADE" 292 | end 293 | 294 | it 'should rename the enum within given name and schema' do 295 | expect { 296 | migration.rename_enum('color', 'shade', schema: 'colors') 297 | }.to change { 298 | enum_fields('shade', 'colors') 299 | }.from(nil).to(%w[red blue]) 300 | end 301 | end 302 | end 303 | 304 | describe 'drop_enum' do 305 | it 'should drop enum with given name' do 306 | migration.execute "CREATE TYPE color AS ENUM ('red', 'blue')" 307 | expect(enum_fields('color')).to eq(%w|red blue|) 308 | migration.drop_enum('color') 309 | 310 | expect(enum_fields('color')).to be_nil 311 | end 312 | 313 | it 'should drop enum within given name and schema' do 314 | begin 315 | migration.execute "CREATE SCHEMA colors; CREATE TYPE colors.color AS ENUM ('red', 'blue')" 316 | expect(enum_fields('color', 'colors')).to eq(%w|red blue|) 317 | migration.drop_enum('color', schema: 'colors') 318 | 319 | expect(enum_fields('color', 'colors')).to be_nil 320 | ensure 321 | migration.execute "DROP SCHEMA colors CASCADE" 322 | end 323 | end 324 | 325 | context 'when the enum does not exist' do 326 | it 'should fail when if_exists: true is not passed' do 327 | expect { 328 | migration.drop_enum('color') 329 | }.to raise_error(ActiveRecord::StatementInvalid) 330 | end 331 | 332 | it 'should fail silently when if_exists: true is passed' do 333 | expect { 334 | migration.drop_enum('color', if_exists: true) 335 | }.to_not raise_error 336 | end 337 | end 338 | 339 | context 'when cascade: true is passed' do 340 | it 'cascades through and drops columns' do 341 | migration.create_enum 'color', %w[red blue green] 342 | migration.create_table :posts do |t| 343 | t.column :text_color, :color 344 | end 345 | 346 | expect { 347 | migration.drop_enum 'color', cascade: true 348 | }.to change { 349 | migration.columns('posts').map(&:name) 350 | }.from(include('text_color')).to not_include('text_color') 351 | end 352 | end 353 | end 354 | 355 | describe 'create_table' do 356 | before do 357 | migration.create_enum 'color', *%w|red green blue|, force: true 358 | end 359 | end 360 | end 361 | --------------------------------------------------------------------------------