airtable-orm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE.txt +21 -0
- data/README.md +121 -0
- data/lib/airtable/orm/associations.rb +153 -0
- data/lib/airtable/orm/attributes.rb +236 -0
- data/lib/airtable/orm/base.rb +33 -0
- data/lib/airtable/orm/batch_result.rb +28 -0
- data/lib/airtable/orm/collection.rb +46 -0
- data/lib/airtable/orm/configuration.rb +70 -0
- data/lib/airtable/orm/core.rb +49 -0
- data/lib/airtable/orm/errors.rb +62 -0
- data/lib/airtable/orm/http/client.rb +85 -0
- data/lib/airtable/orm/http/error_handler.rb +25 -0
- data/lib/airtable/orm/http/rate_limiter.rb +52 -0
- data/lib/airtable/orm/persistence.rb +361 -0
- data/lib/airtable/orm/querying.rb +183 -0
- data/lib/airtable/orm/schema.rb +66 -0
- data/lib/airtable/orm/testing/stub_helpers.rb +53 -0
- data/lib/airtable/orm/testing.rb +3 -0
- data/lib/airtable/orm/version.rb +7 -0
- data/lib/airtable/orm.rb +47 -0
- data/lib/airtable-orm.rb +4 -0
- metadata +131 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Airtable
|
|
6
|
+
module ORM
|
|
7
|
+
# Every host touchpoint is injected here — the gem never reads Rails.*, ENV, or
|
|
8
|
+
# credentials itself. Wired by the host application via Airtable::ORM.configure
|
|
9
|
+
# (in Rails typically from an initializer, inside to_prepare).
|
|
10
|
+
class Configuration
|
|
11
|
+
attr_accessor :api_key, :base_id, :tables, :api_url,
|
|
12
|
+
:open_timeout, :read_timeout, :rate_limit,
|
|
13
|
+
:logger, :http_logger, :cache
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@tables = {}
|
|
17
|
+
@api_url = "https://api.airtable.com"
|
|
18
|
+
@open_timeout = 5
|
|
19
|
+
@read_timeout = 10
|
|
20
|
+
@rate_limit = 5 # Airtable throttles for 30 seconds above 5 requests/s per base
|
|
21
|
+
@logger = Logger.new(IO::NULL)
|
|
22
|
+
@http_logger = nil # set to a Logger to enable Faraday request logging
|
|
23
|
+
@cache = MemoryCache.new # schema cache default; hosts inject Rails.cache
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Example: table_fields(:case) => { _id: "id", advisors: "fldgU5KU1qlN8eJ3v", ... }
|
|
27
|
+
def table_fields(table_name)
|
|
28
|
+
tables.dig(table_name.to_sym, :fields)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Example: table_id(:case) => "tblQeKH7yYesvWf5p"
|
|
32
|
+
def table_id(table_name)
|
|
33
|
+
tables.dig(table_name.to_sym, :id)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def table_ids
|
|
37
|
+
tables.values.pluck(:id)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Default in-process schema cache so an unconfigured consumer doesn't hit the /v0/meta
|
|
41
|
+
# API on every Schema.fetch (and burn the 5 RPS budget on metadata). Hosts inject a real
|
|
42
|
+
# store (Rails.cache); this one honours only :expires_in. The mutex is held across the
|
|
43
|
+
# fetch block (all keys serialize behind it, and nesting fetch calls would deadlock) —
|
|
44
|
+
# fine for the rare schema refresh this exists for.
|
|
45
|
+
class MemoryCache
|
|
46
|
+
def initialize
|
|
47
|
+
@mutex = Mutex.new
|
|
48
|
+
@store = {}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def fetch(key, expires_in: nil)
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
# Hit/miss by key presence, not value truthiness — false/nil are cacheable
|
|
54
|
+
# (matching Rails.cache semantics).
|
|
55
|
+
if (entry = @store[key])
|
|
56
|
+
value, expires_at = entry
|
|
57
|
+
return value if expires_at.nil? || Time.now < expires_at
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
yield.tap { |fresh| @store[key] = [fresh, expires_in && (Time.now + expires_in)] }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def delete(key)
|
|
65
|
+
@mutex.synchronize { @store.delete(key) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airtable
|
|
4
|
+
module ORM
|
|
5
|
+
module Core
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
# Instance comparison
|
|
9
|
+
def ==(other)
|
|
10
|
+
return false unless other.is_a?(self.class)
|
|
11
|
+
return false if new_record? || other.new_record?
|
|
12
|
+
|
|
13
|
+
id == other.id
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
alias eql? ==
|
|
17
|
+
|
|
18
|
+
def hash
|
|
19
|
+
[self.class, id].hash
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Inspect method for better debugging
|
|
23
|
+
def inspect
|
|
24
|
+
attribute_string = self.class.attribute_types.map do |name, _type|
|
|
25
|
+
value = read_raw_attribute(name.to_sym)
|
|
26
|
+
"#{name}: #{value.inspect}"
|
|
27
|
+
end.join(", ")
|
|
28
|
+
|
|
29
|
+
"#<#{self.class.name} id: #{id.inspect}, #{attribute_string}>"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Convert to hash with symbol keys
|
|
33
|
+
def to_h
|
|
34
|
+
{
|
|
35
|
+
id: id,
|
|
36
|
+
created_at: created_at,
|
|
37
|
+
**symbol_attributes
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Freeze the record
|
|
42
|
+
def freeze
|
|
43
|
+
@id.freeze
|
|
44
|
+
@created_at.freeze
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airtable
|
|
4
|
+
module ORM
|
|
5
|
+
# Base error class for all Airtable-related errors
|
|
6
|
+
class Error < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when a record cannot be found
|
|
9
|
+
class RecordNotFound < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when trying to access an unknown field
|
|
12
|
+
class UnknownFieldError < Error; end
|
|
13
|
+
|
|
14
|
+
# Raised when an invalid attribute is provided
|
|
15
|
+
class InvalidAttributeError < Error; end
|
|
16
|
+
|
|
17
|
+
# Raised when a record fails validation.
|
|
18
|
+
# Use the #record method to retrieve the record which did not validate.
|
|
19
|
+
class RecordInvalid < Error
|
|
20
|
+
attr_reader :record
|
|
21
|
+
|
|
22
|
+
def initialize(record = nil)
|
|
23
|
+
if record
|
|
24
|
+
@record = record
|
|
25
|
+
errors = @record.errors.full_messages.join(", ")
|
|
26
|
+
super("Validation failed: #{errors}")
|
|
27
|
+
else
|
|
28
|
+
super("Record invalid")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Raised when trying to perform operations on a non-persisted record
|
|
34
|
+
# (e.g., trying to destroy or reload a new record)
|
|
35
|
+
class RecordNotPersisted < Error; end
|
|
36
|
+
|
|
37
|
+
# Raised when trying to save a new record that fails
|
|
38
|
+
class RecordNotSaved < Error; end
|
|
39
|
+
|
|
40
|
+
# Raised when a record cannot be destroyed
|
|
41
|
+
class RecordNotDestroyed < Error; end
|
|
42
|
+
|
|
43
|
+
# Raised when API communication fails
|
|
44
|
+
class ApiError < Error
|
|
45
|
+
attr_reader :status, :response
|
|
46
|
+
|
|
47
|
+
def initialize(message, status: nil, response: nil)
|
|
48
|
+
super(message)
|
|
49
|
+
@status = status
|
|
50
|
+
@response = response
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Raised when the request never completed — a read timeout, dropped/failed connection, TLS error,
|
|
55
|
+
# etc. — mapped from the raw transport error by Airtable::ORM::Http::ErrorHandler so callers only
|
|
56
|
+
# ever deal in Airtable::* exceptions, never the HTTP client's. Deliberately a sibling of ApiError,
|
|
57
|
+
# not a subclass: the API never responded, so it isn't an "API error". Being outside the ApiError
|
|
58
|
+
# branch also means persistence's `rescue ApiError` ignores it, so a blip propagates (to retry)
|
|
59
|
+
# instead of being swallowed into a `false` save.
|
|
60
|
+
class ConnectionError < Error; end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rate_limiter"
|
|
4
|
+
require_relative "error_handler"
|
|
5
|
+
|
|
6
|
+
module Airtable
|
|
7
|
+
module ORM
|
|
8
|
+
module Http
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :api_key
|
|
11
|
+
|
|
12
|
+
# Shared client instance with credential validation.
|
|
13
|
+
# Used by Persistence and Schema to avoid duplicating client/key setup.
|
|
14
|
+
def self.default
|
|
15
|
+
@default ||= begin
|
|
16
|
+
key = ORM.config.api_key
|
|
17
|
+
if key.blank?
|
|
18
|
+
raise Airtable::ORM::Error,
|
|
19
|
+
"Airtable API key is missing. Set api_key via Airtable::ORM.configure."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
new(key)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.reset!
|
|
27
|
+
@default = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(api_key)
|
|
31
|
+
@api_key = api_key
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def connection
|
|
35
|
+
@connection ||= Faraday.new(url: api_uri, headers: headers) do |builder|
|
|
36
|
+
builder.options.open_timeout = ORM.config.open_timeout
|
|
37
|
+
builder.options.timeout = ORM.config.read_timeout
|
|
38
|
+
# Outermost middleware so its rescue wraps every inner handler and the adapter — maps raw
|
|
39
|
+
# Faraday transport errors to Airtable::ORM::ConnectionError before they leave the client.
|
|
40
|
+
builder.request :airtable_error_handler
|
|
41
|
+
builder.request :json
|
|
42
|
+
builder.request :airtable_rate_limiter, requests_per_second: ORM.config.rate_limit
|
|
43
|
+
builder.response :json
|
|
44
|
+
builder.adapter :net_http_persistent
|
|
45
|
+
|
|
46
|
+
if (http_logger = ORM.config.http_logger)
|
|
47
|
+
builder.response :logger, http_logger, bodies: { request: true, response: false }, headers: false,
|
|
48
|
+
errors: true, log_level: :debug
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def api_uri
|
|
54
|
+
@api_uri ||= URI.parse(ORM.config.api_url)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def headers
|
|
58
|
+
{
|
|
59
|
+
"Authorization" => "Bearer #{api_key}",
|
|
60
|
+
"Content-Type" => "application/json",
|
|
61
|
+
"User-Agent" => "airtable-orm/#{VERSION}"
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def escape(string)
|
|
66
|
+
ERB::Util.url_encode(string)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Parse an Airtable API error response and raise an ApiError.
|
|
70
|
+
def self.raise_api_error(status, error)
|
|
71
|
+
type = (error.is_a?(Hash) && error.dig("error", "type")) || "Communication error"
|
|
72
|
+
msg = case error
|
|
73
|
+
when Hash then error.dig("error", "message")
|
|
74
|
+
when String then error
|
|
75
|
+
when NilClass then "invalid or empty response body (not valid JSON)"
|
|
76
|
+
else error.inspect
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
raise Airtable::ORM::ApiError.new("HTTP #{status}: #{type}: #{msg.to_s.truncate(200, omission: "…")}",
|
|
80
|
+
status: status, response: error)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module Airtable
|
|
6
|
+
module ORM
|
|
7
|
+
module Http
|
|
8
|
+
# Maps the whole Faraday::Error family to Airtable::ORM::ConnectionError so no Faraday type
|
|
9
|
+
# leaks past the client (rationale on the class in error.rb). Safe to catch the base: we don't
|
|
10
|
+
# enable Faraday's :raise_error, so HTTP 4xx/5xx come back as responses (→ Airtable::ORM::ApiError
|
|
11
|
+
# downstream), never raised through here. The original error is kept as #cause.
|
|
12
|
+
class ErrorHandler < Faraday::Middleware
|
|
13
|
+
def call(env)
|
|
14
|
+
@app.call(env)
|
|
15
|
+
rescue Faraday::Error => e
|
|
16
|
+
raise Airtable::ORM::ConnectionError, "Airtable request failed: #{e.class}: #{e.message}"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Faraday::Request.register_middleware(
|
|
24
|
+
airtable_error_handler: Airtable::ORM::Http::ErrorHandler
|
|
25
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Airtable
|
|
4
|
+
module ORM
|
|
5
|
+
module Http
|
|
6
|
+
class RateLimiter < Faraday::Middleware
|
|
7
|
+
def initialize(app, requests_per_second: nil, sleeper: nil)
|
|
8
|
+
super(app)
|
|
9
|
+
@rps = requests_per_second
|
|
10
|
+
@sleeper = sleeper || ->(seconds) { sleep(seconds) }
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
@requests = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(env)
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
wait if too_many_requests_in_last_second?
|
|
18
|
+
@requests << Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
19
|
+
@requests.shift if @rps && @requests.size > @rps
|
|
20
|
+
end
|
|
21
|
+
@app.call(env)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def clear
|
|
25
|
+
@mutex.synchronize { @requests = [] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def too_many_requests_in_last_second?
|
|
31
|
+
return false unless @rps
|
|
32
|
+
return false unless @requests.size >= @rps
|
|
33
|
+
|
|
34
|
+
window_span < 1.0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def wait
|
|
38
|
+
wait_time = 1.0 - window_span
|
|
39
|
+
@sleeper.call(wait_time)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def window_span
|
|
43
|
+
@requests.last - @requests.first
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Faraday::Request.register_middleware(
|
|
51
|
+
airtable_rate_limiter: Airtable::ORM::Http::RateLimiter
|
|
52
|
+
)
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Airtable
|
|
6
|
+
module ORM
|
|
7
|
+
module Persistence
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
attr_reader :id, :created_at
|
|
12
|
+
|
|
13
|
+
# Track if this record has been persisted to Airtable
|
|
14
|
+
define_model_callbacks :save, :create, :update, :destroy
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
BATCH_SIZE = 10
|
|
18
|
+
MAX_FIND_MANY_IDS = 500
|
|
19
|
+
|
|
20
|
+
class_methods do
|
|
21
|
+
# Batch update records (up to 10 per API request).
|
|
22
|
+
# Triages locally: new/invalid → failed, unchanged → skipped, changed → sent.
|
|
23
|
+
def update_many(records)
|
|
24
|
+
result = Airtable::ORM::BatchResult.new
|
|
25
|
+
|
|
26
|
+
pending = []
|
|
27
|
+
records.each do |record|
|
|
28
|
+
if record.new_record? || record.invalid?
|
|
29
|
+
result.failed << record
|
|
30
|
+
elsif !record.changed?
|
|
31
|
+
record.clear_changes_information
|
|
32
|
+
result.skipped << record
|
|
33
|
+
else
|
|
34
|
+
pending << record
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
pending.each_slice(BATCH_SIZE) do |batch|
|
|
39
|
+
send_batch_update(batch, result)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Get the API client
|
|
46
|
+
def client
|
|
47
|
+
Airtable::ORM::Http::Client.default
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Build API path for this table (appends returnFieldsByFieldId=true).
|
|
51
|
+
def api_path(resource_id = nil)
|
|
52
|
+
base_path = "/v0/#{ORM.config.base_id}/#{client.escape(table_id)}"
|
|
53
|
+
full_path = resource_id ? "#{base_path}/#{resource_id}" : base_path
|
|
54
|
+
"#{full_path}?returnFieldsByFieldId=true"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Find a record by ID
|
|
58
|
+
def find(id)
|
|
59
|
+
response = client.connection.get(api_path(id))
|
|
60
|
+
parsed_response = response.body
|
|
61
|
+
|
|
62
|
+
if response.success?
|
|
63
|
+
instantiate_from_api_response(parsed_response)
|
|
64
|
+
else
|
|
65
|
+
Airtable::ORM::Http::Client.raise_api_error(response.status, parsed_response)
|
|
66
|
+
end
|
|
67
|
+
rescue Airtable::ORM::ApiError => e
|
|
68
|
+
raise Airtable::ORM::RecordNotFound, "Couldn't find record with id=#{id}" if e.status == 404
|
|
69
|
+
|
|
70
|
+
raise
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Find multiple records by IDs, preserving order.
|
|
74
|
+
# Uses an OR formula (single API request) and sorts in memory.
|
|
75
|
+
def find_many(ids)
|
|
76
|
+
return Airtable::ORM::Collection.new([], model_class: self) if ids.empty?
|
|
77
|
+
|
|
78
|
+
validated_ids = validate_record_ids(ids)
|
|
79
|
+
|
|
80
|
+
if validated_ids.size > MAX_FIND_MANY_IDS
|
|
81
|
+
raise ArgumentError, "find_many supports at most #{MAX_FIND_MANY_IDS} IDs (got #{validated_ids.size})"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
position_hash = validated_ids.each_with_index.to_h
|
|
85
|
+
|
|
86
|
+
or_args = validated_ids.map { |id| "RECORD_ID() = '#{id}'" }.join(",")
|
|
87
|
+
formula = "OR(#{or_args})"
|
|
88
|
+
where(formula: formula).sort_by { |record| position_hash[record.id] || validated_ids.length }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Create a new record and save it
|
|
92
|
+
def create(attributes = {})
|
|
93
|
+
record = new(attributes)
|
|
94
|
+
record.save
|
|
95
|
+
record
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Create a new record and save it, raising an error if validation fails
|
|
99
|
+
def create!(attributes = {})
|
|
100
|
+
record = new(attributes)
|
|
101
|
+
record.save!
|
|
102
|
+
record
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Instantiate a record from API response
|
|
106
|
+
def instantiate_from_api_response(response)
|
|
107
|
+
data = response.with_indifferent_access
|
|
108
|
+
symbol_attrs = fields_to_symbol_attributes(data[:fields])
|
|
109
|
+
|
|
110
|
+
record = new(**symbol_attrs)
|
|
111
|
+
record.send(:assign_persistence_state,
|
|
112
|
+
id: data[:id],
|
|
113
|
+
created_at: data[:createdTime] ? Time.iso8601(data[:createdTime].to_s) : nil,
|
|
114
|
+
persisted: true)
|
|
115
|
+
record.clear_changes_information
|
|
116
|
+
|
|
117
|
+
record
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Convert API field IDs to symbol attributes.
|
|
121
|
+
# field_mapping already contains only declared attributes.
|
|
122
|
+
def fields_to_symbol_attributes(fields)
|
|
123
|
+
return {} unless fields.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
field_mapping.each_with_object({}) do |(symbol, field_id), hash|
|
|
126
|
+
hash[symbol] = fields[field_id] if fields.key?(field_id)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Send a single batch PATCH request to Airtable.
|
|
133
|
+
def send_batch_update(batch, result)
|
|
134
|
+
body = {
|
|
135
|
+
records: batch.map { |record| { id: record.id, fields: record.fields_for_update } }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
response = client.connection.patch(api_path, body)
|
|
139
|
+
parsed = response.body
|
|
140
|
+
|
|
141
|
+
if response.success?
|
|
142
|
+
parsed = parsed.with_indifferent_access if parsed.is_a?(Hash)
|
|
143
|
+
response_records = parsed[:records] || []
|
|
144
|
+
response_by_id = response_records.index_by { |r| r[:id] }
|
|
145
|
+
|
|
146
|
+
batch.each do |record|
|
|
147
|
+
record_data = response_by_id[record.id]
|
|
148
|
+
if record_data
|
|
149
|
+
record.send(:apply_response_fields, record_data)
|
|
150
|
+
result.updated << record
|
|
151
|
+
else
|
|
152
|
+
result.failed << record
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
else
|
|
156
|
+
batch.each { |record| result.failed << record }
|
|
157
|
+
error_message = parsed.is_a?(Hash) ? parsed.with_indifferent_access.dig(:error, :message) : parsed
|
|
158
|
+
ORM.config.logger.error(
|
|
159
|
+
"Airtable batch update failed: HTTP #{response.status}: #{error_message.to_s.truncate(200)}"
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
rescue Airtable::ORM::ApiError => e
|
|
163
|
+
batch.each { |record| result.failed << record }
|
|
164
|
+
ORM.config.logger.error("Airtable batch update error: #{e.message}")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Validate that record IDs match Airtable's format to prevent formula injection
|
|
168
|
+
# Airtable record IDs follow the pattern: rec + alphanumeric characters
|
|
169
|
+
def validate_record_ids(ids)
|
|
170
|
+
ids.map do |id|
|
|
171
|
+
id_str = id.to_s
|
|
172
|
+
unless id_str.match?(/\Arec[a-zA-Z0-9_-]+\z/)
|
|
173
|
+
raise ArgumentError,
|
|
174
|
+
"Invalid Airtable record ID: #{id_str.inspect}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
id_str
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Initialize a record with symbol attributes
|
|
183
|
+
# Usage: new(email: "test@example.com", first_name: "John")
|
|
184
|
+
def initialize(**attributes)
|
|
185
|
+
# Initialize as a new record (not persisted, not destroyed)
|
|
186
|
+
@id = nil
|
|
187
|
+
@created_at = nil
|
|
188
|
+
@persisted = false
|
|
189
|
+
@destroyed = false
|
|
190
|
+
@previously_new_record = false
|
|
191
|
+
|
|
192
|
+
# Initialize ActiveModel::Attributes
|
|
193
|
+
super()
|
|
194
|
+
|
|
195
|
+
# Set attributes using ActiveModel's assign_attributes
|
|
196
|
+
assign_attributes(attributes) if attributes.present?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Check if this is a new record (not yet saved)
|
|
200
|
+
def new_record?
|
|
201
|
+
!@persisted && !@destroyed
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Check if this record has been saved
|
|
205
|
+
def persisted?
|
|
206
|
+
@persisted == true
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Check if this record has been destroyed
|
|
210
|
+
def destroyed?
|
|
211
|
+
@destroyed == true
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Check if this record was new before the last save
|
|
215
|
+
def previously_new_record?
|
|
216
|
+
@previously_new_record == true
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Save the record (create or update)
|
|
220
|
+
def save(validate: true)
|
|
221
|
+
return false if validate && invalid?
|
|
222
|
+
|
|
223
|
+
result = run_callbacks(:save) do
|
|
224
|
+
new_record? ? create_record : update_record
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
result != false
|
|
228
|
+
rescue Airtable::ORM::ApiError
|
|
229
|
+
false
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Save the record, raising an error if it fails.
|
|
233
|
+
# Unlike save, lets ApiError propagate with full error details.
|
|
234
|
+
def save!(validate: true)
|
|
235
|
+
raise Airtable::ORM::RecordInvalid, self if validate && invalid?
|
|
236
|
+
|
|
237
|
+
result = run_callbacks(:save) do
|
|
238
|
+
new_record? ? create_record : update_record
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
raise Airtable::ORM::RecordNotSaved, "Failed to save the record" if result == false
|
|
242
|
+
|
|
243
|
+
true
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Update attributes and save
|
|
247
|
+
def update(attributes)
|
|
248
|
+
assign_attributes(attributes)
|
|
249
|
+
save
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Update attributes and save, raising an error if it fails
|
|
253
|
+
def update!(attributes)
|
|
254
|
+
assign_attributes(attributes)
|
|
255
|
+
save!
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Destroy the record
|
|
259
|
+
def destroy
|
|
260
|
+
raise Airtable::ORM::RecordNotDestroyed, "Cannot destroy a new record" if new_record?
|
|
261
|
+
|
|
262
|
+
run_callbacks :destroy do
|
|
263
|
+
response = client.connection.delete(self.class.api_path(id))
|
|
264
|
+
parsed_response = response.body
|
|
265
|
+
|
|
266
|
+
if response.success?
|
|
267
|
+
@persisted = false
|
|
268
|
+
@destroyed = true
|
|
269
|
+
freeze
|
|
270
|
+
self
|
|
271
|
+
else
|
|
272
|
+
Airtable::ORM::Http::Client.raise_api_error(response.status, parsed_response)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Reload the record from the API
|
|
278
|
+
def reload
|
|
279
|
+
raise Airtable::ORM::RecordNotPersisted, "Cannot reload a new record" if new_record?
|
|
280
|
+
|
|
281
|
+
fresh_record = self.class.find(id)
|
|
282
|
+
|
|
283
|
+
# Copy attributes from fresh record using raw attribute access
|
|
284
|
+
self.class.attribute_types.each_key do |name|
|
|
285
|
+
write_raw_attribute(name.to_sym, fresh_record.read_raw_attribute(name.to_sym))
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
@created_at = fresh_record.created_at
|
|
289
|
+
clear_changes_information
|
|
290
|
+
|
|
291
|
+
self
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Assign persistence metadata (id, created_at, persisted flag)
|
|
295
|
+
def assign_persistence_state(id:, created_at:, persisted:)
|
|
296
|
+
@id = id
|
|
297
|
+
@created_at = created_at
|
|
298
|
+
@persisted = persisted
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Apply response fields from a create, update, or batch operation.
|
|
302
|
+
# Uses changes_applied (not clear_changes_information) so after_save
|
|
303
|
+
# callbacks can inspect previous_changes / saved_change_to_*? methods.
|
|
304
|
+
def apply_response_fields(data)
|
|
305
|
+
@previously_new_record = new_record?
|
|
306
|
+
|
|
307
|
+
data = data.with_indifferent_access
|
|
308
|
+
symbol_attrs = self.class.fields_to_symbol_attributes(data[:fields])
|
|
309
|
+
symbol_attrs.each { |key, value| write_raw_attribute(key, value) }
|
|
310
|
+
|
|
311
|
+
assign_persistence_state(
|
|
312
|
+
id: data[:id],
|
|
313
|
+
created_at: data[:createdTime] ? Time.iso8601(data[:createdTime].to_s) : nil,
|
|
314
|
+
persisted: true
|
|
315
|
+
)
|
|
316
|
+
changes_applied
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def client
|
|
320
|
+
self.class.client
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Handle API response from create/update operations
|
|
324
|
+
def handle_persistence_response(response)
|
|
325
|
+
parsed = response.body
|
|
326
|
+
|
|
327
|
+
if response.success?
|
|
328
|
+
apply_response_fields(parsed)
|
|
329
|
+
else
|
|
330
|
+
Airtable::ORM::Http::Client.raise_api_error(response.status, parsed)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Create a new record via API
|
|
335
|
+
def create_record
|
|
336
|
+
run_callbacks :create do
|
|
337
|
+
body = { fields: fields_for_create }
|
|
338
|
+
response = client.connection.post(self.class.api_path, body)
|
|
339
|
+
handle_persistence_response(response)
|
|
340
|
+
true
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Update only changed fields via PATCH. Fields changed TO nil are sent
|
|
345
|
+
# (to clear them in Airtable); unchanged nil fields are not sent.
|
|
346
|
+
def update_record
|
|
347
|
+
unless changed?
|
|
348
|
+
clear_changes_information
|
|
349
|
+
return true
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
run_callbacks :update do
|
|
353
|
+
body = { fields: fields_for_update }
|
|
354
|
+
response = client.connection.patch(self.class.api_path(id), body)
|
|
355
|
+
handle_persistence_response(response)
|
|
356
|
+
true
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|