ndb-rails 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 407a37e97a7c0f50f39d4ca365e59bd9ecec788a9fe357e4ff4fbc61add79962
4
- data.tar.gz: 0de5474707d6bf68b55d1a44f56b612a8d5a59bc24cd7e96ff8066af00e98f8b
3
+ metadata.gz: cb272d6aadee253c818c30de3c3820c510903f06d42dac23dd56837bb585280a
4
+ data.tar.gz: 4ccfbb876e868ebea9b229a5ef44292ad3240ed55933a46e3a5a432158f25012
5
5
  SHA512:
6
- metadata.gz: 5eeed45c9f972b596dd588122600f1f7729ca07957ca771a8af0d0ca34085aa8b3c8e76d898a9605e034a6402c8370bdf57e209b2da5320a84d5904176fbeb50
7
- data.tar.gz: d52fa0af1af45659950dd152a68a5561aa311b98c7b3296f70df1185d6071b70740eae7f95763bb58d1aafc35984a6aa11cf04b822d05ab98ed2a02de208a88b
6
+ metadata.gz: 0c24be778d6fc65e219b829d7c8219e810ecc3505a39c0670077e07a17c60a37824d5814a6869dd6926fa52eed09a85b6615622779be126e8c87e582ece20a0d
7
+ data.tar.gz: 6360ba54f2208b4b438f269cfbbfd172fe0ebf9fd6907df523369d5241f88eec5a36ca617504dbde0553624fff00156b09c7cb892b05790d34e00cc4dde51f55
data/README.md CHANGED
@@ -112,13 +112,21 @@ call = Call.find("c1") # raises Ndb::NotFound
112
112
  Call.find_by(status: "failed") # nil when missing
113
113
  Call.find_by!(status: "x") # raises NotFound
114
114
 
115
- call.update!(status: "done") # sends only the changed fields
115
+ call.update!(status: "done") # $set of the changed fields only, filtered by id and version
116
116
  call.version # the document version
117
117
 
118
+ call.increment!(:duration) # atomic $inc on the daemon, refreshes the attribute and the version
119
+ call.decrement!(:duration, 5)
120
+
118
121
  call.destroy
122
+
123
+ # Atomic writes on every match of a where (it needs the id or an indexed field).
124
+ # update takes the operators of the client and returns the number of modified documents.
125
+ Call.where(status: "failed").update({ "$set" => { "status" => "retry" } })
126
+ Call.where(status: "retry").delete # returns how many it deleted
119
127
  ```
120
128
 
121
- - Every load asks for the version (`_v`). `save`, `update` and `destroy` send it back as `if_version`. If another writer changed the document first, you get `Ndb::StaleObjectError` (code `version_conflict`).
129
+ - Every load asks for the version (`_v`). `save`, `update` and `destroy` put the id and `_v` in the filter of the write. If another writer changed the document first, the filter matches nothing and you get `Ndb::StaleObjectError` (code `stale_object`).
122
130
  - Setting an attribute to `nil` and saving removes the field from the document.
123
131
  - A duplicate id raises `RecordNotUnique` (code `duplicate_id`).
124
132
  - `persisted?`, `new_record?`, `destroyed?`, `id` and `version` work as in ActiveRecord.
@@ -132,6 +140,32 @@ rescue Ndb::StaleObjectError
132
140
  end
133
141
  ```
134
142
 
