ndb-rails 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 +7 -0
- data/LICENSE +21 -0
- data/README.md +343 -0
- data/lib/ndb/rails/blob.rb +145 -0
- data/lib/ndb/rails/configuration.rb +36 -0
- data/lib/ndb/rails/errors.rb +19 -0
- data/lib/ndb/rails/persistence.rb +170 -0
- data/lib/ndb/rails/railtie.rb +27 -0
- data/lib/ndb/rails/record.rb +182 -0
- data/lib/ndb/rails/relation.rb +286 -0
- data/lib/ndb/rails/version.rb +7 -0
- data/lib/ndb/rails.rb +42 -0
- data/lib/ndb-rails.rb +3 -0
- metadata +99 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 407a37e97a7c0f50f39d4ca365e59bd9ecec788a9fe357e4ff4fbc61add79962
|
|
4
|
+
data.tar.gz: 0de5474707d6bf68b55d1a44f56b612a8d5a59bc24cd7e96ff8066af00e98f8b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5eeed45c9f972b596dd588122600f1f7729ca07957ca771a8af0d0ca34085aa8b3c8e76d898a9605e034a6402c8370bdf57e209b2da5320a84d5904176fbeb50
|
|
7
|
+
data.tar.gz: d52fa0af1af45659950dd152a68a5561aa311b98c7b3296f70df1185d6071b70740eae7f95763bb58d1aafc35984a6aa11cf04b822d05ab98ed2a02de208a88b
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thadeu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# ndb-rails
|
|
2
|
+
|
|
3
|
+
<img src="https://raw.githubusercontent.com/thadeu/ndb/main/brand/png/lockup-on-light.png" width="220">
|
|
4
|
+
|
|
5
|
+
ActiveModel layer for [ndb](https://github.com/thadeu/ndb), an NDJSON query daemon. It gives you `Ndb::Record` (a model class like ActiveRecord), a lazy relation, validations, blobs and a Rails railtie. It sits on the plain client gem [`ndb`](https://github.com/thadeu/ndb/tree/main/sdk/ruby) (`Ndb::Client`), which does the HTTP over a unix socket or TCP. The gem only builds JSON. The daemon decides what every query means.
|
|
6
|
+
|
|
7
|
+
No Rails, or no models? Use the gem `ndb` alone: it has no runtime dependency.
|
|
8
|
+
|
|
9
|
+
Docs: [client guide](https://github.com/thadeu/ndb/blob/main/docs/dev/clients.md), [query language](https://github.com/thadeu/ndb/blob/main/docs/dev/query.md), [HTTP API](https://github.com/thadeu/ndb/blob/main/docs/dev/http-api.md).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
Needs Ruby 3.2 or newer, `activemodel` and `activesupport` 7.1 or newer, and a running ndb daemon. The gem `ndb` (`~> 0.1`) is installed with it, as a dependency.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# Gemfile
|
|
17
|
+
gem "ndb-rails"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
bundle add ndb-rails
|
|
22
|
+
# or: gem install ndb-rails
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
In Rails, the gem loads a railtie. It reads `config/ndb.yml` and, when the app eager loads (production), it declares the collection of every model at boot. In development a model declares its collection on first use. Without Bundler autorequire, use `require "ndb/rails"`. Without Rails, call `Ndb::Rails.configure` yourself (below).
|
|
26
|
+
|
|
27
|
+
## Connect
|
|
28
|
+
|
|
29
|
+
Rails: create `config/ndb.yml`. It has one block per environment. ERB and YAML anchors work.
|
|
30
|
+
|
|
31
|
+
```yaml
|
|
32
|
+
default: &default
|
|
33
|
+
timeout: 30 # seconds, per request
|
|
34
|
+
pool_size: 16 # idle connections kept
|
|
35
|
+
ready_timeout: 10 # seconds to retry while the daemon starts; 0 turns it off
|
|
36
|
+
|
|
37
|
+
development:
|
|
38
|
+
<<: *default
|
|
39
|
+
socket: /tmp/ndb.sock
|
|
40
|
+
|
|
41
|
+
production:
|
|
42
|
+
<<: *default
|
|
43
|
+
url: tcp://ndb.internal:7070
|
|
44
|
+
token: <%= ENV["NDB_TOKEN"] %>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Keys: `url`, `socket`, `token`, `timeout`, `pool_size`, `ready_timeout`. Any other key is ignored.
|
|
48
|
+
|
|
49
|
+
Any app: configure in code, for example in `config/initializers/ndb.rb`.
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
Ndb::Rails.configure do |c|
|
|
53
|
+
c.socket = "/run/ndb/ndb.sock" # or: c.url = "tcp://10.0.0.5:7070"
|
|
54
|
+
c.token = ENV["NDB_TOKEN"]
|
|
55
|
+
c.timeout = 30
|
|
56
|
+
c.pool_size = 16
|
|
57
|
+
c.ready_timeout = 10
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- The address is, in order: `url`, `socket`, env `NDB_URL`, then the default socket `/var/run/ndb/ndb.sock`.
|
|
62
|
+
- The address is `unix:///path`, `tcp://host:port`, or a bare path that starts with `/`.
|
|
63
|
+
- The token is `token`, or env `NDB_TOKEN`. It goes only in the `Authorization` header. A unix socket does not need it.
|
|
64
|
+
- `ready_timeout` is how long a call retries while the daemon is not ready (`not_ready`, or the socket is missing or refuses). It uses backoff with jitter. A write is retried only when the daemon certainly did not process it. A read is also retried after a dropped connection. A timeout is never retried.
|
|
65
|
+
- The pool drops its connections after a `fork`.
|
|
66
|
+
- `Ndb::Rails.client` is an `Ndb::Client` built from this configuration (`Ndb::Rails.configuration`). You can also build one: `Ndb::Client.new(socket: "/tmp/ndb.sock", token: nil, timeout: 30, pool_size: 16, ready_timeout: 10)`. The [`ndb` README](https://github.com/thadeu/ndb/blob/main/sdk/ruby/README.md) documents every client call.
|
|
67
|
+
|
|
68
|
+
## Define a model
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
class Call < Ndb::Record
|
|
72
|
+
primary_key :call_id
|
|
73
|
+
|
|
74
|
+
attribute :status, :string, index: true
|
|
75
|
+
attribute :caller, :string, unique: true
|
|
76
|
+
attribute :duration, :integer
|
|
77
|
+
attribute :at, :datetime, time: true
|
|
78
|
+
attribute :expires_at, :datetime, expires: true
|
|
79
|
+
attribute :recording, :blob
|
|
80
|
+
|
|
81
|
+
validates :status, presence: true
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Option or method | Meaning |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `primary_key :call_id` | The id field. Default is `_id` (a string, made by the daemon). |
|
|
88
|
+
| `index: true` | Index this field. |
|
|
89
|
+
| `unique: true` | Unique value. A second one raises `RecordNotUnique`. |
|
|
90
|
+
| `time: true` | The collection time field. Used by zone maps, `$dateTrunc` and TTL. |
|
|
91
|
+
| `expires: true` | The field holds the time the document expires. |
|
|
92
|
+
| `ttl 24.hours` | Documents expire this long after the time field. Use `ttl` or `expires:`, not both: the daemon returns `catalog_invalid`. |
|
|
93
|
+
| `self.collection_name = "x"` | Collection name. Default follows Rails naming (`Call` becomes `calls`). |
|
|
94
|
+
| `source "glob"` | Declares a read-only direct-mode collection over files. Writes raise `ReadOnlyCollection`. |
|
|
95
|
+
|
|
96
|
+
- Attributes are ActiveModel attributes, so every ActiveModel type works (`:string`, `:integer`, `:float`, `:boolean`, `:datetime`, `:date`, and so on). Times are sent as RFC 3339 UTC strings with milliseconds. `:blob` is added by this gem.
|
|
97
|
+
- Validations, callbacks (`save`, `create`, `update`, `destroy`), dirty tracking and `as_json` work as in ActiveModel.
|
|
98
|
+
- The model declares its collection on first use, once per process.
|
|
99
|
+
- Mark a base class with `self.abstract_class = true` to share settings.
|
|
100
|
+
|
|
101
|
+
## Insert, find, update, delete
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
call = Call.create!(call_id: "c1", status: "answered", caller: "a", at: Time.now.utc)
|
|
105
|
+
Call.create!([{ call_id: "c2", status: "failed", caller: "b" }, { call_id: "c3", status: "answered", caller: "c" }])
|
|
106
|
+
|
|
107
|
+
call = Call.new(call_id: "c4", status: "answered", caller: "d")
|
|
108
|
+
call.save # false when invalid
|
|
109
|
+
call.save! # raises Ndb::RecordInvalid
|
|
110
|
+
|
|
111
|
+
call = Call.find("c1") # raises Ndb::NotFound
|
|
112
|
+
Call.find_by(status: "failed") # nil when missing
|
|
113
|
+
Call.find_by!(status: "x") # raises NotFound
|
|
114
|
+
|
|
115
|
+
call.update!(status: "done") # sends only the changed fields
|
|
116
|
+
call.version # the document version
|
|
117
|
+
|
|
118
|
+
call.destroy
|
|
119
|
+
```
|
|
120
|
+
|
|
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`).
|
|
122
|
+
- Setting an attribute to `nil` and saving removes the field from the document.
|
|
123
|
+
- A duplicate id raises `RecordNotUnique` (code `duplicate_id`).
|
|
124
|
+
- `persisted?`, `new_record?`, `destroyed?`, `id` and `version` work as in ActiveRecord.
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
begin
|
|
128
|
+
call.update!(status: "done")
|
|
129
|
+
rescue Ndb::StaleObjectError
|
|
130
|
+
call = Call.find(call.id)
|
|
131
|
+
retry
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Query
|
|
136
|
+
|
|
137
|
+
Relations are lazy and immutable. The request goes out when you call `to_a`, `first`, `each`, `count` and so on.
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
Call.where(status: "answered")
|
|
141
|
+
Call.where(status: %w[answered failed]) # $in
|
|
142
|
+
Call.where(duration: 30..60) # $gte and $lte
|
|
143
|
+
Call.where(duration: 30..) # $gte
|
|
144
|
+
Call.where(duration: nil) # $exists: false
|
|
145
|
+
Call.where(at: 1.day.ago..)
|
|
146
|
+
Call.where("agent.id" => 7) # dot path
|
|
147
|
+
Call.where(duration: { "$gt" => 10 }) # any daemon operator, as a Hash
|
|
148
|
+
Call.where.not(status: "failed") # $ne, $nin, $exists
|
|
149
|
+
Call.where(status: "failed").or(Call.where(duration: 60..))
|
|
150
|
+
Call.where("$or" => [{ status: "failed" }, { duration: { "$gt" => 50 } }])
|
|
151
|
+
|
|
152
|
+
Call.where(status: "answered")
|
|
153
|
+
.order(at: :desc, call_id: :asc)
|
|
154
|
+
.limit(50)
|
|
155
|
+
.offset(100)
|
|
156
|
+
.to_a
|
|
157
|
+
|
|
158
|
+
Call.where(status: "answered").pluck(:call_id) # one field: an array of values
|
|
159
|
+
Call.where(status: "answered").pluck(:call_id, :status) # several: an array of arrays
|
|
160
|
+
Call.where(status: "answered").first(5)
|
|
161
|
+
Call.where(status: "failed").exists?
|
|
162
|
+
```
|
|
163
|
+
|
|
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.
|
|
165
|
+
- The order of the `order` keys is the order of the sort. The id is always the last key.
|
|
166
|
+
- `page(n).per(size)` sets offset paging. The default page size is 25.
|
|
167
|
+
- `pluck` asks the daemon for only those fields.
|
|
168
|
+
- There is no `explain` on a relation. Use the client: `Ndb::Rails.client.find(coll, "where" => {...}, "explain" => true)` returns a `Page` with `plan` (the `X-Ndb-Plan` header: `id`, `index:<field>`, `scan`, `snapshot`) and `examined`.
|
|
169
|
+
|
|
170
|
+
### Regular expressions
|
|
171
|
+
|
|
172
|
+
A Ruby `Regexp` works in `where`, `where.not` and `or`. The gem `ndb` turns it into `$regex` and `$options` (the daemon uses RE2). Ruby `^` and `$` match at each line, so `m` is always sent. `/i` is `i`, `/m` is `s`, `/x` is `x`. Look-around, backreferences, atomic groups, possessive quantifiers, `\h`, `\G`, `\R` and `\X` raise `Ndb::InvalidQuery` before the request.
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
Call.where(caller: /^55/)
|
|
176
|
+
Call.where(caller: /^55/i).where.not(status: /fail/)
|
|
177
|
+
Call.where(caller: /^55/).or(Call.where(caller: /^11/))
|
|
178
|
+
Call.where(caller: { "$regex" => "^55" }) # a String: no `m`, and a literal ^prefix uses the index
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
A Regexp is a scan filter. For a prefix search that uses the index, pass the String form. See the [`ndb` README](https://github.com/thadeu/ndb/blob/main/sdk/ruby/README.md) for the full mapping.
|
|
182
|
+
|
|
183
|
+
### Count
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
Call.where(status: "answered").count
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Cursor pagination
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
relation = Call.where(status: "answered").order(:call_id).limit(100)
|
|
193
|
+
rows = relation.to_a
|
|
194
|
+
token = relation.next_cursor # nil on the last page
|
|
195
|
+
|
|
196
|
+
Call.where(status: "answered").order(:call_id).limit(100).after(token).to_a
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
A token works only with the same `where` and `order`. Another query shape raises `InvalidCursor`.
|
|
200
|
+
|
|
201
|
+
To walk every row, use `find_each`. It follows the cursor in batches (default 1000):
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
Call.where(status: "answered").find_each(batch_size: 500) { |call| puts call.call_id }
|
|
205
|
+
|
|
206
|
+
Call.in_batches(of: 500) { |batch| batch.each { |call| ... } }
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Aggregate
|
|
210
|
+
|
|
211
|
+
Relations have simple group calculations. Each one is one aggregate request.
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
Call.group(:status).count # => {"answered" => 2, "failed" => 1}
|
|
215
|
+
Call.group(:status).sum(:duration)
|
|
216
|
+
Call.group(:status, :caller).count # keys are arrays
|
|
217
|
+
Call.where(status: "answered").average(:duration)
|
|
218
|
+
Call.minimum(:duration)
|
|
219
|
+
Call.maximum(:duration)
|
|
220
|
+
Call.sum(:duration)
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
For a full pipeline, use the client. The pipeline needs exactly one `$group`. Accumulators: `$count`, `$sum`, `$avg`, `$min`, `$max`, `$first`, `$last`.
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
page = Ndb::Rails.client.aggregate(Call.collection_name,
|
|
227
|
+
pipeline: [
|
|
228
|
+
{ "$match" => { "duration" => { "$gte" => 1 } } },
|
|
229
|
+
{ "$group" => {
|
|
230
|
+
"_id" => {
|
|
231
|
+
"status" => "$status",
|
|
232
|
+
"hour" => { "$dateTrunc" => { "date" => "$at", "unit" => "hour" } }
|
|
233
|
+
},
|
|
234
|
+
"n" => { "$count" => {} },
|
|
235
|
+
"avg" => { "$avg" => "$duration" }
|
|
236
|
+
} },
|
|
237
|
+
{ "$sort" => { "n" => -1 } }
|
|
238
|
+
],
|
|
239
|
+
limit: 100,
|
|
240
|
+
count: true)
|
|
241
|
+
|
|
242
|
+
page.rows # array of Hash
|
|
243
|
+
page.total # with count: true
|
|
244
|
+
page.next_cursor # nil on the last page
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`$dateTrunc` units are `minute`, `hour`, `day`, `week` and `month`. Optional `"timezone"`: a name such as `"Europe/Rome"`.
|
|
248
|
+
|
|
249
|
+
## Watch
|
|
250
|
+
|
|
251
|
+
A watch streams the changes of a collection to a named consumer: first the events after its last ack, then new events as they happen. The watch API is on the client (`Ndb::Client`).
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
client = Ndb::Rails.client
|
|
255
|
+
|
|
256
|
+
begin
|
|
257
|
+
client.watch(Call.collection_name, "relay", where: { status: "answered" }) do |event|
|
|
258
|
+
# event has "pos", "op" ("put" or "del"), "id", "v" and "doc"
|
|
259
|
+
handle(event)
|
|
260
|
+
|
|
261
|
+
client.ack(Call.collection_name, "relay", event["pos"])
|
|
262
|
+
end
|
|
263
|
+
rescue Ndb::WatchLagged
|
|
264
|
+
retry # the consumer was too slow. watch continues after the last ack.
|
|
265
|
+
rescue Ndb::HistoryTruncated
|
|
266
|
+
raise # the saved position is too old. Start over.
|
|
267
|
+
end
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
- `where:` takes the same conditions as `where` on a model (Hash and Range). Only matching events come. A delete event has no `"doc"`; it is sent only if this stream saw an earlier matching put of that id.
|
|
271
|
+
- `from: "now"` starts a new consumer at the end. `limit: n` ends the stream after n events.
|
|
272
|
+
- Without a block, `watch` returns an Enumerator.
|
|
273
|
+
- Delivery is at least once. After a restart an event can come again, so make the work safe to repeat.
|
|
274
|
+
- `HistoryTruncated` means the WAL files for the position were deleted (`wal_retention`).
|
|
275
|
+
- A watch holds one connection until it ends.
|
|
276
|
+
|
|
277
|
+
## Blobs
|
|
278
|
+
|
|
279
|
+
Large values live in the blob store. The document keeps only a reference (`$blob`, `size`, `type`).
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
call = Call.new(call_id: "c1", status: "answered", caller: "a",
|
|
283
|
+
recording: File.open("call.wav")) # an IO or a String
|
|
284
|
+
call.save! # uploads the blob, then saves
|
|
285
|
+
|
|
286
|
+
call.recording.ref # "sha256:..."
|
|
287
|
+
call.recording.size
|
|
288
|
+
|
|
289
|
+
loaded = Call.find("c1")
|
|
290
|
+
loaded.recording.read # all the bytes
|
|
291
|
+
loaded.recording.each_chunk { |chunk| out.write(chunk) } # 1 MiB ranges
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
A blob behaves like a read-only IO (`read`, `each_chunk`, `eof?`, `rewind`). A loaded blob reads the daemon in 1 MiB ranges, so memory stays small. The same content always gives the same reference. You can also use `Ndb::Blob.new(io_or_string, content_type: "audio/wav")`.
|
|
295
|
+
|
|
296
|
+
## Errors
|
|
297
|
+
|
|
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).
|
|
299
|
+
|
|
300
|
+
| Class | Code |
|
|
301
|
+
|---|---|
|
|
302
|
+
| `Unauthorized` | `unauthorized` |
|
|
303
|
+
| `NotReady` | `not_ready` |
|
|
304
|
+
| `NotFound` | `not_found` |
|
|
305
|
+
| `UnknownCollection` | `unknown_collection` |
|
|
306
|
+
| `StaleObjectError` | `version_conflict` |
|
|
307
|
+
| `RecordNotUnique` | `unique_violation`, `duplicate_id` |
|
|
308
|
+
| `CatalogConflict` | `catalog_conflict` |
|
|
309
|
+
| `CatalogInvalid` | `catalog_invalid` |
|
|
310
|
+
| `ReadOnlyCollection` | `direct_collection` |
|
|
311
|
+
| `InvalidQuery` | `invalid_query`, `invalid_request` |
|
|
312
|
+
| `InvalidCursor` | `cursor_invalid`, `cursor_mismatch`, `cursor_expired` |
|
|
313
|
+
| `WatchLagged` | `watch_lagged` |
|
|
314
|
+
| `HistoryTruncated` | `history_truncated` |
|
|
315
|
+
| `RecordInvalid` | ActiveModel validation failed (has `record`) |
|
|
316
|
+
| `RecordNotSaved` | `save!` returned false without errors |
|
|
317
|
+
| `ConnectionError` | cannot reach the daemon. Subclasses: `ConnectError` (nothing was sent), `TimeoutError` |
|
|
318
|
+
|
|
319
|
+
Any other code is a plain `Ndb::Error`. A response that is not a daemon error has the code `http_status`.
|
|
320
|
+
|
|
321
|
+
```ruby
|
|
322
|
+
rescue Ndb::RecordNotUnique => e
|
|
323
|
+
e.code # "unique_violation"
|
|
324
|
+
e.status # 409
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## Testing
|
|
328
|
+
|
|
329
|
+
The tests of this gem build the daemon from the repository (`go build ./cmd/ndb`), start it on a short temp unix socket (macOS allows 104 bytes), wait for `GET /health` to return 200, and stop it at exit. To do the same by hand:
|
|
330
|
+
|
|
331
|
+
```sh
|
|
332
|
+
ndb -config /nonexistent.yml -data-dir "$(mktemp -d)" -unix /tmp/ndb-test.sock
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
Ndb::Rails.configure { |c| c.socket = "/tmp/ndb-test.sock" }
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Use a new collection name per test (`self.collection_name = ...`). Run the suite with `bundle exec rake test` in `sdk/rails`. Its Gemfile uses the gem `ndb` from `../ruby`.
|
|
340
|
+
|
|
341
|
+
## License
|
|
342
|
+
|
|
343
|
+
MIT.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Ndb
|
|
6
|
+
# A large value in the blob store (plan §7.5). The document keeps only
|
|
7
|
+
# {"$blob" => "sha256:...", "size" => N, "type" => "..."}.
|
|
8
|
+
#
|
|
9
|
+
# It behaves like a read-only IO. A blob built from an IO or a String is
|
|
10
|
+
# pending: it is uploaded when the record is saved. A blob from a loaded
|
|
11
|
+
# record reads the daemon in 1 MiB ranges, so memory stays small.
|
|
12
|
+
class Blob
|
|
13
|
+
CHUNK_SIZE = 1 << 20
|
|
14
|
+
|
|
15
|
+
attr_reader :ref, :size, :content_type
|
|
16
|
+
|
|
17
|
+
def self.from_reference(reference)
|
|
18
|
+
blob = allocate
|
|
19
|
+
|
|
20
|
+
blob.send(:stored!, reference["$blob"], reference["size"], reference["type"])
|
|
21
|
+
|
|
22
|
+
blob
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(source, content_type: nil)
|
|
26
|
+
@source = source.is_a?(String) ? StringIO.new(source) : source
|
|
27
|
+
@content_type = content_type || (source.respond_to?(:content_type) ? source.content_type : nil)
|
|
28
|
+
@pos = 0
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def uploaded?
|
|
32
|
+
!@ref.nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Uploads a pending blob; a stored blob does nothing.
|
|
36
|
+
def upload!(client = Rails.client)
|
|
37
|
+
return self if uploaded?
|
|
38
|
+
|
|
39
|
+
@source.rewind if @source.respond_to?(:rewind)
|
|
40
|
+
|
|
41
|
+
stored = client.put_blob(@source, @content_type)
|
|
42
|
+
|
|
43
|
+
stored!(stored["$blob"], stored["size"], stored["type"])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The document value, uploading first if needed.
|
|
47
|
+
def to_reference
|
|
48
|
+
upload!
|
|
49
|
+
|
|
50
|
+
{ "$blob" => ref, "size" => size, "type" => content_type }.compact
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def as_json(*)
|
|
54
|
+
uploaded? ? to_reference : nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# IO#read: all the rest, or up to length bytes (nil at the end).
|
|
58
|
+
def read(length = nil)
|
|
59
|
+
return @source.read(length) unless uploaded?
|
|
60
|
+
return read_all if length.nil?
|
|
61
|
+
return +"" if length.zero?
|
|
62
|
+
return nil if eof?
|
|
63
|
+
|
|
64
|
+
fetch([length, size - @pos].min)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def each_chunk(chunk_size = CHUNK_SIZE)
|
|
68
|
+
return enum_for(:each_chunk, chunk_size) unless block_given?
|
|
69
|
+
|
|
70
|
+
while (chunk = read(chunk_size))
|
|
71
|
+
yield chunk
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def eof?
|
|
76
|
+
uploaded? ? @pos >= size : @source.eof?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def rewind
|
|
80
|
+
@pos = 0
|
|
81
|
+
|
|
82
|
+
@source.rewind if !uploaded? && @source.respond_to?(:rewind)
|
|
83
|
+
|
|
84
|
+
0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def ==(other)
|
|
88
|
+
other.is_a?(Blob) && uploaded? && other.uploaded? && ref == other.ref
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def stored!(ref, size, content_type)
|
|
94
|
+
@ref = ref
|
|
95
|
+
@size = size
|
|
96
|
+
@content_type = content_type
|
|
97
|
+
@source = nil
|
|
98
|
+
@pos = 0
|
|
99
|
+
|
|
100
|
+
self
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def read_all
|
|
104
|
+
out = +""
|
|
105
|
+
|
|
106
|
+
each_chunk { |chunk| out << chunk }
|
|
107
|
+
|
|
108
|
+
out
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def fetch(count)
|
|
112
|
+
data = Rails.client.get_blob(ref, from: @pos, to: @pos + count - 1)
|
|
113
|
+
|
|
114
|
+
@pos += data.bytesize
|
|
115
|
+
|
|
116
|
+
data
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# attribute :recording, :blob
|
|
121
|
+
class BlobType < ActiveModel::Type::Value
|
|
122
|
+
def type
|
|
123
|
+
:blob
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cast(value)
|
|
127
|
+
case value
|
|
128
|
+
when nil, Blob then value
|
|
129
|
+
when Hash then value.key?("$blob") ? Blob.from_reference(value) : nil
|
|
130
|
+
when String then Blob.new(value)
|
|
131
|
+
else value.respond_to?(:read) ? Blob.new(value) : raise(ArgumentError, "cannot make a blob from #{value.class}")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def deserialize(value)
|
|
136
|
+
cast(value)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def serialize(value)
|
|
140
|
+
value&.to_reference
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
ActiveModel::Type.register(:blob, BlobType)
|
|
145
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Ndb
|
|
7
|
+
module Rails
|
|
8
|
+
# The connection only: the address (url or socket), the token and the
|
|
9
|
+
# timeouts. ready_timeout is how many seconds a call retries while the
|
|
10
|
+
# daemon starts or recovers (0 turns it off). Unset address and token fall back to NDB_URL and NDB_TOKEN.
|
|
11
|
+
class Configuration
|
|
12
|
+
attr_accessor :url, :socket, :token, :timeout, :pool_size, :ready_timeout
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@timeout = 30
|
|
16
|
+
@pool_size = 16
|
|
17
|
+
@ready_timeout = 10
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Reads config/ndb.yml: one block per environment.
|
|
21
|
+
def load_file(path, env)
|
|
22
|
+
raw = YAML.safe_load(ERB.new(File.read(path)).result, aliases: true) || {}
|
|
23
|
+
settings = raw.fetch(env.to_s, {})
|
|
24
|
+
|
|
25
|
+
@url = settings["url"] if settings.key?("url")
|
|
26
|
+
@socket = settings["socket"] if settings.key?("socket")
|
|
27
|
+
@token = settings["token"] if settings.key?("token")
|
|
28
|
+
@timeout = settings["timeout"] if settings.key?("timeout")
|
|
29
|
+
@pool_size = settings["pool_size"] if settings.key?("pool_size")
|
|
30
|
+
@ready_timeout = settings["ready_timeout"] if settings.key?("ready_timeout")
|
|
31
|
+
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
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
|
|
7
|
+
RecordNotUnique = UniqueViolation
|
|
8
|
+
|
|
9
|
+
# Raised by save!/create!/update! when validations fail.
|
|
10
|
+
class RecordInvalid < Error
|
|
11
|
+
attr_reader :record
|
|
12
|
+
|
|
13
|
+
def initialize(record)
|
|
14
|
+
@record = record
|
|
15
|
+
|
|
16
|
+
super("Validation failed: #{record.errors.full_messages.join(', ')}")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ndb
|
|
4
|
+
class RecordNotSaved < Error; end
|
|
5
|
+
|
|
6
|
+
# Writes: dirty attributes -> set/unset, the loaded version -> if_version.
|
|
7
|
+
module Persistence
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
define_model_callbacks :save, :create, :update, :destroy
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class_methods do
|
|
15
|
+
def create(attrs = {}, &block)
|
|
16
|
+
return attrs.map { |a| create(a, &block) } if attrs.is_a?(Array)
|
|
17
|
+
|
|
18
|
+
record = new(attrs)
|
|
19
|
+
|
|
20
|
+
yield record if block
|
|
21
|
+
record.save
|
|
22
|
+
|
|
23
|
+
record
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create!(attrs = {}, &block)
|
|
27
|
+
return attrs.map { |a| create!(a, &block) } if attrs.is_a?(Array)
|
|
28
|
+
|
|
29
|
+
record = new(attrs)
|
|
30
|
+
|
|
31
|
+
yield record if block
|
|
32
|
+
record.save!
|
|
33
|
+
|
|
34
|
+
record
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def instantiate(row)
|
|
38
|
+
row = row.dup
|
|
39
|
+
version = row.delete("_v")
|
|
40
|
+
record = new(row.slice(*attribute_names))
|
|
41
|
+
|
|
42
|
+
record.send(:loaded!, version)
|
|
43
|
+
|
|
44
|
+
record
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attr_reader :version
|
|
49
|
+
|
|
50
|
+
def id
|
|
51
|
+
public_send(self.class.primary_key)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def id=(value)
|
|
55
|
+
public_send("#{self.class.primary_key}=", value)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def persisted?
|
|
59
|
+
@persisted == true && !destroyed?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def new_record?
|
|
63
|
+
!@persisted
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def destroyed?
|
|
67
|
+
@destroyed == true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_key
|
|
71
|
+
persisted? ? [id] : nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def save(validate: true)
|
|
75
|
+
return false if validate && !valid?(new_record? ? :create : :update)
|
|
76
|
+
|
|
77
|
+
run_callbacks(:save) { new_record? ? create_record : update_record } ? true : false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def save!(**)
|
|
81
|
+
save(**) || raise(errors.any? ? RecordInvalid.new(self) : RecordNotSaved.new("Failed to save the record"))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def update(attrs)
|
|
85
|
+
assign_attributes(attrs)
|
|
86
|
+
|
|
87
|
+
save
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def update!(attrs)
|
|
91
|
+
assign_attributes(attrs)
|
|
92
|
+
|
|
93
|
+
save!
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def destroy
|
|
97
|
+
result = run_callbacks(:destroy) do
|
|
98
|
+
self.class.ndb_delete(id, if_version: version) if persisted?
|
|
99
|
+
@destroyed = true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
result ? self : false
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def ==(other)
|
|
106
|
+
other.instance_of?(self.class) && !id.nil? && id == other.id
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def loaded!(version)
|
|
112
|
+
@persisted = true
|
|
113
|
+
@version = version
|
|
114
|
+
|
|
115
|
+
changes_applied
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def create_record
|
|
119
|
+
run_callbacks(:create) do
|
|
120
|
+
result = self.class.ndb_insert(insert_document)
|
|
121
|
+
|
|
122
|
+
self.id = result["id"] if id.nil?
|
|
123
|
+
loaded!(result["v"])
|
|
124
|
+
|
|
125
|
+
true
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def update_record
|
|
130
|
+
run_callbacks(:update) do
|
|
131
|
+
set, unset = changed_fields
|
|
132
|
+
|
|
133
|
+
unless set.empty? && unset.empty?
|
|
134
|
+
result = self.class.ndb_update(id, set: set, unset: unset, if_version: version)
|
|
135
|
+
|
|
136
|
+
@version = result["v"]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
changes_applied
|
|
140
|
+
|
|
141
|
+
true
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def insert_document
|
|
146
|
+
attribute_names.each_with_object({}) do |name, doc|
|
|
147
|
+
value = json_attribute(name)
|
|
148
|
+
|
|
149
|
+
doc[name] = value unless value.nil?
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def changed_fields
|
|
154
|
+
names = changed - [self.class.primary_key]
|
|
155
|
+
set = {}
|
|
156
|
+
unset = []
|
|
157
|
+
|
|
158
|
+
names.each do |name|
|
|
159
|
+
value = json_attribute(name)
|
|
160
|
+
value.nil? ? unset << name : set[name] = value
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
[set, unset]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def json_attribute(name)
|
|
167
|
+
Conditions.json_value(self.class.attribute_types[name].serialize(public_send(name)))
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Ndb
|
|
6
|
+
module Rails
|
|
7
|
+
# Reads config/ndb.yml (the connection only) and declares the collection
|
|
8
|
+
# of every model on boot when the app eager loads (production). In
|
|
9
|
+
# development a model declares its collection on first use.
|
|
10
|
+
class Railtie < ::Rails::Railtie
|
|
11
|
+
initializer "ndb.configure" do |app|
|
|
12
|
+
path = app.root.join("config", "ndb.yml")
|
|
13
|
+
|
|
14
|
+
if path.exist?
|
|
15
|
+
Ndb::Rails.configuration.load_file(path, ::Rails.env)
|
|
16
|
+
Ndb::Rails.configure { |_| }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
config.after_initialize do |app|
|
|
21
|
+
if app.config.eager_load
|
|
22
|
+
Ndb::Record.descendants.reject(&:abstract_class?).each(&:ensure_collection!)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ndb
|
|
4
|
+
# The model class, like ActiveRecord::Base: ActiveModel + a lazy Relation.
|
|
5
|
+
class Record
|
|
6
|
+
include ActiveModel::Model
|
|
7
|
+
include ActiveModel::Attributes
|
|
8
|
+
include ActiveModel::Validations
|
|
9
|
+
include ActiveModel::Callbacks
|
|
10
|
+
include ActiveModel::Dirty
|
|
11
|
+
include ActiveModel::Serializers::JSON
|
|
12
|
+
include Persistence
|
|
13
|
+
|
|
14
|
+
extend ActiveSupport::DescendantsTracker
|
|
15
|
+
|
|
16
|
+
ENSURE_LOCK = Mutex.new
|
|
17
|
+
|
|
18
|
+
attribute :_id, :string
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
QUERY_METHODS = %i[
|
|
22
|
+
where order limit offset page after group find find_by find_by! first count pluck exists?
|
|
23
|
+
find_each in_batches sum average minimum maximum to_a each
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
delegate(*QUERY_METHODS, to: :all)
|
|
27
|
+
|
|
28
|
+
attr_writer :abstract_class
|
|
29
|
+
|
|
30
|
+
def abstract_class?
|
|
31
|
+
@abstract_class == true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def all
|
|
35
|
+
Relation.new(self)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def collection_name
|
|
39
|
+
@collection_name || model_name.plural
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def collection_name=(name)
|
|
43
|
+
@collection_name = name.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def primary_key(name = nil)
|
|
47
|
+
if name
|
|
48
|
+
@primary_key = name.to_s
|
|
49
|
+
attribute(name, :string) unless attribute_types.key?(@primary_key)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@primary_key || (superclass.respond_to?(:primary_key) ? superclass.primary_key : "_id")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def primary_key=(name)
|
|
56
|
+
primary_key(name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def source(glob = nil)
|
|
60
|
+
@source = glob if glob
|
|
61
|
+
|
|
62
|
+
@source
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def attribute(name, *args, index: false, unique: false, time: false, expires: false, **options)
|
|
66
|
+
own_indexed << name.to_s if index
|
|
67
|
+
own_unique << name.to_s if unique
|
|
68
|
+
@time_field = name.to_s if time
|
|
69
|
+
@expires_field = name.to_s if expires
|
|
70
|
+
|
|
71
|
+
super(name, *args, **options)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The collection time field (plan §5): zone maps, $dateTrunc, TTL.
|
|
75
|
+
def time_field
|
|
76
|
+
@time_field || (superclass.respond_to?(:time_field) ? superclass.time_field : nil)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The field that holds the expiry time of each document (plan §11.5).
|
|
80
|
+
def expires_field
|
|
81
|
+
@expires_field || (superclass.respond_to?(:expires_field) ? superclass.expires_field : nil)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Documents expire this long after the time field: `ttl 24.hours`.
|
|
85
|
+
# Returns the Go-style duration the daemon reads, such as "86400s".
|
|
86
|
+
def ttl(duration = nil)
|
|
87
|
+
@ttl = "#{duration.to_i}s" if duration
|
|
88
|
+
|
|
89
|
+
@ttl || (superclass.respond_to?(:ttl) ? superclass.ttl : nil)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def indexed_fields
|
|
93
|
+
inherited_list(:indexed_fields) | own_indexed
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def unique_fields
|
|
97
|
+
inherited_list(:unique_fields) | own_unique
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The ensure_collection body for this model.
|
|
101
|
+
def collection_definition
|
|
102
|
+
definition = { "id" => primary_key }
|
|
103
|
+
|
|
104
|
+
definition["indexes"] = indexed_fields if indexed_fields.any?
|
|
105
|
+
definition["unique"] = unique_fields if unique_fields.any?
|
|
106
|
+
definition["time"] = time_field if time_field
|
|
107
|
+
definition["expires_field"] = expires_field if expires_field
|
|
108
|
+
definition["ttl"] = ttl if ttl
|
|
109
|
+
definition.merge!("write" => "direct", "path" => source) if source
|
|
110
|
+
|
|
111
|
+
definition
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Declares the collection once per process (and once per client).
|
|
115
|
+
def ensure_collection!
|
|
116
|
+
client = Rails.client
|
|
117
|
+
|
|
118
|
+
return if @ensured_client.equal?(client)
|
|
119
|
+
|
|
120
|
+
ENSURE_LOCK.synchronize do
|
|
121
|
+
client.ensure_collection(collection_name, collection_definition)
|
|
122
|
+
|
|
123
|
+
@ensured_client = client
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Every load asks for "_v", so a loaded record can send if_version.
|
|
128
|
+
def ndb_find(body)
|
|
129
|
+
ensure_collection!
|
|
130
|
+
|
|
131
|
+
Rails.client.find(collection_name, body.merge("versions" => true))
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def ndb_aggregate(body)
|
|
135
|
+
ensure_collection!
|
|
136
|
+
|
|
137
|
+
Rails.client.aggregate(collection_name, body)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def ndb_insert(doc)
|
|
141
|
+
ensure_collection!
|
|
142
|
+
|
|
143
|
+
Rails.client.insert(collection_name, [doc]).first
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def ndb_update(id, **change)
|
|
147
|
+
ensure_collection!
|
|
148
|
+
|
|
149
|
+
Rails.client.update(collection_name, id, **change)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def ndb_delete(id, if_version: nil)
|
|
153
|
+
ensure_collection!
|
|
154
|
+
|
|
155
|
+
Rails.client.delete(collection_name, id, if_version: if_version)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def own_indexed
|
|
161
|
+
@own_indexed ||= []
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def own_unique
|
|
165
|
+
@own_unique ||= []
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def inherited_list(name)
|
|
169
|
+
superclass.respond_to?(name) ? superclass.public_send(name) : []
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The generated _id stays out of the JSON when another field is the id.
|
|
174
|
+
def serializable_hash(options = nil)
|
|
175
|
+
hash = super
|
|
176
|
+
|
|
177
|
+
hash.delete("_id") if self.class.primary_key != "_id" && hash["_id"].nil?
|
|
178
|
+
|
|
179
|
+
hash
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ndb
|
|
4
|
+
# A lazy, immutable query. Every builder returns a new relation; the
|
|
5
|
+
# request goes to the daemon on each, to_a, first, count, pluck, ...
|
|
6
|
+
class Relation
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
DEFAULT_PER_PAGE = 25
|
|
10
|
+
BATCH_SIZE = 1000
|
|
11
|
+
EMPTY = { where: [].freeze, order: {}.freeze, group: [].freeze }.freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :klass, :values
|
|
14
|
+
|
|
15
|
+
def initialize(klass, values = EMPTY)
|
|
16
|
+
@klass = klass
|
|
17
|
+
@values = values
|
|
18
|
+
@result = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Builders
|
|
22
|
+
|
|
23
|
+
def where(attrs = :chain)
|
|
24
|
+
return WhereChain.new(self) if attrs == :chain
|
|
25
|
+
raise ArgumentError, "where takes a Hash, got #{attrs.class}" unless attrs.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
add_clause(Conditions.equality(attrs))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def or(other)
|
|
31
|
+
clauses = [Conditions.combine(values[:where]), Conditions.combine(other.values[:where])]
|
|
32
|
+
|
|
33
|
+
with(where: [{ "$or" => clauses }])
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def order(*args)
|
|
37
|
+
sort = args.each_with_object(values[:order].dup) do |arg, out|
|
|
38
|
+
arg.is_a?(Hash) ? arg.each { |f, dir| out[f.to_s] = direction(dir) } : out[arg.to_s] = 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
with(order: sort)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def limit(count)
|
|
45
|
+
with(limit: count)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def offset(count)
|
|
49
|
+
with(offset: count)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def page(number)
|
|
53
|
+
with(page: number.to_i)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def per(size)
|
|
57
|
+
with(per: size.to_i)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def after(token)
|
|
61
|
+
with(after: token)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def group(*fields)
|
|
65
|
+
with(group: fields.flatten.map(&:to_s))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The find body: JSON only, the daemon decides what it means.
|
|
69
|
+
def to_query
|
|
70
|
+
query = {}
|
|
71
|
+
where = Conditions.combine(values[:where])
|
|
72
|
+
|
|
73
|
+
query["where"] = where unless where.empty?
|
|
74
|
+
query["sort"] = values[:order] unless values[:order].empty?
|
|
75
|
+
query.merge!(window)
|
|
76
|
+
query["after"] = values[:after] if values[:after]
|
|
77
|
+
|
|
78
|
+
query
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Loaders
|
|
82
|
+
|
|
83
|
+
def load
|
|
84
|
+
return self if loaded?
|
|
85
|
+
|
|
86
|
+
@result = klass.ndb_find(to_query)
|
|
87
|
+
@records = @result.rows.map { |row| klass.instantiate(row) }
|
|
88
|
+
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def loaded?
|
|
93
|
+
!@result.nil?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def records
|
|
97
|
+
load
|
|
98
|
+
|
|
99
|
+
@records
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def to_a
|
|
103
|
+
records.dup
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def each(&)
|
|
107
|
+
records.each(&)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def length
|
|
111
|
+
records.length
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def next_cursor
|
|
115
|
+
load
|
|
116
|
+
|
|
117
|
+
@result.next_cursor
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def first(count = nil)
|
|
121
|
+
return count ? records.first(count) : records.first if loaded?
|
|
122
|
+
|
|
123
|
+
found = limit(count || 1).to_a
|
|
124
|
+
|
|
125
|
+
count ? found : found.first
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def find(*ids)
|
|
129
|
+
ids = ids.flatten
|
|
130
|
+
|
|
131
|
+
return find_one(ids.first) if ids.size == 1 && !ids.first.is_a?(Array)
|
|
132
|
+
|
|
133
|
+
found = where(klass.primary_key => ids).to_a
|
|
134
|
+
raise NotFound.new("Couldn't find all #{klass.name} with ids #{ids}", code: "not_found") if found.size != ids.size
|
|
135
|
+
|
|
136
|
+
found
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def find_by(attrs)
|
|
140
|
+
where(attrs).first
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def find_by!(attrs)
|
|
144
|
+
find_by(attrs) || raise(NotFound.new("Couldn't find #{klass.name}", code: "not_found"))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def exists?
|
|
148
|
+
klass.ndb_find(to_query.slice("where").merge("limit" => 1, "fields" => [klass.primary_key])).rows.any?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def pluck(*fields)
|
|
152
|
+
raise ArgumentError, "pluck needs at least one field" if fields.empty?
|
|
153
|
+
|
|
154
|
+
names = fields.map(&:to_s)
|
|
155
|
+
rows = klass.ndb_find(to_query.merge("fields" => names)).rows
|
|
156
|
+
|
|
157
|
+
rows.map do |row|
|
|
158
|
+
values = names.map { |name| klass.attribute_types[name].cast(row.dig(*name.split("."))) }
|
|
159
|
+
|
|
160
|
+
values.size == 1 ? values.first : values
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def count
|
|
165
|
+
return calculate(:count) unless values[:group].empty?
|
|
166
|
+
|
|
167
|
+
klass.ndb_find(to_query.slice("where").merge("count" => true, "limit" => 1, "fields" => [klass.primary_key])).total
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Pagination
|
|
171
|
+
|
|
172
|
+
def find_each(batch_size: BATCH_SIZE, &block)
|
|
173
|
+
return enum_for(:find_each, batch_size: batch_size) unless block
|
|
174
|
+
|
|
175
|
+
in_batches(of: batch_size) { |batch| batch.each(&block) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Yields loaded relations, one per cursor page.
|
|
179
|
+
def in_batches(of: BATCH_SIZE)
|
|
180
|
+
return enum_for(:in_batches, of: of) unless block_given?
|
|
181
|
+
|
|
182
|
+
cursor = values[:after]
|
|
183
|
+
|
|
184
|
+
loop do
|
|
185
|
+
batch = with(limit: of, after: cursor, page: nil, offset: nil).load
|
|
186
|
+
|
|
187
|
+
break if batch.records.empty?
|
|
188
|
+
|
|
189
|
+
yield batch
|
|
190
|
+
|
|
191
|
+
cursor = batch.next_cursor
|
|
192
|
+
|
|
193
|
+
break unless cursor
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Aggregates
|
|
198
|
+
|
|
199
|
+
def sum(field) = calculate(:sum, field)
|
|
200
|
+
def average(field) = calculate(:avg, field)
|
|
201
|
+
def minimum(field) = calculate(:min, field)
|
|
202
|
+
def maximum(field) = calculate(:max, field)
|
|
203
|
+
|
|
204
|
+
def aggregate_body(op, field = nil)
|
|
205
|
+
stages = []
|
|
206
|
+
where = Conditions.combine(values[:where])
|
|
207
|
+
|
|
208
|
+
stages << { "$match" => where } unless where.empty?
|
|
209
|
+
stages << { "$group" => { "_id" => group_id, op.to_s => accumulator(op, field) } }
|
|
210
|
+
|
|
211
|
+
{ "pipeline" => stages }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
protected
|
|
215
|
+
|
|
216
|
+
def with(changes)
|
|
217
|
+
self.class.new(klass, values.merge(changes))
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
def add_clause(clause)
|
|
223
|
+
with(where: values[:where] + [clause])
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def direction(value)
|
|
227
|
+
%w[desc -1].include?(value.to_s.downcase) ? -1 : 1
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def window
|
|
231
|
+
if values[:page]
|
|
232
|
+
per = values[:per] || DEFAULT_PER_PAGE
|
|
233
|
+
|
|
234
|
+
return { "skip" => (values[:page] - 1) * per, "limit" => per }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
out = {}
|
|
238
|
+
out["skip"] = values[:offset] if values[:offset]
|
|
239
|
+
out["limit"] = values[:limit] if values[:limit]
|
|
240
|
+
|
|
241
|
+
out
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def find_one(id)
|
|
245
|
+
where(klass.primary_key => id).first ||
|
|
246
|
+
raise(NotFound.new("Couldn't find #{klass.name} with #{klass.primary_key}=#{id}", code: "not_found"))
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def group_id
|
|
250
|
+
fields = values[:group]
|
|
251
|
+
|
|
252
|
+
return nil if fields.empty?
|
|
253
|
+
return "$#{fields.first}" if fields.one?
|
|
254
|
+
|
|
255
|
+
fields.to_h { |f| [f, "$#{f}"] }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def accumulator(op, field)
|
|
259
|
+
op == :count ? { "$count" => {} } : { "$#{op}" => "$#{field}" }
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def calculate(op, field = nil)
|
|
263
|
+
key = op.to_s
|
|
264
|
+
rows = klass.ndb_aggregate(aggregate_body(op, field)).rows
|
|
265
|
+
|
|
266
|
+
return rows.to_h { |row| [group_key(row["_id"]), row[key]] } unless values[:group].empty?
|
|
267
|
+
|
|
268
|
+
rows.first&.fetch(key) || (op == :sum ? 0 : nil)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def group_key(id)
|
|
272
|
+
values[:group].one? ? id : values[:group].map { |f| id[f] }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# where.not(...)
|
|
276
|
+
class WhereChain
|
|
277
|
+
def initialize(relation)
|
|
278
|
+
@relation = relation
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def not(attrs)
|
|
282
|
+
@relation.send(:add_clause, Conditions.negation(attrs))
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
data/lib/ndb/rails.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
require "active_support/all"
|
|
5
|
+
require "ndb"
|
|
6
|
+
|
|
7
|
+
require "ndb/rails/version"
|
|
8
|
+
require "ndb/rails/errors"
|
|
9
|
+
require "ndb/rails/configuration"
|
|
10
|
+
require "ndb/rails/relation"
|
|
11
|
+
require "ndb/rails/blob"
|
|
12
|
+
require "ndb/rails/persistence"
|
|
13
|
+
require "ndb/rails/record"
|
|
14
|
+
|
|
15
|
+
module Ndb
|
|
16
|
+
module Rails
|
|
17
|
+
class << self
|
|
18
|
+
def configuration
|
|
19
|
+
@configuration ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def configure
|
|
23
|
+
yield configuration
|
|
24
|
+
|
|
25
|
+
@client = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def client
|
|
29
|
+
@client ||= Client.new(
|
|
30
|
+
url: configuration.url,
|
|
31
|
+
socket: configuration.socket,
|
|
32
|
+
token: configuration.token,
|
|
33
|
+
timeout: configuration.timeout,
|
|
34
|
+
pool_size: configuration.pool_size,
|
|
35
|
+
ready_timeout: configuration.ready_timeout
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
require "ndb/rails/railtie" if defined?(::Rails::Railtie)
|
data/lib/ndb-rails.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ndb-rails
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- thadeu
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activemodel
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activesupport
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: ndb
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.1'
|
|
54
|
+
description: Ndb::Record, a lazy Relation, validations, blobs and a Rails railtie
|
|
55
|
+
on top of the ndb client gem. The gem only builds JSON; the daemon decides every
|
|
56
|
+
query semantic.
|
|
57
|
+
executables: []
|
|
58
|
+
extensions: []
|
|
59
|
+
extra_rdoc_files: []
|
|
60
|
+
files:
|
|
61
|
+
- LICENSE
|
|
62
|
+
- README.md
|
|
63
|
+
- lib/ndb-rails.rb
|
|
64
|
+
- lib/ndb/rails.rb
|
|
65
|
+
- lib/ndb/rails/blob.rb
|
|
66
|
+
- lib/ndb/rails/configuration.rb
|
|
67
|
+
- lib/ndb/rails/errors.rb
|
|
68
|
+
- lib/ndb/rails/persistence.rb
|
|
69
|
+
- lib/ndb/rails/railtie.rb
|
|
70
|
+
- lib/ndb/rails/record.rb
|
|
71
|
+
- lib/ndb/rails/relation.rb
|
|
72
|
+
- lib/ndb/rails/version.rb
|
|
73
|
+
homepage: https://github.com/thadeu/ndb
|
|
74
|
+
licenses:
|
|
75
|
+
- MIT
|
|
76
|
+
metadata:
|
|
77
|
+
homepage_uri: https://github.com/thadeu/ndb
|
|
78
|
+
source_code_uri: https://github.com/thadeu/ndb/tree/main/sdk/rails
|
|
79
|
+
changelog_uri: https://github.com/thadeu/ndb/blob/main/CHANGELOG.md
|
|
80
|
+
bug_tracker_uri: https://github.com/thadeu/ndb/issues
|
|
81
|
+
rubygems_mfa_required: 'true'
|
|
82
|
+
rdoc_options: []
|
|
83
|
+
require_paths:
|
|
84
|
+
- lib
|
|
85
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - ">="
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.2'
|
|
90
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
91
|
+
requirements:
|
|
92
|
+
- - ">="
|
|
93
|
+
- !ruby/object:Gem::Version
|
|
94
|
+
version: '0'
|
|
95
|
+
requirements: []
|
|
96
|
+
rubygems_version: 3.6.9
|
|
97
|
+
specification_version: 4
|
|
98
|
+
summary: ActiveModel layer for the ndb NDJSON query daemon
|
|
99
|
+
test_files: []
|