# frozen_string_literal: true
require_relative "../api_resource"
module Attio
# @api private
module Internal
# Base class for record-based resources (Person, Company, etc.)
# This class handles the complex Attio Record API and should not be used directly.
# Use Person, Company, or TypedRecord instead.
#
# @api private
class Record < APIResource
# Record doesn't use standard CRUD operations due to object parameter requirement
# We'll define custom methods instead
api_operations :delete
# API endpoint path for records (nested under objects)
# @return [String] The API path
def self.resource_path
"objects"
end
attr_reader :attio_object_id, :object_api_slug
def initialize(attributes = {}, opts = {})
super
normalized_attrs = normalize_attributes(attributes)
@attio_object_id = normalized_attrs[:object_id]
@object_api_slug = normalized_attrs[:object_api_slug]
# Process values into attributes
if normalized_attrs[:values]
process_values(normalized_attrs[:values])
end
end
class << self
# List records for an object
def list(object:, **opts)
validate_object_identifier!(object)
# Extract query parameters from opts
# Handle both opts[:params] (from find_by) and direct opts (from other callers)
params = opts[:params] || opts
query_params = build_query_params(params)
response = execute_request(:POST, "#{resource_path}/#{object}/records/query", query_params, opts)
APIResource::ListObject.new(response, self, opts.merge(object: object), opts)
end
alias_method :all, :list
# Create a new record
def create(object: nil, values: nil, data: nil, **opts)
# Handle both parameter styles
if values
# Test style: create(object: "people", values: {...})
validate_object_identifier!(object)
validate_values!(values)
normalized_values = values
elsif data && data[:values]
# API style: create(object: "people", data: { values: {...} })
validate_object_identifier!(object)
validate_values!(data[:values])
normalized_values = data[:values]
else
raise ArgumentError, "Must provide object and either values or data.values"
end
normalized = normalize_values(normalized_values)
puts "DEBUG: Normalized values: #{normalized.inspect}" if ENV["ATTIO_DEBUG"]
request_params = {
data: {
values: normalized
}
}
response = execute_request(:POST, "#{resource_path}/#{object}/records", request_params, opts)
# Ensure object info is included
record_data = response["data"] || {}
record_data["object_api_slug"] ||= object if record_data.is_a?(Hash)
new(record_data, opts)
end
# Retrieve a specific record
def retrieve(record_id: nil, object: nil, **opts)
validate_object_identifier!(object)
# Extract simple ID if it's a nested hash
simple_record_id = record_id.is_a?(Hash) ? record_id["record_id"] : record_id
validate_id!(simple_record_id)
response = execute_request(:GET, "#{resource_path}/#{object}/records/#{simple_record_id}", {}, opts)
record_data = response["data"] || {}
record_data["object_api_slug"] ||= object if record_data.is_a?(Hash)
new(record_data, opts)
end
alias_method :get, :retrieve
alias_method :find, :retrieve
# Update a record
def update(record_id: nil, object: nil, data: nil, **opts)
validate_object_identifier!(object)
# Extract simple ID if it's a nested hash
simple_record_id = record_id.is_a?(Hash) ? record_id["record_id"] : record_id
validate_id!(simple_record_id)
request_params = {
data: {
values: normalize_values(data[:values] || data)
}
}
response = execute_request(:PUT, "#{resource_path}/#{object}/records/#{simple_record_id}", request_params, opts)
record_data = response["data"] || {}
record_data["object_api_slug"] ||= object if record_data.is_a?(Hash)
new(record_data, opts)
end
# Search records
# Note: The Attio API doesn't have a search endpoint, so we use filtering
# This provides a basic search across common text fields
def search(query, object:, **opts)
# For now, just pass through to list with the query
# Subclasses should override this to provide proper search filters
list(object: object, **opts)
end
private
def validate_object_identifier!(object)
raise ArgumentError, "Object identifier is required" if object.nil? || object.to_s.empty?
end
def validate_values!(values)
raise ArgumentError, "Values must be a Hash" unless values.is_a?(Hash)
end
def build_query_params(params)
query_params = {}
query_params[:filter] = build_filter(params[:filter]) if params[:filter]
query_params[:sort] = build_sort(params[:sort]) if params[:sort]
query_params[:limit] = params[:limit] if params[:limit]
query_params[:cursor] = params[:cursor] if params[:cursor]
# Note: 'q' parameter is not supported by Attio API
query_params
end
def build_filter(filter)
case filter
when Hash
filter
when Array
{"$and" => filter}
else
filter
end
end
def build_sort(sort)
case sort
when String
parse_sort_string(sort)
when Hash
sort
else
sort
end
end
def parse_sort_string(sort_string)
field, direction = sort_string.split(":")
{
field: field,
direction: direction || "asc"
}
end
# Attributes that should be sent as simple arrays of strings or simple values
SIMPLE_ARRAY_ATTRIBUTES = %w[email_addresses domains].freeze
SIMPLE_VALUE_ATTRIBUTES = %w[description linkedin job_title employee_count value stage status close_date probability owner].freeze
# Attributes that are arrays of objects and should be sent as-is
OBJECT_ARRAY_ATTRIBUTES = %w[phone_numbers primary_location company].freeze
def normalize_values(values)
values.map do |key, value|
# Check if this is a simple array attribute
if SIMPLE_ARRAY_ATTRIBUTES.include?(key.to_s) && value.is_a?(Array)
# For email_addresses and domains, keep strings as-is
[key, value]
elsif SIMPLE_VALUE_ATTRIBUTES.include?(key.to_s) && !value.is_a?(Hash) && !value.is_a?(Array)
# For simple string attributes, send directly
[key, value]
elsif OBJECT_ARRAY_ATTRIBUTES.include?(key.to_s) && value.is_a?(Array)
# For arrays of objects like phone_numbers, etc., keep as-is
[key, value]
elsif key.to_s == "name"
# Special handling for name - keep as-is whether string or array
# Company names are strings, Person names are arrays of objects
[key, value]
else
normalized_value = case value
when Array
value.map { |v| normalize_single_value(v) }
else
normalize_single_value(value)
end
[key, normalized_value]
end
end.to_h
end
def normalize_single_value(value)
case value
when Hash
value
when NilClass
nil
else
{value: value}
end
end
end
# Instance methods
# Save changes to the record
def save(**opts)
raise InvalidRequestError, "Cannot update a record without an ID" unless persisted?
raise InvalidRequestError, "Cannot save without object context" unless object_api_slug
return self unless changed?
params = {
data: {
values: prepare_values_for_update
}
}
response = self.class.send(:execute_request, :PATCH, resource_path, params, opts)
update_from(response[:data] || response)
reset_changes!
self
end
# Add this record to a list
def add_to_list(list_id, **)
list = List.retrieve(list_id, **)
list.add_record(id, **)
end
# Get lists containing this record
def lists(**)
raise InvalidRequestError, "Cannot get lists without an ID" unless persisted?
# This is a simplified implementation - in reality you'd need to query the API
# for lists that contain this record
List.list(record_id: id, **)
end
def resource_path
raise InvalidRequestError, "Cannot generate path without object context" unless object_api_slug
record_id = id.is_a?(Hash) ? id["record_id"] : id
"#{self.class.resource_path}/#{object_api_slug}/records/#{record_id}"
end
# Override destroy to use correct path
def destroy(**opts)
raise InvalidRequestError, "Cannot destroy a record without an ID" unless persisted?
raise InvalidRequestError, "Cannot destroy without object context" unless object_api_slug
self.class.send(:execute_request, :DELETE, resource_path, {}, opts)
@attributes.clear
@changed_attributes.clear
@id = nil
freeze
true
end
# Convert record to hash representation
# @return [Hash] Record data as a hash
def to_h
values = @attributes.except(:id, :created_at, :object_id, :object_api_slug)
{
id: id,
object_id: attio_object_id,
object_api_slug: object_api_slug,
created_at: created_at&.iso8601,
values: values
}.compact
end
# Human-readable representation of the record
# @return [String] Inspection string with ID, object, and sample values
def inspect
values_preview = @attributes.take(3).map { |k, v| "#{k}: #{v.inspect}" }.join(", ")
values_preview += "..." if @attributes.size > 3
"#<#{self.class.name}:#{object_id} id=#{id.inspect} object=#{object_api_slug.inspect} values={#{values_preview}}>"
end
private
def process_values(values)
return unless values.is_a?(Hash)
values.each do |key, value_data|
extracted_value = extract_value(value_data)
@attributes[key.to_sym] = extracted_value
@original_attributes[key.to_sym] = deep_copy(extracted_value)
end
end
def extract_value(value_data)
case value_data
when Array
extracted = value_data.map { |v| extract_single_value(v) }
(extracted.length == 1) ? extracted.first : extracted
else
extract_single_value(value_data)
end
end
def extract_single_value(value_data)
case value_data
when Hash
if value_data.key?(:value) || value_data.key?("value")
value_data[:value] || value_data["value"]
elsif value_data.key?(:target_object) || value_data.key?("target_object") ||
value_data.key?(:referenced_actor_type) || value_data.key?("referenced_actor_type")
# Reference value - return the full reference object
value_data
elsif value_data.key?(:currency_value) || value_data.key?("currency_value")
# Currency value - return the full object to preserve currency info
value_data
elsif value_data.key?(:status) || value_data.key?("status")
# Status value - return the full object to preserve status info
value_data
else
value_data
end
else
value_data
end
end
def prepare_values_for_update
changed_attributes.transform_values do |value|
self.class.send(:normalize_values, {key: value})[:key]
end
end
end
end
end