persistent_enum 1.2.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.circleci/config.yml +162 -0
- data/.gitignore +25 -0
- data/.rspec +1 -0
- data/.travis.yml +39 -0
- data/Appraisals +7 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +9 -0
- data/README.md +7 -0
- data/Rakefile +8 -0
- data/gemfiles/rails_5_0.gemfile +7 -0
- data/gemfiles/rails_5_1.gemfile +7 -0
- data/gemfiles/rails_5_2.gemfile +8 -0
- data/gemfiles/rails_6_0_beta.gemfile +8 -0
- data/lib/persistent_enum.rb +437 -0
- data/lib/persistent_enum/acts_as_enum.rb +230 -0
- data/lib/persistent_enum/railtie.rb +12 -0
- data/lib/persistent_enum/version.rb +5 -0
- data/persistent_enum.gemspec +36 -0
- data/spec/spec_helper.rb +108 -0
- data/spec/support/config/database.yml +12 -0
- data/spec/support/helpers/database_helper.rb +67 -0
- data/spec/unit/persistent_enum_spec.rb +758 -0
- metadata +237 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 3cde6573673d70f73eb38d09bd9e4f0fc445e7953f9cf63cb3f631afc2c2b3da
|
4
|
+
data.tar.gz: 2713d64c90a9e857120099c07a7cad9d51dc477f9f1c859086d1cd3d1ac2a0cb
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6c45dd0b3fb7f72e9709f8972f0a7d03b247f542f169c6534680034dee043d1287f2c29a036da1a386bf9f5cce3418f2ff30a17f6da4d4454dc290602c5a5bd7
|
7
|
+
data.tar.gz: 0037fd38a29ae4da1368b0cb13d6da4ae826163dffc4b9c9c02870ee7bf66f897a533f586e73a34037e94603eba1bc015d87d2d5f95cafd062e16b26804c9c5a
|
@@ -0,0 +1,162 @@
|
|
1
|
+
version: 2.1
|
2
|
+
|
3
|
+
executors:
|
4
|
+
ruby-sqlite: &ruby
|
5
|
+
parameters:
|
6
|
+
ruby-version:
|
7
|
+
type: string
|
8
|
+
default: "2.6"
|
9
|
+
gemfile:
|
10
|
+
type: string
|
11
|
+
default: "Gemfile"
|
12
|
+
environment:
|
13
|
+
TEST_DATABASE_ENVIRONMENT: sqlite3
|
14
|
+
docker:
|
15
|
+
- &ruby_docker_ruby
|
16
|
+
image: circleci/ruby:<< parameters.ruby-version >>
|
17
|
+
environment:
|
18
|
+
BUNDLE_JOBS: 3
|
19
|
+
BUNDLE_RETRY: 3
|
20
|
+
BUNDLE_PATH: vendor/bundle
|
21
|
+
RAILS_ENV: test
|
22
|
+
BUNDLE_GEMFILE: << parameters.gemfile >>
|
23
|
+
ruby-pg:
|
24
|
+
<<: *ruby
|
25
|
+
environment:
|
26
|
+
TEST_DATABASE_ENVIRONMENT: postgresql
|
27
|
+
PGHOST: 127.0.0.1
|
28
|
+
PGUSER: eikaiwa
|
29
|
+
docker:
|
30
|
+
- *ruby_docker_ruby
|
31
|
+
- image: circleci/postgres:11-alpine
|
32
|
+
environment:
|
33
|
+
POSTGRES_USER: eikaiwa
|
34
|
+
POSTGRES_DB: persistent_enum_test
|
35
|
+
POSTGRES_PASSWORD: ""
|
36
|
+
ruby-mysql:
|
37
|
+
<<: *ruby
|
38
|
+
environment:
|
39
|
+
TEST_DATABASE_ENVIRONMENT: mysql2
|
40
|
+
MYSQL_HOST: 127.0.0.1
|
41
|
+
MYSQL_USER: root
|
42
|
+
docker:
|
43
|
+
- *ruby_docker_ruby
|
44
|
+
- image: tkuchiki/delayed-mysql
|
45
|
+
environment:
|
46
|
+
MYSQL_ALLOW_EMPTY_PASSWORD: yes
|
47
|
+
MYSQL_ROOT_PASSWORD: ''
|
48
|
+
MYSQL_DATABASE: persistent_enum_test
|
49
|
+
|
50
|
+
jobs:
|
51
|
+
test:
|
52
|
+
parameters:
|
53
|
+
ex:
|
54
|
+
type: executor
|
55
|
+
database-steps:
|
56
|
+
type: steps
|
57
|
+
default: []
|
58
|
+
executor: << parameters.ex >>
|
59
|
+
parallelism: 1
|
60
|
+
steps:
|
61
|
+
- checkout
|
62
|
+
|
63
|
+
- run:
|
64
|
+
# Remove the non-appraisal gemfile for safety: we never want to use it.
|
65
|
+
name: Prepare bundler
|
66
|
+
command: bundle -v && rm Gemfile
|
67
|
+
|
68
|
+
- run:
|
69
|
+
name: Compute a gemfile lock
|
70
|
+
command: bundle lock && cp "${BUNDLE_GEMFILE}.lock" /tmp/gem-lock
|
71
|
+
|
72
|
+
- restore_cache:
|
73
|
+
keys:
|
74
|
+
- iknow_viewmodels-{{ checksum "/tmp/gem-lock" }}
|
75
|
+
- iknow_viewmodels-
|
76
|
+
|
77
|
+
- run:
|
78
|
+
name: Bundle Install
|
79
|
+
command: bundle check || bundle install
|
80
|
+
|
81
|
+
- save_cache:
|
82
|
+
key: iknow_viewmodels-{{ checksum "/tmp/gem-lock" }}
|
83
|
+
paths:
|
84
|
+
- vendor/bundle
|
85
|
+
|
86
|
+
- steps: << parameters.database-steps >>
|
87
|
+
|
88
|
+
- run:
|
89
|
+
name: Run rspec
|
90
|
+
command: bundle exec rspec --profile 10 --format RspecJunitFormatter --out test_results/rspec.xml --format progress
|
91
|
+
|
92
|
+
- store_test_results:
|
93
|
+
path: test_results
|
94
|
+
|
95
|
+
publish:
|
96
|
+
executor: ruby-sqlite
|
97
|
+
steps:
|
98
|
+
- checkout
|
99
|
+
- run:
|
100
|
+
name: Setup Rubygems
|
101
|
+
command: |
|
102
|
+
mkdir ~/.gem &&
|
103
|
+
echo -e "---\r\n:rubygems_api_key: $RUBYGEMS_API_KEY" > ~/.gem/credentials &&
|
104
|
+
chmod 0600 ~/.gem/credentials
|
105
|
+
- run:
|
106
|
+
name: Publish to Rubygems
|
107
|
+
command: |
|
108
|
+
gem build persistent_enum.gemspec
|
109
|
+
gem push persistent_enum-*.gem
|
110
|
+
|
111
|
+
workflows:
|
112
|
+
version: 2
|
113
|
+
build:
|
114
|
+
jobs:
|
115
|
+
- test:
|
116
|
+
name: 'ruby 2.6 rails 5.2 sqlite'
|
117
|
+
ex:
|
118
|
+
name: ruby-sqlite
|
119
|
+
ruby-version: "2.6"
|
120
|
+
gemfile: gemfiles/rails_5_2.gemfile
|
121
|
+
- test:
|
122
|
+
name: 'ruby 2.6 rails 6.0 sqlite'
|
123
|
+
ex:
|
124
|
+
name: ruby-sqlite
|
125
|
+
ruby-version: "2.6"
|
126
|
+
gemfile: gemfiles/rails_6_0_beta.gemfile
|
127
|
+
- test:
|
128
|
+
name: 'ruby 2.6 rails 5.2 pg'
|
129
|
+
ex:
|
130
|
+
name: ruby-pg
|
131
|
+
ruby-version: "2.6"
|
132
|
+
gemfile: gemfiles/rails_5_2.gemfile
|
133
|
+
database-steps: &pg_wait
|
134
|
+
- run: dockerize -wait tcp://localhost:5432 -timeout 1m
|
135
|
+
- test:
|
136
|
+
name: 'ruby 2.6 rails 6.0 pg'
|
137
|
+
ex:
|
138
|
+
name: ruby-pg
|
139
|
+
ruby-version: "2.6"
|
140
|
+
gemfile: gemfiles/rails_6_0_beta.gemfile
|
141
|
+
database-steps: *pg_wait
|
142
|
+
- test:
|
143
|
+
name: 'ruby 2.6 rails 5.2 mysql'
|
144
|
+
ex:
|
145
|
+
name: ruby-mysql
|
146
|
+
ruby-version: "2.6"
|
147
|
+
gemfile: gemfiles/rails_5_2.gemfile
|
148
|
+
database-steps: &mysql_wait
|
149
|
+
- run: dockerize -wait tcp://localhost:3306 -timeout 1m
|
150
|
+
- test:
|
151
|
+
name: 'ruby 2.6 rails 6.0 mysql'
|
152
|
+
ex:
|
153
|
+
name: ruby-mysql
|
154
|
+
ruby-version: "2.6"
|
155
|
+
gemfile: gemfiles/rails_6_0_beta.gemfile
|
156
|
+
database-steps: *mysql_wait
|
157
|
+
- publish:
|
158
|
+
filters:
|
159
|
+
branches:
|
160
|
+
only: master
|
161
|
+
tags:
|
162
|
+
ignore: /.*/
|
data/.gitignore
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
*~
|
2
|
+
.#*
|
3
|
+
*.gem
|
4
|
+
*.rbc
|
5
|
+
.bundle
|
6
|
+
.config
|
7
|
+
.yardoc
|
8
|
+
InstalledFiles
|
9
|
+
_yardoc
|
10
|
+
coverage
|
11
|
+
doc/
|
12
|
+
lib/bundler/man
|
13
|
+
pkg
|
14
|
+
rdoc
|
15
|
+
spec/reports
|
16
|
+
test/tmp
|
17
|
+
test/version_tmp
|
18
|
+
tmp
|
19
|
+
*.bundle
|
20
|
+
*.so
|
21
|
+
*.o
|
22
|
+
*.a
|
23
|
+
mkmf.log
|
24
|
+
gemfiles/*.gemfile.lock
|
25
|
+
Gemfile.lock
|
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--require spec_helper
|
data/.travis.yml
ADDED
@@ -0,0 +1,39 @@
|
|
1
|
+
dist: trusty
|
2
|
+
sudo: false
|
3
|
+
language: ruby
|
4
|
+
cache: bundler
|
5
|
+
|
6
|
+
rvm:
|
7
|
+
- 2.5
|
8
|
+
|
9
|
+
gemfile:
|
10
|
+
- gemfiles/rails_5_2.gemfile
|
11
|
+
|
12
|
+
services:
|
13
|
+
- mysql
|
14
|
+
|
15
|
+
addons:
|
16
|
+
postgresql: "10"
|
17
|
+
apt:
|
18
|
+
packages:
|
19
|
+
- postgresql-10
|
20
|
+
- postgresql-client-10
|
21
|
+
- postgresql-server-dev-10
|
22
|
+
env:
|
23
|
+
global:
|
24
|
+
- PGPORT=5433
|
25
|
+
matrix:
|
26
|
+
- TEST_DATABASE_ENVIRONMENT=postgresql
|
27
|
+
- TEST_DATABASE_ENVIRONMENT=mysql2
|
28
|
+
- TEST_DATABASE_ENVIRONMENT=sqlite3
|
29
|
+
|
30
|
+
before_install:
|
31
|
+
# Travis' Ruby 2.5.0 ships broken rubygems, won't run rake.
|
32
|
+
# Workaround: update rubygems. See travis-ci issue 8978
|
33
|
+
- gem install bundler
|
34
|
+
before_script:
|
35
|
+
- psql -c 'CREATE DATABASE persistent_enum_test;'
|
36
|
+
- mysql -e 'CREATE DATABASE persistent_enum_test;'
|
37
|
+
|
38
|
+
notifications:
|
39
|
+
email: false
|
data/Appraisals
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
Copyright (c) 2014, Cerego LLC. All rights reserved.
|
2
|
+
|
3
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
4
|
+
|
5
|
+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
6
|
+
|
7
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
8
|
+
|
9
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
data/README.md
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
# PersistentEnum
|
2
|
+
[![Build Status](https://travis-ci.org/iknow/persistent_enum.svg?branch=master)](https://travis-ci.org/iknow/persistent_enum)
|
3
|
+
|
4
|
+
Provide an ActiveRecord model that behaves as a database-backed enumeration
|
5
|
+
between indices and symbolic values. This allows us to have a valid foreign key
|
6
|
+
which behaves like a enumeration. Values are cached at startup, and cannot be
|
7
|
+
changed.
|
data/Rakefile
ADDED
@@ -0,0 +1,437 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'persistent_enum/version'
|
4
|
+
require 'persistent_enum/acts_as_enum'
|
5
|
+
|
6
|
+
require 'active_support'
|
7
|
+
require 'active_support/inflector'
|
8
|
+
require 'active_record'
|
9
|
+
require 'activerecord-import'
|
10
|
+
|
11
|
+
# Provide a database-backed enumeration between indices and symbolic
|
12
|
+
# values. This allows us to have a valid foreign key which behaves like a
|
13
|
+
# enumeration. Values are cached at startup, and cannot be changed.
|
14
|
+
module PersistentEnum
|
15
|
+
extend ActiveSupport::Concern
|
16
|
+
class EnumTableInvalid < RuntimeError; end
|
17
|
+
class MissingEnumTypeError < RuntimeError; end
|
18
|
+
|
19
|
+
module ClassMethods
|
20
|
+
def acts_as_enum(required_constants, name_attr: :name, sql_enum_type: nil, &constant_init_block)
|
21
|
+
include ActsAsEnum
|
22
|
+
enum_spec = EnumSpec.new(constant_init_block, required_constants, name_attr, sql_enum_type)
|
23
|
+
initialize_acts_as_enum(enum_spec)
|
24
|
+
end
|
25
|
+
|
26
|
+
# Sets up a association with an enumeration record type. Key resolution is
|
27
|
+
# done via the enumeration type's cache rather than ActiveRecord. The
|
28
|
+
# setter accepts either a model type or the enum constant name as a symbol
|
29
|
+
# or string.
|
30
|
+
def belongs_to_enum(enum_name, class_name: enum_name.to_s.camelize, foreign_key: "#{enum_name}_id")
|
31
|
+
target_class = class_name.constantize
|
32
|
+
|
33
|
+
define_method(enum_name) do
|
34
|
+
target_id = read_attribute(foreign_key)
|
35
|
+
target_class[target_id]
|
36
|
+
end
|
37
|
+
|
38
|
+
define_method("#{enum_name}=") do |enum_member|
|
39
|
+
id = case enum_member
|
40
|
+
when nil
|
41
|
+
nil
|
42
|
+
when target_class
|
43
|
+
enum_member.ordinal
|
44
|
+
else
|
45
|
+
m = target_class.value_of(enum_member)
|
46
|
+
raise NameError.new("#{target_class}: Invalid enum constant '#{enum_member}'") if m.nil?
|
47
|
+
|
48
|
+
m.ordinal
|
49
|
+
end
|
50
|
+
write_attribute(foreign_key, id)
|
51
|
+
end
|
52
|
+
|
53
|
+
# All enum members must be valid
|
54
|
+
validates foreign_key, inclusion: { in: ->(_r) { target_class.all_ordinals } }, allow_nil: true
|
55
|
+
|
56
|
+
# New enum members must be currently active
|
57
|
+
validates foreign_key, inclusion: { in: ->(_r) { target_class.ordinals } }, allow_nil: true, on: :create
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
# Closes over the arguments to `acts_as_enum` so that it can be reloaded.
|
62
|
+
EnumSpec = Struct.new(:constant_block, :constant_hash, :name_attr, :sql_enum_type) do
|
63
|
+
def initialize(constant_block, constant_hash, name_attr, sql_enum_type)
|
64
|
+
unless constant_block.nil? ^ constant_hash.nil?
|
65
|
+
raise ArgumentError.new('Constants must be provided by exactly one of hash argument or builder block')
|
66
|
+
end
|
67
|
+
|
68
|
+
super(constant_block, constant_hash, name_attr.to_s, sql_enum_type)
|
69
|
+
freeze
|
70
|
+
end
|
71
|
+
|
72
|
+
def required_members
|
73
|
+
constant_hash || ConstantEvaluator.new.evaluate(&constant_block)
|
74
|
+
end
|
75
|
+
|
76
|
+
class ConstantEvaluator
|
77
|
+
def evaluate(&block)
|
78
|
+
@constants = {}
|
79
|
+
self.instance_eval(&block)
|
80
|
+
@constants
|
81
|
+
end
|
82
|
+
|
83
|
+
def constant!(name, **args)
|
84
|
+
@constants[name] = args
|
85
|
+
end
|
86
|
+
|
87
|
+
def method_missing(name, **args)
|
88
|
+
constant!(name, **args)
|
89
|
+
end
|
90
|
+
|
91
|
+
def respond_to_missing?(_name, _include_all = true)
|
92
|
+
true
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
class << self
|
98
|
+
# Given an 'enum-like' table with (id, name, ...) structure and a set of
|
99
|
+
# enum members specified as either [name, ...] or {name: {attr: val, ...}, ...},
|
100
|
+
# ensure that there is a row in the table corresponding to each name, and
|
101
|
+
# cache the models as constants on the model class.
|
102
|
+
#
|
103
|
+
# When using a database such as postgresql that supports native enumerated
|
104
|
+
# types, can additionally specify a native enum type to use as the primary
|
105
|
+
# key. In this case, the required members will be added to the native type
|
106
|
+
# by name using `ALTER TYPE` before insertion. This ensures that enum table
|
107
|
+
# ids will have predictable values and can therefore be used in database
|
108
|
+
# level constraints.
|
109
|
+
def cache_constants(model, required_members, name_attr: 'name', required_attributes: nil, sql_enum_type: nil)
|
110
|
+
# normalize member specification
|
111
|
+
unless required_members.is_a?(Hash)
|
112
|
+
required_members = required_members.each_with_object({}) { |c, h| h[c] = {} }
|
113
|
+
end
|
114
|
+
|
115
|
+
# Normalize symbols
|
116
|
+
name_attr = name_attr.to_s
|
117
|
+
required_members = required_members.each_with_object({}) do |(name, attrs), h|
|
118
|
+
h[name.to_s] = attrs.transform_keys(&:to_s)
|
119
|
+
end
|
120
|
+
required_attributes = required_attributes.map(&:to_s) if required_attributes
|
121
|
+
|
122
|
+
# We need to cope with (a) loading this class and (b) ensuring that all the
|
123
|
+
# constants are defined (if not functional) in the case that the database
|
124
|
+
# isn't present yet. If no database is present, create dummy values to
|
125
|
+
# populate the constants.
|
126
|
+
values =
|
127
|
+
begin
|
128
|
+
cache_constants_in_table(model, name_attr, required_members, required_attributes, sql_enum_type)
|
129
|
+
rescue EnumTableInvalid => ex
|
130
|
+
# If we're running the application in any way, under no circumstances
|
131
|
+
# do we want to introduce the dummy models: crash out now. Our
|
132
|
+
# conservative heuristic to detect a 'safe' loading outside the
|
133
|
+
# application is whether there is a current Rake task.
|
134
|
+
unless Object.const_defined?(:Rake) && Rake.try(:application)&.top_level_tasks.present?
|
135
|
+
raise
|
136
|
+
end
|
137
|
+
|
138
|
+
# Otherwise, we want to try as hard as possible to allow the
|
139
|
+
# application to be initialized enough to run the Rake task (e.g.
|
140
|
+
# db:migrate).
|
141
|
+
log_warning("Database table initialization error for model #{model.name}, "\
|
142
|
+
'initializing constants with dummy records instead: ' +
|
143
|
+
ex.message)
|
144
|
+
cache_constants_in_dummy_class(model, name_attr, required_members, required_attributes, sql_enum_type)
|
145
|
+
end
|
146
|
+
|
147
|
+
return cache_values(model, values, name_attr)
|
148
|
+
end
|
149
|
+
|
150
|
+
# Given an 'enum-like' table with (id, name, ...) structure, load existing
|
151
|
+
# records from the database and cache them in constants on this class
|
152
|
+
def cache_records(model, name_attr: :name)
|
153
|
+
if model.table_exists?
|
154
|
+
values = model.scoped
|
155
|
+
cache_values(model, values, name_attr)
|
156
|
+
else
|
157
|
+
puts "Database table for model #{model.name} doesn't exist, no constants cached."
|
158
|
+
end
|
159
|
+
rescue ActiveRecord::NoDatabaseError
|
160
|
+
puts "Database for model #{model.name} doesn't exist, no constants cached."
|
161
|
+
end
|
162
|
+
|
163
|
+
def dummy_class(model, name_attr)
|
164
|
+
if model.const_defined?(:DummyModel, false)
|
165
|
+
dummy_class = model::DummyModel
|
166
|
+
unless dummy_class.superclass == AbstractDummyModel && dummy_class.name_attr == name_attr
|
167
|
+
raise NameError.new("PersistentEnum dummy class type mismatch: '#{dummy_class.inspect}' does not match '#{model.name}'")
|
168
|
+
end
|
169
|
+
|
170
|
+
dummy_class
|
171
|
+
else
|
172
|
+
nil
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
private
|
177
|
+
|
178
|
+
def cache_constants_in_table(model, name_attr, required_members, required_attributes, sql_enum_type)
|
179
|
+
begin
|
180
|
+
unless model.table_exists?
|
181
|
+
raise EnumTableInvalid.new("Database table for model #{model.name} doesn't exist")
|
182
|
+
end
|
183
|
+
rescue ActiveRecord::NoDatabaseError
|
184
|
+
raise EnumTableInvalid.new("Database for model #{model.name} doesn't exist")
|
185
|
+
end
|
186
|
+
|
187
|
+
internal_attributes = ['id', name_attr]
|
188
|
+
table_attributes = (model.column_names - internal_attributes)
|
189
|
+
|
190
|
+
# If not otherwise specified, non-null attributes without defaults are required
|
191
|
+
optional_attributes = model.columns.select { |col| col.null || !col.default.nil? }.map(&:name) - internal_attributes
|
192
|
+
required_attributes ||= table_attributes - optional_attributes
|
193
|
+
|
194
|
+
column_defaults = model.column_defaults
|
195
|
+
|
196
|
+
unless (unknown_attributes = (required_attributes - table_attributes)).blank?
|
197
|
+
log_warning("PersistentEnum error: required attributes #{unknown_attrs.inspect} for model #{model.name} not found in table - ignoring.")
|
198
|
+
required_attributes -= unknown_attributes
|
199
|
+
end
|
200
|
+
|
201
|
+
unless model.connection.indexes(model.table_name).detect { |i| i.columns == [name_attr] && i.unique }
|
202
|
+
raise EnumTableInvalid.new("detected missing unique index on '#{name_attr}'")
|
203
|
+
end
|
204
|
+
|
205
|
+
if model.connection.open_transactions > 0
|
206
|
+
# This case particularly doesn't fall back to dummy initialization as it
|
207
|
+
# indicates that the PersistentEnum wasn't initialized at startup: a
|
208
|
+
# silent fallback to a dummy model could go unnoticed.
|
209
|
+
raise RuntimeError.new("PersistentEnum model #{model.name} detected unsafe class initialization during a transaction: aborting.")
|
210
|
+
end
|
211
|
+
|
212
|
+
if sql_enum_type
|
213
|
+
begin
|
214
|
+
ensure_sql_enum_members(model.connection, required_members.keys, sql_enum_type)
|
215
|
+
rescue MissingEnumTypeError
|
216
|
+
log_warning("Database enum type missing for PersistentEnum #{model.name}: falling back to default id handling")
|
217
|
+
sql_enum_type = nil
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
expected_rows = required_members.map do |name, attrs|
|
222
|
+
attr_names = attrs.keys
|
223
|
+
|
224
|
+
if (extra_attrs = (attr_names - table_attributes)).present?
|
225
|
+
log_warning("PersistentEnum error: specified attributes #{extra_attrs.inspect} for model #{model.name} missing from table - ignoring.")
|
226
|
+
attrs = attrs.except(*extra_attrs)
|
227
|
+
end
|
228
|
+
|
229
|
+
if (missing_attrs = (required_attributes - attr_names)).present?
|
230
|
+
raise EnumTableInvalid.new("enum member error: required attributes #{missing_attrs.inspect} not provided")
|
231
|
+
end
|
232
|
+
|
233
|
+
new_attrs = attrs.dup
|
234
|
+
new_attrs[name_attr] = name
|
235
|
+
new_attrs['id'] = name if sql_enum_type
|
236
|
+
(optional_attributes - attr_names).each do |default_attr|
|
237
|
+
new_attrs[default_attr] = column_defaults[default_attr]
|
238
|
+
end
|
239
|
+
new_attrs
|
240
|
+
end
|
241
|
+
|
242
|
+
model.transaction do
|
243
|
+
upsert_records(model, name_attr, expected_rows)
|
244
|
+
model.where(name_attr => required_members.keys).to_a
|
245
|
+
end
|
246
|
+
end
|
247
|
+
|
248
|
+
def cache_constants_in_dummy_class(model, name_attr, required_members, _required_attributes, sql_enum_type)
|
249
|
+
dummyclass = build_dummy_class(model, name_attr)
|
250
|
+
|
251
|
+
next_id = 999999999
|
252
|
+
|
253
|
+
required_members.map do |member_name, attrs|
|
254
|
+
id = sql_enum_type ? member_name : (next_id += 1)
|
255
|
+
dummyclass.new(id, member_name, attrs)
|
256
|
+
end
|
257
|
+
end
|
258
|
+
|
259
|
+
def upsert_records(model, name_attr, expected_rows)
|
260
|
+
# Not all rows will have the same attributes to upsert: group and upsert by attributes
|
261
|
+
expected_rows.group_by(&:keys).each do |row_attrs, rows|
|
262
|
+
upsert_columns = row_attrs - [name_attr, 'id']
|
263
|
+
|
264
|
+
case model.connection.adapter_name
|
265
|
+
when 'PostgreSQL'
|
266
|
+
model.import!(rows, on_duplicate_key_update: { conflict_target: [name_attr], columns: upsert_columns })
|
267
|
+
when 'Mysql2'
|
268
|
+
if upsert_columns.present?
|
269
|
+
# Even for identical rows in the same order, a INSERT .. ON DUPLICATE
|
270
|
+
# KEY UPDATE can deadlock with itself. Obtain write locks in advance.
|
271
|
+
model.lock('FOR UPDATE').order(:id).pluck(:id)
|
272
|
+
model.import!(rows, on_duplicate_key_update: upsert_columns)
|
273
|
+
else
|
274
|
+
model.import!(rows, on_duplicate_key_ignore: true)
|
275
|
+
end
|
276
|
+
else
|
277
|
+
# No upsert support: use first_or_create optimistically
|
278
|
+
rows.each do |row|
|
279
|
+
record = model.lock.where(name_attr => row[name_attr]).first_or_create!(row.except('id', name_attr))
|
280
|
+
record.assign_attributes(row)
|
281
|
+
record.validate!
|
282
|
+
record.save! if record.changed?
|
283
|
+
end
|
284
|
+
end
|
285
|
+
end
|
286
|
+
end
|
287
|
+
|
288
|
+
ENUM_TYPE_LOCK_KEY = 0x757a6cafedc6084d # Random 64-bit key
|
289
|
+
|
290
|
+
def ensure_sql_enum_members(connection, names, sql_enum_type)
|
291
|
+
# It may be the case that an enum type doesn't yet exist despite the table
|
292
|
+
# existing, for example if the table is presently being migrated to an
|
293
|
+
# enum type. If this is the case, warn and fall back to standard id handling.
|
294
|
+
type_exists = ActiveRecord::Base.connection.select_value(
|
295
|
+
"SELECT true FROM pg_type WHERE typname = #{connection.quote(sql_enum_type)}")
|
296
|
+
raise MissingEnumTypeError.new unless type_exists
|
297
|
+
|
298
|
+
# ALTER TYPE may not be performed within a transaction: obtain a
|
299
|
+
# session-level advisory lock to prevent racing.
|
300
|
+
begin
|
301
|
+
connection.execute("SELECT pg_advisory_lock(#{ENUM_TYPE_LOCK_KEY})")
|
302
|
+
|
303
|
+
quoted_type = connection.quote_table_name(sql_enum_type)
|
304
|
+
current_members = connection.select_values(<<~SQL)
|
305
|
+
SELECT unnest(enum_range(null::#{quoted_type}, null::#{quoted_type}));
|
306
|
+
SQL
|
307
|
+
|
308
|
+
(names - current_members).each do |name|
|
309
|
+
connection.execute("ALTER TYPE #{quoted_type} ADD VALUE #{connection.quote(name)}")
|
310
|
+
end
|
311
|
+
ensure
|
312
|
+
connection.execute("SELECT pg_advisory_unlock(#{ENUM_TYPE_LOCK_KEY})")
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
# Set each value as a constant on this class. If reinitializing, only
|
317
|
+
# replace if the enum constant has changed, otherwise update attributes as
|
318
|
+
# necessary.
|
319
|
+
def cache_values(model, values, name_attr)
|
320
|
+
values.map do |value|
|
321
|
+
constant_name = constant_name(value.read_attribute(name_attr))
|
322
|
+
|
323
|
+
if model.const_defined?(constant_name, false)
|
324
|
+
existing_value = model.const_get(constant_name, false)
|
325
|
+
if existing_value.is_a?(AbstractDummyModel) ||
|
326
|
+
existing_value.read_attribute(name_attr) != value.read_attribute(name_attr)
|
327
|
+
then
|
328
|
+
# Replace with new value
|
329
|
+
model.send(:remove_const, constant_name)
|
330
|
+
model.const_set(constant_name, value)
|
331
|
+
elsif existing_value.attributes != value.attributes
|
332
|
+
existing_value.reload.freeze
|
333
|
+
else
|
334
|
+
existing_value
|
335
|
+
end
|
336
|
+
else
|
337
|
+
model.const_set(constant_name, value)
|
338
|
+
end
|
339
|
+
end
|
340
|
+
end
|
341
|
+
|
342
|
+
def constant_name(member_name)
|
343
|
+
value = member_name.strip.gsub(/[^\w\s-]/, '_').underscore
|
344
|
+
return nil if value.blank?
|
345
|
+
|
346
|
+
value.gsub!(/\s+/, '_')
|
347
|
+
value.gsub!(/_{2,}/, '_')
|
348
|
+
value.upcase!
|
349
|
+
value
|
350
|
+
end
|
351
|
+
|
352
|
+
def log_warning(message)
|
353
|
+
if (logger = ActiveRecord::Base.logger)
|
354
|
+
logger.warn(message)
|
355
|
+
else
|
356
|
+
STDERR.puts(message)
|
357
|
+
end
|
358
|
+
end
|
359
|
+
|
360
|
+
class AbstractDummyModel
|
361
|
+
attr_reader :ordinal, :enum_constant, :attributes
|
362
|
+
|
363
|
+
def initialize(id, name, attributes = {})
|
364
|
+
@ordinal = id
|
365
|
+
@enum_constant = name
|
366
|
+
@attributes = attributes.with_indifferent_access
|
367
|
+
freeze
|
368
|
+
end
|
369
|
+
|
370
|
+
def to_sym
|
371
|
+
enum_constant.to_sym
|
372
|
+
end
|
373
|
+
|
374
|
+
alias id ordinal
|
375
|
+
|
376
|
+
def read_attribute(attr)
|
377
|
+
case attr.to_s
|
378
|
+
when 'id'
|
379
|
+
ordinal
|
380
|
+
when self.class.name_attr.to_s
|
381
|
+
enum_constant
|
382
|
+
else
|
383
|
+
@attributes[attr]
|
384
|
+
end
|
385
|
+
end
|
386
|
+
|
387
|
+
alias [] read_attribute
|
388
|
+
|
389
|
+
def method_missing(meth, *args)
|
390
|
+
if attributes.has_key?(meth)
|
391
|
+
unless args.empty?
|
392
|
+
raise ArgumentError.new("wrong number of arguments (#{args.size} for 0)")
|
393
|
+
end
|
394
|
+
|
395
|
+
attributes[meth]
|
396
|
+
else
|
397
|
+
super
|
398
|
+
end
|
399
|
+
end
|
400
|
+
|
401
|
+
def respond_to_missing?(meth, include_private = false)
|
402
|
+
attributes.has_key?(meth) || super
|
403
|
+
end
|
404
|
+
|
405
|
+
def freeze
|
406
|
+
@ordinal.freeze
|
407
|
+
@enum_constant.freeze
|
408
|
+
@attributes.freeze
|
409
|
+
@attributes.each do |k, v|
|
410
|
+
k.freeze
|
411
|
+
v.freeze
|
412
|
+
end
|
413
|
+
super
|
414
|
+
end
|
415
|
+
|
416
|
+
def self.for_name(name_attr)
|
417
|
+
Class.new(self) do
|
418
|
+
define_singleton_method(:name_attr, -> { name_attr })
|
419
|
+
alias_method name_attr, :enum_constant
|
420
|
+
end
|
421
|
+
end
|
422
|
+
end
|
423
|
+
|
424
|
+
def build_dummy_class(model, name_attr)
|
425
|
+
dummy_model = PersistentEnum.dummy_class(model, name_attr)
|
426
|
+
return dummy_model if dummy_model.present?
|
427
|
+
|
428
|
+
dummy_model = AbstractDummyModel.for_name(name_attr)
|
429
|
+
model.const_set(:DummyModel, dummy_model)
|
430
|
+
dummy_model
|
431
|
+
end
|
432
|
+
end
|
433
|
+
|
434
|
+
ActiveRecord::Base.send(:include, self)
|
435
|
+
end
|
436
|
+
|
437
|
+
require 'persistent_enum/railtie' if defined?(Rails)
|