k9o 1.2.0-aarch64-linux → 1.5.0-aarch64-linux
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 +4 -4
- data/CHANGELOG.md +115 -1
- data/README.md +1575 -180
- data/k9o.gemspec +2 -2
- data/lib/k9o/_ffi/kibo_kms_rest_ffi.rb +928 -25
- data/lib/k9o/_ffi/libkibo_kms_rest_ffi.so +0 -0
- data/lib/k9o/ai.rb +15 -0
- data/lib/k9o/analytics.rb +403 -0
- data/lib/k9o/approvals.rb +253 -0
- data/lib/k9o/audit.rb +162 -14
- data/lib/k9o/client.rb +28 -9
- data/lib/k9o/compute.rb +32 -15
- data/lib/k9o/crypto.rb +471 -0
- data/lib/k9o/custody.rb +28 -0
- data/lib/k9o/data.rb +125 -5
- data/lib/k9o/ed25519.rb +182 -0
- data/lib/k9o/end_users.rb +93 -0
- data/lib/k9o/envelope.rb +20 -5
- data/lib/k9o/functions.rb +127 -9
- data/lib/k9o/gate.rb +52 -22
- data/lib/k9o/http_transport.rb +78 -16
- data/lib/k9o/jcs.rb +117 -0
- data/lib/k9o/kerror.rb +57 -5
- data/lib/k9o/keys.rb +131 -0
- data/lib/k9o/kibo.rb +3 -3
- data/lib/k9o/kibo_client_core.rb +385 -0
- data/lib/k9o/kibo_ffi_core.rb +178 -0
- data/lib/k9o/locate.rb +118 -0
- data/lib/k9o/me.rb +27 -87
- data/lib/k9o/metadata.rb +342 -0
- data/lib/k9o/moat.rb +443 -0
- data/lib/k9o/org.rb +81 -68
- data/lib/k9o/orgs.rb +1 -0
- data/lib/k9o/policies.rb +25 -6
- data/lib/k9o/projects.rb +43 -8
- data/lib/k9o/query.rb +174 -0
- data/lib/k9o/rampart.rb +273 -0
- data/lib/k9o/realtime.rb +261 -32
- data/lib/k9o/region.rb +20 -18
- data/lib/k9o/schedules.rb +109 -0
- data/lib/k9o/schema.rb +35 -0
- data/lib/k9o/streams.rb +94 -0
- data/lib/k9o/studio.rb +21 -11
- data/lib/k9o/url_grammar.rb +46 -20
- data/lib/k9o/vault.rb +79 -84
- data/lib/k9o/vectors.rb +124 -0
- data/lib/k9o/version.rb +1 -1
- data/lib/k9o.rb +17 -0
- data/sig/k9o/ai.rbs +4 -0
- data/sig/k9o/approvals.rbs +71 -0
- data/sig/k9o/datasets.rbs +125 -0
- data/sig/k9o/gate.rbs +9 -6
- data/sig/k9o/kerror.rbs +24 -0
- data/sig/k9o/kibo_client_core.rbs +25 -0
- data/sig/k9o/metadata.rbs +54 -0
- data/sig/k9o/moat.rbs +29 -0
- data/sig/k9o/query.rbs +26 -0
- data/sig/k9o/rampart.rbs +59 -0
- data/sig/k9o/realtime.rbs +34 -0
- data/sig/k9o/region.rbs +14 -3
- data/sig/k9o/workload.rbs +64 -0
- metadata +33 -4
data/lib/k9o/metadata.rb
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module K9o
|
|
4
|
+
# Metadata: k9o.metadata.* filter, search and follow columns you declare on a collection (or on another data
|
|
5
|
+
# surface) through one API, with the same access rules as the data itself.
|
|
6
|
+
#
|
|
7
|
+
# filter(surfaces:, where:, limit:, cursor:) POST /@<slug>/metadata/query
|
|
8
|
+
# set(collection, id, columns) PATCH /@<slug>/metadata/collection/<collection>/<id>
|
|
9
|
+
# search(surface:, field:, q:, limit:) POST /@<slug>/metadata/search
|
|
10
|
+
# vectors.nearest(surface:, column:, embedding:, k:, ...) POST /@<slug>/metadata/vectors/nearest
|
|
11
|
+
# subscribe(surface, from_sequence:, on_resync:) GET /@<slug>/metadata/subscribe?surface= (SSE)
|
|
12
|
+
# index.get(collection) GET /@<slug>/metadata/index/<collection>
|
|
13
|
+
# index.declare(collection, columns) PUT /@<slug>/metadata/index/<collection>
|
|
14
|
+
#
|
|
15
|
+
# Declare the columns first (+index.declare+). +set+ writes declared columns only; +filter+, +search+ and
|
|
16
|
+
# +vectors.nearest+ read declared columns only. Column names given to +filter+ (+where[].field+), +search+ (+field+)
|
|
17
|
+
# and +vectors.nearest+ (+column+) are sent as +metadata.<name>+; the prefix is added when you leave it out. Rows
|
|
18
|
+
# come back with bare column names under +metadata+.
|
|
19
|
+
#
|
|
20
|
+
# Every method takes its options as keyword arguments or as one Hash (with or without braces); keys may be
|
|
21
|
+
# symbols or strings. A bad argument raises K9o::KError (+K9O_INVALID_FIELD_VALUE+, or +K9O_UNKNOWN_SURFACE+ for a
|
|
22
|
+
# surface) before anything is sent. Gateway answers arrive as K9o::KError with the gateway's code:
|
|
23
|
+
#
|
|
24
|
+
# * 400 +K9O_UNKNOWN_SURFACE+: the surface is not +collection:<name>+, +telemetry:<event>+ or +storage:<bucket>+.
|
|
25
|
+
# * 400 +K9O_METADATA_FIELD_NOT_INDEXED+: the column is not declared (+context["field"]+).
|
|
26
|
+
# * 400 +K9O_ENCRYPTED_COLUMN_OPERATION_UNSUPPORTED+: an encrypted column supports only +eq+ (not null) and +in+.
|
|
27
|
+
# * 400 +K9O_INVALID_FIELD_VALUE+, +K9O_INVALID_CURSOR+: a bad body field or cursor.
|
|
28
|
+
# * 403 +K9O_ACCESS_DENIED+, +K9O_EXTENSION_PERMISSION_DENIED+: no access to the surface.
|
|
29
|
+
# * 404 +K9O_COLLECTION_NOT_FOUND+ (declare on a missing collection), +K9O_NOT_FOUND+ (set on a missing record).
|
|
30
|
+
# * 413 +K9O_QUERY_RESULT_TOO_LARGE+: more than 10000 rows across the surfaces.
|
|
31
|
+
# * 501 +K9O_NOT_IMPLEMENTED+: +storage:+ surfaces are accepted by the SDK, and the gateway answers 501 for them.
|
|
32
|
+
class Metadata
|
|
33
|
+
# A page of rows: an Envelope whose +data+ is the Array of rows and whose +cursor+ is the cursor of the next
|
|
34
|
+
# page, or +nil+ when there is none. Each row is { "surface", "scope", "recordId" (collection) or "eventId"
|
|
35
|
+
# (telemetry), "metadata" }.
|
|
36
|
+
class Page < Envelope
|
|
37
|
+
def cursor
|
|
38
|
+
meta.cursor&.next
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Shared checks and the request helper.
|
|
43
|
+
module Support
|
|
44
|
+
OPS = %w[eq neq gt gte lt lte contains startsWith endsWith in notIn and or not].freeze
|
|
45
|
+
METRICS = %w[cosine euclidean dot].freeze
|
|
46
|
+
SLICES = %w[collection telemetry storage].freeze
|
|
47
|
+
SCOPE = /\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z/.freeze
|
|
48
|
+
IDENT = /\A[a-zA-Z_][a-zA-Z0-9_]{0,62}\z/.freeze
|
|
49
|
+
PREFIX = 'metadata.'
|
|
50
|
+
MAX_COLUMNS = 64
|
|
51
|
+
MAX_LIMIT = 1000
|
|
52
|
+
# On a telemetry surface these where fields are bare, never prefixed.
|
|
53
|
+
TELEMETRY_FIELDS = %w[createdAt actorId].freeze
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def invalid(message, context = {})
|
|
58
|
+
ApprovalChecks.invalid(message, context)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def route(tail)
|
|
62
|
+
UrlGrammar.project_path(Studio.require_project_slug(@config, 'metadata'), "metadata/#{tail}")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def segment(value)
|
|
66
|
+
::URI.encode_www_form_component(value).gsub('+', '%20')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def call(method, path, body = nil)
|
|
70
|
+
HttpTransport.request(config: @config, method: method, path: path, body: body)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def page(envelope)
|
|
74
|
+
Page.new(data: envelope.data, meta: envelope.meta)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The options as a Hash with string keys, from keyword arguments, one Hash, or both.
|
|
78
|
+
def options(input, extra, allowed)
|
|
79
|
+
raise invalid('options must be a Hash') unless input.nil? || input.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
opts = {}
|
|
82
|
+
(input || {}).merge(extra).each { |key, value| opts[key.to_s] = value }
|
|
83
|
+
unknown = opts.keys - allowed
|
|
84
|
+
raise invalid("unknown option: #{unknown.join(', ')}", options: unknown) unless unknown.empty?
|
|
85
|
+
|
|
86
|
+
opts
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def slice_of(surface)
|
|
90
|
+
surface[0, surface.index(':')]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def check_surface(surface, name)
|
|
94
|
+
raise invalid("#{name} must be a string such as collection:posts", surface: surface) unless surface.is_a?(String)
|
|
95
|
+
|
|
96
|
+
at = surface.index(':')
|
|
97
|
+
slice = at ? surface[0, at] : ''
|
|
98
|
+
scope = at ? surface[(at + 1)..] : ''
|
|
99
|
+
unless SLICES.include?(slice) && SCOPE.match?(scope) && (slice != 'collection' || IDENT.match?(scope))
|
|
100
|
+
raise KError.new(
|
|
101
|
+
code: 'K9O_UNKNOWN_SURFACE',
|
|
102
|
+
message: "#{name} \"#{surface}\" is not a surface: use collection:<name>, telemetry:<event> or storage:<bucket>",
|
|
103
|
+
context: { surface: surface }
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
surface
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def check_collection_surface(surface, name)
|
|
110
|
+
check_surface(surface, name)
|
|
111
|
+
raise invalid("#{name} must be a collection surface (collection:<name>)", surface: surface) unless slice_of(surface) == 'collection'
|
|
112
|
+
|
|
113
|
+
surface
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def check_limit(value, name)
|
|
117
|
+
return if value.nil?
|
|
118
|
+
return if value.is_a?(Integer) && value >= 1 && value <= MAX_LIMIT
|
|
119
|
+
|
|
120
|
+
raise invalid("#{name} must be a whole number from 1 to #{MAX_LIMIT}", name.to_sym => value)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The name on the wire: metadata.<name>, adding the prefix when it is missing.
|
|
124
|
+
def prefixed(name, label)
|
|
125
|
+
name = name.to_s if name.is_a?(Symbol)
|
|
126
|
+
raise invalid("#{label} must be a column name", label.to_sym => name) if !name.is_a?(String) || name.empty? || name == PREFIX
|
|
127
|
+
|
|
128
|
+
name.start_with?(PREFIX) ? name : "#{PREFIX}#{name}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def wire_where(where, telemetry)
|
|
132
|
+
return nil if where.nil?
|
|
133
|
+
raise invalid('where must be an array of { field, op, value }') unless where.is_a?(Array)
|
|
134
|
+
|
|
135
|
+
where.each_with_index.map do |clause, index|
|
|
136
|
+
raise invalid("where[#{index}] must be a Hash") unless clause.is_a?(Hash)
|
|
137
|
+
|
|
138
|
+
c = clause.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
139
|
+
c['op'] = c['op'].to_s if c['op'].is_a?(Symbol)
|
|
140
|
+
unless c['op'].is_a?(String) && OPS.include?(c['op'])
|
|
141
|
+
raise invalid("where[#{index}].op must be one of: #{OPS.join(', ')}", op: c['op'])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
field = c['field'].is_a?(Symbol) ? c['field'].to_s : c['field']
|
|
145
|
+
bare = telemetry && field.is_a?(String) && TELEMETRY_FIELDS.include?(field)
|
|
146
|
+
c['field'] = bare ? field : prefixed(field, "where[#{index}].field")
|
|
147
|
+
c
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def collection_name(collection)
|
|
152
|
+
collection = collection.to_s if collection.is_a?(Symbol)
|
|
153
|
+
raise invalid('collection must be a collection name', collection: collection) unless collection.is_a?(String) && IDENT.match?(collection)
|
|
154
|
+
|
|
155
|
+
collection
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def finite?(value)
|
|
159
|
+
value.is_a?(Numeric) && !value.is_a?(Complex) && (!value.respond_to?(:finite?) || value.finite?)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
include Support
|
|
164
|
+
|
|
165
|
+
attr_reader :vectors, :index
|
|
166
|
+
|
|
167
|
+
def initialize(config)
|
|
168
|
+
@config = config
|
|
169
|
+
@vectors = MetadataVectors.new(config)
|
|
170
|
+
@index = MetadataIndex.new(config)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Rows of one or more surfaces whose declared columns meet +where+, in request order. +surfaces+ is a non-empty
|
|
174
|
+
# Array of +collection:<name>+, +telemetry:<event-prefix>+ or +storage:<bucket>+. +where+ is an Array of
|
|
175
|
+
# { field:, op:, value: } applied to every surface (+op+ is one of eq, neq, gt, gte, lt, lte, contains, startsWith,
|
|
176
|
+
# endsWith, in, notIn; and, or and not are part of the grammar and refused by the gateway). On a telemetry surface
|
|
177
|
+
# +createdAt+ (gt, gte, lt, lte) and +actorId+ (eq) are sent as they are. +limit+ is 1 to 1000 per surface (the
|
|
178
|
+
# gateway defaults to 100). +cursor+ is the +cursor+ of the previous page and is allowed with exactly one surface.
|
|
179
|
+
# More than 10000 rows in all answers 413 K9O_QUERY_RESULT_TOO_LARGE; a +storage:+ surface answers 501
|
|
180
|
+
# K9O_NOT_IMPLEMENTED. Returns a Page.
|
|
181
|
+
def filter(input = nil, **extra)
|
|
182
|
+
path = route('query')
|
|
183
|
+
opts = options(input, extra, %w[surfaces where limit cursor])
|
|
184
|
+
surfaces = opts['surfaces']
|
|
185
|
+
raise invalid('surfaces must be a non-empty array') unless surfaces.is_a?(Array) && !surfaces.empty?
|
|
186
|
+
|
|
187
|
+
surfaces = surfaces.each_with_index.map { |s, i| check_surface(s, "surfaces[#{i}]") }
|
|
188
|
+
check_limit(opts['limit'], 'limit')
|
|
189
|
+
unless opts['cursor'].nil?
|
|
190
|
+
raise invalid('cursor must be the cursor of a previous page') unless opts['cursor'].is_a?(String) && !opts['cursor'].empty?
|
|
191
|
+
raise invalid('cursor is allowed only with exactly one surface', field: 'cursor') unless surfaces.size == 1
|
|
192
|
+
end
|
|
193
|
+
where = wire_where(opts['where'], surfaces.any? { |s| slice_of(s) == 'telemetry' })
|
|
194
|
+
body = { 'surfaces' => surfaces }
|
|
195
|
+
body['where'] = where unless where.nil?
|
|
196
|
+
body['limit'] = opts['limit'] unless opts['limit'].nil?
|
|
197
|
+
body['cursor'] = opts['cursor'] unless opts['cursor'].nil?
|
|
198
|
+
page(call('POST', path, body))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Write declared columns of one record: <tt>set("posts", "p2", title: "New", priority: 9)</tt> or with a Hash.
|
|
202
|
+
# Keys are bare column names (a leading +metadata.+ is dropped); a column that is not declared answers 400
|
|
203
|
+
# K9O_METADATA_FIELD_NOT_INDEXED. +data+ is the whole updated record. The write runs through the record API's
|
|
204
|
+
# rules, so a write that needs approval is queued as it is there.
|
|
205
|
+
def set(collection, id, columns = nil, **extra)
|
|
206
|
+
name = collection_name(collection)
|
|
207
|
+
raise invalid('id must be a record id', id: id) unless id.is_a?(String) && !id.empty?
|
|
208
|
+
raise invalid('columns must be a Hash with at least one column') unless columns.nil? || columns.is_a?(Hash)
|
|
209
|
+
|
|
210
|
+
merged = (columns || {}).merge(extra)
|
|
211
|
+
raise invalid('columns must be a Hash with at least one column') if merged.empty?
|
|
212
|
+
|
|
213
|
+
bare = merged.each_with_object({}) do |(key, value), out|
|
|
214
|
+
key = key.to_s
|
|
215
|
+
out[key.start_with?(PREFIX) ? key[PREFIX.size..] : key] = value
|
|
216
|
+
end
|
|
217
|
+
call('PATCH', route("collection/#{segment(name)}/#{segment(id)}"), 'metadata' => bare)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Full-text search over one declared text column of a collection: +surface:+ (+collection:<name>+), +field:+
|
|
221
|
+
# (+body+ or +metadata.body+), +q:+ (the words to look for) and optional +limit:+ (1 to 1000, the gateway
|
|
222
|
+
# defaults to 50). Matching is by word stem, so "finance" does not match "financial". Returns the first page
|
|
223
|
+
# only (there is no cursor input) and no rank. Returns a Page.
|
|
224
|
+
def search(input = nil, **extra)
|
|
225
|
+
path = route('search')
|
|
226
|
+
opts = options(input, extra, %w[surface field q limit])
|
|
227
|
+
surface = check_collection_surface(opts['surface'], 'surface')
|
|
228
|
+
field = prefixed(opts['field'], 'field')
|
|
229
|
+
raise invalid('q must be a non-empty string') unless opts['q'].is_a?(String) && !opts['q'].empty?
|
|
230
|
+
|
|
231
|
+
check_limit(opts['limit'], 'limit')
|
|
232
|
+
body = { 'surface' => surface, 'field' => field, 'q' => opts['q'] }
|
|
233
|
+
body['limit'] = opts['limit'] unless opts['limit'].nil?
|
|
234
|
+
page(call('POST', path, body))
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Follow changes of a +collection:<name>+ surface live. Returns an Enumerator of events whose +eventType+ is
|
|
238
|
+
# +row_inserted+, +row_updated+ or +row_deleted+ and whose +payload+ is { "collection", "id" } only, so read the
|
|
239
|
+
# record by id for its values. It is the reader behind Realtime#subscribe: it reconnects with +Last-Event-ID+,
|
|
240
|
+
# drops events it already delivered and calls +on_resync+ (a callable, may be nil) when the position is lost.
|
|
241
|
+
# +from_sequence+ (a whole number, 0 or more) starts from a known feed position; it applies only to the first
|
|
242
|
+
# connection. Changes to undeclared columns are reported too.
|
|
243
|
+
def subscribe(surface, input = nil, **extra)
|
|
244
|
+
path = route('subscribe')
|
|
245
|
+
opts = {}
|
|
246
|
+
raise invalid('options must be a Hash') unless input.nil? || input.is_a?(Hash)
|
|
247
|
+
|
|
248
|
+
(input || {}).merge(extra).each { |key, value| opts[key.to_s] = value }
|
|
249
|
+
from = opts.key?('from_sequence') ? opts['from_sequence'] : opts['fromSequence']
|
|
250
|
+
unknown = opts.keys - %w[from_sequence fromSequence on_resync]
|
|
251
|
+
raise invalid("unknown option: #{unknown.join(', ')}", options: unknown) unless unknown.empty?
|
|
252
|
+
|
|
253
|
+
target = check_collection_surface(surface, 'surface')
|
|
254
|
+
unless from.nil? || (from.is_a?(Integer) && from >= 0)
|
|
255
|
+
raise invalid('from_sequence must be a whole number, 0 or more', from_sequence: from)
|
|
256
|
+
end
|
|
257
|
+
Realtime.new(@config).feed(path, { 'surface' => target }, from.nil? ? nil : { 'fromSequence' => from }, opts['on_resync'])
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# client.metadata.vectors
|
|
262
|
+
class MetadataVectors
|
|
263
|
+
include Metadata::Support
|
|
264
|
+
|
|
265
|
+
def initialize(config)
|
|
266
|
+
@config = config
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The rows whose declared vector column is nearest to +embedding+, closest first: +surface:+ (+collection:<name>+),
|
|
270
|
+
# +column:+ (+embedding+ or +metadata.embedding+), +embedding:+ (a non-empty Array of finite numbers), optional
|
|
271
|
+
# +k:+ (1 to 1000, the gateway defaults to 10), +metric:+ (+cosine+ default, +euclidean+, +dot+) and +where:+
|
|
272
|
+
# (conditions in the grammar of Metadata#filter). Rows carry no score. At most 50 rows come back whatever +k+
|
|
273
|
+
# is, until the gateway forwards a larger page size. Returns a Metadata::Page.
|
|
274
|
+
def nearest(input = nil, **extra)
|
|
275
|
+
path = route('vectors/nearest')
|
|
276
|
+
opts = options(input, extra, %w[surface column embedding k metric where])
|
|
277
|
+
surface = check_collection_surface(opts['surface'], 'surface')
|
|
278
|
+
column = prefixed(opts['column'], 'column')
|
|
279
|
+
embedding = opts['embedding']
|
|
280
|
+
raise invalid('embedding must be a non-empty array of numbers') unless embedding.is_a?(Array) && !embedding.empty?
|
|
281
|
+
raise invalid('embedding must contain only finite numbers') unless embedding.all? { |n| finite?(n) }
|
|
282
|
+
|
|
283
|
+
check_limit(opts['k'], 'k')
|
|
284
|
+
metric = opts['metric']
|
|
285
|
+
metric = metric.to_s if metric.is_a?(Symbol)
|
|
286
|
+
raise invalid("metric must be one of: #{METRICS.join(', ')}", metric: metric) unless metric.nil? || METRICS.include?(metric)
|
|
287
|
+
|
|
288
|
+
where = wire_where(opts['where'], false)
|
|
289
|
+
body = { 'surface' => surface, 'column' => column, 'embedding' => embedding }
|
|
290
|
+
body['k'] = opts['k'] unless opts['k'].nil?
|
|
291
|
+
body['metric'] = metric unless metric.nil?
|
|
292
|
+
body['where'] = where unless where.nil?
|
|
293
|
+
page(call('POST', path, body))
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# client.metadata.index
|
|
298
|
+
class MetadataIndex
|
|
299
|
+
include Metadata::Support
|
|
300
|
+
|
|
301
|
+
def initialize(config)
|
|
302
|
+
@config = config
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# The columns declared on a collection, ordered by name. +data+ is { "collection", "columns" => [{ "column",
|
|
306
|
+
# "indexed" }] }.
|
|
307
|
+
def get(collection)
|
|
308
|
+
name = collection_name(collection)
|
|
309
|
+
call('GET', route("index/#{segment(name)}"))
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Declare the columns of a collection that metadata may use: an Array of { column:, indexed: } (+indexed+
|
|
313
|
+
# defaults to false). The columns must already exist on the collection. The list replaces the previous declaration
|
|
314
|
+
# (an empty list clears it); at most 64 columns, no +id+, no duplicates. Needs the project-admin role and the
|
|
315
|
+
# migrations permission.
|
|
316
|
+
def declare(collection, columns)
|
|
317
|
+
name = collection_name(collection)
|
|
318
|
+
raise invalid('columns must be an array of { column, indexed? }') unless columns.is_a?(Array)
|
|
319
|
+
raise invalid("at most #{MAX_COLUMNS} columns can be declared", count: columns.size) if columns.size > MAX_COLUMNS
|
|
320
|
+
|
|
321
|
+
seen = {}
|
|
322
|
+
wire = columns.each_with_index.map do |entry, index|
|
|
323
|
+
raise invalid("columns[#{index}] must be a Hash") unless entry.is_a?(Hash)
|
|
324
|
+
|
|
325
|
+
c = entry.each_with_object({}) { |(key, value), out| out[key.to_s] = value }
|
|
326
|
+
column = c['column'].is_a?(Symbol) ? c['column'].to_s : c['column']
|
|
327
|
+
unless column.is_a?(String) && IDENT.match?(column) && column != 'id'
|
|
328
|
+
raise invalid("columns[#{index}].column must be a column name other than id", column: c['column'])
|
|
329
|
+
end
|
|
330
|
+
unless c['indexed'].nil? || c['indexed'] == true || c['indexed'] == false
|
|
331
|
+
raise invalid("columns[#{index}].indexed must be true or false")
|
|
332
|
+
end
|
|
333
|
+
raise invalid("column \"#{column}\" is listed twice", column: column) if seen[column]
|
|
334
|
+
|
|
335
|
+
seen[column] = true
|
|
336
|
+
c['column'] = column
|
|
337
|
+
c
|
|
338
|
+
end
|
|
339
|
+
call('PUT', route("index/#{segment(name)}"), 'columns' => wire)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|