schemattr 0.0.1

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 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: []