143
+ ## Expiry (TTL like Redis)
144
+
145
+ Give the model an `expires: true` attribute. The daemon sets it for you.
146
+
147
+ ```ruby
148
+ class Session < Ndb::Record
149
+ primary_key :sid
150
+
151
+ attribute :user, :string
152
+ attribute :expires_at, :datetime, expires: true
153
+ end
154
+
155
+ Session.create!({ sid: "s1", user: "u" }, expire_in: 1.hour) # also create, save, update (seconds, a Duration or "30m")
156
+ session = Session.find("s1")
157
+
158
+ session.expire(30.minutes) # EXPIRE: sets expires_at in the object, other unsaved changes stay
159
+ session.ttl # => 1799, -1 (never expires), or nil (missing or expired)
160
+ session.persist_ttl! # PERSIST: removes the expiry
161
+ ```
162
+
163
+ - `persist_ttl!` is not called `persist!`: ActiveModel already uses `persisted?` for something else.
164
+ - `expire_in:` on `create`, `save` and `update` goes in the same write. `expire_in` wins over the `expires_at` value. After a write with `expire_in`, load the record again (`Session.find`) to see `expires_at`.
165
+ - `expire` and `persist_ttl!` raise `Ndb::NotFound` for a missing or expired document, and `Ndb::TtlNotEnabled` when the model has no `expires: true` attribute.
166
+ - The class-level `ttl 24.hours` stays: every document expires that long after its time field. `expire` and `persist_ttl!` raise `Ndb::TtlDerived` for it.
167
+ - Disk space of expired documents returns after compaction, and fully only with `wal_retention`; see [Freeing disk](https://github.com/thadeu/ndb/blob/main/docs/dev/configuration.md#freeing-disk).
168
+
135
169
  ## Query
136
170
 
137
171
  Relations are lazy and immutable. The request goes out when you call `to_a`, `first`, `each`, `count` and so on.
@@ -161,7 +195,7 @@ Call.where(status: "answered").first(5)
161
195
  Call.where(status: "failed").exists?
162
196
  ```
163
197
 
164
- - Chained `where` calls are ANDed. `$regex` does not exist in the daemon yet. See the [query language](https://github.com/thadeu/ndb/blob/main/docs/dev/query.md) for the full operator list.
198
+ - Chained `where` calls are ANDed. See the [query language](https://github.com/thadeu/ndb/blob/main/docs/dev/query.md) for the full operator list.
165
199
  - The order of the `order` keys is the order of the sort. The id is always the last key.
166
200
  - `page(n).per(size)` sets offset paging. The default page size is 25.
167
201
  - `pluck` asks the daemon for only those fields.
@@ -295,7 +329,7 @@ A blob behaves like a read-only IO (`read`, `each_chunk`, `eof?`, `rewind`). A l
295
329
 
296
330
  ## Errors
297
331
 
298
- The transport and daemon errors are defined by the gem `ndb`. They all live in the `Ndb` module (`Ndb::Error`, `Ndb::NotFound`, ...). For an ActiveRecord-like `rescue`, this gem adds two aliases: `Ndb::StaleObjectError` is `Ndb::VersionConflict`, and `Ndb::RecordNotUnique` is `Ndb::UniqueViolation`. They are the same classes, so a `rescue` of either name catches the same error. `Ndb::RecordInvalid` and `Ndb::RecordNotSaved` exist only here, and they inherit from `Ndb::Error`. Every error has `code` (the daemon code) and `status` (the HTTP status).
332
+ The transport and daemon errors are defined by the gem `ndb`. They all live in the `Ndb` module (`Ndb::Error`, `Ndb::NotFound`, ...). For an ActiveRecord-like `rescue`, this gem adds one alias: `Ndb::RecordNotUnique` is `Ndb::UniqueViolation` (the same class). `Ndb::StaleObjectError`, `Ndb::RecordInvalid` and `Ndb::RecordNotSaved` exist only here, and they inherit from `Ndb::Error`. Every error has `code` (the daemon code) and `status` (the HTTP status).
299
333
 
300
334
  | Class | Code |
301
335
  |---|---|
@@ -303,7 +337,8 @@ The transport and daemon errors are defined by the gem `ndb`. They all live in t
303
337
  | `NotReady` | `not_ready` |
304
338
  | `NotFound` | `not_found` |
305
339
  | `UnknownCollection` | `unknown_collection` |
306
- | `StaleObjectError` | `version_conflict` |
340
+ | `StaleObjectError` | `stale_object` (raised by the gem, not sent by the daemon) |
341
+ | `InvalidUpdate` and its subclasses | `unknown_operator`, `no_update`, `conflicting_update`, `invalid_field`, `unindexed_many`, `empty_filter` |
307
342
  | `RecordNotUnique` | `unique_violation`, `duplicate_id` |
308
343
  | `CatalogConflict` | `catalog_conflict` |
309
344
  | `CatalogInvalid` | `catalog_invalid` |
@@ -312,6 +347,7 @@ The transport and daemon errors are defined by the gem `ndb`. They all live in t
312
347
  | `InvalidCursor` | `cursor_invalid`, `cursor_mismatch`, `cursor_expired` |
313
348
  | `WatchLagged` | `watch_lagged` |
314
349
  | `HistoryTruncated` | `history_truncated` |
350
+ | `TtlNotEnabled`, `TtlDerived` | `ttl_not_enabled`, `ttl_derived` |
315
351
  | `RecordInvalid` | ActiveModel validation failed (has `record`) |
316
352
  | `RecordNotSaved` | `save!` returned false without errors |
317
353
  | `ConnectionError` | cannot reach the daemon. Subclasses: `ConnectError` (nothing was sent), `TimeoutError` |
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ndb
4
- # ActiveRecord-like names for two daemon errors. They are the same classes,
5
- # so a rescue of either name catches the same error.
6
- StaleObjectError = VersionConflict
4
+ # ActiveRecord-like name for a daemon error. It is the same class, so a
5
+ # rescue of either name catches the same error.
7
6
  RecordNotUnique = UniqueViolation
8
7
 
8
+ # A save or destroy matched nothing: the record changed or was deleted
9
+ # since it was loaded (its "_v" guard did not match).
10
+ class StaleObjectError < Error; end
11
+
9
12
  # Raised by save!/create!/update! when validations fail.
10
13
  class RecordInvalid < Error
11
14
  attr_reader :record
@@ -3,7 +3,8 @@
3
3
  module Ndb
4
4
  class RecordNotSaved < Error; end
5
5
 
6
- # Writes: dirty attributes -> set/unset, the loaded version -> if_version.
6
+ # Writes: dirty attributes -> $set/$unset, filtered by the id and the loaded
7
+ # version ("_v"). A write that matches nothing is a stale record.
7
8
  module Persistence
8
9
  extend ActiveSupport::Concern
9
10
 
@@ -12,24 +13,28 @@ module Ndb
12
13
  end
13
14
 
14
15
  class_methods do
15
- def create(attrs = {}, &block)
16
- return attrs.map { |a| create(a, &block) } if attrs.is_a?(Array)
16
+ def create(attrs = {}, expire_in: nil, **rest, &block)
17
+ return attrs.map { |a| create(a, expire_in: expire_in, &block) } if attrs.is_a?(Array)
18
+
19
+ attrs = attrs.merge(rest)
17
20
 
18
21
  record = new(attrs)
19
22
 
20
23
  yield record if block
21
- record.save
24
+ record.save(expire_in: expire_in)
22
25
 
23
26
  record
24
27
  end
25
28
 
26
- def create!(attrs = {}, &block)
27
- return attrs.map { |a| create!(a, &block) } if attrs.is_a?(Array)
29
+ def create!(attrs = {}, expire_in: nil, **rest, &block)
30
+ return attrs.map { |a| create!(a, expire_in: expire_in, &block) } if attrs.is_a?(Array)
31
+
32
+ attrs = attrs.merge(rest)
28
33
 
29
34
  record = new(attrs)
30
35
 
31
36
  yield record if block
32
- record.save!
37
+ record.save!(expire_in: expire_in)
33
38
 
34
39
  record
35
40
  end
@@ -71,43 +76,134 @@ module Ndb
71
76
  persisted? ? [id] : nil
72
77
  end
73
78
 
74
- def save(validate: true)
79
+ # expire_in (seconds, an ActiveSupport::Duration or "30m") makes the daemon
80
+ # set the expires field to now + expire_in in the same write. The model
81
+ # needs an `expires: true` attribute (Ndb::TtlNotEnabled otherwise).
82
+ def save(validate: true, expire_in: nil)
75
83
  return false if validate && !valid?(new_record? ? :create : :update)
76
84
 
85
+ @expire_in = wire_duration(expire_in)
86
+
77
87
  run_callbacks(:save) { new_record? ? create_record : update_record } ? true : false
88
+ ensure
89
+ @expire_in = nil
78
90
  end
79
91
 
80
92
  def save!(**)
81
93
  save(**) || raise(errors.any? ? RecordInvalid.new(self) : RecordNotSaved.new("Failed to save the record"))
82
94
  end
83
95
 
84
- def update(attrs)
85
- assign_attributes(attrs)
96
+ def update(attrs = {}, expire_in: nil, **rest)
97
+ assign_attributes(attrs.merge(rest))
98
+
99
+ save(expire_in: expire_in)
100
+ end
101
+
102
+ def update!(attrs = {}, expire_in: nil, **rest)
103
+ assign_attributes(attrs.merge(rest))
86
104
 
87
- save
105
+ save!(expire_in: expire_in)
88
106
  end
89
107
 
90
- def update!(attrs)
91
- assign_attributes(attrs)
108
+ # Makes the stored document expire in a duration (like EXPIRE in Redis):
109
+ # `session.expire(30.minutes)`. The version and the expires attribute are
110
+ # refreshed; other unsaved changes stay unsaved. Raises Ndb::NotFound for
111
+ # a missing or expired document and Ndb::TtlNotEnabled without an
112
+ # `expires: true` attribute.
113
+ def expire(duration)
114
+ result = Rails.client.expire(self.class.collection_name, id, in: wire_duration(duration))
92
115
 
93
- save!
116
+ refresh_expiry(result, Time.iso8601(result["expires_at"]))
117
+ end
118
+
119
+ # Removes the expiry of the stored document (PERSIST in Redis). It is not
120
+ # called `persist!` or `persisted?`, which mean something else in
121
+ # ActiveModel.
122
+ def persist_ttl!
123
+ refresh_expiry(Rails.client.persist(self.class.collection_name, id), nil)
124
+ end
125
+
126
+ # The seconds left (Integer), -1 when the document never expires, nil when
127
+ # it is missing or expired.
128
+ def ttl
129
+ Rails.client.ttl(self.class.collection_name, id)
94
130
  end
95
131
 
96
132
  def destroy
97
133
  result = run_callbacks(:destroy) do
98
- self.class.ndb_delete(id, if_version: version) if persisted?
134
+ delete_record if persisted?
99
135
  @destroyed = true
100
136
  end
101
137
 
102
138
  result ? self : false
103
139
  end
104
140
 
141
+ # Adds by (default 1) to a number attribute in one atomic write on the
142
+ # daemon, and refreshes the attribute and the version. Other unsaved
143
+ # changes stay unsaved.
144
+ def increment!(attribute, by = 1)
145
+ add_to!(attribute, by)
146
+ end
147
+
148
+ def decrement!(attribute, by = 1)
149
+ add_to!(attribute, -by)
150
+ end
151
+
105
152
  def ==(other)
106
153
  other.instance_of?(self.class) && !id.nil? && id == other.id
107
154
  end
108
155
 
109
156
  private
110
157
 
158
+ def wire_duration(value)
159
+ value.is_a?(ActiveSupport::Duration) ? value.to_i : value
160
+ end
161
+
162
+ def refresh_expiry(result, expires_at)
163
+ @version = result["v"]
164
+
165
+ field = self.class.expires_field
166
+
167
+ return self unless field
168
+
169
+ public_send("#{field}=", expires_at)
170
+ clear_attribute_changes([field])
171
+
172
+ self
173
+ end
174
+
175
+ def record_filter
176
+ filter = { self.class.primary_key => id }
177
+
178
+ filter["_v"] = version if version
179
+
180
+ filter
181
+ end
182
+
183
+ def delete_record
184
+ deleted = self.class.ndb_delete(record_filter)["deleted"]
185
+
186
+ raise StaleObjectError.new("#{self.class.name} #{id} changed or was deleted", code: "stale_object") if deleted.zero?
187
+ end
188
+
189
+ def add_to!(attribute, by)
190
+ name = attribute.to_s
191
+
192
+ raise RecordNotSaved, "Cannot update a record that is not saved" unless persisted?
193
+ raise ArgumentError, "#{name} is not an attribute of #{self.class.name}" unless self.class.attribute_names.include?(name)
194
+
195
+ result = self.class.ndb_update({ self.class.primary_key => id }, { "$inc" => { name => by } })
196
+
197
+ raise NotFound.new("#{self.class.name} #{id} was deleted", code: "not_found") if result["matched"].zero?
198
+
199
+ @version = result["doc"]["_v"]
200
+
201
+ public_send("#{name}=", result["doc"][name])
202
+ clear_attribute_changes([name])
203
+
204
+ self
205
+ end
206
+
111
207
  def loaded!(version)
112
208
  @persisted = true
113
209
  @version = version
@@ -117,7 +213,7 @@ module Ndb
117
213
 
118
214
  def create_record
119
215
  run_callbacks(:create) do
120
- result = self.class.ndb_insert(insert_document)
216
+ result = self.class.ndb_insert(insert_document, expire_in: @expire_in)
121
217
 
122
218
  self.id = result["id"] if id.nil?
123
219
  loaded!(result["v"])
@@ -130,10 +226,12 @@ module Ndb
130
226
  run_callbacks(:update) do
131
227
  set, unset = changed_fields
132
228
 
133
- unless set.empty? && unset.empty?
134
- result = self.class.ndb_update(id, set: set, unset: unset, if_version: version)
229
+ unless set.empty? && unset.empty? && @expire_in.nil?
230
+ result = self.class.ndb_update(record_filter, update_ops(set, unset), expire_in: @expire_in)
135
231
 
136
- @version = result["v"]
232
+ raise StaleObjectError.new("#{self.class.name} #{id} changed or was deleted", code: "stale_object") if result["matched"].zero?
233
+
234
+ @version = result["doc"]["_v"]
137
235
  end
138
236
 
139
237
  changes_applied
@@ -142,6 +240,15 @@ module Ndb
142
240
  end
143
241
  end
144
242
 
243
+ def update_ops(set, unset)
244
+ ops = {}
245
+
246
+ ops["$set"] = set unless set.empty?
247
+ ops["$unset"] = unset.to_h { |name| [name, true] } unless unset.empty?
248
+
249
+ ops.empty? ? { "$set" => {} } : ops
250
+ end
251
+
145
252
  def insert_document
146
253
  attribute_names.each_with_object({}) do |name, doc|
147
254
  value = json_attribute(name)
@@ -124,7 +124,7 @@ module Ndb
124
124
  end
125
125
  end
126
126
 
127
- # Every load asks for "_v", so a loaded record can send if_version.
127
+ # Every load asks for "_v", so a loaded record can guard its write with it.
128
128
  def ndb_find(body)
129
129
  ensure_collection!
130
130
 
@@ -137,22 +137,23 @@ module Ndb
137
137
  Rails.client.aggregate(collection_name, body)
138
138
  end
139
139
 
140
- def ndb_insert(doc)
140
+ def ndb_insert(doc, expire_in: nil)
141
141
  ensure_collection!
142
142
 
143
- Rails.client.insert(collection_name, [doc]).first
143
+ Rails.client.insert(collection_name, [doc], expire_in: expire_in).first
144
144
  end
145
145
 
146
- def ndb_update(id, **change)
146
+ # Atomic update by filter, see Ndb::Client#update.
147
+ def ndb_update(filter, ops, **options)
147
148
  ensure_collection!
148
149
 
149
- Rails.client.update(collection_name, id, **change)
150
+ Rails.client.update(collection_name, filter, ops, **options)
150
151
  end
151
152
 
152
- def ndb_delete(id, if_version: nil)
153
+ def ndb_delete(filter, **options)
153
154
  ensure_collection!
154
155
 
155
- Rails.client.delete(collection_name, id, if_version: if_version)
156
+ Rails.client.delete(collection_name, filter, **options)
156
157
  end
157
158
 
158
159
  private
@@ -78,6 +78,20 @@ module Ndb
78
78
  query
79
79
  end
80
80
 
81
+ # Atomic writes. The daemon matches the where and writes in one step on
82
+ # every match, so the where needs the id or an indexed field
83
+ # (Ndb::UnindexedMany otherwise). ops is the operator Hash of
84
+ # Ndb::Client#update ({ "$inc" => { "n" => 1 } }); returns the number of
85
+ # modified documents.
86
+ def update(ops, expire_in: nil)
87
+ klass.ndb_update(Conditions.combine(values[:where]), ops, many: true, expire_in: expire_in).fetch("modified")
88
+ end
89
+
90
+ # Deletes every match (same rule as update) and returns how many.
91
+ def delete
92
+ klass.ndb_delete(Conditions.combine(values[:where]), many: true).fetch("deleted")
93
+ end
94
+
81
95
  # Loaders
82
96
 
83
97
  def load
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ndb
4
4
  module Rails
5
- VERSION = "0.1.0"
5
+ VERSION = "0.1.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ndb-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - thadeu