enum_kit 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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) }