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
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
|