Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,41 @@ since Active Remote depends on specific Rails versions.

### Changed

- Update to ActiveModel 8.1
- `#freeze` freezes the record, not just its attributes
- A rejected save keeps the caller's edits and change tracking instead of adopting the response
- Responses are merged into the record, so omitted attributes keep their value

### Fixed

- Transport errors raised `NameError` instead of `RpcFailedError` and friends
- `#hash` now agrees with `#==`/`#eql?`
- `#reload` marks the record as persisted, so the next `#save` no longer duplicates it
- `#delete`, `#destroy` and `#remote` clear stale errors before checking the result
- `#delete!` and `#destroy!` report the service's messages, not `#<ActiveModel::Errors>`
- `.primary_key` is inherited by subclasses
- `.attribute_names`, `#to_key` and `#scope_keys` no longer memoize stale values
- `#previous_changes` is populated after a successful save
- `.find` and the `.first_or_*` methods accept the documented protobuf and Active Remote arguments
- The `.first_or_*` methods unwrap a search request's repeated fields, so `name: ["foo"]` no longer
becomes `"[\"foo\"]"`; matching on several values now raises `ArgumentError`
- `belongs_to`/`has_one` memoize a `nil` association and honor an explicitly assigned `nil`
- Errors from repeated RPC calls no longer accumulate duplicates
- `#attribute_for_inspect` accepts a symbol name, matching `#[]` and `#[]=`; it previously
reported `"nil"` for every attribute
- Corrected documentation throughout that described ActiveRecord behavior this gem does not have,
named the wrong method, or had gone stale — including `#cache_key`'s `cache_versioning` claims,
`#to_key`'s example, and the return values documented for `#delete!` and `#destroy!`

### Removed

- `.attr_publishable` and `.publishable_attributes`. Their only consumers, the publication and JSON
serializer modules, were removed in 3.0.0; the macro has had no effect since

## [8.0.0] - 2026-07-24

### Changed

- Update to ActiveModel 8.0
- Require Ruby 3.2 (to match Rails 8.0)
- Fix Standard violations
Expand Down
4 changes: 2 additions & 2 deletions active_remote.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Gem::Specification.new do |spec|
##
# Dependencies
#
spec.add_dependency "activemodel", "~> 8.0.0"
spec.add_dependency "activesupport", "~> 8.0.0"
spec.add_dependency "activemodel", "~> 8.1.0"
spec.add_dependency "activesupport", "~> 8.1.0"
spec.add_dependency "protobuf", ">= 3.0"
end
18 changes: 9 additions & 9 deletions lib/active_remote/association.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,26 +133,26 @@ def validate_scoped_attributes(associated_class, object_class, options)
private

def perform_association(associated_klass, options = {})
ivar = :"@#{associated_klass}"

define_method(associated_klass) do
# Keyed on presence, not truthiness, so a nil association isn't re-queried.
# Checked before resolving the class, since #classify runs the inflector
# uncached and would otherwise dominate every cached read.
return instance_variable_get(ivar) if instance_variable_defined?(ivar)

klass_name = options.fetch(:class_name) { associated_klass }
klass = klass_name.to_s.classify.constantize

self.class.validate_scoped_attributes(klass, self.class, options) if options.key?(:scope)

value = instance_variable_get(:"@#{associated_klass}")

unless value
value = yield(klass, self)
instance_variable_set(:"@#{associated_klass}", value)
end

value
instance_variable_set(ivar, yield(klass, self))
end

define_method(:"#{associated_klass}=") do |new_value|
raise "New value must be an array" if options[:has_many] == true && new_value.class != Array

instance_variable_set(:"@#{associated_klass}", new_value)
instance_variable_set(ivar, new_value)
new_value
end
end
Expand Down
16 changes: 3 additions & 13 deletions lib/active_remote/attribute_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,6 @@ module ActiveRemote
module AttributeMethods
extend ::ActiveSupport::Concern

module ClassMethods
def attribute_names
@attribute_names ||= attribute_types.keys
end
end

def [](attr_name)
name = attr_name.to_s
name = self.class.attribute_aliases[name] || name
Expand All @@ -34,10 +28,10 @@ def []=(attr_name, value)
# person.attribute_for_inspect(:created_at)
# # => "\"2012-10-22 00:15:07\""
#
# person.attribute_for_inspect(:tag_ids)
# # => "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]"
# person.attribute_for_inspect(:age)
# # => "42"
def attribute_for_inspect(attr_name)
value = attribute(attr_name)
value = self[attr_name]

if value.is_a?(String) && value.length > 50
"#{value[0, 50]}...".inspect
Expand All @@ -47,9 +41,5 @@ def attribute_for_inspect(attr_name)
value.inspect
end
end

def attribute_names
@attributes.keys
end
end
end
48 changes: 22 additions & 26 deletions lib/active_remote/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ class Base
include ::ActiveRemote::Search
include ::ActiveRemote::Serialization

# Overrides some methods, providing support for dirty tracking,
# so it needs to be included last.
# Overrides persistence methods to add dirty tracking, so it has to come
# after Persistence and Search.
include ::ActiveRemote::Dirty

# Overrides persistence methods, so it must included after
# Overrides #save/#save! to validate first, so it has to come after Dirty.
include ::ActiveRemote::Validations
include ::ActiveModel::Validations::Callbacks

Expand All @@ -54,15 +54,15 @@ def initialize(attributes = {})
end
end

