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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 22ca62058d684f1b34fbf154b50afb7c8a5c37659b5235fef23614f97642d6c1
4
+ data.tar.gz: 554987924ef0c43b3692adc311cdf72dd367d927e9396f92af7dc4ba1a2f12d5
5
+ SHA512:
6
+ metadata.gz: 52e3f9c34c7f34d6ffa17e2f85ebc97a072917611bac555ee300852c6b20799dd22c448485e69e07ab3d14cf85b88984fb6d52c054893b2a0f24e980293e2bfb
7
+ data.tar.gz: bc2d2694f67595eac95ba645056ff3d2e9778d38dcf072737fd8312a2677eb046dbddbf9414238994f0f9c7f0a9d10c7f343a7f1fb7f97c32279e131b03818bd
data/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-07-10
4
+
5
+ Initial release — the generic Airtable client/ORM extracted from the EUCS Mono application:
6
+
7
+ - `Airtable::ORM::Base` with the attribute DSL, querying (formula building, automatic pagination),
8
+ persistence (incl. `update_many` batching with `BatchResult`), associations with preloading,
9
+ and schema introspection cached via the injected store.
10
+ - Host-agnostic configuration through `Airtable::ORM.configure` (API key, base/table/field IDs,
11
+ timeouts, rate limit, loggers, schema cache — in-process `MemoryCache` by default).
12
+ - Branded error hierarchy (`ApiError`, its deliberate sibling `ConnectionError`, ActiveRecord-style
13
+ record errors) — the underlying HTTP client never leaks to consumers.
14
+ - Client-side rate limiting (5 requests/s per base) and fail-fast timeouts on a persistent
15
+ Faraday connection.
16
+ - Opt-in RSpec test support (`airtable/orm/testing`): Faraday test adapter wiring and
17
+ schema-fixture stubbing.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Roman Sklenar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # Airtable::ORM
2
+
3
+ An ActiveModel-style ORM for the [Airtable API](https://airtable.com/developers/web/api/introduction): CRUD, a query DSL with formula building, associations with preloading, schema introspection, batch updates and client-side rate limiting — behind a branded error hierarchy that never leaks the underlying HTTP client.
4
+
5
+ ```ruby
6
+ class Order < Airtable::ORM::Base
7
+ self.table_name = :order
8
+
9
+ attribute :email, :string
10
+ attribute :state, :string
11
+
12
+ belongs_to :client, class_name: "Client", foreign_key: :client_ids
13
+ end
14
+
15
+ Order.where(formula: "AND({State} = 'Active')").each { |order| puts order.email }
16
+ Order.find_by(email: "jane@example.com")&.update(state: "Closed")
17
+ ```
18
+
19
+ ## Installation
20
+
21
+ ```ruby
22
+ # Gemfile
23
+ gem "airtable-orm"
24
+ ```
25
+
26
+ ## Configuration
27
+
28
+ The gem never reads `Rails.*`, `ENV`, or credentials itself — every host touchpoint is injected:
29
+
30
+ ```ruby
31
+ # e.g. config/initializers/airtable.rb in a Rails app
32
+ Airtable::ORM.configure do |config|
33
+ config.api_key = Rails.application.credentials.dig(:airtable, :api_key)
34
+ config.base_id = "appXXXXXXXXXXXXXX"
35
+ config.tables = {
36
+ order: {
37
+ id: "tblXXXXXXXXXXXXXX",
38
+ fields: { _id: "id", email: "fldXXXXXXXXXXXXXX", state: "fldYYYYYYYYYYYYYY" }
39
+ }
40
+ }
41
+ config.cache = Rails.cache # schema cache; defaults to an in-process MemoryCache
42
+ config.logger = Rails.logger # defaults to a null logger
43
+ end
44
+ ```
45
+
46
+ | Option | Default | Purpose |
47
+ | --- | --- | --- |
48
+ | `api_key` | — (required) | Airtable personal access token |
49
+ | `base_id` | — (required) | The base your models live in |
50
+ | `tables` | `{}` | `table_name => { id:, fields: { attribute => field_id } }` map |
51
+ | `api_url` | `https://api.airtable.com` | API endpoint |
52
+ | `open_timeout` / `read_timeout` | `5` / `10` seconds | Fail fast instead of hanging a worker |
53
+ | `rate_limit` | `5` | Requests/second per base (Airtable throttles for 30 s above 5) |
54
+ | `logger` | null logger | Batch-update failure reporting |
55
+ | `http_logger` | `nil` | Set to a `Logger` to log Faraday requests |
56
+ | `cache` | in-process `MemoryCache` | Schema cache; anything responding to `fetch`/`delete` |
57
+
58
+ `Airtable::ORM.configured?` returns whether an `api_key` is set — handy for gating enqueue hooks so test/CI environments never touch the network.
59
+
60
+ ## Models
61
+
62
+ ```ruby
63
+ class Case < Airtable::ORM::Base
64
+ self.table_name = :case # key into config.tables
65
+
66
+ attribute :label, :string
67
+ attribute :potential, :big_integer
68
+ attribute :tags, :airtable_array # multipleSelects / multipleRecordLinks
69
+ attribute :client_ids, :airtable_array
70
+
71
+ has_many :clients, class_name: "Client", foreign_key: :client_ids
72
+ end
73
+ ```
74
+
75
+ - **Querying:** `all`, `where(formula:, sort:, max_records:)`, `first`, `find`, `find_many`, `find_by`/`find_by!`, `count`. Symbol conditions build (and escape) Airtable formulas; pagination is automatic.
76
+ - **Persistence:** `save`/`save!`, `create`/`create!`, `update`/`update!`, `destroy`, `reload`, plus `Model.update_many(records)` batching 10 per PATCH with a `BatchResult` (`updated`/`skipped`/`failed`).
77
+ - **Associations:** `has_many`/`belongs_to` readers, writers, `add_*`/`remove_*`, memoization and `Collection#preload` for eager loading.
78
+ - **Schema:** table/field metadata from `/v0/meta/bases`, cached via `config.cache`; `Model.field_options(:state)` reads select options.
79
+ - **`#url`** — deeplink to the record in the Airtable UI.
80
+ - **`normalizes`** — available on ActiveModel 8.0+ (the DSL doesn't exist in 7.1).
81
+
82
+ ## Errors
83
+
84
+ Consumers rescue `Airtable::ORM::*` only — the HTTP client (Faraday today) never leaks, so swapping transports is not a breaking change.
85
+
86
+ | Error | Raised when |
87
+ | --- | --- |
88
+ | `Airtable::ORM::ApiError` | Airtable answered with an error (rate limit, 5xx, validation) |
89
+ | `Airtable::ORM::ConnectionError` | The request never completed (timeout, DNS, TLS) — a **sibling** of `ApiError`, so `save`'s `rescue ApiError => false` never swallows an outage |
90
+ | `Airtable::ORM::RecordNotFound` / `RecordInvalid` / `RecordNotSaved` / `RecordNotDestroyed` / `RecordNotPersisted` | ActiveRecord-style persistence failures |
91
+ | `Airtable::ORM::UnknownFieldError` / `InvalidAttributeError` | Mapping problems |
92
+
93
+ All error classes are defined eagerly at require time, so `retry_on Airtable::ORM::ConnectionError` in a class body needs no extra `require`.
94
+
95
+ ## Test support
96
+
97
+ ```ruby
98
+ # spec_helper.rb
99
+ require "airtable/orm/testing"
100
+
101
+ Airtable::ORM::Testing.schema_fixture_path = "spec/fixtures/airtable.schema.json"
102
+
103
+ RSpec.configure do |config|
104
+ config.include Airtable::ORM::Testing::StubHelpers, :airtable
105
+ config.before(:each, :airtable) { stub_airtable_http_client }
106
+ end
107
+ ```
108
+
109
+ `stub_airtable_http_client` routes every request through a Faraday test adapter (returned for stubbing) and serves schema lookups from your fixture — no HTTP leaves the process.
110
+
111
+ ## Development
112
+
113
+ `mise install` (or any Ruby ≥ 3.2), `bin/setup`, then `bundle exec rake` (specs + RuboCop). Release: bump `lib/airtable/orm/version.rb`, update `CHANGELOG.md`, `bundle exec rake release`.
114
+
115
+ ## Contributing
116
+
117
+ Bug reports and pull requests are welcome at <https://github.com/romansklenar/airtable-orm>.
118
+
119
+ ## License
120
+
121
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airtable
4
+ module ORM
5
+ module Associations
6
+ extend ActiveSupport::Concern
7
+
8
+ # Requires Attributes concern. Uses read_raw_attribute/write_raw_attribute to
9
+ # bypass accessors and avoid infinite recursion when association names match attributes.
10
+
11
+ included do
12
+ class_attribute :defined_associations, instance_writer: false, default: {}
13
+ end
14
+
15
+ private
16
+
17
+ # Read linked record IDs from a foreign key, with optional reversal for Airtable UI order.
18
+ # Airtable's API returns record links in reverse order compared to the UI.
19
+ # By default we reverse to match the UI order.
20
+ def read_linked_ids(foreign_key, reverse: true)
21
+ ids = read_raw_attribute(foreign_key) || []
22
+ reverse ? ids.reverse : ids
23
+ end
24
+
25
+ # Set memoized value directly (used by preloading)
26
+ def write_association_cache(name, value)
27
+ instance_variable_set(:"@_memo_#{name}", value)
28
+ end
29
+
30
+ # Memoize association loading with automatic cache management
31
+ def memoize_association(name)
32
+ ivar = :"@_memo_#{name}"
33
+ return instance_variable_get(ivar) if instance_variable_defined?(ivar)
34
+
35
+ instance_variable_set(ivar, yield)
36
+ end
37
+
38
+ # Clear memoization cache for an association
39
+ def clear_association_cache(name)
40
+ ivar = :"@_memo_#{name}"
41
+ remove_instance_variable(ivar) if instance_variable_defined?(ivar)
42
+ end
43
+
44
+ # Extract ID from a record object or return the ID string as-is
45
+ def extract_association_id(record_or_id)
46
+ record_or_id.respond_to?(:id) ? record_or_id.id : record_or_id
47
+ end
48
+
49
+ class_methods do
50
+ # Airtable returns linked IDs in reverse UI order; we reverse on read/write to match.
51
+ def has_many(association_name, class_name:, foreign_key:)
52
+ self.defined_associations = defined_associations.merge(
53
+ association_name => { type: :has_many, class_name: class_name, foreign_key: foreign_key }
54
+ )
55
+
56
+ define_method(association_name) do
57
+ memoize_association(association_name) do
58
+ ids = read_linked_ids(foreign_key)
59
+ klass = class_name.constantize
60
+ ids.empty? ? Airtable::ORM::Collection.new([], model_class: klass) : klass.find_many(ids)
61
+ end
62
+ end
63
+
64
+ define_method("#{association_name}=") do |records|
65
+ ids = Array(records).map { |record| extract_association_id(record) }
66
+ write_raw_attribute(foreign_key, ids.reverse)
67
+ clear_association_cache(association_name)
68
+ end
69
+
70
+ define_method("add_#{association_name.to_s.singularize}") do |record|
71
+ current_ids = read_linked_ids(foreign_key, reverse: false)
72
+ add_id = extract_association_id(record)
73
+ write_raw_attribute(foreign_key, [add_id] + current_ids)
74
+ clear_association_cache(association_name)
75
+ end
76
+
77
+ define_method("remove_#{association_name.to_s.singularize}") do |record|
78
+ current_ids = read_linked_ids(foreign_key, reverse: false)
79
+ remove_id = extract_association_id(record)
80
+
81
+ write_raw_attribute(foreign_key, current_ids - [remove_id])
82
+ clear_association_cache(association_name)
83
+ end
84
+ end
85
+
86
+ def belongs_to(association_name, class_name:, foreign_key:)
87
+ self.defined_associations = defined_associations.merge(
88
+ association_name => { type: :belongs_to, class_name: class_name, foreign_key: foreign_key }
89
+ )
90
+
91
+ define_method(association_name) do
92
+ memoize_association(association_name) do
93
+ id = read_linked_ids(foreign_key).first
94
+ id ? class_name.constantize.find(id) : nil
95
+ end
96
+ end
97
+
98
+ define_method("#{association_name}=") do |record|
99
+ if record.nil?
100
+ write_raw_attribute(foreign_key, [])
101
+ else
102
+ id = extract_association_id(record)
103
+ write_raw_attribute(foreign_key, [id])
104
+ end
105
+
106
+ clear_association_cache(association_name)
107
+ end
108
+ end
109
+
110
+ # In Airtable, linked record fields are stored as ID arrays on both sides,
111
+ # so has_one and belongs_to are functionally identical (unlike ActiveRecord
112
+ # where they differ in which side holds the foreign key).
113
+ alias_method :has_one, :belongs_to
114
+
115
+ # Eager-load associations to avoid N+1 API calls.
116
+ def preload(records, *association_names)
117
+ return if records.empty?
118
+
119
+ association_names.each do |name|
120
+ config = defined_associations[name]
121
+ raise ArgumentError, "Unknown association: #{name.inspect}" unless config
122
+
123
+ klass = config[:class_name].constantize
124
+ fk = config[:foreign_key]
125
+ many = config[:type] == :has_many
126
+
127
+ preload_association(records, name, klass, fk, many: many)
128
+ end
129
+ end
130
+
131
+ def preload_association(records, association_name, klass, foreign_key, many:)
132
+ all_ids = if many
133
+ records.flat_map { |r| r.send(:read_linked_ids, foreign_key) }.uniq
134
+ else
135
+ records.filter_map { |r| r.send(:read_linked_ids, foreign_key).first }.uniq
136
+ end
137
+
138
+ fetched_by_id = all_ids.empty? ? {} : klass.find_many(all_ids).index_by(&:id)
139
+
140
+ records.each do |record|
141
+ ids = record.send(:read_linked_ids, foreign_key)
142
+ value = if many
143
+ Airtable::ORM::Collection.new(ids.filter_map { |id| fetched_by_id[id] }, model_class: klass)
144
+ else
145
+ fetched_by_id[ids.first]
146
+ end
147
+ record.send(:write_association_cache, association_name, value)
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airtable
4
+ module ORM
5
+ module Attributes
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ include ActiveModel::Attributes
10
+ # The `normalizes` DSL ships with ActiveModel 8.0+; on 7.1 models simply don't have it.
11
+ include ActiveModel::Attributes::Normalization if defined?(ActiveModel::Attributes::Normalization)
12
+ include ActiveModel::Dirty
13
+
14
+ # Full symbol → field_id mapping from config/airtable.yml (includes ALL fields).
15
+ # Use field_mapping instead, which filters to only declared attributes.
16
+ class_attribute :config_field_mapping, instance_writer: false, default: {}
17
+ end
18
+
19
+ # Special read-only attributes that are not part of the Airtable fields
20
+ READ_ONLY_ATTRIBUTES = %i[id created_at].freeze
21
+
22
+ class_methods do
23
+ # Set the logical table name (symbol) for this model.
24
+ # This triggers config field mapping from config.
25
+ def table_name=(symbol)
26
+ @table_name = symbol.to_sym
27
+ define_field_mapping
28
+ end
29
+
30
+ def table_name
31
+ @table_name
32
+ end
33
+
34
+ # Filtered mapping: only symbols that have a declared `attribute` in the model.
35
+ # Lazily computed on first access (after class body has been evaluated).
36
+ def field_mapping
37
+ @field_mapping ||= config_field_mapping.select { |symbol, _| attribute_types.key?(symbol.to_s) }
38
+ end
39
+
40
+ # Get the Airtable table ID for this model
41
+ def table_id
42
+ ORM.config.table_id(table_name)
43
+ end
44
+
45
+ # Get schema for this table
46
+ # Memoized at class level to avoid repeated cache lookups
47
+ def schema
48
+ return {} unless table_name
49
+
50
+ @schema ||= Airtable::ORM::Schema.fetch(base_id)[table_id]
51
+ end
52
+
53
+ # Clear the memoized schema cache
54
+ # Useful for testing or when the schema changes
55
+ def clear_schema_cache
56
+ @schema = nil
57
+ @field_mapping = nil
58
+ end
59
+
60
+ # Get field schema by symbol
61
+ def field_schema(symbol)
62
+ field_id = field_mapping[symbol.to_sym]
63
+ return nil unless field_id
64
+
65
+ schema.dig(:fields, field_id)
66
+ end
67
+
68
+ # Extract select options from schema for singleSelect and multipleSelects fields
69
+ # Returns a hash mapping field symbols to their available options
70
+ #
71
+ # @return [Hash{Symbol => Array<String>}] Field symbols mapped to option arrays
72
+ # @example
73
+ # Airtable::Case.schema_options
74
+ # # => { state: ["Open", "Closed"], scope: ["Inquiry", "Claim"], ... }
75
+ #
76
+ # @example Accessing specific field options
77
+ # Airtable::Case.schema_options[:state]
78
+ # # => ["Open", "Closed", "Archived"]
79
+ def schema_options
80
+ return {} unless field_mapping.present?
81
+
82
+ field_mapping.each_with_object({}) do |(symbol, field_id), options|
83
+ field_info = schema.dig(:fields, field_id)
84
+ next unless field_info
85
+
86
+ case field_info[:type]
87
+ when "singleSelect", "multipleSelects"
88
+ choices = field_info.dig(:options, :choices)
89
+ options[symbol] = choices.map { |choice| choice[:name] } if choices
90
+ end
91
+ end
92
+ end
93
+
94
+ # Get options for a specific select field
95
+ # @param field_symbol [Symbol] The field symbol (e.g., :category, :state)
96
+ # @return [Array<String>] Array of available options for the field
97
+ # @example
98
+ # Airtable::Case.field_options(:category)
99
+ # # => ["Personal Injury", "Property", "Business"]
100
+ def field_options(field_symbol)
101
+ schema_options[field_symbol.to_sym] || []
102
+ end
103
+
104
+ private
105
+
106
+ # Build config_field_mapping (symbol → field_id) from config.
107
+ # Stores ALL config fields; field_mapping lazily filters to declared attributes.
108
+ def define_field_mapping
109
+ fields = ORM.config.table_fields(table_name)
110
+ self.config_field_mapping = fields&.except(:_id) || {}
111
+ end
112
+ end
113
+
114
+ # Override [] to use symbol keys only
115
+ def [](key)
116
+ validate_symbol_key!(key)
117
+
118
+ # Handle special read-only attributes
119
+ return read_special_attribute(key) if special_attribute?(key)
120
+
121
+ validate_regular_attribute!(key)
122
+ public_send(key)
123
+ end
124
+
125
+ # Override []= to use symbol keys only
126
+ def []=(key, value)
127
+ validate_symbol_key!(key)
128
+ validate_not_readonly!(key)
129
+ validate_regular_attribute!(key)
130
+
131
+ public_send("#{key}=", value)
132
+ end
133
+
134
+ # @api private — Direct access to @attributes, bypassing accessor methods.
135
+ # Used by Associations to avoid infinite recursion when association name matches the attribute.
136
+ def read_raw_attribute(key)
137
+ return nil unless attribute_defined?(key)
138
+
139
+ @attributes[key.to_s]&.value
140
+ end
141
+
142
+ # @api private — Direct write to @attributes, maintaining Dirty tracking.
143
+ def write_raw_attribute(key, value)
144
+ return unless attribute_defined?(key)
145
+
146
+ @attributes.write_from_user(key.to_s, value)
147
+ end
148
+
149
+ # Get all attributes as a hash with symbol keys.
150
+ # Delegates to ActiveModel::Attributes#attributes which returns the same data with string keys.
151
+ def symbol_attributes
152
+ attributes.symbolize_keys
153
+ end
154
+
155
+ # Non-nil attributes with field IDs as keys (for CREATE).
156
+ def fields_for_create
157
+ map_attributes(key_type: :field_id, exclude_nil: true)
158
+ end
159
+
160
+ # Changed attributes with field IDs as keys (for UPDATE). Includes nil values.
161
+ def fields_for_update
162
+ map_attributes(key_type: :field_id, filter: ->(symbol) { changed.include?(symbol.to_s) })
163
+ end
164
+
165
+ def map_attributes(key_type:, exclude_nil: false, filter: nil)
166
+ return {} unless self.class.field_mapping
167
+
168
+ self.class.field_mapping.each_with_object({}) do |(symbol, field_id), hash|
169
+ next if filter && !filter.call(symbol)
170
+
171
+ value = read_raw_attribute(symbol)
172
+ next if exclude_nil && value.nil?
173
+
174
+ key = key_type == :symbol ? symbol : field_id
175
+ hash[key] = value
176
+ end
177
+ end
178
+
179
+ # Validate that key is a symbol
180
+ def validate_symbol_key!(key)
181
+ return if key.is_a?(Symbol)
182
+
183
+ raise Airtable::ORM::InvalidAttributeError, "Only symbol keys are supported (e.g., record[:email])"
184
+ end
185
+
186
+ # Validate that key is not a read-only attribute
187
+ def validate_not_readonly!(key)
188
+ return unless READ_ONLY_ATTRIBUTES.include?(key)
189
+
190
+ raise Airtable::ORM::InvalidAttributeError, "Cannot set read-only attribute: #{key.inspect}"
191
+ end
192
+
193
+ # Validate that key is a known regular attribute (not special attributes like :id, :created_at)
194
+ def validate_regular_attribute!(key)
195
+ return if attribute_defined?(key)
196
+
197
+ raise Airtable::ORM::UnknownFieldError, "Unknown field symbol: #{key.inspect}"
198
+ end
199
+
200
+ # Check if this is a special read-only attribute
201
+ def special_attribute?(key)
202
+ READ_ONLY_ATTRIBUTES.include?(key)
203
+ end
204
+
205
+ # Read a special read-only attribute value
206
+ def read_special_attribute(key)
207
+ case key
208
+ when :id then @id
209
+ when :created_at then @created_at
210
+ end
211
+ end
212
+
213
+ # Check if attribute is defined (explicitly declared in the model class body)
214
+ def attribute_defined?(symbol)
215
+ self.class.attribute_types.key?(symbol.to_s)
216
+ end
217
+
218
+ # Custom type for Airtable arrays (handles record links and multi-selects)
219
+ class AirtableArrayType < ActiveModel::Type::Value
220
+ def cast(value)
221
+ case value
222
+ when Array then value
223
+ when nil then []
224
+ else [value]
225
+ end
226
+ end
227
+
228
+ def serialize(value)
229
+ cast(value)
230
+ end
231
+ end
232
+
233
+ ActiveModel::Type.register(:airtable_array, AirtableArrayType)
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Load error classes and value objects first
4
+ require_relative "errors"
5
+ require_relative "batch_result"
6
+
7
+ module Airtable
8
+ module ORM
9
+ class Base
10
+ include ActiveModel::Model
11
+ include Core
12
+ include Attributes
13
+ include Persistence
14
+ include Querying
15
+ include Associations
16
+
17
+ # Callback support
18
+ extend ActiveModel::Callbacks
19
+
20
+ class << self
21
+ # Get the base ID from config
22
+ def base_id
23
+ ORM.config.base_id
24
+ end
25
+ end
26
+
27
+ # Public deeplink to this record in the Airtable UI.
28
+ def url
29
+ "https://airtable.com/#{self.class.base_id}/#{self.class.table_id}/#{id}"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airtable
4
+ module ORM
5
+ # Holds the result of a batch update operation.
6
+ #
7
+ # @attr updated [Array] Records that were successfully sent to the API and updated
8
+ # @attr skipped [Array] Records that had no changes and were not sent to the API
9
+ # @attr failed [Array] Records that failed validation or API call
10
+ BatchResult = Struct.new(:updated, :skipped, :failed, keyword_init: true) do
11
+ def initialize(updated: [], skipped: [], failed: [])
12
+ super
13
+ end
14
+
15
+ def none_failed?
16
+ failed.empty?
17
+ end
18
+
19
+ def any_failed?
20
+ failed.any?
21
+ end
22
+
23
+ def total_count
24
+ updated.size + skipped.size + failed.size
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Airtable
4
+ module ORM
5
+ # Thin wrapper around Array that enables chainable preloading.
6
+ #
7
+ # cases = Airtable::Case.all.preload(:clients)
8
+ # active = cases.select { |c| c[:state] == "Active" }.preload(:advisors)
9
+ #
10
+ # Filtering methods (select, reject, sort_by) return new Collections,
11
+ # preserving the ability to chain preload.
12
+ class Collection < DelegateClass(Array)
13
+ attr_reader :model_class
14
+
15
+ def initialize(records, model_class:)
16
+ super(records)
17
+ @model_class = model_class
18
+ end
19
+
20
+ # Eager-load associations for every record in the collection.
21
+ # Returns +self+ so calls can be chained.
22
+ def preload(*names)
23
+ model_class.preload(self, *names)
24
+ self
25
+ end
26
+
27
+ def select(&block)
28
+ return super unless block
29
+
30
+ self.class.new(super, model_class: model_class)
31
+ end
32
+
33
+ def reject(&block)
34
+ return super unless block
35
+
36
+ self.class.new(super, model_class: model_class)
37
+ end
38
+
39
+ def sort_by(&block)
40
+ return super unless block
41
+
42
+ self.class.new(super, model_class: model_class)
43
+ end
44
+ end
45
+ end
46
+ end