ndb-rails 0.1.1 → 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: 93088e1e11be89558d972761a9e2a7a565d617f165b90361e732240ee92c9361
4
- data.tar.gz: e9e5eca9058d1bfa6ea840c4cc8fb17c734c69d5373b933f48b1d3dfb968ebfd
3
+ metadata.gz: cb272d6aadee253c818c30de3c3820c510903f06d42dac23dd56837bb585280a
4
+ data.tar.gz: 4ccfbb876e868ebea9b229a5ef44292ad3240ed55933a46e3a5a432158f25012
5
5
  SHA512:
6
- metadata.gz: c509825a020a9efff7a220a3fbbb842bde284f32c66d66368948234de6aa258585a0cac17b4da57feb1ff44b123fbf5c8d6cde8f998978f51aa406500cbb10ee
7
- data.tar.gz: 853957c1575a0653c04f91bd8f16efb65415cd42656ab5a9de44f39360820d434d63d2df2a54301ec1964805c4b286dcdde969f04fefe54b79ebd5b6784452ed
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.
@@ -187,7 +195,7 @@ Call.where(status: "answered").first(5)
187
195
  Call.where(status: "failed").exists?
188
196
  ```
189
197
 
190
- - 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.
191
199
  - The order of the `order` keys is the order of the sort. The id is always the last key.
192
200
  - `page(n).per(size)` sets offset paging. The default page size is 25.
193
201
  - `pluck` asks the daemon for only those fields.
@@ -321,7 +329,7 @@ A blob behaves like a read-only IO (`read`, `each_chunk`, `eof?`, `rewind`). A l
321
329
 
322
330
  ## Errors
323
331
 
324
- 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).
325
333
 
326
334
  | Class | Code |
327
335
  |---|---|
@@ -329,7 +337,8 @@ The transport and daemon errors are defined by the gem `ndb`. They all live in t
329
337
  | `NotReady` | `not_ready` |
330
338
  | `NotFound` | `not_found` |
331
339
  | `UnknownCollection` | `unknown_collection` |
332
- | `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` |
333
342
  | `RecordNotUnique` | `unique_violation`, `duplicate_id` |
334
343
  | `CatalogConflict` | `catalog_conflict` |
335
344
  | `CatalogInvalid` | `catalog_invalid` |
@@ -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
 
@@ -130,13 +131,24 @@ module Ndb
130
131
 
131
132
  def destroy
132
133
  result = run_callbacks(:destroy) do
133
- self.class.ndb_delete(id, if_version: version) if persisted?
134
+ delete_record if persisted?
134
135
  @destroyed = true
135
136
  end
136
137
 
137
138
  result ? self : false
138
139
  end
139
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
+
140
152
  def ==(other)
141
153
  other.instance_of?(self.class) && !id.nil? && id == other.id
142
154
  end
@@ -160,6 +172,38 @@ module Ndb
160
172
  self
161
173
  end
162
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
+
163
207
  def loaded!(version)
164
208
  @persisted = true
165
209
  @version = version
@@ -183,9 +227,11 @@ module Ndb
183
227
  set, unset = changed_fields
184
228
 
185
229
  unless set.empty? && unset.empty? && @expire_in.nil?
186
- result = self.class.ndb_update(id, set: set, unset: unset, if_version: version, expire_in: @expire_in)
230
+ result = self.class.ndb_update(record_filter, update_ops(set, unset), expire_in: @expire_in)
231
+
232
+ raise StaleObjectError.new("#{self.class.name} #{id} changed or was deleted", code: "stale_object") if result["matched"].zero?
187
233
 
188
- @version = result["v"]
234
+ @version = result["doc"]["_v"]
189
235
  end
190
236
 
191
237
  changes_applied
@@ -194,6 +240,15 @@ module Ndb
194
240
  end
195
241
  end
196
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
+
197
252
  def insert_document
198
253
  attribute_names.each_with_object({}) do |name, doc|
199
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
 
@@ -143,16 +143,17 @@ module Ndb
143
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.1"
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.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - thadeu