scriva 1.2.1

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.
@@ -0,0 +1,648 @@
1
+ require "time"
2
+ require "grpc"
3
+ require "google/protobuf/well_known_types"
4
+ require "scriva/proto/scriva_pb"
5
+ require "scriva/proto/scriva_services_pb"
6
+
7
+ module Scriva
8
+ # Base class for ScrivaDB client errors mapped from engine gRPC status codes.
9
+ class Error < StandardError; end
10
+
11
+ # Raised when a keyed lookup/update/delete referenced a key that no live
12
+ # record holds (gRPC NOT_FOUND).
13
+ class NotFoundError < Error; end
14
+
15
+ # Raised when a keyed insert used a key already held by a live record
16
+ # (gRPC ALREADY_EXISTS).
17
+ class AlreadyExistsError < Error; end
18
+
19
+ # Synchronous gRPC client for ScrivaDB.
20
+ #
21
+ # Example:
22
+ # db = Scriva::Client.new(host: "localhost", port: 5433, api_key: "dev-key")
23
+ # db.create_collection("users")
24
+ # id = db.insert("users", { name: "Alice", age: 30 })
25
+ # record = db.find_by_id("users", id)
26
+ # db.update("users", id, { name: "Alice", age: 31 })
27
+ # db.delete("users", id)
28
+ # db.drop_collection("users")
29
+ # db.close
30
+ class Client
31
+ OP_MAP = {
32
+ "eq" => :EQ,
33
+ "neq" => :NEQ,
34
+ "gt" => :GT,
35
+ "gte" => :GTE,
36
+ "lt" => :LT,
37
+ "lte" => :LTE,
38
+ "contains" => :CONTAINS,
39
+ "regex" => :REGEX,
40
+ }.freeze
41
+
42
+ # Documented short aggregation names → proto AggregateOp enum symbols.
43
+ AGG_MAP = {
44
+ "count" => :AGG_COUNT,
45
+ "sum" => :AGG_SUM,
46
+ "avg" => :AGG_AVG,
47
+ "min" => :AGG_MIN,
48
+ "max" => :AGG_MAX,
49
+ }.freeze
50
+
51
+ WATCH_OP_NAMES = {
52
+ 0 => "UNSPECIFIED",
53
+ 1 => "INSERTED",
54
+ 2 => "UPDATED",
55
+ 3 => "DELETED",
56
+ 4 => "OVERFLOW",
57
+ }.freeze
58
+
59
+ # @param host [String] server hostname
60
+ # @param port [Integer] gRPC port (default 5433)
61
+ # @param api_key [String] sent as x-api-key metadata on every call
62
+ # @param tls_ca_cert [String, nil] PEM CA certificate string or path to PEM file;
63
+ # when nil the channel uses plaintext (insecure) transport
64
+ def initialize(host: "localhost", port: 5433, api_key:, tls_ca_cert: nil)
65
+ @api_key = api_key
66
+ target = "#{host}:#{port}"
67
+
68
+ if tls_ca_cert
69
+ pem = load_pem(tls_ca_cert)
70
+ creds = GRPC::Core::ChannelCredentials.new(pem)
71
+ else
72
+ creds = :this_channel_is_insecure
73
+ end
74
+
75
+ @stub = Scriva::V1::Scriva::Stub.new(target, creds)
76
+ end
77
+
78
+ # -- lifecycle -----------------------------------------------------------
79
+
80
+ def close
81
+ # GRPC::ClientStub does not expose an explicit close method in all
82
+ # versions; the channel is GC-collected. This method exists for API
83
+ # symmetry with other SDKs and for use as a context manager via #tap.
84
+ end
85
+
86
+ # Yields self then closes — use like:
87
+ # Scriva::Client.open(host: ...) { |db| db.insert(...) }
88
+ def self.open(**kwargs, &block)
89
+ client = new(**kwargs)
90
+ if block
91
+ begin
92
+ block.call(client)
93
+ ensure
94
+ client.close
95
+ end
96
+ else
97
+ client
98
+ end
99
+ end
100
+
101
+ # -- collection management -----------------------------------------------
102
+
103
+ # Create a collection. When +default_ttl_seconds+ > 0, records inserted
104
+ # without an explicit ttl expire that many seconds after being written;
105
+ # 0 inherits the server-wide default.
106
+ def create_collection(name, default_ttl_seconds: 0)
107
+ req = Scriva::V1::CreateCollectionRequest.new(
108
+ name: name,
109
+ default_ttl_seconds: default_ttl_seconds,
110
+ )
111
+ resp = @stub.create_collection(req, metadata: metadata)
112
+ resp.name
113
+ end
114
+
115
+ def drop_collection(name)
116
+ req = Scriva::V1::DropCollectionRequest.new(name: name)
117
+ resp = @stub.drop_collection(req, metadata: metadata)
118
+ resp.ok
119
+ end
120
+
121
+ def list_collections
122
+ req = Scriva::V1::ListCollectionsRequest.new
123
+ resp = @stub.list_collections(req, metadata: metadata)
124
+ resp.names.to_a
125
+ end
126
+
127
+ # -- CRUD ----------------------------------------------------------------
128
+
129
+ # Insert one record. Returns the assigned integer ID.
130
+ #
131
+ # +ttl_seconds+ > 0 expires the record that long after insertion, overriding
132
+ # any collection default; 0 applies the collection default (if any).
133
+ #
134
+ # +key+ sets an optional caller-supplied string primary key (keyed create).
135
+ # When set, the record is inserted under this key and a key already held by
136
+ # a live record raises AlreadyExistsError. A keyed insert does not
137
+ # participate in transactions or per-record TTL.
138
+ def insert(collection, data, ttl_seconds: 0, key: "")
139
+ translate_errors do
140
+ req = Scriva::V1::InsertRequest.new(
141
+ collection: collection,
142
+ data: hash_to_struct(data),
143
+ ttl_seconds: ttl_seconds,
144
+ key: key,
145
+ )
146
+ resp = @stub.insert(req, metadata: metadata)
147
+ resp.id
148
+ end
149
+ end
150
+
151
+ # Insert multiple records. Returns an array of assigned IDs.
152
+ # +ttl_seconds+ is applied uniformly to every record in the batch.
153
+ def insert_many(collection, records, ttl_seconds: 0)
154
+ req = Scriva::V1::InsertManyRequest.new(
155
+ collection: collection,
156
+ records: records.map { |r| hash_to_struct(r) },
157
+ ttl_seconds: ttl_seconds,
158
+ )
159
+ resp = @stub.insert_many(req, metadata: metadata)
160
+ resp.ids.to_a
161
+ end
162
+
163
+ # Fetch a single record by ID. Returns a Hash.
164
+ #
165
+ # +fields+ is an optional projection: when non-empty, only those top-level
166
+ # fields are returned in +data+ (id, key and rev are always included).
167
+ # nil/empty returns the full record.
168
+ def find_by_id(collection, id, fields: nil)
169
+ req = Scriva::V1::FindByIdRequest.new(
170
+ collection: collection,
171
+ id: id,
172
+ fields: fields ? fields.map(&:to_s) : [],
173
+ )
174
+ resp = @stub.find_by_id(req, metadata: metadata)
175
+ record_to_hash(resp.record)
176
+ end
177
+
178
+ # Query records. Returns an Array of Hashes.
179
+ # The Find RPC is server-streaming; this collects the stream for convenience.
180
+ # Pass a block to stream results one by one without buffering.
181
+ #
182
+ # @param filter [Hash, nil] filter hash (see filter_to_proto)
183
+ # @param limit [Integer] 0 = no limit
184
+ # @param offset [Integer]
185
+ # @param order_by [String, Array] a single field name (deprecated scalar
186
+ # sort, paired with +descending+) or an Array for a
187
+ # multi-field sort — each item a field-name String, a
188
+ # [field, desc] pair, or a {field:, desc:} Hash.
189
+ # @param descending [Boolean] only honoured for the scalar +order_by+ form
190
+ # @param fields [Array, nil] optional projection (see #find_by_id)
191
+ # @param page_token [String] keyset cursor from a previous #find_page
192
+ def find(collection, filter: nil, limit: 0, offset: 0, order_by: "",
193
+ descending: false, fields: nil, page_token: "")
194
+ req = build_find_request(
195
+ collection, filter, limit, offset, order_by, descending, fields, page_token
196
+ )
197
+ stream = @stub.find(req, metadata: metadata)
198
+ if block_given?
199
+ stream.each { |resp| yield record_to_hash(resp.record) }
200
+ nil
201
+ else
202
+ stream.map { |resp| record_to_hash(resp.record) }
203
+ end
204
+ end
205
+
206
+ # Query one keyset page, returning [records, next_page_token].
207
+ #
208
+ # Pass an ordering (+order_by+) and a +limit+, then feed the returned
209
+ # next-page token back as +page_token+ on the next call to walk the
210
+ # collection page by page in O(page) time. An empty next-page token means
211
+ # the last page was reached. Keep the same filter, ordering and limit on
212
+ # every page.
213
+ def find_page(collection, filter: nil, limit: 0, offset: 0, order_by: "",
214
+ descending: false, fields: nil, page_token: "")
215
+ req = build_find_request(
216
+ collection, filter, limit, offset, order_by, descending, fields, page_token
217
+ )
218
+ records = []
219
+ next_token = ""
220
+ @stub.find(req, metadata: metadata).each do |resp|
221
+ records << record_to_hash(resp.record)
222
+ next_token = resp.page_token unless resp.page_token.empty?
223
+ end
224
+ [records, next_token]
225
+ end
226
+
227
+ # Update a record. +ttl_seconds+ > 0 resets the record's expiry deadline to
228
+ # that long from now; 0 (the default) is sticky and leaves any existing
229
+ # deadline untouched.
230
+ def update(collection, id, data, ttl_seconds: 0)
231
+ req = Scriva::V1::UpdateRequest.new(
232
+ collection: collection,
233
+ id: id,
234
+ data: hash_to_struct(data),
235
+ ttl_seconds: ttl_seconds,
236
+ )
237
+ resp = @stub.update(req, metadata: metadata)
238
+ resp.id
239
+ end
240
+
241
+ def delete(collection, id)
242
+ req = Scriva::V1::DeleteRequest.new(collection: collection, id: id)
243
+ resp = @stub.delete(req, metadata: metadata)
244
+ resp.ok
245
+ end
246
+
247
+ # -- keyed CRUD, upsert & compare-and-swap (N1) --------------------------
248
+
249
+ # Insert +data+ under +key+, or replace the existing keyed record —
250
+ # atomically. If no live record carries +key+ it is inserted; otherwise the
251
+ # existing record's data is replaced and its +rev+ incremented. Returns the
252
+ # resulting record Hash (including "key" and "rev").
253
+ def upsert(collection, key, data)
254
+ req = Scriva::V1::UpsertRequest.new(
255
+ collection: collection,
256
+ key: key,
257
+ data: hash_to_struct(data),
258
+ )
259
+ resp = @stub.upsert(req, metadata: metadata)
260
+ record_to_hash(resp.record)
261
+ end
262
+
263
+ # Fetch the record carrying +key+. Raises NotFoundError if none.
264
+ # +fields+ projects +data+ to those top-level fields (id/key/rev always
265
+ # returned).
266
+ def find_by_key(collection, key, fields: nil)
267
+ translate_errors do
268
+ req = Scriva::V1::FindByKeyRequest.new(
269
+ collection: collection,
270
+ key: key,
271
+ fields: fields ? fields.map(&:to_s) : [],
272
+ )
273
+ resp = @stub.find_by_key(req, metadata: metadata)
274
+ record_to_hash(resp.record)
275
+ end
276
+ end
277
+
278
+ # Overwrite the record carrying +key+, preserving the key itself. Raises
279
+ # NotFoundError if no live record carries +key+. Returns a Hash with "id",
280
+ # "key", "rev" (after the write) and "date_modified".
281
+ def update_by_key(collection, key, data)
282
+ translate_errors do
283
+ req = Scriva::V1::UpdateByKeyRequest.new(
284
+ collection: collection,
285
+ key: key,
286
+ data: hash_to_struct(data),
287
+ )
288
+ resp = @stub.update_by_key(req, metadata: metadata)
289
+ out = { "id" => resp.id }
290
+ out["key"] = resp.key unless resp.key.empty?
291
+ out["rev"] = resp.rev unless resp.rev.zero?
292
+ out["date_modified"] = resp.date_modified unless resp.date_modified.empty?
293
+ out
294
+ end
295
+ end
296
+
297
+ # Delete the record carrying +key+. Returns true on success. Raises
298
+ # NotFoundError if no live record carries +key+.
299
+ def delete_by_key(collection, key)
300
+ translate_errors do
301
+ req = Scriva::V1::DeleteByKeyRequest.new(collection: collection, key: key)
302
+ resp = @stub.delete_by_key(req, metadata: metadata)
303
+ resp.ok
304
+ end
305
+ end
306
+
307
+ # Compare-and-swap update on +key+, conditional on +expected_rev+. The write
308
+ # is applied only if the record's current +rev+ equals +expected_rev+. A
309
+ # stale revision (or a missing key) is a clean no-op — never an error.
310
+ # Returns { "swapped" => Boolean, "record" => Hash | nil }; "record" is the
311
+ # resulting record only when "swapped" is true.
312
+ def update_if_rev(collection, key, expected_rev, data)
313
+ req = Scriva::V1::UpdateIfRevRequest.new(
314
+ collection: collection,
315
+ key: key,
316
+ expected_rev: expected_rev,
317
+ data: hash_to_struct(data),
318
+ )
319
+ resp = @stub.update_if_rev(req, metadata: metadata)
320
+ out = { "swapped" => resp.swapped, "record" => nil }
321
+ out["record"] = record_to_hash(resp.record) if resp.swapped && resp.has_record?
322
+ out
323
+ end
324
+
325
+ # -- secondary indexes ---------------------------------------------------
326
+
327
+ def ensure_index(collection, field)
328
+ req = Scriva::V1::EnsureIndexRequest.new(collection: collection, field: field)
329
+ @stub.ensure_index(req, metadata: metadata)
330
+ nil
331
+ end
332
+
333
+ def drop_index(collection, field)
334
+ req = Scriva::V1::DropIndexRequest.new(collection: collection, field: field)
335
+ resp = @stub.drop_index(req, metadata: metadata)
336
+ resp.ok
337
+ end
338
+
339
+ def list_indexes(collection)
340
+ req = Scriva::V1::ListIndexesRequest.new(collection: collection)
341
+ resp = @stub.list_indexes(req, metadata: metadata)
342
+ resp.fields.to_a
343
+ end
344
+
345
+ # -- transactions --------------------------------------------------------
346
+
347
+ def begin_tx(collection)
348
+ req = Scriva::V1::BeginTxRequest.new(collection: collection)
349
+ resp = @stub.begin_tx(req, metadata: metadata)
350
+ resp.tx_id
351
+ end
352
+
353
+ def commit_tx(tx_id)
354
+ req = Scriva::V1::CommitTxRequest.new(tx_id: tx_id)
355
+ resp = @stub.commit_tx(req, metadata: metadata)
356
+ resp.ok
357
+ end
358
+
359
+ def rollback_tx(tx_id)
360
+ req = Scriva::V1::RollbackTxRequest.new(tx_id: tx_id)
361
+ resp = @stub.rollback_tx(req, metadata: metadata)
362
+ resp.ok
363
+ end
364
+
365
+ # -- watch ---------------------------------------------------------------
366
+
367
+ # Subscribe to change events on a collection.
368
+ #
369
+ # Without a block, returns an Enumerator that yields event Hashes:
370
+ # { op: "INSERTED", collection: "users", record: {...}, ts: "2026-..." }
371
+ #
372
+ # With a block, iterates until the server closes the stream or the block
373
+ # raises StopIteration / the caller breaks.
374
+ #
375
+ # @param filter [Hash, nil] optional filter
376
+ def watch(collection, filter: nil)
377
+ req = Scriva::V1::WatchRequest.new(
378
+ collection: collection,
379
+ filter: filter_to_proto(filter),
380
+ )
381
+ stream = @stub.watch(req, metadata: metadata)
382
+ enum = Enumerator.new do |yielder|
383
+ stream.each do |event|
384
+ out = {
385
+ op: WATCH_OP_NAMES[event.op] || "UNSPECIFIED",
386
+ collection: event.collection,
387
+ record: record_to_hash(event.record),
388
+ }
389
+ out[:ts] = event.ts.to_time.utc.iso8601(9) if event.has_ts?
390
+ yielder << out
391
+ end
392
+ end
393
+ if block_given?
394
+ enum.each { |ev| yield ev }
395
+ nil
396
+ else
397
+ enum
398
+ end
399
+ end
400
+
401
+ # -- aggregations (N4) ---------------------------------------------------
402
+
403
+ # Compute count + numeric aggregations over the filtered live records.
404
+ #
405
+ # The Aggregate RPC is server-streaming — one message per group; this
406
+ # collects them into an Array. Each result Hash carries "group" (the
407
+ # group-by value, nil for the whole-set group), "count", "numeric", and —
408
+ # when the group had at least one numeric +field+ value — "sum"/"avg"/
409
+ # "min"/"max".
410
+ #
411
+ # @param aggregations [Array, nil] which numeric aggregations to compute —
412
+ # any of "count", "sum", "avg", "min", "max". count is always
413
+ # returned; sum/avg/min/max require +field+.
414
+ # @param field [String] numeric field for sum/avg/min/max.
415
+ # @param group_by [String] optional group-by field — one result per value.
416
+ # @param filter [Hash, nil] the same plain-hash filter as #find.
417
+ def aggregate(collection, aggregations: nil, field: "", group_by: "", filter: nil)
418
+ agg_ops = (aggregations || []).map do |name|
419
+ key = name.to_s.downcase
420
+ AGG_MAP[key] or raise ArgumentError,
421
+ "unknown aggregation #{name.inspect}; expected one of #{AGG_MAP.keys.sort.join(", ")}"
422
+ end
423
+
424
+ req = Scriva::V1::AggregateRequest.new(
425
+ collection: collection,
426
+ filter: filter_to_proto(filter),
427
+ group_by: group_by,
428
+ field: field,
429
+ aggregations: agg_ops,
430
+ )
431
+ @stub.aggregate(req, metadata: metadata).map do |resp|
432
+ g = {
433
+ "group" => resp.has_group_value? ? resp.group_value.to_ruby : nil,
434
+ "count" => resp.count,
435
+ "numeric" => resp.numeric,
436
+ }
437
+ if resp.numeric
438
+ g["sum"] = resp.sum
439
+ g["avg"] = resp.avg
440
+ g["min"] = resp.min
441
+ g["max"] = resp.max
442
+ end
443
+ g
444
+ end
445
+ end
446
+
447
+ # Count the live records matching +filter+ (or all records).
448
+ def count(collection, filter: nil)
449
+ groups = aggregate(collection, filter: filter)
450
+ groups.empty? ? 0 : groups.first["count"]
451
+ end
452
+
453
+ # Group live records by +field+ and aggregate each group. Convenience
454
+ # wrapper over #aggregate. +field+ is the group-by field; +metric+ is the
455
+ # numeric field for sum/avg/min/max (pass those in +aggregations+). Returns
456
+ # one Hash per distinct group value; see #aggregate for the Hash shape.
457
+ def group_by(collection, field, aggregations: nil, metric: "", filter: nil)
458
+ aggregate(
459
+ collection,
460
+ aggregations: aggregations,
461
+ field: metric,
462
+ group_by: field,
463
+ filter: filter,
464
+ )
465
+ end
466
+
467
+ # -- stats ---------------------------------------------------------------
468
+
469
+ def stats(collection)
470
+ req = Scriva::V1::CollectionStatsRequest.new(collection: collection)
471
+ resp = @stub.collection_stats(req, metadata: metadata)
472
+ {
473
+ collection: resp.collection,
474
+ record_count: resp.record_count,
475
+ segment_count: resp.segment_count,
476
+ dirty_entries: resp.dirty_entries,
477
+ size_bytes: resp.size_bytes,
478
+ }
479
+ end
480
+
481
+ # -- maintenance ---------------------------------------------------------
482
+
483
+ # Force a synchronous compaction of a collection — merges dirty segments and
484
+ # reclaims space from deleted/overwritten records. Returns only once it is
485
+ # done. Returns true on success.
486
+ def compact(collection)
487
+ req = Scriva::V1::CompactRequest.new(collection: collection)
488
+ resp = @stub.compact(req, metadata: metadata)
489
+ resp.ok
490
+ end
491
+
492
+ # Stream a consistent, gzip-compressed tar snapshot of the whole database.
493
+ #
494
+ # Without a block, returns an Enumerator yielding binary String chunks.
495
+ # With a block, yields each chunk in turn. Concatenate the chunks in order
496
+ # to reconstruct the archive; restore with `tar xzf backup.tar.gz`.
497
+ def snapshot(&block)
498
+ req = Scriva::V1::SnapshotRequest.new
499
+ stream = @stub.snapshot(req, metadata: metadata)
500
+ enum = Enumerator.new { |y| stream.each { |chunk| y << chunk.data } }
501
+ if block
502
+ enum.each(&block)
503
+ nil
504
+ else
505
+ enum
506
+ end
507
+ end
508
+
509
+ # Stream a whole-database snapshot straight to a file (binary). Returns the
510
+ # number of bytes written.
511
+ def snapshot_to_file(path)
512
+ total = 0
513
+ File.open(path, "wb") do |f|
514
+ snapshot { |chunk| total += f.write(chunk) }
515
+ end
516
+ total
517
+ end
518
+
519
+ private
520
+
521
+ def metadata
522
+ { "x-api-key" => @api_key }
523
+ end
524
+
525
+ # Map gRPC NOT_FOUND / ALREADY_EXISTS onto typed exceptions; other status
526
+ # codes propagate as the original GRPC::BadStatus.
527
+ def translate_errors
528
+ yield
529
+ rescue GRPC::NotFound => e
530
+ raise NotFoundError, e.details
531
+ rescue GRPC::AlreadyExists => e
532
+ raise AlreadyExistsError, e.details
533
+ end
534
+
535
+ def load_pem(tls_ca_cert)
536
+ return tls_ca_cert if tls_ca_cert.include?("BEGIN CERTIFICATE")
537
+ File.read(tls_ca_cert)
538
+ end
539
+
540
+ # Convert a plain Ruby Hash into a google.protobuf.Struct message.
541
+ def hash_to_struct(hash)
542
+ Google::Protobuf::Struct.from_hash(stringify_keys(hash))
543
+ end
544
+
545
+ def stringify_keys(hash)
546
+ hash.transform_keys(&:to_s)
547
+ end
548
+
549
+ # Convert a proto Record into a plain Ruby Hash. The caller-supplied "key"
550
+ # (when set) and the per-record "rev" (present on every live record,
551
+ # starting at 1) are surfaced alongside "id", "data" and the timestamps.
552
+ def record_to_hash(record)
553
+ return {} if record.nil?
554
+ out = {
555
+ "id" => record.id,
556
+ "data" => record.has_data? ? record.data.to_h : {},
557
+ }
558
+ out["key"] = record.key unless record.key.empty?
559
+ out["rev"] = record.rev unless record.rev.zero?
560
+ out["date_added"] = record.date_added.to_time.utc.iso8601(9) if record.has_date_added?
561
+ out["date_modified"] = record.date_modified.to_time.utc.iso8601(9) if record.has_date_modified?
562
+ out
563
+ end
564
+
565
+ # Build a FindRequest shared by #find and #find_page, wiring projection,
566
+ # keyset pagination and either the multi-field or the deprecated scalar sort.
567
+ def build_find_request(collection, filter, limit, offset, order_by, descending, fields, page_token)
568
+ req = Scriva::V1::FindRequest.new(
569
+ collection: collection,
570
+ filter: filter_to_proto(filter),
571
+ limit: limit,
572
+ offset: offset,
573
+ fields: fields ? fields.map(&:to_s) : [],
574
+ page_token: page_token,
575
+ )
576
+ specs = order_by_to_proto(order_by)
577
+ if specs
578
+ specs.each { |o| req.order_by_fields.push(o) }
579
+ elsif order_by.is_a?(String) && !order_by.empty?
580
+ # Deprecated single-field path — honoured only when order_by_fields is
581
+ # empty (server-side).
582
+ req.order_by = order_by
583
+ req.descending = descending
584
+ end
585
+ req
586
+ end
587
+
588
+ # Convert a multi-field +order_by+ argument to an Array of OrderBy specs.
589
+ # Returns nil when +order_by+ is empty or a plain String (the deprecated
590
+ # single-field path, handled by the caller). Otherwise accepts an Array
591
+ # whose items are field-name Strings, [field, desc] pairs, or
592
+ # {field:, desc:} Hashes.
593
+ def order_by_to_proto(order_by)
594
+ return nil if order_by.nil? || order_by.is_a?(String)
595
+ order_by.map do |item|
596
+ case item
597
+ when String
598
+ Scriva::V1::OrderBy.new(field: item, desc: false)
599
+ when Hash
600
+ h = item.transform_keys(&:to_s)
601
+ desc = h.fetch("desc", h.fetch("descending", false))
602
+ Scriva::V1::OrderBy.new(field: h["field"].to_s, desc: !!desc)
603
+ when Array
604
+ desc = item.length > 1 ? !!item[1] : false
605
+ Scriva::V1::OrderBy.new(field: item[0].to_s, desc: desc)
606
+ else
607
+ raise ArgumentError,
608
+ "order_by items must be a field-name String, a [field, desc] pair, " \
609
+ "or a {field:, desc:} Hash"
610
+ end
611
+ end
612
+ end
613
+
614
+ # Convert a plain Hash filter into a proto Filter message.
615
+ #
616
+ # Accepted shapes:
617
+ # { field: "age", op: "gt", value: "30" }
618
+ # { and: [ <filter>, ... ] }
619
+ # { or: [ <filter>, ... ] }
620
+ def filter_to_proto(hash)
621
+ return nil if hash.nil?
622
+
623
+ h = hash.transform_keys(&:to_s)
624
+
625
+ if h.key?("and")
626
+ children = h["and"].map { |c| filter_to_proto(c) }
627
+ return Scriva::V1::Filter.new(and: Scriva::V1::AndFilter.new(filters: children))
628
+ end
629
+
630
+ if h.key?("or")
631
+ children = h["or"].map { |c| filter_to_proto(c) }
632
+ return Scriva::V1::Filter.new(or: Scriva::V1::OrFilter.new(filters: children))
633
+ end
634
+
635
+ op_name = h["op"].to_s.downcase
636
+ op_val = OP_MAP[op_name] or raise ArgumentError,
637
+ "unknown filter op #{h["op"].inspect}; expected one of #{OP_MAP.keys.sort.join(", ")}"
638
+
639
+ Scriva::V1::Filter.new(
640
+ field: Scriva::V1::FieldFilter.new(
641
+ field: h["field"].to_s,
642
+ op: Scriva::V1::FilterOp.const_get(op_val),
643
+ value: h["value"].to_s,
644
+ )
645
+ )
646
+ end
647
+ end
648
+ end