schemattr 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3162b47b2a1acf700ca81a8ee47222391cf0711ad4b5194b9d5c46cf19584162
4
+ data.tar.gz: 12b294d20e189e9d16bb23644a26199829bccd3c42ba1b7662aea33b79ae4662
5
+ SHA512:
6
+ metadata.gz: 1134ffee2611f6f203f3891fe21088121a80f7ceb6a02197dd8cf4aa97b114d575a82c6652217faafb840ecbf6dbf904d61169984ee3cf7fb499dcb035f1a2fe
7
+ data.tar.gz: dee2aa5f9b8120885f8a0422de822e5ced1bf60103668149963d8ce8866c535db0915123427968af0b3b85653fb18626540190bad2ca16120da5bd79c7385c26
data/MIT.LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ Copyright 2015 Jeremy Jackson / ModeSet
2
+
3
+ https://github.com/modeset/schemattr
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,307 @@
1
+ Schemattr
2
+ =========
3
+
4
+ [![Gem Version](https://img.shields.io/gem/v/schemattr.svg)](http://badge.fury.io/rb/schemattr)
5
+ [![Build Status](https://img.shields.io/travis/modeset/schemattr.svg)](https://travis-ci.org/modeset/schemattr)
6
+ [![Code Climate](https://codeclimate.com/github/modeset/schemattr/badges/gpa.svg)](https://codeclimate.com/github/modeset/schemattr)
7
+ [![Test Coverage](https://codeclimate.com/github/modeset/schemattr/badges/coverage.svg)](https://codeclimate.com/github/modeset/schemattr)
8
+ [![License](https://img.shields.io/badge/license-MIT-brightgreen.svg)](http://opensource.org/licenses/MIT)
9
+ [![Dependency Status](https://gemnasium.com/modeset/schemattr.svg)](https://gemnasium.com/modeset/schemattr)
10
+
11
+ Schemattr is an ActiveRecord extension that provides a helpful schema-less attribute DSL. It can be used to define a
12
+ simple schema for a single attribute that can change over time without having to migrate existing data.
13
+
14
+ ### Background
15
+
16
+ Let's say you have a User model, and that model has a simple concept of settings -- just one for now. It's a boolean
17
+ named `opted_in`, and it means that the user is opted in to receive email updates. Sweet, we go add a migration for this
18
+ setting and migrate. Ship it, we're done with that feature.
19
+
20
+ Ok, so now it's a year later and your project has grown a lot. You have over 4MM users, and in that year there's been a
21
+ lot of business requirements that necessitated new settings for users. Each setting has been added ad hoc, as needed --
22
+ there's now three email lists, and users can opt in and out of each one independently.
23
+
24
+ This is where Schemattr comes in. Adding a new setting, or changing the name of an existing setting is non-trivial at
25
+ this point of your projects life-cycle, and requires a multi-step migration. You'll need to add the column (don't set a
26
+ default for that column, because that locks the table!), then you'll need to update each record in batches, once
27
+ complete you'll set a default, and finally you'll want to add a null constraint. This can become a hassle, and
28
+ introduces complexity to your deployments.
29
+
30
+ Schemattr allows you to move all of those settings into a single JSON (or similarly serialized) column. It can behave as
31
+ though the column is defined on the record itself through delegation, allows providing overrides for getter/setter
32
+ methods, can keep a real column synced with one if its fields, and more.
33
+
34
+ If you're using Schemattr and want to add a new setting field, it's as simple as adding a new field to the attribute
35
+ schema and setting a default right there in the code. No migrations, no hassles, easy deployment.
36
+
37
+
38
+ ## Table of Contents
39
+
40
+ 1. [Installation](#installation)
41
+ 2. [Usage](#usage)
42
+ - [Field types](#field-types)
43
+ - [Delegating](#delegating)
44
+ - [Strict mode](#strict-mode-vs-arbitrary-fields)
45
+ - [Overriding](#overriding-functionality)
46
+ - [Renaming fields](#renaming-fields)
47
+ - [Syncing attributes](#syncing-attributes)
48
+
49
+
50
+ ## Installation
51
+
52
+ Add it to your Gemfile:
53
+ ```ruby
54
+ gem 'schemattr'
55
+ ```
56
+
57
+ And then execute:
58
+ ```shell
59
+ $ bundle
60
+ ```
61
+
62
+ Or install it yourself as:
63
+ ```shell
64
+ $ gem install schemattr
65
+ ```
66
+
67
+
68
+ ## Usage
69
+
70
+ In the examples we assume there's already a User model and table.
71
+
72
+ First, let's create a migration to add your schema-less attribute. In postgres you can use a JSON column. We use the
73
+ postgres JSON type as our example because the JSON type allows queries and indexing, and hstore does annoying things to
74
+ booleans. We don't need to set our default value to an empty object because Schemattr handles that for us.
75
+
76
+ *Note*: If you're using a different database provider, like sqlite3 for instance, you can use a text column and tell
77
+ ActiveRecord to serialize that column (e.g. `serialize :settings` in your model). Though, you won't be able to easily
78
+ query in these cases so consider your options.
79
+
80
+ ```ruby
81
+ class AddSettingsToUsers < ActiveRecord::Migration
82
+ def change
83
+ add_column :users, :settings, :json
84
+ end
85
+ end
86
+ ```
87
+
88
+ Schemattr hooks into ActiveRecord and provides the `attribute_schema` method on any model that inherits from
89
+ ActiveRecord. This method provides a simple DSL that allows you to define the schema for the attribute. You can define
90
+ various fields, specify their types, defaults if needed, and additional options.
91
+
92
+ ```ruby
93
+ class User < ActiveRecord::Base
94
+ attribute_schema :settings do
95
+ boolean :opted_in, default: true
96
+ boolean :email_list_advanced, default: false
97
+ boolean :email_list_expert, default: false
98
+ end
99
+ end
100
+ ```
101
+
102
+ Notice that we've done nothing else, but we already have a working version of what we want. It's shippable.
103
+
104
+ ```
105
+ user = User.new
106
+ user.settings.opted_in? # => true
107
+ user.settings.email_group_advanced? # => false
108
+ user.settings.email_group_expert? # => false
109
+ ```
110
+
111
+ If we save the user at this point, these settings will be persisted. We can also make changes to them at this point, and
112
+ when they're persisted they'll include whatever we've changed them to be. If we don't save the user, that's ok too --
113
+ they'll just be the defaults if we ever ask again.
114
+
115
+ ### Field types
116
+
117
+ The various field types are outlined below. When you define a string field for instance, the value will be coerced into
118
+ a string at the time that it's set.
119
+
120
+ type | description
121
+ ---------|--------------
122
+ boolean | boolean value
123
+ string | string value
124
+ text | same as string type
125
+ integer | number value
126
+ bigint | same as integer
127
+ float | floating point number value
128
+ decimal | same as float
129
+ datetime | datetime object
130
+ time | time object (stored the same as datetime)
131
+ date | date object
132
+
133
+ You can additionally define your own types using `field :foo, :custom_type` and there will no coercion at the time the
134
+ field is set -- this is intended for when you need something that doesn't care what type it is. This generally makes it
135
+ harder to use in forms however.
136
+
137
+ ### Delegating
138
+
139
+ If you don't like the idea of having to access these attributes at `user.settings` you can specify that you'd like them
140
+ delegated. This adds delegation of the methods that exist on settings to the User instances.
141
+
142
+ ```ruby
143
+ attribute_schema :settings, delegated: true do
144
+ field :opted_in, :boolean, default: true
145
+ end
146
+ ```
147
+
148
+ ```ruby
149
+ user = User.new
150
+ user.opted_in = false
151
+ user.settings.opted_in? # => false
152
+ user.opted_in? # => false
153
+ ```
154
+
155
+ ### Strict mode vs. arbitrary fields
156
+
157
+ By default, Schemattr doesn't allow arbitrary fields to be added, but it supports it. When strict mode is disabled, it
158
+ allows any arbitrary field to be set or asked for.
159
+
160
+ *Note*: When delegated and strict mode is disabled, you cannot set arbitrary fields on the model directly and must
161
+ access them through the attribute that you've defined -- in our case, it's `settings`.
162
+
163
+ ```ruby
164
+ attribute_schema :settings, delegated: true, strict: false do
165
+ field :opted_in, :boolean, default: true
166
+ end
167
+ ```
168
+
169
+ ```ruby
170
+ user = User.new
171
+ user.settings.foo # => nil
172
+ user.settings.foo = "bar"
173
+ user.settings.foo # => "bar"
174
+ user.foo # => NoMethodError
175
+ ```
176
+
177
+ ### Overriding
178
+
179
+ Schemattr provides the ability to specify your own attribute class. By doing so you can provide your own getters and
180
+ setters and do more complex logic. In this example we're providing the inverse of `opted_in` with an `opted_out` psuedo
181
+ field.
182
+
183
+ ```ruby
184
+ class UserSettings < Schemattr::Attribute
185
+ def opted_out
186
+ !self[:opted_in]
187
+ end
188
+ alias_method :opted_out, :opted_out?
189
+
190
+ def opted_out=(val)
191
+ opted_in = !val
192
+ end
193
+ end
194
+ ```
195
+
196
+ ```ruby
197
+ attribute_schema :settings, class: UserSettings do
198
+ field :opted_in, :boolean, default: true
199
+ end
200
+ ```
201
+
202
+ ```ruby
203
+ user = User.new
204
+ user.settings.opted_out? # => false
205
+ user.settings.opted_in? # => true
206
+ user.settings.opted_out = true
207
+ user.settings.opted_in? # => false
208
+ ```
209
+
210
+ Our custom `opted_out` psuedo field won't be persisted, because it's not a defined field and is just an accessor for an
211
+ existing field that is persisted (`opted_in`).
212
+
213
+ #### Getters and setters
214
+
215
+ When overriding the attribute class with your own, you can provide your own custom getters and setters as well. These
216
+ will not be overridden by whatever Schemattr thinks they should do. Take this example, where when someone turns on or
217
+ off a setting we want to subscribe/unsubscribe them to an email list via a third party.
218
+
219
+ ```ruby
220
+ class UserSettings < Schemattr::Attribute
221
+ def opted_in=(val)
222
+ if val
223
+ SubscribeEmail.perform_async(model.email)
224
+ else
225
+ UnsubscribeEmail.perform_async(model.email)
226
+ end
227
+ # there is no super, so you must set it manually.
228
+ self[:opted_in] = val
229
+ end
230
+ end
231
+ ```
232
+
233
+ *Note*: This is not a real world scenario but serves our purposes of describing an example.
234
+
235
+ ### Renaming fields
236
+
237
+ Schemattr makes it easy to rename fields as well. Let's say you've got a field named `opted_in`, as the examples have
238
+ shown thus far. But you've added new email lists, and you think `opted_in` is too vague. Like, opted in for what?
239
+
240
+ We can create a new field that is correctly named, and specify what attribute we want to pull the value from.
241
+
242
+ ```ruby
243
+ attribute_schema :settings do
244
+ # field :opted_in, :boolean, default: true
245
+ field :email_list_beginner, :boolean, from: :opted_in, default: true
246
+ end
247
+ ```
248
+
249
+ Specifying the `from: :opted_in` option will tell Schemattr to look for the value that may have already been defined in
250
+ `opted_in` before the rename. This allows for slow migrations, but you can also write a migration to ensure this happens
251
+ quickly.
252
+
253
+ ### Syncing attributes
254
+
255
+ There's a down side to keeping some things internal to this settings attribute. You can query JSON types in postgres,
256
+ but it may not be optimal given your indexing strategy. Schemattr provides a mechanism to keep an attribute in sync, but
257
+ it's important to understand it and handle it with care.
258
+
259
+ Let's say we want to be able to be able to easily query users who have opted in. We can add the `opted_in` column to (or
260
+ leave it, as the case may be) on the users table.
261
+
262
+ ```ruby
263
+ attribute_schema :settings do
264
+ field :email_list_beginner, :boolean, default: true, sync: :opted_in
265
+ end
266
+ ```
267
+
268
+ ```ruby
269
+ user = User.new
270
+ user.settings.email_list_beginner = false
271
+ user.read_attribute(:opted_in) # => false
272
+ user.save!
273
+ User.where(opted_in: false) # => user
274
+ ```
275
+
276
+ By adding the sync option to the field, Schemattr will try to keep that attribute in sync. There are some caveats that
277
+ can lead to confusion however.
278
+
279
+ First, when you do this, it forces delegation of `user.opted_in` to `user.settings.opted_in` -- this is to make keeping
280
+ things in sync easier. The second issue can arise is when this attribute is set directly in the database -- which means
281
+ using things like `user.update_column(:opted_in, false)`, and `User.update_all(opted_in: false)` will allow things to
282
+ get out of sync.
283
+
284
+
285
+ ## Querying a JSON column
286
+
287
+ This has come up a little bit, and so it's worth documenting -- though it has very little to do with Schemattr. When you
288
+ have a JSON column in postgres, you can query values from within that column in various ways.
289
+
290
+ [The documentation](http://www.postgresql.org/docs/9.4/static/functions-json.html) can be a little hard to grok, so
291
+ these are the common scenarios that we've used.
292
+
293
+ ```
294
+ User.where("(settings->>'opted_in')::boolean") # boolean query
295
+ User.where("settings->>'string_value' = ?", "some string") # string query
296
+ ```
297
+
298
+
299
+ ## License
300
+
301
+ Licensed under the [MIT License](http://creativecommons.org/licenses/MIT)
302
+
303
+ Copyright 2015 [Mode Set](https://github.com/modeset)
304
+
305
+
306
+ ## Make Code Not War
307
+ ![crest](https://secure.gravatar.com/avatar/aa8ea677b07f626479fd280049b0e19f?s=75)
data/lib/schemattr.rb ADDED
@@ -0,0 +1,6 @@
1
+ require "schemattr/version"
2
+ require "schemattr/dsl"
3
+ require "schemattr/attribute"
4
+ require "schemattr/active_record_extension.rb"
5
+
6
+ ActiveRecord::Base.send(:include, Schemattr::ActiveRecordExtension) if defined?(ActiveRecord)
@@ -0,0 +1,46 @@
1
+ module Schemattr
2
+ module ActiveRecordExtension
3
+ module ClassMethods
4
+ def attribute_schema(name, options = {}, &block)
5
+ raise ArgumentError, "No schema provided, block expected for schemaless_attribute." unless block_given?
6
+
7
+ name = name.to_sym
8
+ attribute_schema = DSL.new(options[:class], &block)
9
+ if options[:delegated]
10
+ delegate(*attribute_schema.attribute_class.instance_methods(false), to: name)
11
+ else
12
+ delegate(*attribute_schema.delegated, to: name)
13
+ end
14
+
15
+ define_method "#{name}=" do |val|
16
+ raise ArgumentError, "Setting #{name} requires a hash" unless val.is_a?(Hash)
17
+ delegator = send(name)
18
+ val.each do |k, v|
19
+ endpoint = options[:delegated] && self.respond_to?("#{k}=") ? self : delegator
20
+ endpoint.send("#{k}=", v)
21
+ end
22
+ val
23
+ end
24
+
25
+ define_method "#{name}" do
26
+ _schemaless_attributes[name] ||= attribute_schema.attribute_class.new(self, name, options[:strict] == false)
27
+ end
28
+ end
29
+ end
30
+
31
+ def self.included(base = nil, &_block)
32
+ base.extend(ClassMethods)
33
+ end
34
+
35
+ def reload(*_args)
36
+ _schemaless_attributes.keys.each { |name| _schemaless_attributes[name] = nil }
37
+ super
38
+ end
39
+
40
+ private
41
+
42
+ def _schemaless_attributes
43
+ @_schemaless_attributes ||= {}
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,60 @@
1
+ module Schemattr
2
+ class Attribute
3
+ attr_accessor :model, :attr_name, :hash
4
+
5
+ def initialize(model, attr_name, allow_arbitrary_attributes = false)
6
+ @model = model
7
+ @attr_name = attr_name
8
+ @allow_arbitrary_attributes = allow_arbitrary_attributes
9
+ @hash = defaults.merge(model[attr_name] || {})
10
+ end
11
+
12
+ def field_names
13
+ (self.class.defaults.keys || []).map { |k| k.to_sym }
14
+ end
15
+
16
+ def defaults
17
+ self.class.defaults
18
+ end
19
+
20
+ def as_json(*args)
21
+ @hash
22
+ end
23
+
24
+ private
25
+
26
+ def method_missing(m, *args)
27
+ if @allow_arbitrary_attributes
28
+ self[$1] = args[0] if args.length == 1 && /^(\w+)=$/ =~ m
29
+ self[m.to_s.gsub(/\?$/, "")]
30
+ else
31
+ raise NoMethodError, "undefined method '#{m}' for #{self.class}"
32
+ end
33
+ end
34
+
35
+ def migrate_value(val, from)
36
+ return val unless from
37
+ if (old_val = self[from]).nil?
38
+ val
39
+ else
40
+ @hash.delete(from.to_s)
41
+ old_val
42
+ end
43
+ end
44
+
45
+ def sync_value(val, to)
46
+ model[to] = val if to
47
+ val
48
+ end
49
+
50
+ def []=(key, val)
51
+ hash[key.to_s] = val
52
+ model[attr_name] = hash
53
+ val
54
+ end
55
+
56
+ def [](key)
57
+ hash[key.to_s]
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,86 @@
1
+ module Schemattr
2
+ class DSL
3
+ attr_accessor :attribute_class, :delegated, :defaults
4
+
5
+ def initialize(klass_override = nil, &block)
6
+ @attribute_class = Class.new(klass_override || Attribute)
7
+ @delegated = []
8
+ @defaults = defaults = {}
9
+
10
+ instance_eval(&block)
11
+
12
+ @attribute_class.define_singleton_method("defaults") { defaults }
13
+ end
14
+
15
+ protected
16
+
17
+ def field(name, type, options = {})
18
+ if respond_to?(type, true)
19
+ send(type, name, options)
20
+ else
21
+ _define(name, false, options)
22
+ end
23
+ end
24
+
25
+ def string(name, options = {})
26
+ _define name, false, options, setter: lambda { |val| sync_value(self[name] = val.to_s, options[:sync]) }
27
+ end
28
+
29
+ def integer(name, options = {})
30
+ _define name, false, options, setter: lambda { |val| sync_value(self[name] = val.to_i, options[:sync]) }
31
+ end
32
+
33
+ def float(name, options = {})
34
+ _define name, false, options, setter: lambda { |val| sync_value(self[name] = val.to_f, options[:sync]) }
35
+ end
36
+
37
+ def datetime(name, options = {})
38
+ _define name, false, options
39
+ end
40
+
41
+ def date(name, options = {})
42
+ _define name, false, options
43
+ end
44
+
45
+ def boolean(name, options = {})
46
+ _define name, true, options, setter: lambda { |val|
47
+ bool = ActiveRecord::Type::Boolean.new.deserialize(val)
48
+ sync_value(self[name] = bool, options[:sync])
49
+ }
50
+ end
51
+
52
+ alias_method :text, :string
53
+ alias_method :bigint, :integer
54
+ alias_method :decimal, :float
55
+ alias_method :time, :datetime
56
+
57
+ private
58
+
59
+ def _define(name, boolean, options, blocks = {})
60
+ setter = blocks[:setter] || lambda { sync_value(self[name] = val, options[:sync]) }
61
+ getter = blocks[:getter] || lambda { migrate_value(self[name], options[:from]) }
62
+ _default(name, options[:default])
63
+ _method("#{name}=", options[:sync], &setter)
64
+ _method(name, options[:sync], &getter)
65
+ _alias("#{name}?", name, options[:sync]) if boolean
66
+ end
67
+
68
+ def _default(name, default)
69
+ @defaults[name.to_s] = default
70
+ end
71
+
72
+ def _method(name, delegated = false, &block)
73
+ @delegated.push(name.to_s) if delegated
74
+ unless attribute_class.instance_methods.include?(name.to_sym)
75
+ attribute_class.send(:define_method, name, &block)
76
+ end
77
+ end
78
+
79
+ def _alias(new, old, delegated)
80
+ @delegated.push(new.to_s) if delegated
81
+ unless attribute_class.instance_methods.include?(new.to_sym)
82
+ attribute_class.send(:alias_method, new, old)
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,3 @@
1
+ module Schemattr
2
+ VERSION = "0.0.1"
3
+ end
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: schemattr
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - jejacks0n
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-11-16 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: ''
14
+ email:
15
+ - info@modeset.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - MIT.LICENSE
21
+ - README.md
22
+ - lib/schemattr.rb
23
+ - lib/schemattr/active_record_extension.rb
24
+ - lib/schemattr/attribute.rb
25
+ - lib/schemattr/dsl.rb
26
+ - lib/schemattr/version.rb
27
+ homepage: https://github.com/modeset/schemattr
28
+ licenses:
29
+ - MIT
30
+ metadata: {}
31
+ post_install_message:
32
+ rdoc_options: []
33
+ require_paths:
34
+ - lib
35
+ required_ruby_version: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ required_rubygems_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: '0'
45
+ requirements: []
46
+ rubyforge_project:
47
+ rubygems_version: 2.7.6
48
+ signing_key:
49
+ specification_version: 4
50
+ summary: ''
51
+ test_files: []