Class: Hecks::Adapters::SqliteProjection

Inherits:
Sqlite
  • Object
show all
Defined in:
lib/hecks/adapters/driven/sqlite/projection.rb

Overview

Rebuilds a read store from the authoritative journal, entry by entry. A value object is written as its JSON object and a reference as the bare id it holds — the same shapes the command path writes, so there is no second representation to accept here any more.

Constant Summary

Constants inherited from Sqlite

Hecks::Adapters::Sqlite::SQL_TYPES

Constants included from SqlQueryBuilder

Hecks::Adapters::SqlQueryBuilder::COMPARATORS

Instance Attribute Summary

Attributes inherited from Sqlite

#aggregate, #path

Instance Method Summary collapse

Methods inherited from Sqlite

#all, #append, #atomic_put, #count, #delete, #delete_saga, #each_saga, #entries, #events, #find, #initialize, #persistence_capabilities, #record_event, #reset!, #save, #save_saga, #table

Methods included from SqlQueryBuilder

#query

Constructor Details

This class inherits a constructor from Hecks::Adapters::Sqlite

Instance Method Details

#project(entry) ⇒ Object



20
21
22
23
24
25
26
27
28
29
30
# File 'lib/hecks/adapters/driven/sqlite/projection.rb', line 20

def project(entry)
  return @db.execute("DELETE FROM #{quoted_table} WHERE id = ?", [entry.id]) if entry.delete?

  columns = (["id"] + persisted_fields.map { |field| field[:name].to_s }).map { |column| quote_ident(column) }
  values  = [entry.id.to_s] + persisted_fields.map { |field| encode_field(field, entry.state[field[:name]]) }
  @db.execute(
    "INSERT OR REPLACE INTO #{quoted_table} (#{columns.join(', ')}) VALUES (#{Array.new(columns.size,
                                                                                        '?').join(', ')})", values
  )
  entry
end

#query_read_model(_domain, model, args, bluebook = nil) ⇒ Object

Execute a declared read model against the projected aggregate-head tables. The report shape is assembled from SQL-selected rows, rather than scanning repositories and matching references in Ruby.

M19 (docs/audits/2026-08-10-main-bug-audit.md, docs/audits/2026-08-11-bug-triage.md) — this used to diverge from Runtime::ReadModelInterpreter#project (the in-process path) on two counts, both fixed here to agree with it:

MISSING ROOT: the in-process path's own fetch refuses with NotFound when the reference argument names no record — query_read_model used to answer a silent {root: nil, ...} instead, the one path a caller could dispatch a read model against a record that never existed and get back something that LOOKS like an empty report rather than the refusal every other path gives.

CHAINED-INCLUDE JOIN SCOPE: a non-root head was always matched against the ROOT's own id, regardless of what it actually references — correct for a head that references the root directly, silently EMPTY for one that references another included head instead (Leaf -> Mid -> Root, Leaf itself has no attribute referencing Root at all, so references was always []). The in-process path's own root-first fix (ReadModelInterpreter#project's "ROOT FIRST, ALWAYS" comment) already matches a head against ANY already-projected source, not only the root — select_related now does the same: each head is matched against every source resolved so far (root first, then declared order — the same one-level-of-declaration-order dependency the in-process path itself still has, documented there as L2, not a gap introduced here).

Raises:

  • (ArgumentError)


63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/hecks/adapters/driven/sqlite/projection.rb', line 63

def query_read_model(_domain, model, args, bluebook = nil)
  raise ArgumentError, "projection query needs its domain bluebook" unless bluebook

  # The REFERENCE's own shape, read the same way ReadModelInterpreter
  # reads it — not the identity unwrap, which is gone : an identity is
  # declared as a path and followed.
  reference_id = args.fetch(model.reference_name).to_s
  eligible = model.filtered_head_name

  # ROOT FIRST, ALWAYS — see this method's own header. Mirrors
  # `ReadModelInterpreter#project`'s identical partition, for the
  # identical reason: a later head's own join has to be able to
  # match against a root (or another head) already resolved.
  root_heads, other_heads = model.aggregate_heads.partition { |head| head[:aggregate] == model.reference_target }
  projected = []
  reports = {}
  (root_heads + other_heads).each do |head|
    aggregate = bluebook.aggregate(head[:aggregate])
    rows = if head[:aggregate] == model.reference_target
             [select_projected(aggregate, reference_id) ||
               raise(Runtime::NotFound,
                     Runtime::RefusalWording.render("NotFound", "read_model_reference_missing",
                                                    aggregate: head[:aggregate],
                                                    offered:   Hecks::Rendering.describe(reference_id)))]
           else
             select_related(aggregate, projected)
           end
    rows = Ports::Query::InMemory.execute(rows, model, args) if head[:as] == eligible
    projected << { aggregate: head[:aggregate], rows: rows }
    reports[head[:as]] = if head[:many]
                           rows.map { |row| Runtime::Value.materialize(row.to_h) }
                         else
                           rows.first && Runtime::Value.materialize(rows.first.to_h)
                         end
  end
  [reports]
end