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 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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ndb
4
+ module Rails
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ndb/rails"
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: []