attr_pouch 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 58a432e44f2c9f4bdcc8bcf5b505134b1c0c56ab
4
- data.tar.gz: 91d784b4dfc7b673f3cb46d07fb94152dea35b09
3
+ metadata.gz: 1e70f1aaa49e8c698db7004d2137dff0fb396462
4
+ data.tar.gz: e29ed8ec737976e0e65180c79c3a4736f92d691f
5
5
  SHA512:
6
- metadata.gz: 93891ecdbe1a8eb5a31ee34a956472352abbf53ce76a006dbe5e58cffe8087af64e816e425366d5b51609d472d0dd28f9333b8ac1862b2b6f8c1f89b62f26a86
7
- data.tar.gz: 973525f442cfd29f521046b3fe1b6a4a5efbf4273134c752b18dab565ba1a55f0eb0ec736cc6ad708941cabdbfa5449894471246f1500ac989a29f5e177183d1
6
+ metadata.gz: 4961584f437d46255b6e0ce970b61b7bd9d820b943397e498c129e75176553bca6975ee52e2470fad4790ab5bfcfdb022ca920367e32a7f89b301a4fbf4ec00d
7
+ data.tar.gz: fd0c772494d398b7bfdfdd4e08cd52e93644cd30af48b6b98fa494b304d4ea1b8ce84d95b019098bb5f4578813a79d4d52c7707c0d7ccc1742b94f2048c0532a
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- attr_pouch (0.0.1)
4
+ attr_pouch (0.1.0)
5
5
 
6
6
  GEM
7
7
  remote: https://rubygems.org/
data/README.md CHANGED
@@ -26,11 +26,12 @@ model.
26
26
 
27
27
  ### Usage
28
28
 
29
- AttrPouch allows you to designate a "pouch" database `hstore` field
30
- that provides schema-less storage for any `Sequel::Model`
31
- object. Within this pouch, you define additional fields that behave
32
- largely like standard Sequel fields (i.e., as if they were backed by
33
- their own database columns), but are all in fact stored in the pouch:
29
+ AttrPouch allows you to designate a "pouch" database field (of type
30
+ `jsonb`, `hstore`, or `json`) that provides schema-less storage for
31
+ any `Sequel::Model` object. Within this pouch, you can define
32
+ sub-fields that behave largely like standard Sequel fields (i.e., as
33
+ if they were backed by their own database columns), but are all in
34
+ fact stored in the pouch:
34
35
 
35
36
  ```ruby
36
37
  class User < Sequel::Model
@@ -107,10 +108,10 @@ AttrPouch.configure do |config|
107
108
  end
108
109
  ```
109
110
 
110
- Note that your encoder and decoder do have access to the field object,
111
- which includes name, type, and any options you've configured in the
112
- field definition. Option names are not checked by `attr_vault`, so
113
- custom decoder or encoder options are possible.
111
+ Note that your encoder and decoder do have access to the field
112
+ definition object, which includes name, type, and any options you've
113
+ configured in the field definition. Option names are not checked by
114
+ `attr_vault`, so custom decoder or encoder options are possible.
114
115
 
115
116
  When an encoder or decoder is specified via symbol, it will only work
116
117
  for fields whose type is declared to be exactly that symbol. When
@@ -123,7 +124,7 @@ This can be illustrated via the last built-in codec, for
123
124
  ```ruby
124
125
  class User < Sequel::Model
125
126
  pouch(:preferences) do
126
- field :bff, User
127
+ field :bff, type: User
127
128
  end
128
129
  end
129
130
 
@@ -145,7 +146,7 @@ infers types as follows:
145
146
  * `:bool`: name ends with `?`
146
147
  * `String`: anything else
147
148
 
148
- If this is not suitable, you can register your own type inference
149
+ If this is not suitable, you can register your own "type inference"
149
150
  mechanism instead:
150
151
 
151
152
  ```ruby
@@ -171,7 +172,11 @@ end
171
172
 
172
173
  karen = User.create(name: 'karen')
173
174
  karen.update(proxy_address: '10.11.12.13:8001')
174
- karen.delete_proxy_address
175
+ karen.delete_proxy_address # note this does not save the object
176
+ karen.reload
177
+ karen.proxy_address # still '10.11.12.13:8001'; we never saved
178
+ karen.delete_proxy_address! # or call #save or #save_changes after a delete
179
+ karen.proxy_address # now it's gone
175
180
  ```
176
181
 
177
182
  Deletable fields are automatically given a default of `nil` if no
@@ -217,7 +222,7 @@ end
217
222
 
218
223
  nils = User[name: 'nils'] # in db we have `{ ssl?: true, byzantion?: true }`
219
224
  nils.tls? # true
220
- nils.consantinople? # true
225
+ nils.constantinople? # true
221
226
  ```
222
227
 
223
228
  Note that no direct accessors are defined for the old names, and if
@@ -225,9 +230,10 @@ the value is updated, it is written under the new name and any old
225
230
  values in the pouch are deleted:
226
231
 
227
232
  ```ruby
233
+ nils.istanbul = false
228
234
  nils.tls? # true
229
- nils.instanbul? # true
230
- nils.save_changes # now in db as `{ tls?: true, instanbul?: true }`
235
+ nils.instanbul? # false
236
+ nils.save_changes # now in db as `{ tls?: true, instanbul?: false }`
231
237
  ```
232
238
 
233
239
  #### Raw value access
@@ -244,8 +250,9 @@ absent field value deferring to the default:
244
250
  ```ruby
245
251
  class User < Sequel::Model
246
252
  pouch(:preferences) do
247
- field :bff, User, raw_field: :bff_id
248
- field :arch_nemesis, raw_field: :nemesis_id, default: User[name: 'donald']
253
+ field :bff, type: User, raw_field: :bff_id
254
+ field :arch_nemesis, type: User, raw_field: :nemesis_id,
255
+ default: User[name: 'donald']
249
256
  end
250
257
  end
251
258
 
@@ -261,18 +268,70 @@ raw field value is updated, values present under any of the `was` keys
261
268
  will be deleted.
262
269
 
263
270
 
271
+ ### Dataset querying
272
+
273
+ AttrPouch provides a dataset method to simplify querying pouch
274
+ contents. The interface is similar to querying first-class columns:
275
+
276
+ ```ruby
277
+ class User < Sequel::Model
278
+ pouch(:preferences) do
279
+ field :bff, type: User
280
+ field :favorite_color
281
+ end
282
+ end
283
+
284
+ # match on multiple fields
285
+ User.where_pouch(bff: User[name: 'georgia'], favorite_color: 'goldenrod').all
286
+ # match on multiple values
287
+ User.where_pouch(favorite_color: [ 'gray', 'dark gray', 'black' ]).all
288
+ # check for nil value (or absence of key)
289
+ User.where_pouch(bff: nil).all
290
+ ```
291
+
292
+ Note that you can speed up your queries considerably by adding an
293
+ index. AttrPouch uses containment queries to implement dataset
294
+ querying, so you can use GIN indexes with both
295
+ [`hstore`](https://www.postgresql.org/docs/current/static/hstore.html#AEN160038)
296
+ and
297
+ [`jsonb`](https://www.postgresql.org/docs/current/static/datatype-json.html#JSON-INDEXING):
298
+
299
+ ```ruby
300
+ Sequel.migration do
301
+ up do
302
+ execute <<-EOF
303
+ CREATE INDEX users_prefs_idx ON users USING gin (preferences);
304
+ EOF
305
+ end
306
+ down do
307
+ execute <<-EOF
308
+ DROP INDEX users_prefs_idx;
309
+ EOF
310
+ end
311
+ EOF
312
+ end
313
+ ```
314
+
315
+ N.B.: For simplicity, AttrPouch treats the absence of a key and the
316
+ presence of a key with a `NULL` value the same when querying, which
317
+ means queries looking for `nil` values do not use the indexes.
318
+
319
+ Dataset queries are not supported for fields backed by columns of
320
+ type `json`.
321
+
264
322
  ### Schema
265
323
 
266
324
  AttrPouch requires a new storage field for each pouch added to a
267
- model. It is currently designed for and tested with `hstore`. Consider
268
- using a single pouch per model class unless you clearly need several
269
- distinct pouches.
325
+ model. It is currently designed for and tested with `jsonb`, `hstore`,
326
+ and `json` (although `json`-format fields do not currently support
327
+ dataset querying, as per above). Consider using a single pouch per
328
+ model class unless you clearly need several distinct pouches.
270
329
 
271
330
  ```ruby
272
331
  Sequel.migration do
273
332
  change do
274
333
  alter_table(:users) do
275
- add_column :preferences, :hstore
334
+ add_column :preferences, :jsonb
276
335
  end
277
336
  end
278
337
  end
@@ -290,8 +349,8 @@ $ createdb attr_pouch_test
290
349
  $ DATABASE_URL=postgres:///attr_pouch_test bundle exec rspec
291
350
  ```
292
351
 
293
- Please follow the project's general coding style and open issues for
294
- any significant behavior or API changes.
352
+ Please follow the project's general coding style and open issues
353
+ beforehand to discuss any significant behavior or API changes.
295
354
 
296
355
  A pull request is understood to mean you are offering your code to the
297
356
  project under the MIT License.
data/TODO CHANGED
@@ -1,6 +1,8 @@
1
1
  # TODO thoughts:
2
2
  - docs
3
- - support for storage in JSON / plain text / arbitrary backing format
3
+ - dataset
4
+ - update to note json / jsonb support
5
+ - support for access via [] and []= methods
4
6
  - opt: eager_default on create for fields
5
7
  - opt: run tracking
6
8
  - more efficient updates via merging with structure in-database field-by-field
@@ -3,6 +3,8 @@ module AttrPouch
3
3
  class Error < StandardError; end
4
4
  class MissingCodecError < Error; end
5
5
  class InvalidFieldError < Error; end
6
+ class InvalidPouchError < Error; end
6
7
  class MissingRequiredFieldError < Error; end
7
8
  class ImmutableFieldUpdateError < Error; end
9
+ class UnsupportedError < Error; end
8
10
  end
@@ -1,3 +1,3 @@
1
1
  module AttrPouch
2
- VERSION = "0.0.1"
2
+ VERSION = "0.1.0"
3
3
  end
data/lib/attr_pouch.rb CHANGED
@@ -14,10 +14,46 @@ module AttrPouch
14
14
 
15
15
  def self.included(base)
16
16
  base.extend(ClassMethods)
17
+ # we can't just independently define this in ClassMethods since
18
+ # `def_dataset_method` is only defined on Sequel::Model subclasses
19
+ base.def_dataset_method(:where_pouch) do |pouch_field, expr_hash|
20
+ ds = self
21
+ pouch = model.pouch(pouch_field)
22
+ if pouch.nil?
23
+ raise ArgumentError,
24
+ "No pouch defined for #{pouch_field}"
25
+ end
26
+ if pouch.json?
27
+ raise UnsupportedError, "Dataset queries not supported for columns of type json"
28
+ end
29
+
30
+ expr_hash.each do |key, value|
31
+ key = key.to_s
32
+ field = pouch.field_definition(key)
33
+ if field.nil?
34
+ raise ArgumentError,
35
+ "No field #{key} defined for pouch #{pouch_field}"
36
+ end
37
+
38
+ if value.respond_to?(:each)
39
+ value.each_with_index do |v,i|
40
+ condition = pouch.store.contains(pouch.wrap(key => field.encode(v)))
41
+ ds = i == 0 ? ds.where(condition) : ds.or(condition)
42
+ end
43
+ elsif value.nil?
44
+ ds = ds.where(pouch.store.has_key?(key) => false)
45
+ .or(pouch.store.contains(pouch.wrap(key => field.encode(value))))
46
+ else
47
+ ds = ds.where(pouch.store
48
+ .contains(pouch.wrap(key => field.encode(value))))
49
+ end
50
+ end
51
+ ds
52
+ end
17
53
  end
18
54
 
19
55
  class Field
20
- attr_reader :name, :type, :raw_type, :opts
56
+ attr_reader :pouch, :name, :type, :raw_type, :opts
21
57
 
22
58
  def self.encode(type, &block)
23
59
  @@encoders ||= {}
@@ -48,22 +84,26 @@ module AttrPouch
48
84
  end
49
85
  end
50
86
 
51
- def initialize(name, opts)
52
- @name = name
87
+ def initialize(pouch, name, opts)
88
+ @name = name.to_s
53
89
  if opts.has_key?(:type)
54
90
  @type = to_class(opts.fetch(:type))
55
91
  else
56
92
  @type = self.class.infer_type(self)
57
93
  end
58
- @raw_type = type
94
+ @pouch = pouch
59
95
  @opts = opts
60
96
  end
61
97
 
98
+ def raw_type
99
+ @opts.fetch(:type, nil)
100
+ end
101
+
62
102
  def alias_as(new_name)
63
103
  if new_name == name
64
104
  self
65
105
  else
66
- self.class.new(new_name, opts)
106
+ self.class.new(pouch, new_name, opts)
67
107
  end
68
108
  end
69
109
 
@@ -89,11 +129,12 @@ module AttrPouch
89
129
 
90
130
  def previous_aliases
91
131
  was = opts.fetch(:was, [])
92
- if was.respond_to?(:to_a)
93
- was.to_a
94
- else
95
- [ was ]
96
- end
132
+ aliases = if was.respond_to?(:to_a)
133
+ was.to_a
134
+ else
135
+ [ was ]
136
+ end
137
+ aliases.map(&:to_s)
97
138
  end
98
139
 
99
140
  def all_names
@@ -127,11 +168,7 @@ module AttrPouch
127
168
  end
128
169
  elsif present_as == name
129
170
  raw = store.fetch(name)
130
- if decode
131
- decode(raw)
132
- else
133
- raw
134
- end
171
+ decode ? decode(raw) : raw
135
172
  else
136
173
  alias_as(present_as).read(store)
137
174
  end
@@ -215,15 +252,62 @@ module AttrPouch
215
252
  class Pouch
216
253
  VALID_FIELD_NAME_REGEXP = %r{\A[a-zA-Z0-9_]+\??\z}
217
254
 
218
- def initialize(host, storage_field, default_pouch: Sequel.hstore({}))
255
+ def initialize(host, storage_field)
219
256
  @host = host
220
- @storage_field = storage_field
221
- @default_pouch = default_pouch
257
+ @storage_field = storage_field.to_sym
222
258
  @fields = {}
223
259
  end
224
260
 
225
261
  def field_definition(name)
226
- @fields[name]
262
+ @fields[name.to_s]
263
+ end
264
+
265
+ def hstore?
266
+ pouch_column_info.fetch(:db_type) == 'hstore'
267
+ end
268
+
269
+ def json?
270
+ pouch_column_info.fetch(:db_type) == 'json'
271
+ end
272
+
273
+ def jsonb?
274
+ pouch_column_info.fetch(:db_type) == 'jsonb'
275
+ end
276
+
277
+ def either_json?
278
+ json? || jsonb?
279
+ end
280
+
281
+ def pouch_column_info
282
+ @host.db_schema.find { |k,_| k == @storage_field }.last
283
+ end
284
+
285
+ def wrap(hash)
286
+ if hstore?
287
+ Sequel.hstore(hash)
288
+ elsif json?
289
+ Sequel.pg_json(hash)
290
+ elsif jsonb?
291
+ Sequel.pg_jsonb(hash)
292
+ else
293
+ raise InvalidPouchError, "Pouch must use hstore, json, or jsonb column"
294
+ end
295
+ end
296
+
297
+ def store
298
+ if hstore?
299
+ Sequel.hstore(@storage_field)
300
+ elsif json?
301
+ Sequel.pg_json_op(@storage_field)
302
+ elsif jsonb?
303
+ Sequel.pg_jsonb_op(@storage_field)
304
+ else
305
+ raise InvalidPouchError, "Pouch must use hstore, json, or jsonb column"
306
+ end
307
+ end
308
+
309
+ def default_pouch
310
+ wrap({})
227
311
  end
228
312
 
229
313
  def field(name, opts={})
@@ -231,51 +315,13 @@ module AttrPouch
231
315
  raise InvalidFieldError, "Field name must match #{VALID_FIELD_NAME_REGEXP}"
232
316
  end
233
317
 
234
- field = Field.new(name, opts)
235
- @fields[name] = field
318
+ field = Field.new(self, name, opts)
319
+ @fields[name.to_s] = field
236
320
 
237
321
  storage_field = @storage_field
238
- default = @default_pouch
322
+ default = default_pouch
239
323
 
240
324
  @host.class_eval do
241
- def_dataset_method(:where_pouch) do |pouch_field, expr_hash|
242
- # TODO: encode the values so we can query properly
243
- ds = self
244
- expr_hash.each do |key, value|
245
- pouch = model.pouch(pouch_field)
246
- if pouch.nil?
247
- raise ArgumentError,
248
- "No pouch defined for #{pouch_field}"
249
- end
250
- field = pouch.field_definition(key)
251
- if field.nil?
252
- raise ArgumentError,
253
- "No field #{key} defined for pouch #{pouch_field}"
254
- end
255
-
256
- if value.respond_to?(:each)
257
- value.each_with_index do |v,i|
258
- encoded_val = field.encode(v)
259
- if i == 0
260
- ds = ds.where(Sequel.hstore(pouch_field)
261
- .contains(Sequel.hstore(key => encoded_val)))
262
- else
263
- ds = ds.or(Sequel.hstore(pouch_field)
264
- .contains(Sequel.hstore(key => encoded_val)))
265
- end
266
- end
267
- elsif value.nil?
268
- ds = ds.where(Sequel.hstore(pouch_field).has_key?(key.to_s) => false)
269
- .or(Sequel.hstore(pouch_field)
270
- .contains(Sequel.hstore(key => nil)))
271
- else
272
- ds = ds.where(Sequel.hstore(pouch_field)
273
- .contains(Sequel.hstore(key => field.encode(value))))
274
- end
275
- end
276
- ds
277
- end
278
-
279
325
  define_method(name) do
280
326
  store = self[storage_field]
281
327
  field.read(store)
@@ -380,7 +426,7 @@ AttrPouch.configure do |config|
380
426
  value.to_s
381
427
  end
382
428
  config.decode(:bool) do |field, value|
383
- value == 'true'
429
+ value.to_s == 'true'
384
430
  end
385
431
 
386
432
  config.encode(Sequel::Model) do |field, value|