SimplyBased 0.1.0 — Couchbase-native ActiveModel ORM
A from-scratch fork of simply_couch's model layer that runs on Couchbase natively: `include SimplyBased::Model` instead of `include SimplyCouch::Model`. - SimplyBased::Store replaces the CouchDB layer: KV documents, N1QL/SQL++ finders + lazily-created GSIs, CAS mapped onto _rev. No design documents or JavaScript map/reduce — the hand-written association views become N1QL (belongs_to/has_many/has_one scalar FK; habtm via ANY ... SATISFIES + array index, or KV multi-get on the storing side). - Redis replacements on the same cluster: Counter (atomic increment/decrement), Cache (ActiveSupport::Cache::Store over KV+TTL, drop-in Rails.cache/ActiveJob), Lock (insert-if-absent + TTL + CAS-checked release). - Runs the simply_couch test suite against live Couchbase: 378/432 applicable examples green (CouchDB-mechanic specs tagged :couchdb_only and excluded). - Remaining: ancestry (tree) + embedded_in reverse-lookups (recursive/embedded N1QL) and a few pagination/lifecycle edges. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
86299b8827
84 files changed
+9904
No files matched your search
@@ -0,0 +1,460 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'securerandom'
|
||||
require 'uri'
|
||||
require 'couchbase'
|
||||
|
||||
module SimplyBased
|
||||
# The Couchbase-native data store. Plays the role SimplyCouch's
|
||||
# Model::DatabaseInstance played, but speaks the SDK directly: key/value for
|
||||
# documents, N1QL/SQL++ for queries, CAS for optimistic concurrency. There is
|
||||
# no CouchDB design document or JavaScript map/reduce anywhere.
|
||||
#
|
||||
# v0 data model: one collection, every document tagged `ruby_class`; queries
|
||||
# filter on it and partial GSIs are scoped `WHERE ruby_class = '...'`. A future
|
||||
# `collection :name` per-model mode maps each class to its own collection.
|
||||
class Store
|
||||
attr_reader :cluster, :bucket_name, :scope_name, :collection_name
|
||||
|
||||
def initialize(connection = nil)
|
||||
cfg = parse_connection(connection)
|
||||
@bucket_name = cfg[:bucket]
|
||||
@scope_name = cfg[:scope] || '_default'
|
||||
@collection_name = cfg[:collection] || '_default'
|
||||
@cluster = ::Couchbase::Cluster.connect(cfg[:conn_str], cfg[:user], cfg[:password])
|
||||
@ensured_indexes = {}
|
||||
end
|
||||
|
||||
def keyspace
|
||||
"`#{bucket_name}`.`#{scope_name}`.`#{collection_name}`"
|
||||
end
|
||||
|
||||
# ── single document (KV) ────────────────────────────────────────────────
|
||||
|
||||
def save_document(document, validate = true)
|
||||
if document.new?
|
||||
create_document(document, validate)
|
||||
else
|
||||
update_document(document, validate)
|
||||
end
|
||||
rescue ::Couchbase::Error::CasMismatch, ::Couchbase::Error::DocumentExists
|
||||
raise SimplyBased::Conflict.new
|
||||
end
|
||||
|
||||
def save_document!(document)
|
||||
save_document(document) || raise("Validations failed: #{document.errors.full_messages}")
|
||||
end
|
||||
|
||||
def load_document(id)
|
||||
raise "Can't load a document without an id (got nil)" if id.nil?
|
||||
_track("GET #{id}")
|
||||
res = collection.get(id)
|
||||
hydrate(res.content, id, res.cas)
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
def destroy_document(document, run_callbacks = true)
|
||||
remove = lambda do
|
||||
document._deleted = true
|
||||
collection.remove(document._id) if document._id
|
||||
end
|
||||
run_callbacks ? document.run_callbacks(:destroy) { remove.call } : remove.call
|
||||
document._id = nil
|
||||
document._rev = nil
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
document._id = nil; document._rev = nil
|
||||
end
|
||||
|
||||
def delete_document(document)
|
||||
collection.remove(document._id) if document._id
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
# ── bulk (batched KV) ───────────────────────────────────────────────────
|
||||
|
||||
def bulk_load(ids)
|
||||
ids = Array(ids).compact
|
||||
return [] if ids.empty?
|
||||
ids.filter_map { |id| load_document(id) }
|
||||
end
|
||||
|
||||
def bulk_save(documents, validate = true)
|
||||
documents = Array(documents)
|
||||
return { saved: [], invalid: [], failed: [] } if documents.empty?
|
||||
invalid = []; saved = []; failed = []; now = Time.now
|
||||
documents.each do |document|
|
||||
document.database = self
|
||||
document.created_at ||= now if document.respond_to?(:created_at) && document.new?
|
||||
document.updated_at = now if document.respond_to?(:updated_at=)
|
||||
if validate
|
||||
document.errors.clear
|
||||
(invalid << document; next) unless valid_document?(document)
|
||||
end
|
||||
begin
|
||||
persist(document)
|
||||
document.send(:clear_changes_information) if document.respond_to?(:clear_changes_information, true)
|
||||
saved << document
|
||||
rescue ::Couchbase::Error::CouchbaseError => e
|
||||
failed << [document, e.message]
|
||||
end
|
||||
end
|
||||
{ saved: saved, invalid: invalid, failed: failed }
|
||||
end
|
||||
|
||||
def bulk_destroy(documents)
|
||||
documents = Array(documents)
|
||||
return [] if documents.empty?
|
||||
rows = documents.map do |d|
|
||||
begin
|
||||
collection.remove(d._id) if d._id
|
||||
{ 'id' => d._id, 'ok' => true }
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
{ 'id' => d._id, 'error' => 'not_found' }
|
||||
end
|
||||
end
|
||||
documents.each { |d| d._id = nil; d._rev = nil }
|
||||
rows
|
||||
end
|
||||
|
||||
# ── view specs (finders / count) → N1QL ─────────────────────────────────
|
||||
# Handles ModelViewSpec / DeletedModelViewSpec (all_documents, find_by, count).
|
||||
|
||||
def view(spec)
|
||||
kls = spec.klass
|
||||
vp = spec.view_parameters
|
||||
where = ["c.`ruby_class` = $rc"]
|
||||
params = { rc: kls.name }
|
||||
where << not_deleted(kls) if deleted_spec?(spec)
|
||||
keyexp = key_expr(spec.options[:key])
|
||||
where.concat(key_filters(keyexp, vp, params))
|
||||
|
||||
results =
|
||||
if vp[:reduce]
|
||||
[{ 'value' => run_count(where, params) }]
|
||||
else
|
||||
run_docs(where, params, keyexp, vp).map { |doc| { 'doc' => doc, 'id' => doc._id } }
|
||||
end
|
||||
processed = spec.process_results({ 'rows' => results })
|
||||
assign_db(processed)
|
||||
processed
|
||||
end
|
||||
|
||||
def first(spec)
|
||||
spec.view_parameters = spec.view_parameters.merge(limit: 1)
|
||||
Array(view(spec)).first
|
||||
end
|
||||
|
||||
# ── associations → N1QL (replaces JS join views) ────────────────────────
|
||||
# has_many / has_one / belongs_to: docs of `from_class` whose scalar foreign
|
||||
# key column equals `value`.
|
||||
def find_by_foreign_key(from_class, column, value, opts = {})
|
||||
ensure_index(from_class, [column])
|
||||
where = ["c.`ruby_class` = $rc", "c.`#{column}` = $v"]
|
||||
params = { rc: from_class.name, v: value }
|
||||
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
|
||||
run_docs(where, params, 'c.`created_at`', order_opts(opts))
|
||||
end
|
||||
|
||||
def count_by_foreign_key(from_class, column, value, opts = {})
|
||||
ensure_index(from_class, [column])
|
||||
where = ["c.`ruby_class` = $rc", "c.`#{column}` = $v"]
|
||||
params = { rc: from_class.name, v: value }
|
||||
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
|
||||
run_count(where, params)
|
||||
end
|
||||
|
||||
# has_and_belongs_to_many: docs of `from_class` whose array column contains value.
|
||||
def find_by_array_contains(from_class, column, value, opts = {})
|
||||
ensure_array_index(from_class, column)
|
||||
where = ["c.`ruby_class` = $rc", "ANY x IN c.`#{column}` SATISFIES x = $v END"]
|
||||
params = { rc: from_class.name, v: value }
|
||||
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
|
||||
run_docs(where, params, 'c.`created_at`', order_opts(opts))
|
||||
end
|
||||
|
||||
def count_by_array_contains(from_class, column, value, opts = {})
|
||||
ensure_array_index(from_class, column)
|
||||
where = ["c.`ruby_class` = $rc", "ANY x IN c.`#{column}` SATISFIES x = $v END"]
|
||||
params = { rc: from_class.name, v: value }
|
||||
where << not_deleted(from_class) unless opts[:with_deleted] || !soft_deletes?(from_class)
|
||||
run_count(where, params)
|
||||
end
|
||||
|
||||
# ── admin ───────────────────────────────────────────────────────────────
|
||||
|
||||
def create_database!
|
||||
query("CREATE PRIMARY INDEX IF NOT EXISTS ON #{keyspace}")
|
||||
self
|
||||
rescue ::Couchbase::Error::CouchbaseError
|
||||
self
|
||||
end
|
||||
|
||||
# Clear all documents. Indexes survive (DELETE removes rows, not GSIs), so
|
||||
# the @ensured_indexes cache is intentionally kept — between test examples we
|
||||
# want to wipe data without rebuilding every association index.
|
||||
def drop_database!
|
||||
query("DELETE FROM #{keyspace}")
|
||||
rescue ::Couchbase::Error::CouchbaseError
|
||||
nil
|
||||
end
|
||||
|
||||
# ── raw KV primitives (used by Counter / Cache / Lock) ──────────────────
|
||||
|
||||
# Server-side atomic counter (Couchbase binary increment/decrement). Returns
|
||||
# the new value. Creates the key at `initial` if absent.
|
||||
def counter_incr(key, delta = 1, initial: 0, ttl: nil)
|
||||
o = ::Couchbase::Options::Increment.new(delta: delta, initial: initial)
|
||||
o.expiry = ttl if ttl
|
||||
collection.binary.increment(key, o).content
|
||||
end
|
||||
|
||||
def counter_decr(key, delta = 1, initial: 0, ttl: nil)
|
||||
o = ::Couchbase::Options::Decrement.new(delta: delta, initial: initial)
|
||||
o.expiry = ttl if ttl
|
||||
collection.binary.decrement(key, o).content
|
||||
end
|
||||
|
||||
def kv_set(key, value, ttl: nil)
|
||||
o = ::Couchbase::Options::Upsert.new
|
||||
o.expiry = ttl if ttl
|
||||
collection.upsert(key, value, o).cas
|
||||
end
|
||||
|
||||
# Insert only if absent — returns CAS, or nil if the key already exists.
|
||||
def kv_add(key, value, ttl: nil)
|
||||
o = ::Couchbase::Options::Insert.new
|
||||
o.expiry = ttl if ttl
|
||||
collection.insert(key, value, o).cas
|
||||
rescue ::Couchbase::Error::DocumentExists
|
||||
nil
|
||||
end
|
||||
|
||||
def kv_get(key)
|
||||
collection.get(key).content
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
def kv_get_cas(key)
|
||||
r = collection.get(key)
|
||||
[r.content, r.cas]
|
||||
rescue ::Couchbase::Error::DocumentNotFound
|
||||
nil
|
||||
end
|
||||
|
||||
def kv_delete(key, cas: nil)
|
||||
o = ::Couchbase::Options::Remove.new
|
||||
o.cas = cas if cas
|
||||
collection.remove(key, o)
|
||||
true
|
||||
rescue ::Couchbase::Error::DocumentNotFound, ::Couchbase::Error::CasMismatch
|
||||
false
|
||||
end
|
||||
|
||||
def query(statement, params = {})
|
||||
_track(statement)
|
||||
opts = ::Couchbase::Options::Query.new(named_parameters: params)
|
||||
opts.scan_consistency = :request_plus # read-your-writes; the suite expects it
|
||||
cluster.query(statement, opts)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Record a database round-trip for the spec query-count matchers (no-op in
|
||||
# production, where $performed_queries is undefined).
|
||||
def _track(op)
|
||||
$performed_queries << { url: op } if defined?($performed_queries) && $performed_queries
|
||||
end
|
||||
|
||||
def collection
|
||||
@collection ||= cluster.bucket(bucket_name).scope(scope_name).collection(collection_name)
|
||||
end
|
||||
|
||||
# ── N1QL builders ────────────────────────────────────────────────────────
|
||||
|
||||
def run_docs(where, params, order_col, vp)
|
||||
sql = +"SELECT c.*, META(c).id AS `__id`, META(c).cas AS `__cas` FROM #{keyspace} AS c WHERE #{where.join(' AND ')}"
|
||||
if order_col
|
||||
dir = vp[:descending] ? 'DESC' : 'ASC'
|
||||
# META().id tiebreaker keeps ordering deterministic (created_at is only
|
||||
# second-precision) so ascending and descending are exact reverses.
|
||||
sql << " ORDER BY #{order_col} #{dir}, META(c).id #{dir}"
|
||||
end
|
||||
sql << " LIMIT #{Integer(vp[:limit])}" if vp[:limit]
|
||||
sql << " OFFSET #{Integer(vp[:skip])}" if vp[:skip]
|
||||
query(sql, params).rows.to_a.map { |row| row_to_doc(row) }
|
||||
end
|
||||
|
||||
def run_count(where, params)
|
||||
sql = "SELECT COUNT(*) AS `value` FROM #{keyspace} AS c WHERE #{where.join(' AND ')}"
|
||||
r = query(sql, params).rows.first
|
||||
(r && r['value']) || 0
|
||||
end
|
||||
|
||||
def key_expr(key)
|
||||
case key
|
||||
when nil then nil
|
||||
when Array then '[' + key.map { |k| "c.`#{k}`" }.join(', ') + ']'
|
||||
else "c.`#{key}`"
|
||||
end
|
||||
end
|
||||
|
||||
def key_filters(keyexp, vp, params)
|
||||
return [] unless keyexp
|
||||
if vp.key?(:key)
|
||||
params[:key] = vp[:key]; ["#{keyexp} = $key"]
|
||||
elsif vp.key?(:keys)
|
||||
params[:keys] = vp[:keys]; ["#{keyexp} IN $keys"]
|
||||
else
|
||||
low, high = vp[:descending] ? %i[endkey startkey] : %i[startkey endkey]
|
||||
f = []
|
||||
(params[:startkey] = vp[low]; f << "#{keyexp} >= $startkey") if vp.key?(low)
|
||||
(params[:endkey] = vp[high]; f << "#{keyexp} <= $endkey") if vp.key?(high)
|
||||
f
|
||||
end
|
||||
end
|
||||
|
||||
def order_opts(opts)
|
||||
{ descending: opts[:descending], limit: opts[:limit], skip: opts[:skip] }
|
||||
end
|
||||
|
||||
def not_deleted(kls)
|
||||
a = kls.soft_delete_attribute
|
||||
"(c.`#{a}` IS MISSING OR c.`#{a}` IS NULL)"
|
||||
end
|
||||
|
||||
def soft_deletes?(kls)
|
||||
kls.respond_to?(:soft_deleting_enabled?) && kls.soft_deleting_enabled?
|
||||
end
|
||||
|
||||
def deleted_spec?(spec)
|
||||
spec.class.name.end_with?('DeletedModelViewSpec')
|
||||
end
|
||||
|
||||
# Lazily create the GSIs an association needs — the N1QL analogue of
|
||||
# SimplyCouch creating a design document on first view query.
|
||||
def ensure_index(kls, columns)
|
||||
key = [kls.name, columns].inspect
|
||||
return if @ensured_indexes[key]
|
||||
name = "sb_#{kls.name.gsub(/\W/, '_').downcase}_#{columns.join('_')}"[0, 60]
|
||||
cols = columns.map { |col| "`#{col}`" }.join(', ')
|
||||
query("CREATE INDEX `#{name}` IF NOT EXISTS ON #{keyspace}(#{cols}) WHERE `ruby_class` = '#{kls.name}'")
|
||||
@ensured_indexes[key] = true
|
||||
rescue ::Couchbase::Error::CouchbaseError
|
||||
@ensured_indexes[key] = true
|
||||
end
|
||||
|
||||
def ensure_array_index(kls, column)
|
||||
key = [kls.name, :array, column].inspect
|
||||
return if @ensured_indexes[key]
|
||||
name = "sb_#{kls.name.gsub(/\W/, '_').downcase}_arr_#{column}"[0, 60]
|
||||
query("CREATE INDEX `#{name}` IF NOT EXISTS ON #{keyspace}(DISTINCT ARRAY x FOR x IN `#{column}` END) WHERE `ruby_class` = '#{kls.name}'")
|
||||
@ensured_indexes[key] = true
|
||||
rescue ::Couchbase::Error::CouchbaseError
|
||||
@ensured_indexes[key] = true
|
||||
end
|
||||
|
||||
# ── persistence helpers ──────────────────────────────────────────────────
|
||||
|
||||
def create_document(document, validate)
|
||||
document.database = self
|
||||
if validate
|
||||
document.errors.clear
|
||||
return false if false == document.run_callbacks(:validation) do
|
||||
return false if false == document.run_callbacks(:validation_on_create) do
|
||||
return false unless valid_document?(document)
|
||||
end
|
||||
end
|
||||
end
|
||||
return false if false == document.run_callbacks(:save) do
|
||||
return false if false == document.run_callbacks(:create) do
|
||||
document._id ||= SecureRandom.uuid
|
||||
res = collection.insert(document._id, document.to_hash)
|
||||
document._rev = res.cas.to_s
|
||||
end
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def update_document(document, validate)
|
||||
if validate
|
||||
document.errors.clear
|
||||
return false if false == document.run_callbacks(:validation) do
|
||||
return false if false == document.run_callbacks(:validation_on_update) do
|
||||
return false unless valid_document?(document)
|
||||
end
|
||||
end
|
||||
end
|
||||
return false if false == document.run_callbacks(:save) do
|
||||
return false if false == document.run_callbacks(:update) do
|
||||
persist(document) if document.changed?
|
||||
end
|
||||
end
|
||||
true
|
||||
end
|
||||
|
||||
def persist(document)
|
||||
if document.new?
|
||||
document._id ||= SecureRandom.uuid
|
||||
_track("INSERT #{document._id}")
|
||||
res = collection.insert(document._id, document.to_hash)
|
||||
else
|
||||
opts = ::Couchbase::Options::Replace.new
|
||||
opts.cas = document._rev.to_i if document._rev
|
||||
_track("REPLACE #{document._id}")
|
||||
res = collection.replace(document._id, document.to_hash, opts)
|
||||
end
|
||||
document._rev = res.cas.to_s
|
||||
document._id
|
||||
end
|
||||
|
||||
def valid_document?(document)
|
||||
original = document.errors.to_hash
|
||||
document.valid?
|
||||
original.each { |k, v| Array(v).each { |m| document.errors.add(k, m) } }
|
||||
document.errors.empty?
|
||||
end
|
||||
|
||||
# ── hydration ────────────────────────────────────────────────────────────
|
||||
|
||||
def row_to_doc(row)
|
||||
id = row['__id'] || row['_id']
|
||||
cas = row['__cas'] # the document's CAS is its _rev surrogate
|
||||
doc = row.reject { |k, _| k == '__id' || k == '__cas' }
|
||||
doc['_id'] ||= id
|
||||
hydrate(doc, id, cas || doc['_rev'])
|
||||
end
|
||||
|
||||
def hydrate(content, id, rev)
|
||||
return content unless content.is_a?(Hash)
|
||||
hash = content.dup
|
||||
hash['_id'] ||= id
|
||||
hash['_rev'] = rev.to_s unless rev.nil?
|
||||
model = SimplyBased::DocumentCodec.from_hash(hash)
|
||||
model.database = self if model.respond_to?(:database=)
|
||||
model
|
||||
end
|
||||
|
||||
def assign_db(results)
|
||||
return unless results.respond_to?(:each)
|
||||
results.each { |d| d.database = self if d.respond_to?(:database=) }
|
||||
end
|
||||
|
||||
def parse_connection(connection)
|
||||
return connection if connection.is_a?(Hash)
|
||||
uri = URI.parse(connection.to_s)
|
||||
q = URI.decode_www_form(uri.query.to_s).to_h
|
||||
host = [uri.host, (":#{uri.port}" if uri.port)].compact.join
|
||||
{
|
||||
conn_str: "#{uri.scheme || 'couchbase'}://#{host}",
|
||||
user: uri.user && URI.decode_www_form_component(uri.user),
|
||||
password: uri.password && URI.decode_www_form_component(uri.password),
|
||||
bucket: uri.path.sub(%r{\A/}, ''),
|
||||
scope: q['scope'],
|
||||
collection: q['collection']
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user