Commit dd369803 authored by Mehmet Emin INAC's avatar Mehmet Emin INAC Committed by Ash McKenzie

Implement declarative enum abstraction

Instead of spreading the enum information all around the codebase
between different modules like models and GraphQL types, we can set a
single source of the truth for the Enum values.

To do so, we should promote the enums to be first class members of the
project by giving them a new top level directory to live in.
parent da836ecd
...@@ -5,6 +5,25 @@ module Types ...@@ -5,6 +5,25 @@ module Types
extend GitlabStyleDeprecations extend GitlabStyleDeprecations
class << self class << self
# Registers enum definition by the given DeclarativeEnum module
#
# @param enum_mod [Module] The enum module to be used
# @param use_name [Boolean] Does not override the name if set `false`
# @param use_description [Boolean] Does not override the description if set `false`
#
# Example:
#
# class MyEnum < BaseEnum
# declarative_enum MyDeclarativeEnum
# end
#
def declarative_enum(enum_mod, use_name: true, use_description: true)
graphql_name(enum_mod.name) if use_name
description(enum_mod.description) if use_description
enum_mod.definition.each { |enum_key, enum_content| value enum_key.to_s.upcase, enum_content }
end
def value(*args, **kwargs, &block) def value(*args, **kwargs, &block)
enum[args[0].downcase] = kwargs[:value] || args[0] enum[args[0].downcase] = kwargs[:value] || args[0]
kwargs = gitlab_deprecation(kwargs) kwargs = gitlab_deprecation(kwargs)
......
...@@ -77,4 +77,8 @@ class ApplicationRecord < ActiveRecord::Base ...@@ -77,4 +77,8 @@ class ApplicationRecord < ActiveRecord::Base
def self.where_exists(query) def self.where_exists(query)
where('EXISTS (?)', query.select(1)) where('EXISTS (?)', query.select(1))
end end
def self.declarative_enum(enum_mod)
enum enum_mod.key => enum_mod.definition.transform_values { |v| v[:value] }
end
end end
# frozen_string_literal: true
module Vulnerabilities
module DismissalReasonEnum
extend DeclarativeEnum
key :dismissal_reason
name 'VulnerabilityDismissalReason'
description 'The dismissal reason of the Vulnerability'
define do
acceptable_risk value: 0, description: 'The likelihood of the Vulnerability occurring and its impact are deemed acceptable'
false_positive value: 1, description: 'The Vulnerability was incorrectly identified as being present'
mitigating_control value: 2, description: 'There is a mitigating control that eliminates the Vulnerability or makes its risk acceptable'
used_in_tests value: 3, description: 'The Vulnerability is used in tests and does not pose an actual risk'
not_applicable value: 4, description: 'Other reasons for dismissal'
end
end
end
...@@ -3,20 +3,7 @@ ...@@ -3,20 +3,7 @@
module Types module Types
module Vulnerabilities module Vulnerabilities
class DismissalReasonEnum < BaseEnum class DismissalReasonEnum < BaseEnum
graphql_name 'VulnerabilityDismissalReason' declarative_enum ::Vulnerabilities::DismissalReasonEnum
description 'The dismissal reason of the Vulnerability'
DISMISSAL_DESCRIPTIONS = {
acceptable_risk: 'The likelihood of the Vulnerability occurring and its impact are deemed acceptable',
false_positive: 'The Vulnerability was incorrectly identified as being present',
mitigating_control: 'There is a mitigating control that eliminates the Vulnerability or makes its risk acceptable',
used_in_tests: 'The Vulnerability is used in tests and does not pose an actual risk',
not_applicable: 'Other reasons for dismissal'
}.freeze
::Vulnerabilities::Feedback.dismissal_reasons.keys.each do |dismissal_reason|
value dismissal_reason.to_s.upcase, value: dismissal_reason.to_s, description: DISMISSAL_DESCRIPTIONS[dismissal_reason.to_sym]
end
end end
end end
end end
...@@ -14,9 +14,9 @@ module Vulnerabilities ...@@ -14,9 +14,9 @@ module Vulnerabilities
attr_accessor :vulnerability_data attr_accessor :vulnerability_data
enum dismissal_reason: { acceptable_risk: 0, false_positive: 1, mitigating_control: 2, used_in_tests: 3, not_applicable: 4 }
enum feedback_type: { dismissal: 0, issue: 1, merge_request: 2 }, _prefix: :for enum feedback_type: { dismissal: 0, issue: 1, merge_request: 2 }, _prefix: :for
enum category: ::Enums::Vulnerability.report_types enum category: ::Enums::Vulnerability.report_types
declarative_enum DismissalReasonEnum
validates :project, presence: true validates :project, presence: true
validates :author, presence: true validates :author, presence: true
......
# frozen_string_literal: true
# Extending this module will give you the ability of defining
# enum values in a declarative way.
#
# module DismissalReasons
# extend DeclarativeEnum
#
# key :dismissal_reason
# name 'DismissalReasonOfVulnerability'
#
# description <<~TEXT
# This enum holds the user selected dismissal reason
# when they are dismissing the vulnerabilities
# TEXT
#
# define do
# acceptable_risk value: 0, description: 'The vulnerability is known but is considered to be an acceptable business risk.'
# false_positive value: 1, description: 'An error in reporting the presence of a vulnerability in a system when the vulnerability is not present.'
# used_in_tests value: 2, description: 'The finding is not a vulnerability because it is part of a test or is test data.'
# end
#
# Then we can use this module to register enums for our Active Record models like so,
#
# class VulnerabilityFeedback
# declarative_enum DismissalReasons
# end
#
# Also we can use this module to create GraphQL Enum types like so,
#
# module Types
# module Vulnerabilities
# class DismissalReasonEnum < BaseEnum
# declarative_enum DismissalReasons
# end
# end
# end
#
# rubocop:disable Gitlab/ModuleWithInstanceVariables
module DeclarativeEnum
# This `prepended` hook will merge the enum definition
# of the prepended module into the base module to be
# used by `prepend_if_ee` helper method.
def prepended(base)
base.definition.merge!(definition)
end
def key(new_key = nil)
@key = new_key if new_key
@key
end
def name(new_name = nil)
@name = new_name if new_name
@name
end
def description(new_description = nil)
@description = new_description if new_description
@description
end
def define(&block)
raise LocalJumpError.new('No block given') unless block
@definition = Builder.new(definition, block).build
end
# We can use this method later to apply some sanity checks
# but for now, returning a Hash without any check is enough.
def definition
@definition.to_h
end
class Builder
class KeyCollisionError < StandardError
def initialize(key)
super("`#{key}` collides with an existing enum key!")
end
end
def initialize(definition, block)
@definition = definition
@block = block
end
def build
instance_exec(&@block)
@definition
end
private
def method_missing(name, *arguments, value: nil, description: nil, &block)
raise KeyCollisionError.new(name) if @definition[name.downcase.to_sym]
@definition[name.downcase.to_sym] = {
value: value,
description: description
}
end
end
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
...@@ -3,7 +3,75 @@ ...@@ -3,7 +3,75 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Types::BaseEnum do RSpec.describe Types::BaseEnum do
describe '#enum' do describe '.declarative_enum' do
let(:use_name) { true }
let(:use_description) { true }
let(:enum_type) do
Class.new(described_class) do
graphql_name 'OriginalName'
description 'Original description'
end
end
let(:enum_module) do
Module.new do
extend DeclarativeEnum
name 'Name'
description 'Description'
define do
foo value: 0, description: 'description of foo'
end
end
end
subject(:set_declarative_enum) { enum_type.declarative_enum(enum_module, use_name: use_name, use_description: use_description) }
describe '#graphql_name' do
context 'when the use_name is `true`' do
it 'changes the graphql_name' do
expect { set_declarative_enum }.to change { enum_type.graphql_name }.from('OriginalName').to('Name')
end
end
context 'when the use_name is `false`' do
let(:use_name) { false }
it 'does not change the graphql_name' do
expect { set_declarative_enum }.not_to change { enum_type.graphql_name }.from('OriginalName')
end
end
end
describe '#description' do
context 'when the use_description is `true`' do
it 'changes the description' do
expect { set_declarative_enum }.to change { enum_type.description }.from('Original description').to('Description')
end
end
context 'when the use_description is `false`' do
let(:use_description) { false }
it 'does not change the description' do
expect { set_declarative_enum }.not_to change { enum_type.description }.from('Original description')
end
end
end
describe '#values' do
it 'sets the values defined by the declarative enum' do
set_declarative_enum
expect(enum_type.values.keys).to eq(['FOO'])
expect(enum_type.values.values.map(&:description)).to eq(['description of foo'])
expect(enum_type.values.values.map(&:value)).to eq([0])
end
end
end
describe '.enum' do
let(:enum) do let(:enum) do
Class.new(described_class) do Class.new(described_class) do
value 'TEST', value: 3 value 'TEST', value: 3
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe DeclarativeEnum do
let(:enum_module) do
Module.new do
extend DeclarativeEnum
key :my_enum
name 'MyEnumName'
description "Enum description"
define do
foo value: 0, description: 'description of foo'
bar value: 1, description: 'description of bar'
end
end
end
let(:original_definition) do
{
foo: { description: 'description of foo', value: 0 },
bar: { description: 'description of bar', value: 1 }
}
end
describe '.key' do
subject(:key) { enum_module.key(new_key) }
context 'when the argument is set' do
let(:new_key) { :new_enum_key }
it 'changes the key' do
expect { key }.to change { enum_module.key }.from(:my_enum).to(:new_enum_key)
end
end
context 'when the argument is `nil`' do
let(:new_key) { nil }
it { is_expected.to eq(:my_enum) }
end
end
describe '.name' do
subject(:name) { enum_module.name(new_name) }
context 'when the argument is set' do
let(:new_name) { 'NewMyEnumName' }
it 'changes the name' do
expect { name }.to change { enum_module.name }.from('MyEnumName').to('NewMyEnumName')
end
end
context 'when the argument is `nil`' do
let(:new_name) { nil }
it { is_expected.to eq('MyEnumName') }
end
end
describe '.description' do
subject(:description) { enum_module.description(new_description) }
context 'when the argument is set' do
let(:new_description) { 'New enum description' }
it 'changes the description' do
expect { description }.to change { enum_module.description }.from('Enum description').to('New enum description')
end
end
context 'when the argument is `nil`' do
let(:new_description) { nil }
it { is_expected.to eq('Enum description') }
end
end
describe '.define' do
subject(:define) { enum_module.define(&block) }
context 'when there is a block given' do
context 'when the given block tries to register the same key' do
let(:block) do
proc do
foo value: 2, description: 'description of foo'
end
end
it 'raises a `KeyCollisionError`' do
expect { define }.to raise_error(DeclarativeEnum::Builder::KeyCollisionError)
end
end
context 'when the given block does not try to register the same key' do
let(:expected_new_definition) { original_definition.merge(zoo: { description: 'description of zoo', value: 0 }) }
let(:block) do
proc do
zoo value: 0, description: 'description of zoo'
end
end
it 'appends the new definition' do
expect { define }.to change { enum_module.definition }.from(original_definition).to(expected_new_definition)
end
end
end
context 'when there is no block given' do
let(:block) { nil }
it 'raises a LocalJumpError' do
expect { define }.to raise_error(LocalJumpError)
end
end
end
describe '.definition' do
subject { enum_module.definition }
it { is_expected.to eq(original_definition) }
end
describe 'extending the enum module' do
let(:extended_definition) { original_definition.merge(zoo: { value: 2, description: 'description of zoo' }) }
let(:new_enum_module) do
Module.new do
extend DeclarativeEnum
define do
zoo value: 2, description: 'description of zoo'
end
end
end
subject(:prepend_new_enum_module) { enum_module.prepend(new_enum_module) }
it 'extends the values of the base enum module' do
expect { prepend_new_enum_module }.to change { enum_module.definition }.from(original_definition)
.to(extended_definition)
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment