attr_pouch 0.0.1 → 0.1.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 +4 -4
- data/Gemfile.lock +1 -1
- data/README.md +82 -23
- data/TODO +3 -1
- data/lib/attr_pouch/errors.rb +2 -0
- data/lib/attr_pouch/version.rb +1 -1
- data/lib/attr_pouch.rb +107 -61
- data/spec/attr_pouch_spec.rb +468 -399
- data/spec/spec_helper.rb +9 -2
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1e70f1aaa49e8c698db7004d2137dff0fb396462
|
4
|
+
data.tar.gz: e29ed8ec737976e0e65180c79c3a4736f92d691f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4961584f437d46255b6e0ce970b61b7bd9d820b943397e498c129e75176553bca6975ee52e2470fad4790ab5bfcfdb022ca920367e32a7f89b301a4fbf4ec00d
|
7
|
+
data.tar.gz: fd0c772494d398b7bfdfdd4e08cd52e93644cd30af48b6b98fa494b304d4ea1b8ce84d95b019098bb5f4578813a79d4d52c7707c0d7ccc1742b94f2048c0532a
|
data/Gemfile.lock
CHANGED
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
|
30
|
-
that provides schema-less storage for
|
31
|
-
object. Within this pouch, you define
|
32
|
-
largely like standard Sequel fields (i.e., as
|
33
|
-
their own database columns), but are all in
|
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
|
111
|
-
which includes name, type, and any options you've
|
112
|
-
field definition. Option names are not checked by
|
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.
|
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? #
|
230
|
-
nils.save_changes # now in db as `{ tls?: true, instanbul?:
|
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,
|
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
|
268
|
-
|
269
|
-
|
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, :
|
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
|
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
|
-
|
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
|
data/lib/attr_pouch/errors.rb
CHANGED
@@ -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
|
data/lib/attr_pouch/version.rb
CHANGED
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
|
-
@
|
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
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
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
|
-
|
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
|
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 =
|
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|
|