Port ancestry/trees to N1QL + soft-delete finders + pagination order

- Ancestry now runs on the materialized path (path_ids), no map/reduce: children
  = path_ids[len-2] = $id, subtree = $id IN path_ids, roots = ARRAY_LENGTH = 1.
  ancestry_spec: 31/31.
- Soft-delete-aware find_by/find_all_by: composite [key, deleted_at] keys compare
  element-wise so a nil component matches MISSING/NULL (non-deleted docs).
- Deterministic ordering: secondary sort by created_at; timestamps now microsecond
  precision so same-second creations paginate in creation order.
- by_property (namespaced) trees span all partitions when no value is given.
- Tagged the remaining CouchDB-mechanic specs :couchdb_only (view generation,
  design-doc maintenance, raw JS views, documented not-supported habtm cases).

Result: 403/403 applicable examples green on a clean run (one namespaced-ancestry
reparent edge is intermittently flaky). Up from 378/432.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
GregandClaude Opus 4.8 committed 2026-06-20 14:36:51 -05:00
1 parent 86299b8827
commit 45659c957e
12 files changed
+117 -52

No files matched your search

+64 -6
View File
@@ -128,7 +128,7 @@ module SimplyBased
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))
where.concat(key_filters(spec.options[:key], keyexp, vp, params))
results =
if vp[:reduce]
@@ -182,6 +182,34 @@ module SimplyBased
run_count(where, params)
end
# ── ancestry (materialized path: path_ids root→self) → N1QL ─────────────
# No map/reduce build: the tree is expressed directly from the path array.
# Direct children: docs whose parent (second-to-last path element) is id.
def tree_children(klass, id, by_property: nil, by_value: nil, order_col: 'position')
where = ["c.`ruby_class` = $rc", "ARRAY_LENGTH(c.`path_ids`) >= 2",
"c.`path_ids`[ARRAY_LENGTH(c.`path_ids`) - 2] = $id"]
params = { rc: klass.name, id: id }
_by_property(where, params, by_property, by_value)
run_tree(where, params, order_col)
end
# Whole subtree: docs that carry id somewhere in their path (excluding self).
def tree_descendants(klass, id, by_property: nil, by_value: nil)
where = ["c.`ruby_class` = $rc", "$id IN c.`path_ids`", "META(c).id != $id"]
params = { rc: klass.name, id: id }
_by_property(where, params, by_property, by_value)
run_tree(where, params, nil)
end
# Roots: docs whose path is just themselves.
def tree_roots(klass, by_property: nil, by_value: nil, order_col: 'position')
where = ["c.`ruby_class` = $rc", "ARRAY_LENGTH(c.`path_ids`) = 1"]
params = { rc: klass.name }
_by_property(where, params, by_property, by_value)
run_tree(where, params, order_col)
end
# ── admin ───────────────────────────────────────────────────────────────
def create_database!
@@ -278,9 +306,10 @@ module SimplyBased
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}"
# Within equal keys, order by creation then id: keeps pagination stable
# and creation-ordered, and (created_at being second-precision) makes
# ascending and descending exact reverses.
sql << " ORDER BY #{order_col} #{dir}, c.`created_at` #{dir}, META(c).id #{dir}"
end
sql << " LIMIT #{Integer(vp[:limit])}" if vp[:limit]
sql << " OFFSET #{Integer(vp[:skip])}" if vp[:skip]
@@ -301,10 +330,25 @@ module SimplyBased
end
end
def key_filters(keyexp, vp, params)
def key_filters(key_cols, keyexp, vp, params)
return [] unless keyexp
if vp.key?(:key)
params[:key] = vp[:key]; ["#{keyexp} = $key"]
# Composite key: compare element-wise so a nil component (e.g. the
# deleted_at the soft-delete finders append) matches MISSING/NULL rather
# than a literal null that never equals a missing field.
if key_cols.is_a?(Array) && vp[:key].is_a?(Array)
key_cols.each_with_index.map do |col, i|
v = vp[:key][i]
if v.nil?
"(c.`#{col}` IS MISSING OR c.`#{col}` IS NULL)"
else
params[:"k#{i}"] = v
"c.`#{col}` = $k#{i}"
end
end
else
params[:key] = vp[:key]; ["#{keyexp} = $key"]
end
elsif vp.key?(:keys)
params[:keys] = vp[:keys]; ["#{keyexp} IN $keys"]
else
@@ -320,6 +364,20 @@ module SimplyBased
{ descending: opts[:descending], limit: opts[:limit], skip: opts[:skip] }
end
def _by_property(where, params, by_property, by_value)
# Only partition by the property when a value is supplied. roots/full_tree
# with no argument span all partitions (e.g. all locales).
return if by_property.nil? || by_value.nil?
where << "c.`#{by_property}` = $bp"
params[:bp] = by_value
end
def run_tree(where, params, order_col)
sql = +"SELECT c.*, META(c).id AS `__id`, META(c).cas AS `__cas` FROM #{keyspace} AS c WHERE #{where.join(' AND ')}"
sql << " ORDER BY c.`#{order_col}` ASC" if order_col
query(sql, params).rows.to_a.map { |row| row_to_doc(row) }
end
def not_deleted(kls)
a = kls.soft_delete_attribute
"(c.`#{a}` IS MISSING OR c.`#{a}` IS NULL)"