ndb 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: b4be3ecd3f672ee851429ab1c7c80167be5a7f3d4c40e399eab1d1f301c55f4b
4
- data.tar.gz: 2601743744f84d4962a395e6168432d60e67d0c557a9da48fa95b245050b4c1d
3
+ metadata.gz: b60b364a32410e0d6c1356e427e4844bba9b31a65dcd0d5afb4fcb9acbafa3aa
4
+ data.tar.gz: e2a3603636b2ab7bcf922c9c0d759a98d871dc292bc9a2299ef3d284c6faaf26
5
5
  SHA512:
6
- metadata.gz: 4b5625d6c7158f777187868a0bb099155916eafc5774e0d275e2513a5adfd868834f5c15403a6501cf4148af1af43d330f8b12ae72d457b387d26351c678fc71
7
- data.tar.gz: 0a2f5b44deddc4140f4de275a762da655bbb44e4bacb3dd91178f1ba5a5d77f61abc7aebb7b09886ae6eb9d1c0fb3746e16d888b9c3917692bacfc598154626f
6
+ metadata.gz: e26d00115fc7530e4f766437f1019202a644ada9a4a0127f96dbd23efcf9e83d6afefda557f2ad63f892be159d8a7c38bd18112bc75744fb740f6ceeddd9edf5
7
+ data.tar.gz: 45dde6bb1ceb82b3a381f2047f2bf09e33d1d8182214422537ee2a6dc97a0bdb01463e7f2eadeb4371007863f71a1566653081e53fb9db49620c1c77e9a4565c
data/README.md CHANGED
@@ -80,26 +80,26 @@ results = ndb.insert("calls", [
80
80
  ])
81
81
  results # => [{"id" => "c1", "v" => 1}, {"id" => "c2", "v" => 1}]
82
82
 
83
- ndb.update("calls", "c1", set: { status: "done" }, unset: ["caller"]) # => {"id" => "c1", "v" => 2}
84
- ndb.update("calls", "c1", set: { duration: 50 }, if_version: 2) # only if the version is still 2
83
+ # Atomic update by filter: the filter, then the write operators.
84
+ ndb.update("calls", { call_id: "c1" }, { "$set" => { status: "done" }, "$unset" => { caller: true } })
85
+ # => {"matched" => 1, "modified" => 1, "doc" => {"call_id" => "c1", ..., "_v" => 2}}
85
86
 
86
- ndb.delete("calls", "c2")
87
- ndb.delete("calls", "c1", if_version: 3)
88
- ```
87
+ ndb.update("calls", { call_id: "c1", _v: 2 }, { "$set" => { duration: 50 } }) # only if the version is still 2
88
+ ndb.update("stock", { sku: "a", qty: { "$gte" => 1 } }, { "$inc" => { qty: -1 } }) # guarded decrement
89
89
 
90
- - `if_version:` is optimistic locking. If another writer changed the document first, you get `Ndb::VersionConflict` (code `version_conflict`). Reload and try again.
91
- - A duplicate id raises `Ndb::UniqueViolation` (code `duplicate_id`). A broken unique field raises it too (code `unique_violation`).
92
- - A missing document raises `Ndb::NotFound`.
90
+ ndb.update("calls", { status: "failed" }, { "$set" => { status: "retry" } }, many: true) # => {"matched" => 3, "modified" => 3}
91
+ ndb.update("counters", { name: "hits" }, { "$inc" => { n: 1 } }, upsert: true)
93
92
 
94
- ```ruby
95
- begin
96
- ndb.update("calls", "c1", set: { status: "done" }, if_version: version)
97
- rescue Ndb::VersionConflict
98
- version = ndb.find("calls", where: { call_id: "c1" }, versions: true).rows.first["_v"]
99
- retry
100
- end
93
+ ndb.delete("calls", { call_id: "c2" }) # => {"deleted" => 1}
94
+ ndb.delete("calls", { status: "failed" }, many: true)
101
95
  ```
102
96
 
97
+ - Operators: `$set`, `$unset`, `$inc`, `$push` (with `{ "$each" => [...] }`), `$pull`, `$min`, `$max`. Field names can be dot paths. A key of the operators without `$` raises `ArgumentError` before anything is sent.
98
+ - Nothing matched is not an error: `matched` is 0. A stale `_v` is a filter that matches nothing, so read again and retry. `modified` is 0 when the operators leave the document as it is.
99
+ - `many: true` needs the id or an indexed field in the filter (`Ndb::UnindexedMany`). `upsert: true` creates the document from the equality fields of the filter. The answer has `doc` only for one document.
100
+ - The contract errors are `Ndb::UnknownOperator`, `NoUpdate`, `ConflictingUpdate`, `InvalidField`, `UnindexedMany` and `EmptyFilter`, all under `Ndb::InvalidUpdate`.
101
+ - A duplicate id raises `Ndb::UniqueViolation` (code `duplicate_id`). A broken unique field raises it too (code `unique_violation`).
102
+
103
103
  ## Expiry (TTL like Redis)
104
104
 
105
105
  The collection needs `expires_field`.
@@ -108,10 +108,10 @@ The collection needs `expires_field`.
108
108
  ndb.ensure_collection("sessions", { id: "sid", expires_field: "exp" })
109
109
 
110
110
  ndb.insert("sessions", [{ sid: "s1" }], expire_in: 3600) # seconds, or "1h" / "30m" / "1d"
111
- ndb.update("sessions", "s1", set: { user: "u" }, expire_in: "30m")
111
+ ndb.update("sessions", { sid: "s1" }, { "$set" => { user: "u" } }, expire_in: "30m")
112
112
 
113
113
  ndb.expire("sessions", "s1", in: 1800) # => {"id", "v", "expires_at"}
114
- ndb.expire("sessions", "s1", at: Time.now + 7200, if_version: 2)
114
+ ndb.expire("sessions", "s1", at: Time.now + 7200)
115
115
  ndb.ttl("sessions", "s1") # => 7199, -1 (never expires), or nil (missing or expired)
116
116
  ndb.persist("sessions", "s1") # => {"id", "v"}
117
117
  ```
