enum_kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +46 -0
  3. data/.rspec +2 -0
  4. data/.rubocop.yml +21 -0
  5. data/.travis.yml +13 -0
  6. data/Gemfile +5 -0
  7. data/Guardfile +17 -0
  8. data/LICENSE +21 -0
  9. data/README.md +82 -0
  10. data/Rakefile +8 -0
  11. data/bin/console +7 -0
  12. data/bin/setup +6 -0
  13. data/config.ru +9 -0
  14. data/enum_kit.gemspec +35 -0
  15. data/lib/enum_kit/active_record_extensions/connection_adapters/postgresql/column_dumper.rb +29 -0
  16. data/lib/enum_kit/active_record_extensions/connection_adapters/postgresql/schema_dumper.rb +29 -0
  17. data/lib/enum_kit/active_record_extensions/connection_adapters/postgresql_adapter.rb +74 -0
  18. data/lib/enum_kit/active_record_extensions/migration/command_recorder.rb +45 -0
  19. data/lib/enum_kit/active_record_extensions/schema_dumper.rb +31 -0
  20. data/lib/enum_kit/active_record_patches/connection_adapters/postgresql/column_methods.rb +32 -0
  21. data/lib/enum_kit/active_record_patches/connection_adapters/postgresql/oid/enum.rb +32 -0
  22. data/lib/enum_kit/active_record_patches/connection_adapters/postgresql/oid/type_map_initializer.rb +27 -0
  23. data/lib/enum_kit/active_record_patches/enum/enum_type.rb +25 -0
  24. data/lib/enum_kit/active_record_patches/enum.rb +39 -0
  25. data/lib/enum_kit/active_record_patches/validations/pg_enum_validator.rb +31 -0
  26. data/lib/enum_kit/constants.rb +7 -0
  27. data/lib/enum_kit/helpers.rb +84 -0
  28. data/lib/enum_kit.rb +62 -0
  29. data/spec/active_record/base_spec.rb +15 -0
  30. data/spec/active_record/connection_adapters/postgresql_adapter_spec.rb +62 -0
  31. data/spec/active_record/validations/pg_enum_validator_spec.rb +19 -0
  32. data/spec/enum_kit/constants_spec.rb +11 -0
  33. data/spec/enum_kit/helpers_spec.rb +106 -0
  34. data/spec/internal/app/models/shirt.rb +7 -0
  35. data/spec/internal/config/database.yml +3 -0
  36. data/spec/internal/db/schema.rb +10 -0
  37. data/spec/internal/log/.gitignore +1 -0
  38. data/spec/spec_helper.rb +96 -0
  39. metadata +231 -0
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc:
4
+ #
5
+ module ActiveRecord
6
+ # :nodoc:
7
+ #
8
+ module Validations
9
+ # Validates whether an enum's value is acceptable by comparing with the acceptable values defined in the PostgreSQL
10
+ # database.
11
+ #
12
+ class PgEnumValidator < ActiveModel::EachValidator
13
+ # Validate the given value is acceptable for the enum.
14
+ #
15
+ # @param record [ActiveRecord::Base] The record being validated.
16
+ # @param attribute [Symbol] The enum attribute being validated.
17
+ # @param value [String, Symbol, nil] The current value of the enum.
18
+ #
19
+ def validate_each(record, attribute, value)
20
+ values = record.class.pg_enum_values(attribute)
21
+
22
+ return if values.include?(value)
23
+
24
+ record.errors.add(attribute, options[:message] || :invalid, **options.except(:message).merge!(
25
+ attribute: record.class.human_attribute_name(attribute),
26
+ values: values.join(', ')
27
+ ))
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnumKit
4
+ # @return [String] The gem's semantic version number.
5
+ #
6
+ VERSION = '0.1.0'
7
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EnumKit
4
+ # Makes an underscored, lowercase form from the expression in the string.
5
+ #
6
+ # Changes '::' to '/' to convert namespaces to paths.
7
+ # This method is based on the `ActiveSupport::Inflector.underscore` method.
8
+ #
9
+ # @param value [String] A value to transform.
10
+ # @return [String] The underscored, lowercase form of the expression.
11
+ #
12
+ def self.underscore(value)
13
+ return value unless /[A-Z-]|::/.match?(value)
14
+
15
+ value = value.to_s.gsub('::', '/')
16
+ value.gsub!('PostgreSQL', 'Postgresql')
17
+ value.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
18
+ value.gsub!(/([a-z\d])([A-Z])/, '\1_\2')
19
+ value.tr!('-', '_')
20
+ value.downcase!
21
+ value
22
+ end
23
+
24
+ # Convert a value into a String that can be used in SQL.
25
+ #
26
+ # @param value [Array|String|Symbol] A value to convert into SQL format.
27
+ # @return [String] The SQL representation of the value.
28
+ #
29
+ def self.sqlize(value)
30
+ case value
31
+ when Array
32
+ '(' + value.map { |v| sqlize(v) }.join(', ') + ')'
33
+ when String
34
+ ActiveRecord::Base.connection.quote(value)
35
+ when Symbol
36
+ sqlize(value.to_s)
37
+ else
38
+ raise ArgumentError, "Unable to convert value of type #{value.class} into SQL format."
39
+ end
40
+ end
41
+
42
+ # Sanitize the name of the enum.
43
+ #
44
+ # @param name [String|Symbol] An enum name.
45
+ # @return [String] The sanitized name.
46
+ #
47
+ def self.sanitize_name!(name)
48
+ raise ArgumentError, 'Enum names must be a String or a Symbol.' unless name.is_a?(String) || name.is_a?(Symbol)
49
+
50
+ name = name.to_s
51
+
52
+ return name if name =~ /^[a-z0-9_]+$/
53
+
54
+ raise ArgumentError, 'Enum names may contain only lowercase letters, numbers and underscores.'
55
+ end
56
+
57
+ # Sanitize a single value of an enum.
58
+ #
59
+ # @param value [String|Symbol] An enum value.
60
+ # @return [Array] The sanitized value.
61
+ #
62
+ def self.sanitize_value!(value)
63
+ raise ArgumentError, 'Enum values must be a String or a Symbol.' unless value.is_a?(String) || value.is_a?(Symbol)
64
+
65
+ value = value.to_s
66
+
67
+ return value if value =~ /^[a-z0-9_ ]+$/
68
+
69
+ raise ArgumentError, 'Enum values may contain only lowercase letters, numbers, underscores and spaces.'
70
+ end
71
+
72
+ # Sanitize the values of an enum.
73
+ #
74
+ # @param values [Array] An Array of String or Symbol values.
75
+ # @return [Array] A sanitized Array of String values.
76
+ #
77
+ def self.sanitize_values!(values)
78
+ return nil if values.nil?
79
+
80
+ raise ArgumentError, 'Enum values must be an Array of String and/or Symbol objects.' unless values.is_a?(Array)
81
+
82
+ values.map { |value| sanitize_value!(value) }
83
+ end
84
+ end
data/lib/enum_kit.rb ADDED
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'enum_kit/constants'
4
+ require 'enum_kit/helpers'
5
+
6
+ # Used as a namespace to encapsulate the logic for the EnumKit gem.
7
+ #
8
+ module EnumKit
9
+ # Queue loading of the patches/extensions and database type registration for when `ActiveRecord` has loaded.
10
+ #
11
+ def self.load!
12
+ require 'active_record'
13
+ require 'active_record/connection_adapters/postgresql_adapter'
14
+ require 'active_support/lazy_load_hooks'
15
+
16
+ ActiveSupport.on_load(:active_record) do
17
+ EnumKit.load_patches!
18
+ EnumKit.load_extensions!
19
+ EnumKit.register_database_type!
20
+ end
21
+ end
22
+
23
+ # Load the `ActiveRecord` monkey patches.
24
+ #
25
+ def self.load_patches!
26
+ require 'enum_kit/active_record_patches/connection_adapters/postgresql/column_methods'
27
+ require 'enum_kit/active_record_patches/connection_adapters/postgresql/oid/enum'
28
+ require 'enum_kit/active_record_patches/connection_adapters/postgresql/oid/type_map_initializer'
29
+ require 'enum_kit/active_record_patches/enum'
30
+ require 'enum_kit/active_record_patches/enum/enum_type'
31
+ require 'enum_kit/active_record_patches/validations/pg_enum_validator'
32
+ end
33
+
34
+ # Load the `ActiveRecord` extensions.
35
+ #
36
+ def self.load_extensions!
37
+ %w[
38
+ ConnectionAdapters::PostgreSQL::ColumnDumper
39
+ ConnectionAdapters::PostgreSQL::SchemaDumper
40
+ ConnectionAdapters::PostgreSQLAdapter
41
+ Migration::CommandRecorder
42
+ SchemaDumper
43
+ ].each do |extension|
44
+ next unless Object.const_defined?("ActiveRecord::#{extension}")
45
+
46
+ require File.join('enum_kit', 'active_record_extensions', EnumKit.underscore(extension))
47
+
48
+ target_constant = Object.const_get("ActiveRecord::#{extension}")
49
+ extension_constant = Object.const_get("EnumKit::ActiveRecordExtensions::#{extension}")
50
+
51
+ target_constant.prepend(extension_constant)
52
+ end
53
+ end
54
+
55
+ # Register `:enum` as a native database type.
56
+ #
57
+ def self.register_database_type!
58
+ ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::NATIVE_DATABASE_TYPES[:enum] = { name: 'enum' }
59
+ end
60
+ end
61
+
62
+ EnumKit.load!
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe ActiveRecord::Base do
4
+ describe '.pg_enum' do
5
+ it 'is defined' do
6
+ expect(described_class).to respond_to(:pg_enum)
7
+ end
8
+ end
9
+
10
+ describe '.pg_enum_values' do
11
+ it 'is defined' do
12
+ expect(described_class).to respond_to(:pg_enum_values)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe ActiveRecord::ConnectionAdapters::PostgreSQLAdapter do
4
+ subject(:connection) { ActiveRecord::Base.connection }
5
+
6
+ describe '#create_enum' do
7
+ after { connection.execute 'DROP TYPE IF EXISTS an_enum' }
8
+
9
+ it 'is defined' do
10
+ expect(connection).to respond_to(:create_enum)
11
+ end
12
+
13
+ context 'when called with valid arguments' do
14
+ subject { connection.create_enum(:an_enum, [:first_value, 'second value']) }
15
+
16
+ it 'creates an enum' do
17
+ expect(subject.result_status).to eq(PG::PGRES_COMMAND_OK)
18
+ end
19
+ end
20
+
21
+ context 'when called with a malformed name' do
22
+ subject { connection.create_enum('an enum', [:first_value, 'second value']) }
23
+
24
+ it 'raises an ArgumentError' do
25
+ expect { subject }.to raise_exception(ArgumentError)
26
+ end
27
+ end
28
+
29
+ context 'when called with malformed values' do
30
+ subject { connection.create_enum(:an_enum, [:good_value, 'bad$value']) }
31
+
32
+ it 'raises an ArgumentError' do
33
+ expect { subject }.to raise_exception(ArgumentError)
34
+ end
35
+ end
36
+ end
37
+
38
+ describe '#drop_enum' do
39
+ before { connection.execute "CREATE TYPE an_enum AS ENUM ('first', 'second')" }
40
+ after { connection.execute 'DROP TYPE IF EXISTS an_enum' }
41
+
42
+ it 'is defined' do
43
+ expect(connection).to respond_to(:drop_enum)
44
+ end
45
+
46
+ context 'when called with an existing enum' do
47
+ subject { connection.drop_enum(:an_enum) }
48
+
49
+ it 'drops the enum' do
50
+ expect(subject.result_status).to eq(PG::PGRES_COMMAND_OK)
51
+ end
52
+ end
53
+
54
+ context 'when called with a non-existent enum' do
55
+ subject { connection.drop_enum(:non_existent_enum) }
56
+
57
+ it 'raises ActiveRecord::StatementInvalid' do
58
+ expect { subject }.to raise_exception(ActiveRecord::StatementInvalid)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe ActiveRecord::Validations::PgEnumValidator do
4
+ subject { Shirt.create(name: 'Plain Shirt', size: :small) }
5
+
6
+ it 'permits known values' do
7
+ expect { subject.update!(size: :large) }.not_to raise_exception
8
+ expect { subject.update!(size: :medium) }.not_to raise_exception
9
+ expect { subject.update!(size: :small) }.not_to raise_exception
10
+ end
11
+
12
+ it 'rejects unknown values' do
13
+ expect { subject.update!(size: :other) }.to raise_exception(ActiveRecord::RecordInvalid)
14
+ end
15
+
16
+ it 'rejects nil values' do
17
+ expect { subject.update!(size: nil) }.to raise_exception(ActiveRecord::RecordInvalid)
18
+ end
19
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe EnumKit do
4
+ describe '::VERSION' do
5
+ subject { described_class::VERSION }
6
+
7
+ it 'is semantic' do
8
+ expect(subject).to match(/[0-9]+\.[0-9]+\.[0-9]+/)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe EnumKit do
4
+ describe '.underscore' do
5
+ it 'converts camel case into snake case' do
6
+ expect(described_class.underscore('EnumKit')).to eq('enum_kit')
7
+ end
8
+
9
+ it 'converts namespaces into paths' do
10
+ expect(described_class.underscore('EnumKit::Example')).to eq('enum_kit/example')
11
+ end
12
+
13
+ it 'treats PostgreSQL as an acronym' do
14
+ expect(described_class.underscore('PostgreSQL')).to eq('postgresql')
15
+ end
16
+ end
17
+
18
+ describe '.sqlize' do
19
+ context 'with a String' do
20
+ subject { described_class.sqlize('value') }
21
+
22
+ it 'returns a quoted String' do
23
+ expect(subject).to eq("'value'")
24
+ end
25
+ end
26
+
27
+ context 'with a Symbol' do
28
+ subject { described_class.sqlize(:value) }
29
+
30
+ it 'returns a quoted String' do
31
+ expect(subject).to eq("'value'")
32
+ end
33
+ end
34
+
35
+ context 'with an Array' do
36
+ subject { described_class.sqlize(%i[one two three]) }
37
+
38
+ it 'returns a SQL array representation' do
39
+ expect(subject).to eq("('one', 'two', 'three')")
40
+ end
41
+ end
42
+ end
43
+
44
+ describe '.sanitize_name!' do
45
+ it 'permits lowercase letters' do
46
+ expect(described_class.sanitize_name!('abc')).to eq('abc')
47
+ end
48
+
49
+ it 'permits numbers' do
50
+ expect(described_class.sanitize_name!('123')).to eq('123')
51
+ end
52
+
53
+ it 'permits underscores' do
54
+ expect(described_class.sanitize_name!('_')).to eq('_')
55
+ end
56
+
57
+ it 'rejects hyphens' do
58
+ expect { described_class.sanitize_name!('-') }.to raise_exception(ArgumentError)
59
+ end
60
+
61
+ it 'rejects spaces' do
62
+ expect { described_class.sanitize_name!(' ') }.to raise_exception(ArgumentError)
63
+ end
64
+
65
+ it 'rejects uppercase letters' do
66
+ expect { described_class.sanitize_name!('ABC') }.to raise_exception(ArgumentError)
67
+ end
68
+ end
69
+
70
+ describe '.sanitize_value!' do
71
+ it 'permits lowercase letters' do
72
+ expect(described_class.sanitize_value!('abc')).to eq('abc')
73
+ end
74
+
75
+ it 'permits numbers' do
76
+ expect(described_class.sanitize_value!('123')).to eq('123')
77
+ end
78
+
79
+ it 'permits spaces' do
80
+ expect(described_class.sanitize_value!(' ')).to eq(' ')
81
+ end
82
+
83
+ it 'permits underscores' do
84
+ expect(described_class.sanitize_value!('_')).to eq('_')
85
+ end
86
+
87
+ it 'rejects hyphens' do
88
+ expect { described_class.sanitize_value!('-') }.to raise_exception(ArgumentError)
89
+ end
90
+
91
+ it 'rejects uppercase letters' do
92
+ expect { described_class.sanitize_value!('ABC') }.to raise_exception(ArgumentError)
93
+ end
94
+ end
95
+
96
+ describe '.sanitize_values!' do
97
+ subject { described_class.sanitize_values!(%i[one two three]) }
98
+
99
+ it 'calls .sanitize_value! for each of the inputs' do
100
+ expect(described_class).to receive(:sanitize_value!).with(:one).once
101
+ expect(described_class).to receive(:sanitize_value!).with(:two).once
102
+ expect(described_class).to receive(:sanitize_value!).with(:three).once
103
+ subject
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Shirt < ActiveRecord::Base
4
+ pg_enum :size, exceptions: false
5
+
6
+ validates :size, pg_enum: true
7
+ end
@@ -0,0 +1,3 @@
1
+ test:
2
+ adapter: postgresql
3
+ url: <%= ENV.fetch('DATABASE_URL') { 'postgresql://127.0.0.1:5432/enum_kit_test' } %>
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveRecord::Schema.define do
4
+ create_enum :shirt_size, %w[small medium large]
5
+
6
+ create_table :shirts do |t|
7
+ t.string :name
8
+ t.enum :size, name: :shirt_size
9
+ end
10
+ end
@@ -0,0 +1 @@
1
+ *.log
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Conventionally, all specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
4
+ # The `.rspec` file contains `--require spec_helper` which will cause this file to always be loaded,
5
+ # without a need to explicitly require it in any files.
6
+ #
7
+ # Given that it is always loaded, you are encouraged to keep this file as light-weight as possible.
8
+ # Requiring heavyweight dependencies from this file will add to the boot time of your test suite on EVERY test run,
9
+ # even for an individual file that may not need all of that loaded. Instead, consider making a separate helper file
10
+ # that requires the additional dependencies and performs the additional setup, and require it from the spec files
11
+ # that actually need it.
12
+ #
13
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
14
+
15
+ # Bundler
16
+ require 'bundler'
17
+
18
+ Bundler.require :default, :test
19
+
20
+ # Combustion
21
+ require 'combustion'
22
+
23
+ Combustion.initialize! :active_record
24
+
25
+ # Rails
26
+ require 'rspec/rails'
27
+
28
+ # RSpec
29
+ RSpec.configure do |config|
30
+ # rspec-expectations config goes here. You can use an alternate assertion/expectation library such as wrong or the
31
+ # stdlib/minitest assertions if you prefer.
32
+ config.expect_with :rspec do |expectations|
33
+ # This option will default to `true` in RSpec 4.
34
+ # It makes the `description` and `failure_message` of custom matchers include text for helper methods defined using
35
+ # `chain`, e.g.:
36
+ # be_bigger_than(2).and_smaller_than(4).description
37
+ # # => "be bigger than 2 and smaller than 4"
38
+ # ...rather than:
39
+ # # => "be bigger than 2"
40
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
41
+ end
42
+
43
+ # rspec-mocks config goes here. You can use an alternate test double library (such as bogus or mocha) by changing the
44
+ # `mock_with` option here.
45
+ config.mock_with :rspec do |mocks|
46
+ # Prevents you from mocking or stubbing a method that does not exist on a real object.
47
+ # This is generally recommended, and will default to `true` in RSpec 4.
48
+ mocks.verify_partial_doubles = true
49
+ end
50
+
51
+ # This option will default to `:apply_to_host_groups` in RSpec 4 (and will have no way to turn it off -- the option
52
+ # exists only for backwards compatibility in RSpec 3).
53
+ # It causes shared context metadata to be inherited by the metadata hash of host groups and examples, rather than
54
+ # triggering implicit auto-inclusion in groups with matching metadata.
55
+ config.shared_context_metadata_behavior = :apply_to_host_groups
56
+
57
+ # This allows you to limit a spec run to individual examples or groups you care about by tagging them with `:focus`
58
+ # metadata. When nothing is tagged with `:focus`, all examples get run.
59
+ # RSpec also provides aliases for `it`, `describe`, and `context` that include `:focus` metadata: `fit`, `fdescribe`
60
+ # and `fcontext`, respectively.
61
+ config.filter_run_when_matching :focus
62
+
63
+ # Allows RSpec to persist some state between runs in order to support the `--only-failures` and `--next-failure` CLI
64
+ # options. We recommend you configure your source control system to ignore this file.
65
+ config.example_status_persistence_file_path = 'spec/examples.txt'
66
+
67
+ # Limits the available syntax to the non-monkey patched syntax that is recommended. For more details, see:
68
+ # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
69
+ # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
70
+ # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
71
+ config.disable_monkey_patching!
72
+
73
+ # Many RSpec users commonly either run the entire suite or an individual file, and it's useful to allow more verbose
74
+ # output when running an individual spec file.
75
+ if config.files_to_run.one?
76
+ # Use the documentation formatter for detailed output, unless a formatter has already been configured (e.g. via a
77
+ # command-line flag).
78
+ config.default_formatter = 'doc'
79
+ end
80
+
81
+ # Print the 10 slowest examples and example groups at the end of the spec run, to help surface which specs are
82
+ # running particularly slow.
83
+ # config.profile_examples = 10
84
+
85
+ # Run specs in random order to surface order dependencies. If you find an order dependency and want to debug it, you
86
+ # can fix the order by providing the seed, which is printed after each run.
87
+ # --seed 1234
88
+ config.order = :random
89
+
90
+ # Seed global randomization in this process using the `--seed` CLI option.
91
+ # Setting this allows you to use `--seed` to deterministically reproduce test failures related to randomization by
92
+ # passing the same `--seed` value as the one that triggered the failure.
93
+ Kernel.srand config.seed
94
+ end
95
+
96
+ Dir[File.expand_path('support/**/*.rb', __dir__)].each { |path| require(path) }