unmagic-enum 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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE +21 -0
- data/README.md +139 -0
- data/lib/unmagic/enum/active_record_extensions.rb +85 -0
- data/lib/unmagic/enum/version.rb +8 -0
- data/lib/unmagic/enum.rb +355 -0
- data/lib/unmagic_enum.rb +3 -0
- metadata +102 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2e0707a39bcaed61e94384ad0f2b80da72d62034788b7b5cfcebfe8ed11430e1
|
|
4
|
+
data.tar.gz: 0d54f6ef948a3ffe7667a28032dbab381c974578632cba85eced47f1faac99cf
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c5859e7c7836a1e81752180a32c20acd9bd628e6f31dbacd9287376ba4994208dda2e13f4c10dd4ffe60dee98f7ac6f1b35e6759c728f244588c2b3384f539c6
|
|
7
|
+
data.tar.gz: 69d503aa8641966f37525bccc1216c6dd66d32a80cd1f33786b1f9700c5c2c391f270642d790c554d1ca565427750329c788965a2ac5d55c74a294b673a8b300
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-06-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Initial release
|
|
14
|
+
- `Unmagic::Enum` base class for defining type-safe, immutable enums backed by string values
|
|
15
|
+
- Custom attributes via `attribute :name`, with `default:` values and `alias:` reader names
|
|
16
|
+
- Key/value separation (`new("entity", value: "bot")`) for mapping code identifiers to differing database values
|
|
17
|
+
- Support for symbols, integers, and classes as keys (preserving original type), enabling clean STI integration
|
|
18
|
+
- Dynamic query methods (`status.active?`) for checking enum keys
|
|
19
|
+
- Lookups by key or value with `Enum[...]`, plus `all`, `keys`, `values`, and `valid?` helpers
|
|
20
|
+
- Duplicate key and value detection, and reserved-method conflict detection, raised at definition time
|
|
21
|
+
- `InvalidValueError` raised on invalid assignment, mirroring Rails enum behaviour
|
|
22
|
+
- ActiveRecord integration: `Enum.column_type` for serialization, casting, and eager validation in `attribute` declarations
|
|
23
|
+
- Rails presence support (`blank?`/`present?`) and JSON serialization (`as_json`)
|
|
24
|
+
- Empty strings treated as `nil`
|
|
25
|
+
|
|
26
|
+
[Unreleased]: https://github.com/unreasonable-magic/unmagic-enum/compare/v0.1.0...HEAD
|
|
27
|
+
[0.1.0]: https://github.com/unreasonable-magic/unmagic-enum/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keith Pitt
|
|
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 all
|
|
13
|
+
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 THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Unmagic::Enum
|
|
2
|
+
|
|
3
|
+
Type-safe enums with attributes for Rails applications.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Type-safe enumeration values with string storage
|
|
8
|
+
- Custom attributes with defaults and aliases
|
|
9
|
+
- ActiveRecord integration with custom column type
|
|
10
|
+
- STI (Single Table Inheritance) support
|
|
11
|
+
- Query methods for checking enum values
|
|
12
|
+
- Duplicate key/value detection
|
|
13
|
+
- Works with symbols, integers, classes as keys
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Add to your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem 'unmagic-enum'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### Basic Enum
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
class Status < Unmagic::Enum
|
|
29
|
+
ACTIVE = new("active")
|
|
30
|
+
PENDING = new("pending")
|
|
31
|
+
ARCHIVED = new("archived")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Usage
|
|
35
|
+
status = Status::ACTIVE
|
|
36
|
+
status.active? # => true
|
|
37
|
+
status == "active" # => true
|
|
38
|
+
status.to_s # => "active"
|
|
39
|
+
Status["active"] # => Status::ACTIVE
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Enum with Attributes
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
class Priority < Unmagic::Enum
|
|
46
|
+
attribute :label
|
|
47
|
+
attribute :color
|
|
48
|
+
attribute :level, default: 0
|
|
49
|
+
|
|
50
|
+
HIGH = new("high", label: "High Priority", color: "red", level: 3)
|
|
51
|
+
MEDIUM = new("medium", label: "Medium Priority", color: "yellow", level: 2)
|
|
52
|
+
LOW = new("low", label: "Low Priority", color: "green", level: 1)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
priority = Priority::HIGH
|
|
56
|
+
priority.label # => "High Priority"
|
|
57
|
+
priority.color # => "red"
|
|
58
|
+
priority.level # => 3
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### ActiveRecord Integration
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
class Message < ApplicationRecord
|
|
65
|
+
class State < Unmagic::Enum
|
|
66
|
+
DRAFT = new("draft")
|
|
67
|
+
SENT = new("sent")
|
|
68
|
+
DELIVERED = new("delivered")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Use the column type for proper serialization
|
|
72
|
+
attribute :state, State.column_type
|
|
73
|
+
|
|
74
|
+
# Create scopes
|
|
75
|
+
scope :delivered, -> { where(state: State::DELIVERED) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Usage
|
|
79
|
+
message = Message.new(state: "sent")
|
|
80
|
+
message.state # => Message::State::SENT
|
|
81
|
+
message.state.sent? # => true
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Key/Value Separation
|
|
85
|
+
|
|
86
|
+
Useful when database values differ from code identifiers:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
class MessageType < Unmagic::Enum
|
|
90
|
+
USER = new("user") # key and value both "user"
|
|
91
|
+
ENTITY = new("entity", value: "bot") # key: "entity", value: "bot" (legacy DB)
|
|
92
|
+
SYSTEM = new("system", value: "s") # key: "system", value: "s" (short code)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
MessageType::ENTITY.key # => "entity"
|
|
96
|
+
MessageType::ENTITY.value # => "bot"
|
|
97
|
+
MessageType["bot"] # => MessageType::ENTITY
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### STI Support
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
class User < ApplicationRecord
|
|
104
|
+
class Type < Unmagic::Enum
|
|
105
|
+
# Pass the actual class - no constantize needed!
|
|
106
|
+
CUSTOMER = new(Customer)
|
|
107
|
+
ADMIN = new(Admin)
|
|
108
|
+
MODERATOR = new(Moderator)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
attribute :type, Type.column_type
|
|
112
|
+
|
|
113
|
+
def self.find_sti_class(type_name)
|
|
114
|
+
if enum_value = Type[type_name]
|
|
115
|
+
enum_value.key # Returns the class directly
|
|
116
|
+
else
|
|
117
|
+
super
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
After checking out the repo, install dependencies and run the tests:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
bundle install
|
|
129
|
+
bundle exec rake spec
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Contributing
|
|
133
|
+
|
|
134
|
+
Bug reports and pull requests are welcome on GitHub at
|
|
135
|
+
https://github.com/unreasonable-magic/unmagic-enum.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
Released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# ActiveRecord extensions for Unmagic::Enum
|
|
4
|
+
# This module provides database type casting and serialization support
|
|
5
|
+
module Unmagic
|
|
6
|
+
class Enum
|
|
7
|
+
module ActiveRecordExtensions
|
|
8
|
+
class ColumnType < ActiveRecord::Type::Value
|
|
9
|
+
def initialize(enum_class)
|
|
10
|
+
@enum_class = enum_class
|
|
11
|
+
super()
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def type
|
|
15
|
+
:string
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Cast a value to its enum instance. Lenient, mirroring
|
|
19
|
+
# ActiveRecord::Enum::EnumType#cast: an unknown value resolves to nil
|
|
20
|
+
# rather than raising. Rejection of bad input happens eagerly, on
|
|
21
|
+
# assignment, in #assert_valid_value (the same hook Rails enums use).
|
|
22
|
+
def cast(value)
|
|
23
|
+
return nil if value.nil? || value == ''
|
|
24
|
+
return value if value.is_a?(@enum_class)
|
|
25
|
+
|
|
26
|
+
@enum_class[value.to_s]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Deserialize a database value. Lenient like #cast (and like Rails'
|
|
30
|
+
# EnumType, which maps an unknown column value to nil) so that reading a
|
|
31
|
+
# row never raises on data the enum no longer recognises.
|
|
32
|
+
def deserialize(value)
|
|
33
|
+
return nil if value.nil? || value == ''
|
|
34
|
+
|
|
35
|
+
@enum_class[value]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Validate a value at assignment time, the way ActiveRecord::Enum does:
|
|
39
|
+
# ActiveModel::Attribute#with_value_from_user calls this before storing,
|
|
40
|
+
# so an invalid value raises immediately on `record.attr = ...` instead
|
|
41
|
+
# of later, lazily, when the attribute is read. Blank is allowed (becomes
|
|
42
|
+
# nil); an enum instance or a known key/value passes.
|
|
43
|
+
def assert_valid_value(value)
|
|
44
|
+
return if value.nil? || value == ''
|
|
45
|
+
return if value.is_a?(@enum_class)
|
|
46
|
+
return if @enum_class[value]
|
|
47
|
+
|
|
48
|
+
raise Unmagic::Enum::InvalidValueError, "Invalid #{@enum_class.name} value: #{value.inspect}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Serialize value for database storage
|
|
52
|
+
def serialize(value)
|
|
53
|
+
return nil if value.nil?
|
|
54
|
+
|
|
55
|
+
value.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check if the value has changed
|
|
59
|
+
def changed_in_place?(raw_old_value, new_value)
|
|
60
|
+
raw_old_value.to_s != new_value.to_s
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
module ClassMethods
|
|
65
|
+
# For ActiveRecord attribute type definition
|
|
66
|
+
def column_type
|
|
67
|
+
@column_type ||= Unmagic::Enum::ActiveRecordExtensions::ColumnType.new(self)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
module InstanceMethods
|
|
72
|
+
# Support for ActiveRecord type casting in SQL queries
|
|
73
|
+
# This allows Enum instances to be used directly in where clauses
|
|
74
|
+
def to_type_for_database
|
|
75
|
+
@value
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def self.included(base)
|
|
80
|
+
base.extend(ClassMethods)
|
|
81
|
+
base.include(InstanceMethods)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/unmagic/enum.rb
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
require 'unmagic/enum/version'
|
|
6
|
+
|
|
7
|
+
# ActiveRecord is optional. We eagerly (but tolerantly) require it so that the
|
|
8
|
+
# integration below activates regardless of gem load order in a Rails app. When
|
|
9
|
+
# ActiveRecord isn't installed the LoadError is swallowed and the gem works as a
|
|
10
|
+
# plain Ruby enum.
|
|
11
|
+
begin
|
|
12
|
+
require 'active_record'
|
|
13
|
+
rescue LoadError
|
|
14
|
+
# ActiveRecord is optional
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module Unmagic
|
|
18
|
+
# Base class for creating type-safe enums with string values
|
|
19
|
+
#
|
|
20
|
+
# Basic usage:
|
|
21
|
+
# class Status < Unmagic::Enum
|
|
22
|
+
# ACTIVE = new("active")
|
|
23
|
+
# PENDING = new("pending")
|
|
24
|
+
# ARCHIVED = new("archived")
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# With attributes:
|
|
28
|
+
# class Priority < Unmagic::Enum
|
|
29
|
+
# attribute :label
|
|
30
|
+
# attribute :color
|
|
31
|
+
#
|
|
32
|
+
# HIGH = new("high", label: "High Priority", color: "red")
|
|
33
|
+
# MEDIUM = new("medium", label: "Medium Priority", color: "yellow")
|
|
34
|
+
# LOW = new("low", label: "Low Priority", color: "green")
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# Key/Value separation (useful for database migrations):
|
|
38
|
+
# class MessageType < Unmagic::Enum
|
|
39
|
+
# # The key is what you use in code, value is what's stored in DB
|
|
40
|
+
# USER = new("user") # key and value both "user"
|
|
41
|
+
# ENTITY = new("entity", value: "bot") # key: "entity", value: "bot" (legacy DB)
|
|
42
|
+
# SYSTEM = new("system", value: "s") # key: "system", value: "s" (short code)
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# Different key types (symbols, integers, classes):
|
|
46
|
+
# class MixedEnum < Unmagic::Enum
|
|
47
|
+
# # Keys preserve their original type
|
|
48
|
+
# SYMBOL = new(:active) # key is :active (Symbol)
|
|
49
|
+
# INTEGER = new(1, value: "one") # key is 1 (Integer)
|
|
50
|
+
# CLASS = new(User) # key is User (Class)
|
|
51
|
+
# end
|
|
52
|
+
#
|
|
53
|
+
# STI (Single Table Inheritance) integration:
|
|
54
|
+
# class User < ApplicationRecord
|
|
55
|
+
# class Type < Unmagic::Enum
|
|
56
|
+
# # Pass the actual class - no constantize needed!
|
|
57
|
+
# CUSTOMER = new(Customer) # key: Customer class, value: "Customer"
|
|
58
|
+
# ADMIN = new(Admin, value: "a") # key: Admin class, value: "a"
|
|
59
|
+
# MODERATOR = new(Moderator) # key: Moderator class, value: "Moderator"
|
|
60
|
+
# end
|
|
61
|
+
#
|
|
62
|
+
# attribute :type, Type.column_type
|
|
63
|
+
#
|
|
64
|
+
# # Clean STI integration - enum.key returns the actual class
|
|
65
|
+
# def self.find_sti_class(type_name)
|
|
66
|
+
# if enum_value = Type[type_name]
|
|
67
|
+
# enum_value.key # Returns the class directly, no constantize!
|
|
68
|
+
# else
|
|
69
|
+
# super
|
|
70
|
+
# end
|
|
71
|
+
# end
|
|
72
|
+
#
|
|
73
|
+
# def self.sti_name
|
|
74
|
+
# Type.all.find { |e| e.key == self }&.value || name
|
|
75
|
+
# end
|
|
76
|
+
# end
|
|
77
|
+
#
|
|
78
|
+
# Usage patterns:
|
|
79
|
+
# status = Status::ACTIVE
|
|
80
|
+
# status.active? # => true (query method)
|
|
81
|
+
# status == "active" # => true (string equality)
|
|
82
|
+
# status == :active # => false (symbols don't match strings)
|
|
83
|
+
# status.to_s # => "active" (for database)
|
|
84
|
+
#
|
|
85
|
+
# # Lookups work with any type
|
|
86
|
+
# Status["active"] # => Status::ACTIVE
|
|
87
|
+
# MixedEnum[:active] # => MixedEnum::SYMBOL
|
|
88
|
+
# MixedEnum[1] # => MixedEnum::INTEGER
|
|
89
|
+
# MixedEnum[User] # => MixedEnum::CLASS
|
|
90
|
+
#
|
|
91
|
+
class Enum
|
|
92
|
+
class InvalidValueError < StandardError; end
|
|
93
|
+
class ReservedValueError < StandardError; end
|
|
94
|
+
|
|
95
|
+
class << self
|
|
96
|
+
# Get enum instances dynamically from constants
|
|
97
|
+
def instances_by_key
|
|
98
|
+
# Build hash from constants each time (stateless)
|
|
99
|
+
constants.each_with_object({}) do |const_name, hash|
|
|
100
|
+
const = const_get(const_name)
|
|
101
|
+
next unless const.is_a?(Unmagic::Enum)
|
|
102
|
+
|
|
103
|
+
hash[const.key_string] = const
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Get enum instances by value (database value)
|
|
108
|
+
def instances_by_value
|
|
109
|
+
# Build hash from constants each time (stateless)
|
|
110
|
+
constants.each_with_object({}) do |const_name, hash|
|
|
111
|
+
const = const_get(const_name)
|
|
112
|
+
next unless const.is_a?(Unmagic::Enum)
|
|
113
|
+
|
|
114
|
+
hash[const.value] = const
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Backward compatibility - instances is an alias for instances_by_key
|
|
119
|
+
def instances
|
|
120
|
+
instances_by_key
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Declare attributes for this enum class with options
|
|
124
|
+
def attribute(*names, **options)
|
|
125
|
+
@attribute_metadata ||= {}
|
|
126
|
+
|
|
127
|
+
names.each do |name|
|
|
128
|
+
# Store metadata for this attribute
|
|
129
|
+
@attribute_metadata[name] = options
|
|
130
|
+
|
|
131
|
+
# Create the reader method
|
|
132
|
+
attr_reader name
|
|
133
|
+
|
|
134
|
+
# Create alias if specified
|
|
135
|
+
next unless options[:alias]
|
|
136
|
+
|
|
137
|
+
aliases = Array(options[:alias])
|
|
138
|
+
aliases.each do |alias_name|
|
|
139
|
+
alias_method alias_name, name
|
|
140
|
+
|
|
141
|
+
# Track reserved method names to prevent conflicts
|
|
142
|
+
@reserved_methods ||= Set.new
|
|
143
|
+
@reserved_methods.add(alias_name.to_s)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Get declared attributes (just the names)
|
|
149
|
+
def attributes
|
|
150
|
+
@attribute_metadata&.keys || []
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Get metadata for an attribute
|
|
154
|
+
def attribute_metadata
|
|
155
|
+
@attribute_metadata || {}
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Get reserved method names (from aliases)
|
|
159
|
+
def reserved_methods
|
|
160
|
+
@reserved_methods || Set.new
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Get all enum values
|
|
164
|
+
def all
|
|
165
|
+
instances.values
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Look up enum by key or value
|
|
169
|
+
def [](lookup)
|
|
170
|
+
lookup_str = lookup.to_s
|
|
171
|
+
# Try key first, then value
|
|
172
|
+
instances_by_key[lookup_str] || instances_by_value[lookup_str]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Alias for [] to support dry-initializer type coercion
|
|
176
|
+
# dry-initializer expects types to respond to .call with 1 argument
|
|
177
|
+
def call(value)
|
|
178
|
+
self[value]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Get all valid database values (useful for validations)
|
|
182
|
+
def values
|
|
183
|
+
instances_by_value.keys
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Get all valid keys (identifiers used in code)
|
|
187
|
+
def keys
|
|
188
|
+
instances_by_key.keys
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Check if a value is valid for this enum
|
|
192
|
+
def valid?(value)
|
|
193
|
+
case value
|
|
194
|
+
when self
|
|
195
|
+
true
|
|
196
|
+
when String
|
|
197
|
+
instances.key?(value)
|
|
198
|
+
else
|
|
199
|
+
false
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Ensure each subclass has its own metadata
|
|
204
|
+
def inherited(subclass)
|
|
205
|
+
super
|
|
206
|
+
# Initialize metadata for attributes and reserved methods
|
|
207
|
+
subclass.instance_variable_set(:@attribute_metadata, {})
|
|
208
|
+
subclass.instance_variable_set(:@reserved_methods, Set.new)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The key (identifier used in code - preserves original type)
|
|
213
|
+
attr_reader :key
|
|
214
|
+
|
|
215
|
+
# The key as a string (used for lookups and comparisons)
|
|
216
|
+
attr_reader :key_string
|
|
217
|
+
|
|
218
|
+
# The value (what gets stored in database)
|
|
219
|
+
attr_reader :value
|
|
220
|
+
|
|
221
|
+
# Override equality to work with strings and same-class enums
|
|
222
|
+
def ==(other)
|
|
223
|
+
if other.is_a?(Unmagic::Enum)
|
|
224
|
+
# Only equal if same class and same value
|
|
225
|
+
other.class == self.class && @value == other.value
|
|
226
|
+
elsif other.is_a?(String)
|
|
227
|
+
# Check both key_string and value for flexibility
|
|
228
|
+
[@key_string, @value].include?(other)
|
|
229
|
+
else
|
|
230
|
+
# Check if it matches the original key (for symbols, classes, etc.)
|
|
231
|
+
@key == other
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Ensure different enum classes don't match
|
|
236
|
+
def eql?(other)
|
|
237
|
+
other.is_a?(self.class) && to_s == other.to_s
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Hash code based on string value and class
|
|
241
|
+
def hash
|
|
242
|
+
[self.class, to_s].hash
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Human-readable inspect showing how to reference this enum in code
|
|
246
|
+
def inspect
|
|
247
|
+
# Find the constant name for this enum instance
|
|
248
|
+
constant_name = self.class.constants.find do |const|
|
|
249
|
+
self.class.const_get(const) == self
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
if constant_name
|
|
253
|
+
"#{self.class.name}::#{constant_name}"
|
|
254
|
+
else
|
|
255
|
+
# Fallback showing how to access via bracket notation
|
|
256
|
+
# Show the original key type for clarity
|
|
257
|
+
"#{self.class.name}[#{@key.inspect}]"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Allow enum to be used directly in database queries and assignments
|
|
262
|
+
def to_str
|
|
263
|
+
to_s
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Return the database value (for serialization)
|
|
267
|
+
def to_s
|
|
268
|
+
@value
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Return the value when used in JSON
|
|
272
|
+
def as_json
|
|
273
|
+
to_s
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Initialize the enum with key and optional value
|
|
277
|
+
def initialize(key, **attributes)
|
|
278
|
+
@key = key # Keep original type (class, symbol, integer, string, etc.)
|
|
279
|
+
@key_string = key.to_s # String version for lookups and comparisons
|
|
280
|
+
|
|
281
|
+
# Extract the special 'value' option, default to string version of key
|
|
282
|
+
@value = attributes.delete(:value)&.to_s || @key_string
|
|
283
|
+
|
|
284
|
+
# Check for duplicate keys
|
|
285
|
+
if self.class.instances_by_key[@key_string]
|
|
286
|
+
raise InvalidValueError.new("Enum key '#{@key_string}' has already been defined")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Check for duplicate values
|
|
290
|
+
existing = self.class.instances_by_value[@value]
|
|
291
|
+
if existing
|
|
292
|
+
raise InvalidValueError.new("Enum value '#{@value}' has already been defined for key '#{existing.key_string}'")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Check for conflicts with reserved methods (using string key for query methods)
|
|
296
|
+
key_method = "#{@key_string}?"
|
|
297
|
+
if self.class.reserved_methods.include?(key_method)
|
|
298
|
+
raise ReservedValueError.new("Cannot create enum key '#{@key_string}' because it would conflict with alias method '#{key_method}'")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Set declared attributes with defaults
|
|
302
|
+
self.class.attribute_metadata.each do |attr, metadata|
|
|
303
|
+
value = if attributes.key?(attr)
|
|
304
|
+
attributes[attr]
|
|
305
|
+
elsif metadata.key?(:default)
|
|
306
|
+
metadata[:default]
|
|
307
|
+
else
|
|
308
|
+
nil
|
|
309
|
+
end
|
|
310
|
+
instance_variable_set("@#{attr}", value)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Warn about undeclared attributes in development
|
|
314
|
+
if defined?(Rails) && Rails.env.development?
|
|
315
|
+
extra_attrs = attributes.keys - self.class.attributes
|
|
316
|
+
if extra_attrs.any?
|
|
317
|
+
warn "[Unmagic::Enum] Undeclared attributes passed to #{self.class.name}: #{extra_attrs.join(', ')}"
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
freeze
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Implement query methods like `user?` for checking enum keys
|
|
325
|
+
def method_missing(method_name, *args)
|
|
326
|
+
if method_name.to_s.end_with?('?')
|
|
327
|
+
key_to_check = method_name.to_s[0..-2] # Remove the '?'
|
|
328
|
+
@key_string == key_to_check # Compare string versions
|
|
329
|
+
else
|
|
330
|
+
super
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Properly handle respond_to? for query methods
|
|
335
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
336
|
+
method_name.to_s.end_with?('?') || super
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Rails presence validation support - enums are never blank
|
|
340
|
+
def blank?
|
|
341
|
+
false
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Rails presence validation support - enums are always present
|
|
345
|
+
def present?
|
|
346
|
+
true
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Load ActiveRecord extensions if ActiveRecord is available
|
|
350
|
+
if defined?(ActiveRecord)
|
|
351
|
+
require 'unmagic/enum/active_record_extensions'
|
|
352
|
+
include ActiveRecordExtensions
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
data/lib/unmagic_enum.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: unmagic-enum
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Keith Pitt
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-04 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: activesupport
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '7.0'
|
|
20
|
+
- - ">="
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: 7.0.0
|
|
23
|
+
type: :runtime
|
|
24
|
+
prerelease: false
|
|
25
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
26
|
+
requirements:
|
|
27
|
+
- - "~>"
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '7.0'
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 7.0.0
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: rspec
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '3.12'
|
|
40
|
+
type: :development
|
|
41
|
+
prerelease: false
|
|
42
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '3.12'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: rake
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '13.0'
|
|
54
|
+
type: :development
|
|
55
|
+
prerelease: false
|
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.0'
|
|
61
|
+
description: A powerful enum system providing type-safe enumerations with custom attributes,
|
|
62
|
+
STI integration, and ActiveRecord support
|
|
63
|
+
email:
|
|
64
|
+
- keith@unreasonable-magic.com
|
|
65
|
+
executables: []
|
|
66
|
+
extensions: []
|
|
67
|
+
extra_rdoc_files: []
|
|
68
|
+
files:
|
|
69
|
+
- CHANGELOG.md
|
|
70
|
+
- LICENSE
|
|
71
|
+
- README.md
|
|
72
|
+
- lib/unmagic/enum.rb
|
|
73
|
+
- lib/unmagic/enum/active_record_extensions.rb
|
|
74
|
+
- lib/unmagic/enum/version.rb
|
|
75
|
+
- lib/unmagic_enum.rb
|
|
76
|
+
homepage: https://github.com/unreasonable-magic/unmagic-enum
|
|
77
|
+
licenses:
|
|
78
|
+
- MIT
|
|
79
|
+
metadata:
|
|
80
|
+
homepage_uri: https://github.com/unreasonable-magic/unmagic-enum
|
|
81
|
+
source_code_uri: https://github.com/unreasonable-magic/unmagic-enum
|
|
82
|
+
changelog_uri: https://github.com/unreasonable-magic/unmagic-enum/blob/main/CHANGELOG.md
|
|
83
|
+
post_install_message:
|
|
84
|
+
rdoc_options: []
|
|
85
|
+
require_paths:
|
|
86
|
+
- lib
|
|
87
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
88
|
+
requirements:
|
|
89
|
+
- - ">="
|
|
90
|
+
- !ruby/object:Gem::Version
|
|
91
|
+
version: '3.0'
|
|
92
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - ">="
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '0'
|
|
97
|
+
requirements: []
|
|
98
|
+
rubygems_version: 3.5.22
|
|
99
|
+
signing_key:
|
|
100
|
+
specification_version: 4
|
|
101
|
+
summary: Type-safe enums with attributes for Rails applications
|
|
102
|
+
test_files: []
|