Files
simply_based/lib/simply_based/model/attachment/couch.rb
T
GregandClaude Opus 4.8 86299b8827 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>
2026-06-20 13:58:47 -05:00

174 lines
5.9 KiB
Ruby

# frozen_string_literal: true
module SimplyBased
module Model
module Attachment
# CouchDB inline attachments — declarative, lazy-loaded.
#
# Two levels of API:
#
# 1. Low-level (instance methods):
# put_couch_attachment(name, data, content_type: 'image/png')
# fetch_couch_attachment(name)
# delete_couch_attachment(name)
#
# 2. High-level (class macro):
# has_couch_attached :invoice
#
# record.invoice = File.read('invoice.pdf') # auto-detects content_type
# record.invoice # fetches binary data
# record.invoice_content_type # => 'application/pdf'
# record.invoice_size # => 24501
#
module Couch
def self.included(base)
base.after_save :_save_pending_couch_attachments
end
# ── Low-level instance methods ──────────────────────────────────
def put_couch_attachment(name, file, content_type: 'binary/octet-stream')
result = _couchrest_database.put_attachment(
to_hash, name, file, content_type: content_type
)
self._rev = result['rev'] if result['ok']
self._attachments = (_attachments || {}).merge(name => {})
result
end
def fetch_couch_attachment(name)
_couchrest_database.fetch_attachment(to_hash, name)
rescue CouchRest::NotFound
nil
end
def delete_couch_attachment(name)
result = _couchrest_database.delete_attachment(to_hash, name)
self._rev = result['rev'] if result['ok']
self._attachments = (_attachments || {}).except(name) if result['ok']
result
end
def couch_attachment_names
(_attachments || {}).keys
end
def couch_attachment?(name)
couch_attachment_names.include?(name.to_s)
end
# ── High-level: has_couch_attached ──────────────────────────────
def self.define_couch_attached(base, name, options = {})
options = {
content_type: nil,
filename: nil # template: '{updated_at|iso8601date}-invoice.pdf'
}.merge(options)
# Properties for metadata
base.property :"#{name}_content_type"
base.property :"#{name}_size"
# Resolve the attachment filename
base.define_method(:"#{name}_filename") do
template = options[:filename] || name.to_s
if template.include?('{')
_resolve_filename_template(template)
else
template
end
end
# Getter — fetches from CouchDB on first access
base.define_method(name) do
cache_key = :"@_couch_attachment_#{name}"
return instance_variable_get(cache_key) if instance_variable_defined?(cache_key)
filename = public_send(:"#{name}_filename")
data = fetch_couch_attachment(filename)
instance_variable_set(cache_key, data)
data
end
# Setter — queues attachment for save
base.define_method(:"#{name}=") do |value|
cache_key = :"@_couch_attachment_#{name}"
instance_variable_set(cache_key, value)
# Detect content type
content_type = options[:content_type] || _detect_content_type(value)
public_send(:"#{name}_content_type=", content_type) if value
# Track size
size = value.respond_to?(:bytesize) ? value.bytesize : value.to_s.bytesize
public_send(:"#{name}_size=", size) if value
# Queue for save
@_pending_couch_attachments ||= {}
@_pending_couch_attachments[name] = {
filename: public_send(:"#{name}_filename"),
file: value,
content_type: content_type
}
value
end
# URL getter
base.define_method(:"#{name}_url") do
db_url = _couchrest_database.root
doc_id = _id
filename = public_send(:"#{name}_filename")
"#{db_url}/#{doc_id}/#{filename}"
end
end
private
def _save_pending_couch_attachments
return unless @_pending_couch_attachments&.any?
@_pending_couch_attachments.each do |_name, opts|
put_couch_attachment(opts[:filename], opts[:file], content_type: opts[:content_type])
end
@_pending_couch_attachments = nil
end
def _detect_content_type(data)
return nil unless data.is_a?(String) || data.respond_to?(:read)
# Try to detect from magic bytes using the `marcel` gem if available,
# otherwise fall back to octet-stream.
if defined?(Marcel)
content = data.respond_to?(:read) ? data.read : data
Marcel::MimeType.for(StringIO.new(content.to_s))
else
'binary/octet-stream'
end
rescue StandardError
'binary/octet-stream'
end
def _resolve_filename_template(template)
template.gsub(/\{(\w+)(?:\|(\w+))?\}/) do
method = Regexp.last_match(1)
format = Regexp.last_match(2)
value = respond_to?(method) ? send(method) : nil
if format && value.respond_to?(:"to_#{format}")
value.send(:"to_#{format}")
else
value.to_s
end
end
end
def _couchrest_database
if respond_to?(:database) && database.respond_to?(:couchrest_database)
database.couchrest_database
else
self.class.database.couchrest_database
end
end
end
end
end
end