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
- .apply_update(doc, update) ⇒ Object
-
.bind(value) ⇒ Object
Bind a Ruby value for comparison against json_extract output.
-
.compile_filter(query) ⇒ Object
Compile a Mongo-style filter Hash into [sql_fragment, params].
- .compile_op(field, op, operand) ⇒ Object
-
.decode_value(value) ⇒ Object
Stored JSON value -> Ruby, rehydrating ObjectId (24-hex) and Time (ISO).
- .deep_string_keys(value) ⇒ Object
- .default_db ⇒ Object
-
.encode_value(value) ⇒ Object
Ruby value -> JSON-serialisable, sortable scalar form (for storage/queries).
- .extract(field) ⇒ Object
-
.get_collection(name) ⇒ Object
Return a collection for ‘name`.
- .get_path(doc, dotted) ⇒ Object
-
.id_key(value) ⇒ Object
Canonical string key for the _id column.
-
.iso(time) ⇒ Object
– Value encoding: keep scalars queryable, rehydrate types on read ——–.
-
.json_path(field) ⇒ Object
Field name -> a JSON path.
- .json_type(field) ⇒ Object
-
.mongo_uri ⇒ Object
The configured Mongo URI, reusing the app-wide queue/session env vars.
-
.project(doc, projection) ⇒ Object
– Projection / update helpers ——————————————–.
-
.regexp_match(pattern, value) ⇒ Object
REGEXP user function body, registered on the SQLite connection.
-
.reset_default_store ⇒ Object
Drop the cached default SQLite store (test helper).
-
.serverless? ⇒ Boolean
True when no Mongo is configured, so the SQLite fallback is in effect.
- .set_path(doc, dotted, value) ⇒ Object
- .truthy?(value) ⇒ Boolean
- .unset_path(doc, dotted) ⇒ Object
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_db ⇒ Object
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_uri ⇒ Object
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_store ⇒ Object
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.
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
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 |