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