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:
1 parent
86299b8827
commit
45659c957e
12 files changed
+117
-52
No files matched your search
@@ -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)"
|
||||
|
||||
Reference in new issue
Block a user