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:
GregandClaude Opus 4.8 committed 2026-06-20 13:58:47 -05:00
commit 86299b8827
84 files changed
+9904

No files matched your search

+460
View File
@@ -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