constant_record 0.5.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/.gitignore +22 -0
- data/.travis.yml +5 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +215 -0
- data/Rakefile +8 -0
- data/constant_record.gemspec +31 -0
- data/lib/constant_record/version.rb +3 -0
- data/lib/constant_record.rb +220 -0
- data/spec/constant_record_spec.rb +161 -0
- data/spec/data/authors.yml +9 -0
- data/spec/data/empty.yml +0 -0
- data/spec/data/publishers.yml +7 -0
- data/spec/spec_helper.rb +70 -0
- metadata +135 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 0c54e7e14d065796037c8c68789bf16ed980cbdc
|
4
|
+
data.tar.gz: e2756dc08f225ce136ac3f07dc20d1b773fa579e
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 56847eabe09d8c35111905feca49d35e8dafc5ea70226b213e05c116ea01b75d61709aa6e553e3aa5902a22e7a910232082cf3c7663cd1f2605b64f4f254059d
|
7
|
+
data.tar.gz: d5c59bf34e24b0ab5b7fbfcd509de8c5e006cf164528dada95de3a6183486f412c40b4214a1eafcd5be97676c0975f6a9f9030ea2d776070bac7b0cada8bb6e8
|
data/.gitignore
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
.bundle
|
4
|
+
.config
|
5
|
+
.yardoc
|
6
|
+
Gemfile.lock
|
7
|
+
InstalledFiles
|
8
|
+
_yardoc
|
9
|
+
coverage
|
10
|
+
doc/
|
11
|
+
lib/bundler/man
|
12
|
+
pkg
|
13
|
+
rdoc
|
14
|
+
spec/reports
|
15
|
+
test/tmp
|
16
|
+
test/version_tmp
|
17
|
+
tmp
|
18
|
+
*.bundle
|
19
|
+
*.so
|
20
|
+
*.o
|
21
|
+
*.a
|
22
|
+
mkmf.log
|
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Nate Wiger
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,215 @@
|
|
1
|
+
# ConstantRecord
|
2
|
+
|
3
|
+
In-memory ActiveRecord querying and associations for static data.
|
4
|
+
|
5
|
+
Compatible with all current versions of Rails, from 3.x up through 4.1
|
6
|
+
(and beyond, theoretically).
|
7
|
+
|
8
|
+
Unlike previous (ambitious) approaches that have tried to duplicate ActiveRecord
|
9
|
+
functionality in a separate set of classes, this is a simple shim of < 200 LOC
|
10
|
+
that creates an in-memory SQLite database for the relevant models. This is designed
|
11
|
+
to minimize breakage between Rails versions, and also avoids recreating ActiveRecord
|
12
|
+
features.
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
Add `constant_record` to your Gemfile:
|
17
|
+
|
18
|
+
gem 'constant_record'
|
19
|
+
|
20
|
+
Then run `bundle install`. Or, install it yourself manually, if you're into that sort of thing:
|
21
|
+
|
22
|
+
$ gem install constant_record
|
23
|
+
|
24
|
+
*Note: The gem name is constant_record with an underscore, unlike activerecord.*
|
25
|
+
|
26
|
+
## Usage
|
27
|
+
|
28
|
+
Just `include ConstantRecord` in any ActiveRecord class. Then, you can use `data` to add
|
29
|
+
data directly in that class for clarity:
|
30
|
+
|
31
|
+
class Genre < ActiveRecord::Base
|
32
|
+
include ConstantRecord
|
33
|
+
|
34
|
+
data id: 1, name: "Rock", slug: "rock"
|
35
|
+
data id: 2, name: "Hip-Hop", slug: "hiphop"
|
36
|
+
data id: 3, name: "Pop", slug: "pop"
|
37
|
+
end
|
38
|
+
|
39
|
+
Or, you can choose to keep your data in a YAML file:
|
40
|
+
|
41
|
+
class Genre < ActiveRecord::Base
|
42
|
+
include ConstantRecord
|
43
|
+
load_data File.join(Rails.root, 'config', 'data', 'genres.yml')
|
44
|
+
end
|
45
|
+
|
46
|
+
The YAML file should be an array of hashes:
|
47
|
+
|
48
|
+
# config/data/genres.yml
|
49
|
+
---
|
50
|
+
- id: 1
|
51
|
+
name: Rock
|
52
|
+
slug: rock
|
53
|
+
- id: 2
|
54
|
+
name: Hip-Hop
|
55
|
+
slug: hiphop
|
56
|
+
- id: 3
|
57
|
+
name: Pop
|
58
|
+
slug: hop
|
59
|
+
|
60
|
+
You can omit the filename if it follows the naming convention of `config/data/[table_name].yml`:
|
61
|
+
|
62
|
+
class Genre < ActiveRecord::Base
|
63
|
+
include ConstantRecord
|
64
|
+
load_data # config/data/genres.yml
|
65
|
+
end
|
66
|
+
|
67
|
+
Alternatively, you can load your data via some other external method. Note that you will need
|
68
|
+
to reload your data each time Rails restarts, since the data is in-memory only. This means
|
69
|
+
adding a reload hook after Unicorn / Passenger / Puma fork:
|
70
|
+
|
71
|
+
Genre.reload!
|
72
|
+
|
73
|
+
Once you define your class, this will create an in-memory `sqlite` database which is then
|
74
|
+
hooked into ActiveRecord. A database table is created on the fly, consisting of the columns
|
75
|
+
you use in the *first* `data` declaration. **Important:** This means if you have a couple
|
76
|
+
columns that aren't always present, *make sure to include them with `column_name: nil` on
|
77
|
+
the first `data` line:*
|
78
|
+
|
79
|
+
class Genre < ActiveRecord::Base
|
80
|
+
include ConstantRecord
|
81
|
+
|
82
|
+
data id: 1, name: "Rock", slug: "rock", region: nil, country: nil
|
83
|
+
data id: 2, name: "Hip-Hop", slug: "hiphop", region: 'North America'
|
84
|
+
data id: 3, name: "Pop", slug: "pop", country: 'US'
|
85
|
+
end
|
86
|
+
|
87
|
+
Once setup, all the familiar ActiveRecord finders work:
|
88
|
+
|
89
|
+
Genre.find(1)
|
90
|
+
Genre.find_by_slug("pop")
|
91
|
+
Genre.where(name: "Rock").first
|
92
|
+
|
93
|
+
Attempts to modify values will fail:
|
94
|
+
|
95
|
+
@genre = Genre.find(2)
|
96
|
+
@genre.slug = "hip-hop"
|
97
|
+
@genre.save! # nope
|
98
|
+
|
99
|
+
You'll get an `ActiveRecord::ReadOnlyRecord` exception.
|
100
|
+
|
101
|
+
## Auto Constants
|
102
|
+
|
103
|
+
ConstantRecord will also create constants on the fly for you if you have a `name` column.
|
104
|
+
Revisiting our example:
|
105
|
+
|
106
|
+
class Genre < ActiveRecord::Base
|
107
|
+
include ConstantRecord
|
108
|
+
|
109
|
+
data id: 1, name: "Rock", slug: "rock"
|
110
|
+
data id: 2, name: "Hip-Hop", slug: "hiphop"
|
111
|
+
data id: 3, name: "Pop", slug: "pop"
|
112
|
+
end
|
113
|
+
|
114
|
+
This will create:
|
115
|
+
|
116
|
+
Genre::ROCK = 1
|
117
|
+
Genre::HIP_HOP = 2
|
118
|
+
Genre::POP = 3
|
119
|
+
|
120
|
+
This makes it cleaner to do queries in your app:
|
121
|
+
|
122
|
+
Genre.find(Genre::ROCK)
|
123
|
+
Song.where(genre_id: Genre::ROCK)
|
124
|
+
|
125
|
+
And so on.
|
126
|
+
|
127
|
+
## Associations
|
128
|
+
|
129
|
+
Internally, ActiveRecord tries to do joins to retrieve associations. This doesn't work, since
|
130
|
+
the records live in different tables. Have no fear, you just need to `include ConstantRecord::Associations`
|
131
|
+
in the normal ActiveRecord class that is trying to associate to your ConstantRecord class:
|
132
|
+
|
133
|
+
class Genre < ActiveRecord::Base
|
134
|
+
include ConstantRecord
|
135
|
+
|
136
|
+
has_many :song_genres
|
137
|
+
has_many :songs, through: :song_genres
|
138
|
+
|
139
|
+
data id: 1, name: "Rock", slug: "rock", region: nil, country: nil
|
140
|
+
data id: 2, name: "Hip-Hop", slug: "hiphop", region: 'North America'
|
141
|
+
data id: 3, name: "Pop", slug: "pop", country: 'US'
|
142
|
+
end
|
143
|
+
|
144
|
+
class SongGenre < ActiveRecord::Base
|
145
|
+
belongs_to :genre_id
|
146
|
+
belongs_to :song_id
|
147
|
+
end
|
148
|
+
|
149
|
+
class Song < ActiveRecord::Base
|
150
|
+
include ConstantRecord::Associations
|
151
|
+
has_many :song_genres
|
152
|
+
has_many :songs, through: :song_genres
|
153
|
+
end
|
154
|
+
|
155
|
+
If you forget to do this, you'll get an error like this:
|
156
|
+
|
157
|
+
irb(main):001:0> @song = Song.first
|
158
|
+
irb(main):002:0> @song.genres
|
159
|
+
ActiveRecord::StatementInvalid: Could not find table 'song_genres'
|
160
|
+
|
161
|
+
It would be great to remove this shim, but I can't currently see a way without monkey-patching
|
162
|
+
the internals of ActiveRecord, which I don't want to do for 17 different reasons.
|
163
|
+
|
164
|
+
## Debugging
|
165
|
+
|
166
|
+
If you forget to define data, you'll get a "table doesn't exist" error:
|
167
|
+
|
168
|
+
class Publisher < ActiveRecord::Base
|
169
|
+
include ConstantRecord
|
170
|
+
|
171
|
+
# Oops no data
|
172
|
+
|
173
|
+
has_many :article_publishers
|
174
|
+
has_many :articles, through: :article_publishers
|
175
|
+
end
|
176
|
+
|
177
|
+
irb(main):001:0> @publisher = Publisher.first
|
178
|
+
irb(main):002:0> @publisher.articles
|
179
|
+
ActiveRecord::StatementInvalid: Could not find table 'articles'
|
180
|
+
|
181
|
+
This is because the table is created lazily when you first load data.
|
182
|
+
|
183
|
+
If you try to add a custom column on a different `data` line:
|
184
|
+
|
185
|
+
class Genre < ActiveRecord::Base
|
186
|
+
include ConstantRecord
|
187
|
+
|
188
|
+
data id: 1, name: "Rock", slug: "rock"
|
189
|
+
data id: 2, name: "Hip-Hop", slug: "hiphop"
|
190
|
+
data id: 3, name: "Pop", slug: "pop", ranking: 1 # oops
|
191
|
+
end
|
192
|
+
|
193
|
+
You'll get a table error:
|
194
|
+
|
195
|
+
ActiveRecord::UnknownAttributeError: unknown attribute: ranking
|
196
|
+
|
197
|
+
The solution is to include the same columns on each `data` line.
|
198
|
+
|
199
|
+
## Other Projects
|
200
|
+
|
201
|
+
Inspired by a couple previous efforts:
|
202
|
+
|
203
|
+
* Christoph Petschnig's [constantrecord](https://github.com/cpetschnig/constantrecord)
|
204
|
+
* Aaron Quint's [static_model](https://github.com/quirkey/static_model)
|
205
|
+
* Nico Taing's [yaml_record](https://github.com/nicotaing/yaml_record)
|
206
|
+
|
207
|
+
Other projects seen in the wild:
|
208
|
+
|
209
|
+
* [static_record](https://github.com/dejan/static_record)
|
210
|
+
* [constant_record](https://github.com/topdan/constant_record)
|
211
|
+
* [frozen_record](https://github.com/byroot/frozen_record)
|
212
|
+
|
213
|
+
All are good efforts, but unfortunately the Rails team is known to make sweeping
|
214
|
+
changes to internal ActiveRecord implementation details between different versions
|
215
|
+
of Rails. This makes it very difficult to maintain compatibility over time.
|
data/Rakefile
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'constant_record/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "constant_record"
|
8
|
+
spec.version = ConstantRecord::VERSION
|
9
|
+
spec.authors = ["Nate Wiger"]
|
10
|
+
spec.email = ["nwiger@gmail.com"]
|
11
|
+
spec.summary = %q{In-memory ActiveRecord querying and associations for static data.}
|
12
|
+
spec.description = <<-EndDesc
|
13
|
+
In-memory ActiveRecord querying and associations for static data.
|
14
|
+
Improves performance and decreases bugs due to data mismatches.
|
15
|
+
EndDesc
|
16
|
+
spec.homepage = ""
|
17
|
+
spec.license = "MIT"
|
18
|
+
|
19
|
+
spec.files = `git ls-files -z`.split("\x0")
|
20
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
21
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
22
|
+
spec.require_paths = ["lib"]
|
23
|
+
|
24
|
+
# Allow any AR versions, but need at least one. Also sqlite for the table.
|
25
|
+
spec.add_dependency "activerecord"
|
26
|
+
spec.add_dependency "sqlite3"
|
27
|
+
|
28
|
+
spec.add_development_dependency "bundler", "~> 1.6"
|
29
|
+
spec.add_development_dependency "rake"
|
30
|
+
spec.add_development_dependency "bacon"
|
31
|
+
end
|
@@ -0,0 +1,220 @@
|
|
1
|
+
#
|
2
|
+
# To use, `include ConstantRecord` in any ActiveRecord class. Then, you can use `data`
|
3
|
+
# to add data directly in that class for clarity:
|
4
|
+
#
|
5
|
+
# class Genre < ActiveRecord::Base
|
6
|
+
# include ConstantRecord
|
7
|
+
#
|
8
|
+
# data id: 1, name: "Rock", slug: "rock"
|
9
|
+
# data id: 2, name: "Hip-Hop", slug: "hiphop"
|
10
|
+
# data id: 3, name: "Pop", slug: "pop"
|
11
|
+
# end
|
12
|
+
#
|
13
|
+
# Or, you can choose to keep your data in a YAML file:
|
14
|
+
#
|
15
|
+
# class Genre < ActiveRecord::Base
|
16
|
+
# include ConstantRecord
|
17
|
+
# load_data File.join(Rails.root, 'config', 'data', 'genres.yml')
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
# The YAML file should be an array of hashes. Once initialized, all
|
21
|
+
# familiar ActiveRecord finders and associations should work as expected.
|
22
|
+
#
|
23
|
+
require "active_record"
|
24
|
+
require "constant_record/version"
|
25
|
+
|
26
|
+
module ConstantRecord
|
27
|
+
class Error < StandardError; end
|
28
|
+
class BadDataFile < Error; end
|
29
|
+
|
30
|
+
MEMORY_DBCONFIG = {adapter: 'sqlite3', database: ":memory:", pool: 5}.freeze
|
31
|
+
|
32
|
+
|
33
|
+
class << self
|
34
|
+
def memory_dbconfig
|
35
|
+
@memory_dbconfig || MEMORY_DBCONFIG
|
36
|
+
end
|
37
|
+
|
38
|
+
def memory_dbconfig=(config)
|
39
|
+
@memory_dbconfig = config
|
40
|
+
end
|
41
|
+
|
42
|
+
def data_dir
|
43
|
+
@data_dir || File.join('config', 'data')
|
44
|
+
end
|
45
|
+
|
46
|
+
def data_dir=(path)
|
47
|
+
@data_dir = path
|
48
|
+
end
|
49
|
+
|
50
|
+
def included(base)
|
51
|
+
base.extend DataLoading
|
52
|
+
base.extend Associations
|
53
|
+
base.send :include, ReadOnly
|
54
|
+
base.establish_connection(memory_dbconfig) unless base.send :connected?
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
#
|
59
|
+
# Loads data either directly in the model class, or from a YAML file.
|
60
|
+
#
|
61
|
+
module DataLoading
|
62
|
+
def data_file
|
63
|
+
@data_file || File.join(ConstantRecord.data_dir, "#{self.to_s.tableize}.yml")
|
64
|
+
end
|
65
|
+
|
66
|
+
def load_data(file=nil)
|
67
|
+
@data_file = file
|
68
|
+
reload!
|
69
|
+
end
|
70
|
+
|
71
|
+
def load(reload=false)
|
72
|
+
return if loaded? && !reload
|
73
|
+
records = YAML.load_file(data_file)
|
74
|
+
|
75
|
+
if !records.is_a?(Array) or records.empty?
|
76
|
+
raise BadDataFile, "Expected array in data file #{data_file}: #{records.inspect}"
|
77
|
+
end
|
78
|
+
|
79
|
+
# Call our method to populate data
|
80
|
+
records.each{|r| data r}
|
81
|
+
|
82
|
+
@loaded = true
|
83
|
+
end
|
84
|
+
|
85
|
+
def reload!
|
86
|
+
load(true)
|
87
|
+
end
|
88
|
+
|
89
|
+
def loaded?
|
90
|
+
@loaded || false
|
91
|
+
end
|
92
|
+
|
93
|
+
# Define a constant record: data id: 1, name: "California", slug: "CA"
|
94
|
+
def data(attrib)
|
95
|
+
attrib.symbolize_keys!
|
96
|
+
raise ArgumentError, "#{self}.data expects a Hash of attributes" unless attrib.is_a?(Hash)
|
97
|
+
|
98
|
+
unless attrib[primary_key.to_sym]
|
99
|
+
raise ArgumentError, "#{self}.data missing primary key '#{primary_key}': #{attrib.inspect}"
|
100
|
+
end
|
101
|
+
|
102
|
+
unless @table_was_created
|
103
|
+
create_memory_table(attrib)
|
104
|
+
@table_was_created = true
|
105
|
+
end
|
106
|
+
|
107
|
+
new_record = new(attrib)
|
108
|
+
new_record.id = attrib[primary_key.to_sym]
|
109
|
+
|
110
|
+
# Check for duplicates
|
111
|
+
if old_record = find_by_id(new_record.id)
|
112
|
+
raise ActiveRecord::RecordNotUnique,
|
113
|
+
"Duplicate #{self} id=#{new_record.id} found: #{new_record} vs #{old_record}"
|
114
|
+
end
|
115
|
+
new_record.save!
|
116
|
+
|
117
|
+
# create Ruby constants as well, so "id: 3, name: Sky" gets SKY=3
|
118
|
+
if new_record.respond_to?(:name) and name = new_record.name
|
119
|
+
const_name =
|
120
|
+
name.to_s.upcase.strip.gsub(/[-\s]+/,'_').sub(/^[0-9_]+/,'').gsub(/\W+/,'')
|
121
|
+
const_set const_name, new_record.id unless const_defined?(const_name)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
protected
|
126
|
+
|
127
|
+
# Create our in-memory table based on columns we have defined in our data.
|
128
|
+
def create_memory_table(attrib)
|
129
|
+
db_columns = {}
|
130
|
+
attrib.each do |col,val|
|
131
|
+
next if col.to_s == 'id' # skip pk
|
132
|
+
db_columns[col] =
|
133
|
+
case val
|
134
|
+
when Integer then :integer
|
135
|
+
when Float then :decimal
|
136
|
+
when Date then :date
|
137
|
+
when DateTime, Time then :datetime
|
138
|
+
else :string
|
139
|
+
end
|
140
|
+
end
|
141
|
+
|
142
|
+
# Create the table in memory
|
143
|
+
connection.create_table(table_name) do |t|
|
144
|
+
db_columns.each do |col,type|
|
145
|
+
t.column col, type
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
#
|
152
|
+
# Hooks to integrate ActiveRecord associations with constant records.
|
153
|
+
#
|
154
|
+
module Associations
|
155
|
+
def self.included(base)
|
156
|
+
base.extend self # support "include" as well
|
157
|
+
end
|
158
|
+
|
159
|
+
#
|
160
|
+
# Override the default ActiveRecord.has_many(:through) that does in-database joins,
|
161
|
+
# with a method that makes two fetches. It's the only reliable way to traverse
|
162
|
+
# databases. Hopefully one (or both) of these tables are in-memory ConstantRecords
|
163
|
+
# so that we're not making real DB calls.
|
164
|
+
#
|
165
|
+
def has_many(other_table, options={})
|
166
|
+
# puts "#{self}(#{table_name}).has_many #{other_table.inspect}, #{options.inspect}"
|
167
|
+
if join_tab = options[:through]
|
168
|
+
foreign_key = options[:foreign_key] || other_table.to_s.singularize.foreign_key
|
169
|
+
prime_key = options[:primary_key] || primary_key
|
170
|
+
class_name = options[:class_name] || other_table.to_s.classify
|
171
|
+
join_key = table_name.to_s.singularize.foreign_key
|
172
|
+
|
173
|
+
define_method other_table do
|
174
|
+
join_class = join_tab.to_s.classify.constantize
|
175
|
+
ids = join_class.where(join_key => send(prime_key)).pluck(foreign_key)
|
176
|
+
return [] if ids.empty?
|
177
|
+
class_name.constantize.where(id: ids)
|
178
|
+
end
|
179
|
+
else
|
180
|
+
super other_table, options
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
#
|
186
|
+
# Raise an error if the application attempts to change constant records.
|
187
|
+
#
|
188
|
+
module ReadOnly
|
189
|
+
def self.included(base)
|
190
|
+
base.extend ClassMethods
|
191
|
+
end
|
192
|
+
|
193
|
+
def readonly?
|
194
|
+
# have to allow inserts to load_data
|
195
|
+
new_record? ? false : true
|
196
|
+
end
|
197
|
+
|
198
|
+
def delete
|
199
|
+
raise ActiveRecord::ReadOnlyRecord
|
200
|
+
end
|
201
|
+
|
202
|
+
def destroy
|
203
|
+
raise ActiveRecord::ReadOnlyRecord
|
204
|
+
end
|
205
|
+
|
206
|
+
module ClassMethods
|
207
|
+
def delete(id_or_array)
|
208
|
+
raise ActiveRecord::ReadOnlyRecord
|
209
|
+
end
|
210
|
+
|
211
|
+
def delete_all(conditions = nil)
|
212
|
+
raise ActiveRecord::ReadOnlyRecord
|
213
|
+
end
|
214
|
+
|
215
|
+
def update_all(conditions = nil)
|
216
|
+
raise ActiveRecord::ReadOnlyRecord
|
217
|
+
end
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
@@ -0,0 +1,161 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
describe "ConstantRecord" do
|
4
|
+
describe "loading data" do
|
5
|
+
it "data(attr: val)" do
|
6
|
+
date1 = Time.now
|
7
|
+
date2 = Date.new
|
8
|
+
date3 = DateTime.new
|
9
|
+
|
10
|
+
# insert out of order to ensure we can override ID
|
11
|
+
Author.data(id: 1, name: "One", birthday: date1)
|
12
|
+
Author.data(id: 3, name: "Three", birthday: date3)
|
13
|
+
Author.data(id: 2, name: "Two", birthday: date2)
|
14
|
+
|
15
|
+
Author.count.should == 3
|
16
|
+
Author.find(1).name.should == "One"
|
17
|
+
Author.find(2).name.should == "Two"
|
18
|
+
Author.find(3).name.should == "Three"
|
19
|
+
|
20
|
+
author = Author.find_by_name("One")
|
21
|
+
author.id.should == 1
|
22
|
+
author.birthday.should == date1
|
23
|
+
|
24
|
+
author = Author.find_by_name("Two")
|
25
|
+
author.id.should == 2
|
26
|
+
author.birthday.should == date2
|
27
|
+
|
28
|
+
author = Author.find_by_name("Three")
|
29
|
+
author.id.should == 3
|
30
|
+
author.birthday.should == date3
|
31
|
+
end
|
32
|
+
|
33
|
+
it "supports AR finders" do
|
34
|
+
Author.where(id: [1,2]).count.should == 2
|
35
|
+
Author.where(['name like ?', 'Three']).count.should == 1
|
36
|
+
Author.where(['birthday <= ?', Time.now]).count.should == 3
|
37
|
+
end
|
38
|
+
|
39
|
+
it "rejects dup ID's" do
|
40
|
+
should.raise(ActiveRecord::RecordNotUnique){ Author.data(id: 3, name: "Three") }
|
41
|
+
end
|
42
|
+
|
43
|
+
it "loads a YAML file path/to/my.yml" do
|
44
|
+
Publisher.load_data
|
45
|
+
Publisher.count.should == 3
|
46
|
+
Publisher.find(2).name == 'Penguin'
|
47
|
+
end
|
48
|
+
|
49
|
+
it "supports reload!" do
|
50
|
+
Publisher.where('id is not null').delete_all # hackaround ReadOnlyRecord
|
51
|
+
Publisher.count.should == 0
|
52
|
+
Publisher.reload!
|
53
|
+
Publisher.count.should == 3
|
54
|
+
Publisher.data(id: 23, name: "Flop")
|
55
|
+
Publisher.count.should == 4
|
56
|
+
end
|
57
|
+
|
58
|
+
it "rejects missing data files" do
|
59
|
+
should.raise(Errno::ENOENT){ Publisher.load_data 'nope.yml' }
|
60
|
+
end
|
61
|
+
|
62
|
+
it "rejects empty data files" do
|
63
|
+
should.raise(ConstantRecord::BadDataFile){ Publisher.load_data 'spec/data/empty.yml' }
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
describe "creates constants" do
|
68
|
+
it "simple values" do
|
69
|
+
Publisher.data(id: 3, name: "Simple Value")
|
70
|
+
Publisher::SIMPLE_VALUE.should == 3
|
71
|
+
end
|
72
|
+
it "complex strings" do
|
73
|
+
Publisher.data(id: 4, name: " 2 Non-Fiction, Bestsellers! ")
|
74
|
+
Publisher::NON_FICTION_BESTSELLERS.should == 4
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
describe "associations" do
|
79
|
+
it "belongs_to" do
|
80
|
+
Article.create!(author_id: 1)
|
81
|
+
Article.create!(author_id: 2)
|
82
|
+
Article.create!(author_id: 3)
|
83
|
+
Article.create!(author_id: 2)
|
84
|
+
Article.create!(author_id: 1)
|
85
|
+
author = Author.find(1)
|
86
|
+
article = Article.find(5)
|
87
|
+
article.author.id.should == author.id
|
88
|
+
end
|
89
|
+
|
90
|
+
it "has_many" do
|
91
|
+
author = Author.find(2)
|
92
|
+
author.articles.count.should == 2
|
93
|
+
author.articles.find(4).id.should == 4
|
94
|
+
end
|
95
|
+
|
96
|
+
it "has_many through (up)" do
|
97
|
+
ArticlePublisher.create!(article_id: 4, publisher_id: 7)
|
98
|
+
ArticlePublisher.create!(article_id: 5, publisher_id: 7)
|
99
|
+
ArticlePublisher.create!(article_id: 60, publisher_id: 7) # bogus
|
100
|
+
publisher = Publisher.find(7)
|
101
|
+
publisher.name.should == "Marvel"
|
102
|
+
publisher.article_publishers.count.should == 3
|
103
|
+
publisher.articles.count.should == 2
|
104
|
+
publisher.articles.each do |art|
|
105
|
+
art.should == Article.find(art.id)
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
it "has_many through (down)" do
|
110
|
+
ArticlePublisher.create!(article_id: 2, publisher_id: 1)
|
111
|
+
ArticlePublisher.create!(article_id: 2, publisher_id: 2)
|
112
|
+
ArticlePublisher.create!(article_id: 2, publisher_id: 30) # bogus
|
113
|
+
article = Article.find(2)
|
114
|
+
article.article_publishers.count.should == 3
|
115
|
+
article.publishers.count.should == 2
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
describe "readonly records" do
|
120
|
+
before do
|
121
|
+
@publisher = Publisher.find(1)
|
122
|
+
end
|
123
|
+
|
124
|
+
it "readonly? == true" do
|
125
|
+
@publisher.readonly?.should.be.true?
|
126
|
+
end
|
127
|
+
|
128
|
+
it "rejects destroy" do
|
129
|
+
should.raise(ActiveRecord::ReadOnlyRecord){ @publisher.destroy }
|
130
|
+
end
|
131
|
+
|
132
|
+
it "rejects delete" do
|
133
|
+
should.raise(ActiveRecord::ReadOnlyRecord){ @publisher.delete }
|
134
|
+
end
|
135
|
+
|
136
|
+
it "rejects update_all" do
|
137
|
+
should.raise(ActiveRecord::ReadOnlyRecord){ Publisher.update_all('id = null') }
|
138
|
+
end
|
139
|
+
|
140
|
+
# it "rejects update_all thru associations" do
|
141
|
+
# should.raise(ActiveRecord::ReadOnlyRecord){ Publisher.where(id: 1).update_all('id = null') }
|
142
|
+
# end
|
143
|
+
|
144
|
+
it "rejects delete_all" do
|
145
|
+
should.raise(ActiveRecord::ReadOnlyRecord){ Publisher.delete_all }
|
146
|
+
end
|
147
|
+
|
148
|
+
# it "rejects delete_all thru associations" do
|
149
|
+
# should.raise(ActiveRecord::ReadOnlyRecord){ Publisher.where(id: 1).delete_all }
|
150
|
+
# end
|
151
|
+
|
152
|
+
it "rejects destroy_all" do
|
153
|
+
should.raise(ActiveRecord::ReadOnlyRecord){ Publisher.destroy_all }
|
154
|
+
end
|
155
|
+
|
156
|
+
# it "rejects destroy_all thru associations" do
|
157
|
+
# should.raise(ActiveRecord::ReadOnlyRecord){ Publisher.where(id: 1).destroy_all }
|
158
|
+
# end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
data/spec/data/empty.yml
ADDED
File without changes
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
$LOAD_PATH.unshift File.expand_path(File.dirname(__FILE__) + '/../lib')
|
2
|
+
require 'bacon'
|
3
|
+
Bacon.summary_at_exit
|
4
|
+
if $0 =~ /\brspec$/
|
5
|
+
raise "\n===\nThese tests are in bacon, not rspec. Try: bacon #{ARGV * ' '}\n===\n"
|
6
|
+
end
|
7
|
+
|
8
|
+
require 'date' # datetime
|
9
|
+
require 'constant_record'
|
10
|
+
require 'active_record'
|
11
|
+
require 'sqlite3'
|
12
|
+
|
13
|
+
# Override path for testing purposes
|
14
|
+
TEST_YAML_DATA_DIR = File.join(File.dirname(__FILE__), 'data')
|
15
|
+
ConstantRecord.data_dir = TEST_YAML_DATA_DIR
|
16
|
+
|
17
|
+
# Our "persistent" sqlite database for "real" records (not in-memory)
|
18
|
+
TEST_SQLITE_DB_FILE = File.join(File.dirname(__FILE__), 'test.sqlite3')
|
19
|
+
File.unlink TEST_SQLITE_DB_FILE rescue nil
|
20
|
+
at_exit do
|
21
|
+
File.unlink TEST_SQLITE_DB_FILE rescue nil
|
22
|
+
end
|
23
|
+
|
24
|
+
ActiveRecord::Base.establish_connection(
|
25
|
+
adapter: 'sqlite3',
|
26
|
+
database: TEST_SQLITE_DB_FILE,
|
27
|
+
pool: 5
|
28
|
+
)
|
29
|
+
|
30
|
+
if ENV['DEBUG']
|
31
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
32
|
+
ActiveRecord::Base.logger.level = Logger::DEBUG
|
33
|
+
end
|
34
|
+
|
35
|
+
class Author < ActiveRecord::Base
|
36
|
+
include ConstantRecord
|
37
|
+
|
38
|
+
has_many :articles
|
39
|
+
end
|
40
|
+
|
41
|
+
class Publisher < ActiveRecord::Base
|
42
|
+
include ConstantRecord
|
43
|
+
|
44
|
+
has_many :article_publishers
|
45
|
+
has_many :articles, through: :article_publishers
|
46
|
+
end
|
47
|
+
|
48
|
+
class Article < ActiveRecord::Base
|
49
|
+
include ConstantRecord::Associations
|
50
|
+
belongs_to :author
|
51
|
+
has_many :article_publishers
|
52
|
+
has_many :publishers, through: :article_publishers
|
53
|
+
end
|
54
|
+
|
55
|
+
|
56
|
+
class ArticlePublisher < ActiveRecord::Base
|
57
|
+
belongs_to :article
|
58
|
+
belongs_to :publisher
|
59
|
+
end
|
60
|
+
|
61
|
+
# Setup ActiveRecord tables
|
62
|
+
Article.connection.create_table(:articles) do |t|
|
63
|
+
t.string :title
|
64
|
+
t.integer :author_id
|
65
|
+
end
|
66
|
+
|
67
|
+
ArticlePublisher.connection.create_table(:article_publishers) do |t|
|
68
|
+
t.string :article_id
|
69
|
+
t.integer :publisher_id
|
70
|
+
end
|
metadata
ADDED
@@ -0,0 +1,135 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: constant_record
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.5.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Nate Wiger
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-06-18 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: activerecord
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: sqlite3
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: bundler
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.6'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.6'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rake
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: bacon
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
description: |2
|
84
|
+
In-memory ActiveRecord querying and associations for static data.
|
85
|
+
Improves performance and decreases bugs due to data mismatches.
|
86
|
+
email:
|
87
|
+
- nwiger@gmail.com
|
88
|
+
executables: []
|
89
|
+
extensions: []
|
90
|
+
extra_rdoc_files: []
|
91
|
+
files:
|
92
|
+
- ".gitignore"
|
93
|
+
- ".travis.yml"
|
94
|
+
- Gemfile
|
95
|
+
- LICENSE.txt
|
96
|
+
- README.md
|
97
|
+
- Rakefile
|
98
|
+
- constant_record.gemspec
|
99
|
+
- lib/constant_record.rb
|
100
|
+
- lib/constant_record/version.rb
|
101
|
+
- spec/constant_record_spec.rb
|
102
|
+
- spec/data/authors.yml
|
103
|
+
- spec/data/empty.yml
|
104
|
+
- spec/data/publishers.yml
|
105
|
+
- spec/spec_helper.rb
|
106
|
+
homepage: ''
|
107
|
+
licenses:
|
108
|
+
- MIT
|
109
|
+
metadata: {}
|
110
|
+
post_install_message:
|
111
|
+
rdoc_options: []
|
112
|
+
require_paths:
|
113
|
+
- lib
|
114
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
115
|
+
requirements:
|
116
|
+
- - ">="
|
117
|
+
- !ruby/object:Gem::Version
|
118
|
+
version: '0'
|
119
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
120
|
+
requirements:
|
121
|
+
- - ">="
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: '0'
|
124
|
+
requirements: []
|
125
|
+
rubyforge_project:
|
126
|
+
rubygems_version: 2.2.2
|
127
|
+
signing_key:
|
128
|
+
specification_version: 4
|
129
|
+
summary: In-memory ActiveRecord querying and associations for static data.
|
130
|
+
test_files:
|
131
|
+
- spec/constant_record_spec.rb
|
132
|
+
- spec/data/authors.yml
|
133
|
+
- spec/data/empty.yml
|
134
|
+
- spec/data/publishers.yml
|
135
|
+
- spec/spec_helper.rb
|