Module: Tina4::DocStore

Defined in:
lib/tina4/docstore.rb

Defined Under Namespace

Classes: Cursor, DeleteResult, InsertManyResult, InsertOneResult, InvalidId, ObjectId, SqliteCollection, SqliteDatabase, UpdateResult

Constant Summary collapse

OID_RE =
/\A[0-9a-fA-F]{24}\z/.freeze
ISO_RE =
/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?\z/.freeze
COMPARATORS =

– Query translation: Mongo filter Hash -> SQL WHERE over json_extract —-

{ "$gt" => ">", "$gte" => ">=", "$lt" => "<", "$lte" => "<=" }.freeze

Class Method Summary collapse

Class Method Details

.apply_update(doc, update) ⇒ Object



318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
# File 'lib/tina4/docstore.rb', line 318

def apply_update(doc, update)
  update = update.transform_keys(&:to_s)
  unless update.keys.any? { |k| k.start_with?("$") }
    # Full-document replace (keep the existing _id).
    new_doc = deep_string_keys(update)
    new_doc["_id"] = doc["_id"] unless new_doc.key?("_id")
    return new_doc
  end

  new_doc = doc.dup
  update.each do |op, fields|
    case op
    when "$set"
      fields.each { |k, v| set_path(new_doc, k.to_s, v) }
    when "$unset"
      fields.each_key { |k| unset_path(new_doc, k.to_s) }
    when "$inc"
      fields.each { |k, v| set_path(new_doc, k.to_s, (get_path(new_doc, k.to_s) || 0) + v) }
    else
      raise ArgumentError, "DocStore: unsupported update operator #{op.inspect}"
    end
  end
  new_doc
end

.bind(value) ⇒ Object

Bind a Ruby value for comparison against json_extract output.



274
275
276
277
278
279
280
281
282
# File 'lib/tina4/docstore.rb', line 274

def bind(value)
  case value
  when true then 1
  when false then 0
  when ObjectId, Time then encode_value(value)
  when Integer, Float, String, nil then value
  else JSON.generate(encode_value(value))
  end
end

.compile_filter(query) ⇒ Object

Compile a Mongo-style filter Hash into [sql_fragment, params]. Returns [“1=1”, []] for an empty filter. Supports implicit AND across keys, $or / $and, and the per-field operator set.



199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/tina4/docstore.rb', line 199

def compile_filter(query)
  return ["1=1", []] if query.nil? || query.empty?

  clauses = []
  params = []
  query.each do |key, value|
    key = key.to_s
    if key == "$or" || key == "$and"
      joiner = key == "$or" ? " OR " : " AND "
      subs = []
      Array(value).each do |sub|
        frag, p = compile_filter(sub)
        subs << "(#{frag})"
        params.concat(p)
      end
      clauses << "(#{subs.join(joiner)})" unless subs.empty?
      next
    end

    if value.is_a?(Hash) && !value.empty? && value.keys.all? { |k| k.to_s.start_with?("$") }
      value.each do |op, operand|
        frag, p = compile_op(key, op.to_s, operand)
        clauses << frag
        params.concat(p)
      end
    elsif value.nil?
      clauses << "#{extract(key)} IS NULL"
    else
      clauses << "#{extract(key)} = ?"
      params << bind(value)
    end
  end

  [clauses.empty? ? "1=1" : clauses.join(" AND "), params]
end

.compile_op(field, op, operand) ⇒ Object



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
# File 'lib/tina4/docstore.rb', line 235