@@ -275,7 +275,7 @@ All errors inherit from `Ndb::Error`, which has `code` (the daemon code) and `st
275
275
  | `NotReady` | `not_ready` |
276
276
  | `NotFound` | `not_found` |
277
277
  | `UnknownCollection` | `unknown_collection` |
278
- | `VersionConflict` | `version_conflict` |
278
+ | `InvalidUpdate` and its subclasses | `unknown_operator`, `no_update`, `conflicting_update`, `invalid_field`, `unindexed_many`, `empty_filter` |
279
279
  | `UniqueViolation` | `unique_violation`, `duplicate_id` |
280
280
  | `CatalogConflict` | `catalog_conflict` |
281
281
  | `CatalogInvalid` | `catalog_invalid` |
data/lib/ndb/client.rb CHANGED
@@ -50,19 +50,30 @@ module Ndb
50
50
  json(post(coll, "insert", body)).fetch("results")
51
51
  end
52
52
 
53
- def update(coll, id, set: {}, unset: [], if_version: nil, expire_in: nil)
54
- change = { id: id, set: set, unset: unset }
55
- change[:if_version] = if_version if if_version
56
- change[:expire_in] = expire_in if expire_in
57
-
58
- json(post(coll, "update", update: change))
53
+ # Atomic update by filter. The filter and the write operators go in one
54
+ # body; the daemon matches and writes in one step on the newest state, so
55
+ # concurrent updates never interleave.
56
+ #
57
+ # client.update("stock", { sku: "a", qty: { "$gte" => 1 } }, { "$inc" => { qty: -1 } })
58
+ # # => {"matched" => 1, "modified" => 1, "doc" => {...with "_v"}}
59
+ #
60
+ # Operators: $set, $unset, $inc, $push, $pull, $min, $max (dot paths work).
61
+ # Nothing matched is not an error: {"matched" => 0, "modified" => 0}. A
62
+ # version guard is a filter: { id: "x", _v: 7 }. many: true updates every
63
+ # match (no "doc" in the answer) and needs the id or an indexed field in
64
+ # the filter. upsert: true creates the document from the equality fields
65
+ # of the filter when nothing matches. expire_in (seconds, or "30m") sets
66
+ # the expires_field.
67
+ def update(coll, filter, ops, many: false, upsert: false, expire_in: nil)
68
+ query = write_query(many: many, upsert: upsert, expire_in: expire_in)
69
+
70
+ json(post(coll, "update#{query}", merge_update(filter, ops)))
59
71
  end
60
72
 
61
- def delete(coll, id, if_version: nil)
62
- change = { id: id }
63
- change[:if_version] = if_version if if_version
64
-
65
- json(post(coll, "delete", delete: change))
73
+ # Deletes the documents that the filter matches (one, or every match with
74
+ # many: true, which needs the id or an indexed field). {"deleted" => n}
75
+ def delete(coll, filter, many: false)
76
+ json(post(coll, "delete#{write_query(many: many)}", filter))
66
77
  end
67
78
 
68
79
  # Makes a document expire, like EXPIRE in Redis: in: seconds or "30m", or
@@ -70,11 +81,10 @@ module Ndb
70
81
  # Raises NotFound for a missing or expired document, TtlNotEnabled when
71
82
  # the collection has no expires_field, TtlDerived when the expiry comes
72
83
  # from ttl + time.
73
- def expire(coll, id, if_version: nil, **time)
84
+ def expire(coll, id, **time)
74
85
  body = { id: id }
75
86
  body[:in] = time[:in] if time[:in]
76
87
  body[:at] = time[:at].respond_to?(:utc) ? time[:at].utc.iso8601(9) : time[:at] if time[:at]
77
- body[:if_version] = if_version if if_version
78
88
 
79
89
  json(post(coll, "expire", body))
80
90
  end
@@ -143,6 +153,28 @@ module Ndb
143
153
  value.to_s.strip.empty? ? nil : value
144
154
  end
145
155
 
156
+ def write_query(many: false, upsert: false, expire_in: nil)
157
+ params = {}
158
+
159
+ params["many"] = "true" if many
160
+ params["upsert"] = "true" if upsert
161
+ params["expire_in"] = expire_in.to_s if expire_in
162
+
163
+ params.empty? ? "" : "?#{URI.encode_www_form(params)}"
164
+ end
165
+
166
+ # The filter and the operators in one body. An operator key without "$"
167
+ # would be read as a filter key by the daemon, so it is refused here.
168
+ def merge_update(filter, ops)
169
+ ops.each_key do |key|
170
+ next if key.to_s.start_with?("$")
171
+
172
+ raise ArgumentError, "update operator #{key.to_s.inspect} must start with $ (for example \"$set\")"
173
+ end
174
+
175
+ filter.to_h.transform_keys(&:to_s).merge(ops.to_h.transform_keys(&:to_s))
176
+ end
177
+
146
178
  def post(coll, verb, body)
147
179
  request("POST", "#{path(coll)}/#{verb}", body)
148
180
  end
data/lib/ndb/errors.rb CHANGED
@@ -25,7 +25,6 @@ module Ndb
25
25
  class Unauthorized < Error; end
26
26
  class NotFound < Error; end
27
27
  class UnknownCollection < Error; end
28
- class VersionConflict < Error; end
29
28
  class UniqueViolation < Error; end
30
29
  class CatalogConflict < Error; end
31
30
  class CatalogInvalid < Error; end
@@ -33,6 +32,15 @@ module Ndb
33
32
  class InvalidQuery < Error; end
34
33
  class InvalidCursor < Error; end
35
34
 
35
+ # The update or delete body breaks the contract (see Client#update).
36
+ class InvalidUpdate < Error; end
37
+ class UnknownOperator < InvalidUpdate; end
38
+ class NoUpdate < InvalidUpdate; end
39
+ class ConflictingUpdate < InvalidUpdate; end
40
+ class InvalidField < InvalidUpdate; end
41
+ class UnindexedMany < InvalidUpdate; end
42
+ class EmptyFilter < InvalidUpdate; end
43
+
36
44
  # The collection has no expires_field, or its expiry comes from ttl + time.
37
45
  class TtlNotEnabled < Error; end
38
46
  class TtlDerived < Error; end
@@ -50,7 +58,6 @@ module Ndb
50
58
  "not_ready" => NotReady,
51
59
  "not_found" => NotFound,
52
60
  "unknown_collection" => UnknownCollection,
53
- "version_conflict" => VersionConflict,
54
61
  "unique_violation" => UniqueViolation,
55
62
  "duplicate_id" => UniqueViolation,
56
63
  "catalog_conflict" => CatalogConflict,
@@ -64,7 +71,13 @@ module Ndb
64
71
  "watch_lagged" => WatchLagged,
65
72
  "history_truncated" => HistoryTruncated,
66
73
  "ttl_not_enabled" => TtlNotEnabled,
67
- "ttl_derived" => TtlDerived
74
+ "ttl_derived" => TtlDerived,
75
+ "unknown_operator" => UnknownOperator,
76
+ "no_update" => NoUpdate,
77
+ "conflicting_update" => ConflictingUpdate,
78
+ "invalid_field" => InvalidField,
79
+ "unindexed_many" => UnindexedMany,
80
+ "empty_filter" => EmptyFilter
68
81
  }.freeze
69
82
 
70
83
  def self.error_for(code, message, status)
data/lib/ndb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ndb
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.2"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ndb
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