# Returns true if +comparison_object+ is the same exact object, or +comparison_object+
# is of the same type and +self+ has an ID and it is equal to +comparison_object.id+.
# Returns true if +comparison_object+ is the same exact object, or is of the
# same type and its primary key is set and equal to this record's.
#
# Note that new records are different from any other record by definition, unless the
# other record is the receiver itself. Besides, if you fetch existing records with
# +select+ and leave the ID out, you're on your own, this predicate will return false.
# Note that this does not consider whether either record is persisted: two
# unsaved records built with the same primary key compare equal. Records
# whose primary key is nil are only equal to themselves.
#
# Note also that destroying a record preserves its ID in the model instance, so deleted
# models are still comparable.
# Note also that destroying a record preserves its attributes in the model
# instance, so deleted models are still comparable.
def ==(other)
super ||
other.instance_of?(self.class) &&
Expand All @@ -71,6 +71,16 @@ def ==(other)
end
alias_method :eql?, :==

# Records that are +eql?+ must hash alike, or Set, Array#uniq and Hash keys
# treat them as distinct.
def hash
if (key = send(primary_key))
[self.class, key].hash
else
super
end
end

# Allows sort on objects
def <=>(other)
if other.is_a?(self.class)
Expand All @@ -80,17 +90,8 @@ def <=>(other)
end
end

def freeze
@attributes.freeze
self
end

def frozen?
@attributes.frozen?
end

# Initialize an object with the attributes hash directly
# When used with allocate, bypasses initialize
# Initialize an object from an ActiveModel::AttributeSet, as built by
# .build_from_rpc. When used with allocate, bypasses initialize.
def init_with(attributes)
@attributes = attributes
@new_record = false
Expand Down Expand Up @@ -118,11 +119,6 @@ def inspect

"#<#{self.class} #{inspection}>"
end

# Returns a hash of the given methods with their names as keys and returned values as values.
def slice(*methods)
methods.flatten.map! { |method| [method, public_send(method)] }.to_h.with_indifferent_access
end
end

::ActiveModel::Type.register(:value, ::ActiveModel::Type::Value)
Expand Down
28 changes: 16 additions & 12 deletions lib/active_remote/dirty.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,35 +18,39 @@ def reload(*)
end
end

# Override #remote to provide dirty tracking.
# Override #remote to provide dirty tracking. A rejected write keeps its
# pending changes so the caller can fix and retry.
#
def remote(*)
super.tap do
clear_changes_information
super.tap do |success|
clear_changes_information if success
end
end

# Override #save to store changes as previous changes then clear them.
# Override #save to expose the changes it persisted as #previous_changes.
# Clearing them is #remote's job, which super calls.
#
def save(*)
if (status = super)
changes_applied
end
# Snapshot first: #remote clears the tracker before super returns.
mutations = mutations_from_database

status
super.tap do |status|
@mutations_before_last_save = mutations if status
end
end

# Override #save to store changes as previous changes then clear them.
# Override #instantiate to provide dirty tracking. It swaps @attributes, so
# the tracker has to be reset or a freshly loaded record reports changes.
#
def save!(*)
def instantiate(*)
super.tap do
changes_applied
clear_changes_information
end
end

private

# Override #update to only send changed attributes.
# Override #remote_update to only send changed attributes.
#
def remote_update(*)
super(changed)
Expand Down
24 changes: 0 additions & 24 deletions lib/active_remote/dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,6 @@ module DSL
extend ActiveSupport::Concern

module ClassMethods
# Whitelist enable attributes for serialization purposes.
#
# ====Examples
#
# # To only publish the :guid and :status attributes:
# class User < ActiveRemote::Base
# attr_publishable :guid, :status
# end
#
def attr_publishable(*attributes)
@publishable_attributes ||= []
@publishable_attributes += attributes
end

def endpoint_for_create(endpoint)
endpoints create: endpoint
end
Expand Down Expand Up @@ -66,12 +52,6 @@ def namespace(name = false)
@namespace
end

# Retrieve the attributes that have been whitelisted for serialization.
#
def publishable_attributes
@publishable_attributes
end

# Set the RPC service class directly. By default, ActiveRemote determines
# the RPC service by constantizing the namespace and service name.
#
Expand Down Expand Up @@ -140,10 +120,6 @@ def _endpoints
self.class.endpoints
end

def _publishable_attributes
self.class.publishable_attributes
end

def _service_name
self.class.service_name
end
Expand Down
11 changes: 7 additions & 4 deletions lib/active_remote/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ class ActiveRemoteError < StandardError
class DangerousAttributeError < ActiveRemoteError
end

# Raised by ActiveRemove::Base.save when the remote record is readonly.
# Raised by ActiveRemote::Base#save, #delete, #destroy and #update_attribute
# when the remote record or its class is readonly.
class ReadOnlyRemoteRecord < ActiveRemoteError
end

# Raised by ActiveRemote::Validations when save is called on an invalid record.
# Raised by ActiveRemote::Validations when save! is called on a record that
# fails local validation. #save returns false instead.
class RemoteRecordInvalid < ActiveRemoteError
attr_reader :record

Expand Down Expand Up @@ -40,8 +42,9 @@ def initialize(class_or_message = "")
end
end

# Raised by ActiveRemove::Base.save! and ActiveRemote::Base.create! methods
# when remote record cannot be saved because it is invalid.
# Raised by ActiveRemote::Base#save!, .create! and #update_attributes! when
# the service rejects the write. Local validation failures raise
# RemoteRecordInvalid instead, since Validations runs first.
class RemoteRecordNotSaved < ActiveRemoteError
attr_reader :record

Expand Down
Loading
Loading