airctiverecord 0.2.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/.idea/airctiverecord.iml +136 -0
- data/.idea/modules.xml +8 -0
- data/.idea/vcs.xml +6 -0
- data/.idea/workspace.xml +81 -0
- data/README.md +341 -0
- data/Rakefile +12 -0
- data/examples/associations_example.rb +88 -0
- data/examples/basic_usage.rb +91 -0
- data/examples/boolean_fields.rb +52 -0
- data/examples/chainable_queries.rb +60 -0
- data/examples/field_mapping_example.rb +101 -0
- data/examples/readonly_fields.rb +52 -0
- data/examples/scope_isolation.rb +67 -0
- data/lib/airctiverecord/associations.rb +48 -0
- data/lib/airctiverecord/attribute_methods.rb +98 -0
- data/lib/airctiverecord/base.rb +136 -0
- data/lib/airctiverecord/callbacks.rb +20 -0
- data/lib/airctiverecord/relation.rb +54 -0
- data/lib/airctiverecord/scoping.rb +68 -0
- data/lib/airctiverecord/validations.rb +24 -0
- data/lib/airctiverecord/version.rb +5 -0
- data/lib/airctiverecord.rb +20 -0
- data/sig/airctiverecord.rbs +4 -0
- metadata +121 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "airctiverecord"
|
|
6
|
+
|
|
7
|
+
# Configure Airtable API
|
|
8
|
+
Norairrecord.api_key = ENV.fetch("AIRTABLE_API_KEY")
|
|
9
|
+
BASE_KEY = ENV.fetch("AIRTABLE_BASE_KEY")
|
|
10
|
+
|
|
11
|
+
# Define models with associations
|
|
12
|
+
class Team < AirctiveRecord::Base
|
|
13
|
+
self.base_key = BASE_KEY
|
|
14
|
+
self.table_name = "Teams"
|
|
15
|
+
|
|
16
|
+
attribute :name
|
|
17
|
+
attribute :description
|
|
18
|
+
|
|
19
|
+
has_many :users
|
|
20
|
+
has_one :leader, class_name: "User", foreign_key: "Leader"
|
|
21
|
+
|
|
22
|
+
validates :name, presence: true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class User < AirctiveRecord::Base
|
|
26
|
+
self.base_key = BASE_KEY
|
|
27
|
+
self.table_name = "Users"
|
|
28
|
+
|
|
29
|
+
attribute :name
|
|
30
|
+
attribute :email
|
|
31
|
+
|
|
32
|
+
belongs_to :team
|
|
33
|
+
has_many :tasks
|
|
34
|
+
|
|
35
|
+
validates :name, presence: true
|
|
36
|
+
validates :email, presence: true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class Task < AirctiveRecord::Base
|
|
40
|
+
self.base_key = BASE_KEY
|
|
41
|
+
self.table_name = "Tasks"
|
|
42
|
+
|
|
43
|
+
attribute :title
|
|
44
|
+
attribute :status
|
|
45
|
+
attribute :description
|
|
46
|
+
|
|
47
|
+
belongs_to :user
|
|
48
|
+
has_one :team, through: :user
|
|
49
|
+
|
|
50
|
+
validates :title, presence: true
|
|
51
|
+
validates :status, inclusion: { in: %w[pending in_progress completed] }
|
|
52
|
+
|
|
53
|
+
scope :pending, -> { where("{Status} = 'pending'") }
|
|
54
|
+
scope :completed, -> { where("{Status} = 'completed'") }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Example usage (uncomment to run with actual Airtable data):
|
|
58
|
+
|
|
59
|
+
# Find a team and its users
|
|
60
|
+
# team = Team.first
|
|
61
|
+
# puts "Team: #{team.name}"
|
|
62
|
+
# puts "Members:"
|
|
63
|
+
# team.users.each do |user|
|
|
64
|
+
# puts " - #{user.name} (#{user.email})"
|
|
65
|
+
# end
|
|
66
|
+
# puts "Leader: #{team.leader&.name}"
|
|
67
|
+
|
|
68
|
+
# Find a user and their tasks
|
|
69
|
+
# user = User.find_by(email: "alice@example.com")
|
|
70
|
+
# puts "\n#{user.name}'s tasks:"
|
|
71
|
+
# user.tasks.pending.each do |task|
|
|
72
|
+
# puts " - #{task.title} [#{task.status}]"
|
|
73
|
+
# end
|
|
74
|
+
|
|
75
|
+
# Create associations
|
|
76
|
+
# team = Team.first
|
|
77
|
+
# new_user = User.create!(
|
|
78
|
+
# name: "Bob Johnson",
|
|
79
|
+
# email: "bob@example.com",
|
|
80
|
+
# team: team
|
|
81
|
+
# )
|
|
82
|
+
# puts "Created user #{new_user.name} in team #{team.name}"
|
|
83
|
+
|
|
84
|
+
# has_many through example
|
|
85
|
+
# task = Task.first
|
|
86
|
+
# puts "Task '#{task.title}' belongs to team: #{task.team&.name}"
|
|
87
|
+
|
|
88
|
+
puts "Association examples ready (uncomment code to run)"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "airctiverecord"
|
|
6
|
+
|
|
7
|
+
# Configure Airtable API
|
|
8
|
+
Norairrecord.api_key = ENV.fetch("AIRTABLE_API_KEY")
|
|
9
|
+
|
|
10
|
+
# Define a User model
|
|
11
|
+
class User < AirctiveRecord::Base
|
|
12
|
+
self.base_key = ENV.fetch("AIRTABLE_BASE_KEY")
|
|
13
|
+
self.table_name = "Users"
|
|
14
|
+
|
|
15
|
+
# Define attributes
|
|
16
|
+
attribute :name
|
|
17
|
+
attribute :email
|
|
18
|
+
attribute :age
|
|
19
|
+
attribute :role
|
|
20
|
+
|
|
21
|
+
# Validations
|
|
22
|
+
validates :name, presence: true
|
|
23
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
24
|
+
validates :age, numericality: { greater_than: 0, less_than: 150 }, allow_nil: true
|
|
25
|
+
validates :role, inclusion: { in: %w[admin user guest] }, allow_nil: true
|
|
26
|
+
|
|
27
|
+
# Callbacks
|
|
28
|
+
before_save :normalize_email
|
|
29
|
+
after_create :log_creation
|
|
30
|
+
|
|
31
|
+
# Scopes
|
|
32
|
+
scope :admins, -> { where("{Role} = 'admin'") }
|
|
33
|
+
scope :active, -> { where("{Active} = TRUE()") }
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def normalize_email
|
|
38
|
+
self.email = email.downcase.strip if email.present?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def log_creation
|
|
42
|
+
puts "Created user: #{name} (#{email})"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Example: Create a new user
|
|
47
|
+
puts "Creating a new user..."
|
|
48
|
+
user = User.new(
|
|
49
|
+
name: "Alice Smith",
|
|
50
|
+
email: "ALICE@EXAMPLE.COM", # Will be normalized
|
|
51
|
+
age: 30,
|
|
52
|
+
role: "admin"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if user.valid?
|
|
56
|
+
puts "User is valid!"
|
|
57
|
+
# Uncomment to actually save:
|
|
58
|
+
# user.save
|
|
59
|
+
# puts "User saved with ID: #{user.id}"
|
|
60
|
+
else
|
|
61
|
+
puts "Validation errors:"
|
|
62
|
+
user.errors.full_messages.each { |msg| puts " - #{msg}" }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Example: Query users
|
|
66
|
+
puts "\nQuerying users..."
|
|
67
|
+
# Uncomment to run actual queries:
|
|
68
|
+
# all_users = User.all
|
|
69
|
+
# admin_users = User.admins
|
|
70
|
+
# specific_user = User.find_by(email: "alice@example.com")
|
|
71
|
+
|
|
72
|
+
# Example: Update a user
|
|
73
|
+
puts "\nUpdating a user..."
|
|
74
|
+
# Uncomment to run:
|
|
75
|
+
# user = User.first
|
|
76
|
+
# user.update(name: "Alice Johnson")
|
|
77
|
+
|
|
78
|
+
# Example: Dirty tracking
|
|
79
|
+
puts "\nDirty tracking example..."
|
|
80
|
+
user.name = "Alice Johnson"
|
|
81
|
+
puts "Name changed? #{user.name_changed?}"
|
|
82
|
+
puts "Name was: #{user.name_was}"
|
|
83
|
+
puts "Name is now: #{user.name}"
|
|
84
|
+
|
|
85
|
+
# Example: Invalid user
|
|
86
|
+
puts "\nTrying to create invalid user..."
|
|
87
|
+
invalid_user = User.new(email: "not-an-email")
|
|
88
|
+
puts "Valid? #{invalid_user.valid?}"
|
|
89
|
+
puts "Errors: #{invalid_user.errors.full_messages.join(', ')}"
|
|
90
|
+
|
|
91
|
+
puts "\nDone!"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "airctiverecord"
|
|
6
|
+
|
|
7
|
+
class User < AirctiveRecord::Base
|
|
8
|
+
self.base_key = "appTest123"
|
|
9
|
+
self.table_name = "Users"
|
|
10
|
+
|
|
11
|
+
field :name, "Name"
|
|
12
|
+
field :email, "Email"
|
|
13
|
+
field :active, "Active", type: :boolean
|
|
14
|
+
field :verified, "Verified", type: :boolean
|
|
15
|
+
field :admin, "Admin", type: :boolean
|
|
16
|
+
|
|
17
|
+
scope :active, -> { where(active: true) }
|
|
18
|
+
scope :verified, -> { where(verified: true) }
|
|
19
|
+
scope :admins, -> { where(admin: true) }
|
|
20
|
+
|
|
21
|
+
def self.records(**params)
|
|
22
|
+
puts "records(#{params.inspect})"
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
puts "=== boolean field with type: :boolean ==="
|
|
28
|
+
user = User.new(
|
|
29
|
+
name: "Alice",
|
|
30
|
+
active: true,
|
|
31
|
+
verified: false,
|
|
32
|
+
admin: nil
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
puts "active: #{user.active.inspect}"
|
|
36
|
+
puts "active?: #{user.active?}"
|
|
37
|
+
|
|
38
|
+
puts "\nverified: #{user.verified.inspect}"
|
|
39
|
+
puts "verified?: #{user.verified?}"
|
|
40
|
+
|
|
41
|
+
puts "\nadmin (nil): #{user.admin.inspect}"
|
|
42
|
+
puts "admin?: #{user.admin?} (falsy)"
|
|
43
|
+
|
|
44
|
+
puts "\n=== regular field (no type specified) ==="
|
|
45
|
+
puts "name: #{user.name.inspect}"
|
|
46
|
+
puts "name?: #{user.name?} (presence check)"
|
|
47
|
+
|
|
48
|
+
puts "\n=== scopes with booleans ==="
|
|
49
|
+
User.active.to_a
|
|
50
|
+
puts
|
|
51
|
+
|
|
52
|
+
User.active.verified.admins.to_a
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "airctiverecord"
|
|
6
|
+
|
|
7
|
+
class User < AirctiveRecord::Base
|
|
8
|
+
self.base_key = "appTest123"
|
|
9
|
+
self.table_name = "Users"
|
|
10
|
+
|
|
11
|
+
field :first_name, "First Name"
|
|
12
|
+
field :email, "Email Address"
|
|
13
|
+
field :role, "Role"
|
|
14
|
+
field :active, "Active"
|
|
15
|
+
field :age, "Age"
|
|
16
|
+
|
|
17
|
+
scope :active, -> { where(active: true) }
|
|
18
|
+
scope :admins, -> { where(role: "admin") }
|
|
19
|
+
scope :adults, -> { where("AND({Age} >= 18, {Age} < 65)") }
|
|
20
|
+
scope :recent, -> { order(created_at: :desc).limit(10) }
|
|
21
|
+
|
|
22
|
+
def self.records(**params)
|
|
23
|
+
puts "Would call Airtable API with:"
|
|
24
|
+
puts params.inspect
|
|
25
|
+
puts
|
|
26
|
+
[]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
puts "=== chainable queries ==="
|
|
31
|
+
User.where(role: "admin").where(active: true).order(first_name: :asc).limit(10).to_a
|
|
32
|
+
puts
|
|
33
|
+
|
|
34
|
+
puts "=== scopes chain! ==="
|
|
35
|
+
User.active.admins.recent.to_a
|
|
36
|
+
puts
|
|
37
|
+
|
|
38
|
+
puts "=== hash queries with field mappings ==="
|
|
39
|
+
User.where(first_name: "Alice", email: "alice@example.com").to_a
|
|
40
|
+
puts
|
|
41
|
+
|
|
42
|
+
puts "=== range queries ==="
|
|
43
|
+
User.where(age: 18..65).to_a
|
|
44
|
+
puts
|
|
45
|
+
|
|
46
|
+
puts "=== IN queries ==="
|
|
47
|
+
User.where(role: ["admin", "moderator", "guest"]).to_a
|
|
48
|
+
puts
|
|
49
|
+
|
|
50
|
+
puts "=== raw formulas still work ==="
|
|
51
|
+
User.where("{Age} > 18").where(active: true).to_a
|
|
52
|
+
puts
|
|
53
|
+
|
|
54
|
+
puts "=== lazy loading ==="
|
|
55
|
+
query = User.where(role: "admin")
|
|
56
|
+
puts "Query built (no API call yet)"
|
|
57
|
+
puts "Calling to_airtable:"
|
|
58
|
+
puts query.to_airtable
|
|
59
|
+
puts "\nNow iterating:"
|
|
60
|
+
query.each { |u| puts u }
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "airctiverecord"
|
|
6
|
+
|
|
7
|
+
# Configure Airtable API
|
|
8
|
+
Norairrecord.api_key = ENV.fetch("AIRTABLE_API_KEY", "test_key")
|
|
9
|
+
|
|
10
|
+
# Example: Mapping Airtable fields with spaces to Ruby-friendly names
|
|
11
|
+
class Contact < AirctiveRecord::Base
|
|
12
|
+
self.base_key = ENV.fetch("AIRTABLE_BASE_KEY", "appTest123")
|
|
13
|
+
self.table_name = "Contacts"
|
|
14
|
+
|
|
15
|
+
# Map Ruby attribute names to Airtable field names with spaces
|
|
16
|
+
field :first_name, "First Name"
|
|
17
|
+
field :last_name, "Last Name"
|
|
18
|
+
field :email_address, "Email Address"
|
|
19
|
+
field :phone_number, "Phone Number (Mobile)"
|
|
20
|
+
field :company_name, "Company Name"
|
|
21
|
+
field :job_title, "Job Title"
|
|
22
|
+
field :linkedin_url, "LinkedIn URL"
|
|
23
|
+
field :date_added, "Date Added"
|
|
24
|
+
field :is_vip, "VIP?"
|
|
25
|
+
|
|
26
|
+
# Validations using Ruby attribute names
|
|
27
|
+
validates :first_name, :last_name, presence: true
|
|
28
|
+
validates :email_address, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
|
|
29
|
+
validates :phone_number, length: { minimum: 10 }, allow_blank: true
|
|
30
|
+
|
|
31
|
+
# Callbacks
|
|
32
|
+
before_save :normalize_email
|
|
33
|
+
|
|
34
|
+
# Scopes
|
|
35
|
+
scope :vip, -> { where("{VIP?} = TRUE()") }
|
|
36
|
+
scope :with_email, -> { where("{Email Address} != ''") }
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def normalize_email
|
|
41
|
+
self.email_address = email_address&.downcase&.strip
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Example usage
|
|
46
|
+
puts "Creating a contact with field mappings..."
|
|
47
|
+
contact = Contact.new(
|
|
48
|
+
first_name: "Jane",
|
|
49
|
+
last_name: "Smith",
|
|
50
|
+
email_address: "JANE.SMITH@EXAMPLE.COM",
|
|
51
|
+
phone_number: "555-123-4567",
|
|
52
|
+
company_name: "Acme Corp",
|
|
53
|
+
job_title: "Senior Engineer",
|
|
54
|
+
is_vip: true
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
puts "\nContact attributes (Ruby names):"
|
|
58
|
+
puts " First Name: #{contact.first_name}"
|
|
59
|
+
puts " Last Name: #{contact.last_name}"
|
|
60
|
+
puts " Email: #{contact.email_address}"
|
|
61
|
+
puts " Phone: #{contact.phone_number}"
|
|
62
|
+
puts " Company: #{contact.company_name}"
|
|
63
|
+
puts " VIP?: #{contact.is_vip}"
|
|
64
|
+
|
|
65
|
+
puts "\nValidation:"
|
|
66
|
+
if contact.valid?
|
|
67
|
+
puts " ✓ Contact is valid"
|
|
68
|
+
else
|
|
69
|
+
puts " ✗ Validation errors:"
|
|
70
|
+
contact.errors.full_messages.each { |msg| puts " - #{msg}" }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
puts "\nDirty tracking with field mappings:"
|
|
74
|
+
contact.first_name = "Janet"
|
|
75
|
+
puts " first_name changed? #{contact.first_name_changed?}"
|
|
76
|
+
puts " first_name was: #{contact.first_name_was}"
|
|
77
|
+
puts " first_name is now: #{contact.first_name}"
|
|
78
|
+
puts " Changes: #{contact.changes.inspect}"
|
|
79
|
+
|
|
80
|
+
puts "\nPresence checking:"
|
|
81
|
+
puts " first_name present? #{contact.first_name?}"
|
|
82
|
+
puts " linkedin_url present? #{contact.linkedin_url?}"
|
|
83
|
+
|
|
84
|
+
puts "\nActual Airtable fields (what gets sent to Airtable):"
|
|
85
|
+
puts " #{contact.fields.inspect}"
|
|
86
|
+
|
|
87
|
+
puts "\nField mappings:"
|
|
88
|
+
puts " #{Contact.field_mappings.inspect}"
|
|
89
|
+
|
|
90
|
+
# Example with invalid data
|
|
91
|
+
puts "\n\nCreating invalid contact..."
|
|
92
|
+
invalid_contact = Contact.new(
|
|
93
|
+
email_address: "not-an-email",
|
|
94
|
+
phone_number: "123" # too short
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
puts "Valid? #{invalid_contact.valid?}"
|
|
98
|
+
puts "Errors:"
|
|
99
|
+
invalid_contact.errors.full_messages.each { |msg| puts " - #{msg}" }
|
|
100
|
+
|
|
101
|
+
puts "\nDone!"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "airctiverecord"
|
|
6
|
+
|
|
7
|
+
class AirpplicationRecord < AirctiveRecord::Base
|
|
8
|
+
self.base_key = "appTest123"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class Contact < AirpplicationRecord
|
|
12
|
+
self.table_name = "Contacts"
|
|
13
|
+
|
|
14
|
+
field :name, "Name"
|
|
15
|
+
field :email, "Email"
|
|
16
|
+
field :company, "Company"
|
|
17
|
+
|
|
18
|
+
# lookup fields (pulled from linked Company record)
|
|
19
|
+
field :company_name, "Company Name (from Company)", readonly: true
|
|
20
|
+
field :company_address, "Company Address (from Company)", readonly: true
|
|
21
|
+
|
|
22
|
+
# formula fields (computed by airtable)
|
|
23
|
+
field :full_name, "Full Name (formula)", readonly: true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
contact = Contact.new(
|
|
27
|
+
name: "Alice",
|
|
28
|
+
email: "alice@example.com",
|
|
29
|
+
company_name: "Acme Corp" # readonly field - silently ignored
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
puts "=== readonly fields are readable ==="
|
|
33
|
+
contact.instance_variable_set(:@fields, {
|
|
34
|
+
"Name" => "Alice",
|
|
35
|
+
"Email" => "alice@example.com",
|
|
36
|
+
"Company Name (from Company)" => "Acme Corp",
|
|
37
|
+
"Full Name (formula)" => "Alice Smith"
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
puts "company_name: #{contact.company_name}"
|
|
41
|
+
puts "full_name: #{contact.full_name}"
|
|
42
|
+
|
|
43
|
+
puts "\n=== trying to set readonly field ==="
|
|
44
|
+
contact.company_name = "Evil Corp"
|
|
45
|
+
puts "company_name after set: #{contact.company_name} (unchanged)"
|
|
46
|
+
|
|
47
|
+
puts "\n=== serializable_fields excludes readonly ==="
|
|
48
|
+
puts "fields: #{contact.fields.keys}"
|
|
49
|
+
puts "serializable_fields: #{contact.serializable_fields.keys}"
|
|
50
|
+
|
|
51
|
+
puts "\n=== readonly_fields list ==="
|
|
52
|
+
puts "Contact.readonly_fields: #{Contact.readonly_fields.to_a.inspect}"
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "airctiverecord"
|
|
6
|
+
|
|
7
|
+
class User < AirctiveRecord::Base
|
|
8
|
+
self.base_key = "appTest123"
|
|
9
|
+
self.table_name = "Users"
|
|
10
|
+
|
|
11
|
+
field :role, "Role"
|
|
12
|
+
field :active, "Active"
|
|
13
|
+
|
|
14
|
+
scope :active, -> { where(active: true) }
|
|
15
|
+
scope :admins, -> { where(role: "admin") }
|
|
16
|
+
|
|
17
|
+
def self.records(**params)
|
|
18
|
+
puts "User.records called with: #{params.inspect}"
|
|
19
|
+
[]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class Post < AirctiveRecord::Base
|
|
24
|
+
self.base_key = "appTest123"
|
|
25
|
+
self.table_name = "Posts"
|
|
26
|
+
|
|
27
|
+
field :status, "Status"
|
|
28
|
+
field :featured, "Featured"
|
|
29
|
+
|
|
30
|
+
scope :published, -> { where(status: "published") }
|
|
31
|
+
scope :featured, -> { where(featured: true) }
|
|
32
|
+
|
|
33
|
+
def self.records(**params)
|
|
34
|
+
puts "Post.records called with: #{params.inspect}"
|
|
35
|
+
[]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
puts "=== User has its own scopes ==="
|
|
40
|
+
puts "User relation class: #{User.relation_class.object_id}"
|
|
41
|
+
puts "User scopes: #{User.relation_class.instance_methods(false).sort}"
|
|
42
|
+
puts
|
|
43
|
+
|
|
44
|
+
puts "=== Post has its own scopes ==="
|
|
45
|
+
puts "Post relation class: #{Post.relation_class.object_id}"
|
|
46
|
+
puts "Post scopes: #{Post.relation_class.instance_methods(false).sort}"
|
|
47
|
+
puts
|
|
48
|
+
|
|
49
|
+
puts "=== Different relation classes ==="
|
|
50
|
+
puts "Same class? #{User.relation_class == Post.relation_class}"
|
|
51
|
+
puts
|
|
52
|
+
|
|
53
|
+
puts "=== User.active.admins ==="
|
|
54
|
+
User.active.admins.to_a
|
|
55
|
+
puts
|
|
56
|
+
|
|
57
|
+
puts "=== Post.published.featured ==="
|
|
58
|
+
Post.published.featured.to_a
|
|
59
|
+
puts
|
|
60
|
+
|
|
61
|
+
puts "=== Trying to call Post scope on User (should fail) ==="
|
|
62
|
+
begin
|
|
63
|
+
User.all.published.to_a
|
|
64
|
+
puts "ERROR: Should have raised NoMethodError!"
|
|
65
|
+
rescue NoMethodError => e
|
|
66
|
+
puts "✓ Correctly raised: #{e.message}"
|
|
67
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AirctiveRecord
|
|
4
|
+
module Associations
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
class_methods do
|
|
8
|
+
# just add activerecord-style defaults, norairrecord does the heavy lifting
|
|
9
|
+
def has_many(name, options_arg = nil, **options_kwargs)
|
|
10
|
+
options = options_arg || options_kwargs
|
|
11
|
+
|
|
12
|
+
if options[:through]
|
|
13
|
+
define_has_many_through(name, options)
|
|
14
|
+
else
|
|
15
|
+
column = options[:column] || options[:foreign_key] || "#{name.to_s.singularize}_ids"
|
|
16
|
+
klass = options[:class_name] || name.to_s.classify
|
|
17
|
+
|
|
18
|
+
super(name, { column: column, class: klass }.merge(options))
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def belongs_to(name, **options)
|
|
23
|
+
column = options[:column] || options[:foreign_key] || "#{name}_id"
|
|
24
|
+
klass = options[:class_name] || name.to_s.classify
|
|
25
|
+
|
|
26
|
+
super(name, { column: column, class: klass }.merge(options))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def has_one(name, **options)
|
|
30
|
+
column = options[:column] || options[:foreign_key] || "#{name}_id"
|
|
31
|
+
klass = options[:class_name] || name.to_s.classify
|
|
32
|
+
|
|
33
|
+
super(name, { column: column, class: klass }.merge(options))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def define_has_many_through(name, options)
|
|
39
|
+
through = options[:through]
|
|
40
|
+
source = options[:source] || name.to_s.singularize
|
|
41
|
+
|
|
42
|
+
define_method(name) do
|
|
43
|
+
send(through).flat_map { |record| Array(record.send(source)) }.compact
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AirctiveRecord
|
|
4
|
+
module AttributeMethods
|
|
5
|
+
extend ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
include ActiveModel::AttributeMethods
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class_methods do
|
|
12
|
+
def field_mappings
|
|
13
|
+
@field_mappings ||= {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def readonly_fields
|
|
17
|
+
@readonly_fields ||= Set.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def field(attr_name, airtable_field_name = nil, **options)
|
|
21
|
+
attr_name = attr_name.to_s
|
|
22
|
+
airtable_field_name ||= attr_name
|
|
23
|
+
readonly = options[:readonly] || options[:read_only]
|
|
24
|
+
field_type = options[:type]
|
|
25
|
+
|
|
26
|
+
field_mappings[attr_name] = airtable_field_name
|
|
27
|
+
readonly_fields << airtable_field_name if readonly
|
|
28
|
+
|
|
29
|
+
define_attribute_methods attr_name
|
|
30
|
+
|
|
31
|
+
define_method(attr_name) do
|
|
32
|
+
field_name = self.class.field_mappings[attr_name]
|
|
33
|
+
self[field_name]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if readonly
|
|
37
|
+
# readonly fields silently ignore sets (airtable rejects them anyway)
|
|
38
|
+
define_method("#{attr_name}=") do |value|
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
define_method("#{attr_name}=") do |value|
|
|
43
|
+
field_name = self.class.field_mappings[attr_name]
|
|
44
|
+
return if self[field_name] == value
|
|
45
|
+
|
|
46
|
+
send("#{attr_name}_will_change!") unless self[field_name] == value
|
|
47
|
+
self[field_name] = value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ? method - different behavior for booleans vs other fields
|
|
52
|
+
if field_type == :boolean
|
|
53
|
+
# boolean: returns the actual boolean value
|
|
54
|
+
define_method("#{attr_name}?") do
|
|
55
|
+
field_name = self.class.field_mappings[attr_name]
|
|
56
|
+
!!self[field_name]
|
|
57
|
+
end
|
|
58
|
+
else
|
|
59
|
+
# regular: checks presence
|
|
60
|
+
define_method("#{attr_name}?") do
|
|
61
|
+
field_name = self.class.field_mappings[attr_name]
|
|
62
|
+
value = self[field_name]
|
|
63
|
+
!value.nil? && !(value.respond_to?(:empty?) && value.empty?)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def attributes
|
|
70
|
+
fields
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def attributes=(attrs)
|
|
74
|
+
attrs.each do |key, value|
|
|
75
|
+
if respond_to?("#{key}=")
|
|
76
|
+
send("#{key}=", value)
|
|
77
|
+
else
|
|
78
|
+
self[key.to_s] = value
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def read_attribute(attr_name)
|
|
84
|
+
field_name = self.class.field_mappings[attr_name.to_s] || attr_name.to_s
|
|
85
|
+
self[field_name]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def write_attribute(attr_name, value)
|
|
89
|
+
field_name = self.class.field_mappings[attr_name.to_s] || attr_name.to_s
|
|
90
|
+
self[field_name] = value
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def attribute_present?(attr_name)
|
|
94
|
+
value = read_attribute(attr_name)
|
|
95
|
+
!value.nil? && !(value.respond_to?(:empty?) && value.empty?)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|