Class: Lyra::Projections::ModelProjection

Inherits:
Object
  • Object
show all
Defined in:
lib/lyra/projections/model_projection.rb

Overview

Synchronous projection handler for updating model tables from events.

In event_sourcing mode, this class is responsible for keeping the model (read model) tables in sync with the event store. It uses raw SQL operations (insert, update_all, delete_all) to avoid triggering ActiveRecord callbacks or PaperTrail versioning.

Usage:

ModelProjection.project(Registration, :create, result)
ModelProjection.project(Registration, :update, result)
ModelProjection.project(Registration, :destroy, result)

Class Method Summary collapse

Class Method Details

.project(model_class, operation, result) ⇒ Object

Project a command result to the model table

Parameters:

  • model_class (Class) —

    The ActiveRecord model class

  • operation (Symbol) —

    :create, :update, or :destroy

  • result (CommandResult) —

    The result from CommandHandler



24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/lyra/projections/model_projection.rb', line 24

def project(model_class, operation, result)
  case operation
  when :create
    project_create(model_class, result)
  when :update
    project_update(model_class, result)
  when :destroy
    project_destroy(model_class, result)
  else
    raise ArgumentError, "Unknown operation: #{operation}"
  end
end

.project_create(model_class, result) ⇒ Object

Insert a new record into the model table

Uses insert() to bypass all callbacks and validations. This is intentional - the event is the source of truth.



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/lyra/projections/model_projection.rb', line 41

def project_create(model_class, result)
  attributes = result.attributes.dup

  # Ensure we have an ID
  raise "Cannot project create without ID" unless attributes[:id] || attributes["id"]

  # Add timestamps if not present
  now = Time.current
  attributes[:created_at] ||= now
  attributes[:updated_at] ||= now

  # Convert to database-safe format
  insert_attrs = sanitize_attributes(model_class, attributes)

  # Use insert to bypass callbacks
  # Bypass strict data access - projections are legitimate bulk operations
  previous = Thread.current[:lyra_bypass_strict_access]
  Thread.current[:lyra_bypass_strict_access] = true
  begin
    model_class.insert(insert_attrs)
  ensure
    Thread.current[:lyra_bypass_strict_access] = previous
  end
end

.project_destroy(model_class, result) ⇒ Object

Delete a record from the model table

Uses delete_all() to bypass callbacks and dependent destroy.



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# File 'lib/lyra/projections/model_projection.rb', line 103

def project_destroy(model_class, result)
  event = result.events&.first
  return unless event

  model_id = event.data[:model_id] || event.data["model_id"]

  # Use delete_all to bypass callbacks
  # Bypass strict data access - projections are legitimate bulk operations
  previous = Thread.current[:lyra_bypass_strict_access]
  Thread.current[:lyra_bypass_strict_access] = true
  begin
    model_class.where(id: model_id).delete_all
  ensure
    Thread.current[:lyra_bypass_strict_access] = previous
  end
end

.project_update(model_class, result) ⇒ Object

Update an existing record in the model table

Uses update_all() to bypass all callbacks.



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
# File 'lib/lyra/projections/model_projection.rb', line 69

def project_update(model_class, result)
  event = result.events&.first
  return unless event

  model_id = event.data[:model_id] || event.data["model_id"]
  changes = event.data[:changes] || event.data["changes"] || {}

  return if changes.empty?

  # Extract new values from changes (changes are [old_value, new_value] tuples)
  # Use symbol keys consistently to avoid duplicate column errors
  updates = {}
  changes.each do |field, change|
    new_value = change.is_a?(Array) ? change.last : change
    updates[field.to_sym] = new_value
  end

  # Ensure updated_at timestamp (may already be in changes)
  updates[:updated_at] ||= Time.current

  # Use update_all to bypass callbacks
  # Bypass strict data access - projections are legitimate bulk operations
  previous = Thread.current[:lyra_bypass_strict_access]
  Thread.current[:lyra_bypass_strict_access] = true
  begin
    model_class.where(id: model_id).update_all(updates)
  ensure
    Thread.current[:lyra_bypass_strict_access] = previous
  end
end