def compile_op(field, op, operand)
  ex = extract(field)
  if COMPARATORS.key?(op)
    return ["#{ex} #{COMPARATORS[op]} ?", [bind(operand)]]
  end

  case op
  when "$eq"
    return ["#{ex} IS NULL", []] if operand.nil?

    ["#{ex} = ?", [bind(operand)]]
  when "$ne"
    return ["#{ex} IS NOT NULL", []] if operand.nil?

    ["(#{ex} <> ? OR #{ex} IS NULL)", [bind(operand)]]
  when "$in"
    items = Array(operand)
    return ["0", []] if items.empty?

    placeholders = (["?"] * items.length).join(",")
    ["#{ex} IN (#{placeholders})", items.map { |v| bind(v) }]
  when "$nin"
    items = Array(operand)
    return ["1", []] if items.empty?

    placeholders = (["?"] * items.length).join(",")
    ["(#{ex} NOT IN (#{placeholders}) OR #{ex} IS NULL)", items.map { |v| bind(v) }]
  when "$exists"
    # json_type is NULL when the path is absent; present-but-null still has a type.
    [operand ? "#{json_type(field)} IS NOT NULL" : "#{json_type(field)} IS NULL", []]
  when "$regex"
    pattern = operand.is_a?(Hash) ? operand["$regex"].to_s : operand.to_s
    ["#{ex} REGEXP ?", [pattern]]
  else
    raise ArgumentError, "DocStore: unsupported query operator #{op.inspect}"
  end
end

.decode_value(value) ⇒ Object

Stored JSON value -> Ruby, rehydrating ObjectId (24-hex) and Time (ISO).



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
# File 'lib/tina4/docstore.rb', line 151

def decode_value(value)
  case value
  when String
    if value.match?(OID_RE)
      ObjectId.new(value)
    elsif value.match?(ISO_RE)
      begin
        Time.parse(value)
      rescue ArgumentError
        value
      end
    else
      value
    end
  when Hash  then value.each_with_object({}) { |(k, v), h| h[k] = decode_value(v) }
  when Array then value.map { |v| decode_value(v) }
  else value
  end
end

.deep_string_keys(value) ⇒ Object



343
344
345
346
347
348
349
# File 'lib/tina4/docstore.rb', line 343

def deep_string_keys(value)
  case value
  when Hash  then value.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_string_keys(v) }
  when Array then value.map { |v| deep_string_keys(v) }
  else value
  end
end

.default_dbObject



723
724
725
726
727
728
729
# File 'lib/tina4/docstore.rb', line 723

def default_db
  return @default_db if @default_db

  @default_lock.synchronize do
    @default_db ||= SqliteDatabase.new
  end
end

.encode_value(value) ⇒ Object

Ruby value -> JSON-serialisable, sortable scalar form (for storage/queries).



140
141
142
143
144
145
146
147
148
# File 'lib/tina4/docstore.rb', line 140

def encode_value(value)
  case value
  when ObjectId then value.to_s
  when Time     then iso(value)
  when Hash     then value.each_with_object({}) { |(k, v), h| h[k.to_s] = encode_value(v) }
  when Array    then value.map { |v| encode_value(v) }
  else value
  end
end

.extract(field) ⇒ Object



192
# File 'lib/tina4/docstore.rb', line 192

def extract(field) = "json_extract(doc, '#{json_path(field)}')"

.get_collection(name) ⇒ Object

Return a collection for ‘name`.

A real Mongo driver Collection when a Mongo URI is configured (and the mongo gem is installed); otherwise a SqliteCollection backed by the local SQLite file. Same call sites either way - only the backend differs.



736
737
738
739
740
741
742
743
# File 'lib/tina4/docstore.rb', line 736

def get_collection(name)
  return default_db.get_collection(name) if serverless?

  require "mongo"
  db_name = ENV["TINA4_MONGO_DB"] || ENV["TINA4_SESSION_MONGO_DB"] || "tina4"
  client = Mongo::Client.new(mongo_uri, database: db_name)
  client[name]
end

.get_path(doc, dotted) ⇒ Object



371
372
373
374
375
376
377
378
379
380
# File 'lib/tina4/docstore.rb', line 371

def get_path(doc, dotted)
  parts = dotted.split(".")
  node = doc
  parts.each do |p|
    return nil unless node.is_a?(Hash)

    node = node[p]
  end
  node
end

.id_key(value) ⇒ Object

Canonical string key for the _id column.



172
173
174
175
176
177
178
# File 'lib/tina4/docstore.rb', line 172

def id_key(value)
  case value
  when ObjectId then value.to_s
  when Time     then iso(value)
  else value.to_s
  end
end

.iso(time) ⇒ Object

– Value encoding: keep scalars queryable, rehydrate types on read ——–



134
135
136
137
# File 'lib/tina4/docstore.rb', line 134

def iso(time)
  time.getutc.strftime("%Y-%m-%dT%H:%M:%S") +
    (time.getutc.subsec.zero? ? "" : format(".%06d", time.getutc.usec)) + "Z"
end

.json_path(field) ⇒ Object

Field name -> a JSON path. Dotted names address nested keys.



185
186
187
188
189
190
# File 'lib/tina4/docstore.rb', line 185

def json_path(field)
  segments = field.to_s.split(".").map do |s|
    s.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/) ? s : "\"#{s}\""
  end
  "$." + segments.join(".")
end

.json_type(field) ⇒ Object



194
# File 'lib/tina4/docstore.rb', line 194

def json_type(field) = "json_type(doc, '#{json_path(field)}')"

.mongo_uriObject

The configured Mongo URI, reusing the app-wide queue/session env vars. Canonical TINA4_SESSION_MONGO_URI; TINA4_SESSION_MONGO_URL is a legacy alias.



699
700
701
702
703
704
# File 'lib/tina4/docstore.rb', line 699

def mongo_uri
  (ENV["TINA4_MONGO_URI"] ||
   ENV["TINA4_SESSION_MONGO_URI"] ||
   ENV["TINA4_SESSION_MONGO_URL"] ||
   "").strip
end

.project(doc, projection) ⇒ Object

– Projection / update helpers ——————————————–



297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# File 'lib/tina4/docstore.rb', line 297

def project(doc, projection)
  return doc if projection.nil? || projection.empty?

  proj = projection.transform_keys(&:to_s)
  include_keys = proj.select { |k, v| truthy?(v) && k != "_id" }.keys
  exclude_keys = proj.reject { |_k, v| truthy?(v) }.keys

  unless include_keys.empty?
    out = {}
    include_keys.each { |k| out[k] = doc[k] if doc.key?(k) }
    out["_id"] = doc["_id"] if proj.fetch("_id", 1) && doc.key?("_id") && truthy?(proj.fetch("_id", 1))
    return out
  end

  doc.reject { |k, _v| exclude_keys.include?(k) }
end

.regexp_match(pattern, value) ⇒ Object

REGEXP user function body, registered on the SQLite connection.



285
286
287
288
289
290
291
292
293
# File 'lib/tina4/docstore.rb', line 285

def regexp_match(pattern, value)
  return 0 if value.nil?

  begin
    Regexp.new(pattern.to_s).match?(value.to_s) ? 1 : 0
  rescue RegexpError
    0
  end
end

.reset_default_storeObject

Drop the cached default SQLite store (test helper).



746
747
748
749
750
751
# File 'lib/tina4/docstore.rb', line 746

def reset_default_store
  @default_lock.synchronize do
    @default_db&.close
    @default_db = nil
  end
end

.serverless?Boolean

True when no Mongo is configured, so the SQLite fallback is in effect.

Returns:

  • (Boolean)


707
708
709
710
711
712
713
714
715
716
717
718
# File 'lib/tina4/docstore.rb', line 707

def serverless?
  return true if mongo_uri.empty?

  begin
    require "mongo"
    false
  rescue LoadError
    # A URI is set but the driver is absent: degrade to the local store
    # rather than crash.
    true
  end
end

.set_path(doc, dotted, value) ⇒ Object



351
352
353
354
355
356
357
358
359
# File 'lib/tina4/docstore.rb', line 351

def set_path(doc, dotted, value)
  parts = dotted.split(".")
  node = doc
  parts[0...-1].each do |p|
    node[p] = {} unless node[p].is_a?(Hash)
    node = node[p]
  end
  node[parts[-1]] = value
end

.truthy?(value) ⇒ Boolean

Returns:

  • (Boolean)


314
315
316
# File 'lib/tina4/docstore.rb', line 314

def truthy?(value)
  !(value.nil? || value == false || value == 0)
end

.unset_path(doc, dotted) ⇒ Object



361
362
363
364
365
366
367
368
369
# File 'lib/tina4/docstore.rb', line 361

def unset_path(doc, dotted)
  parts = dotted.split(".")
  node = doc
  parts[0...-1].each do |p|
    node = node[p]
    return unless node.is_a?(Hash)
  end
  node.delete(parts[-1]) if node.is_a?(Hash)
end