From afb4d5952dd14211fc78e7b5e5e8cec970fa41d1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 07:36:33 +0000 Subject: [PATCH 1/6] Initial plan From c9c3b0bf5e4d5e45e3fcf12c03c82c1ad5656793 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 07:48:19 +0000 Subject: [PATCH 2/6] Add find_or_create_by and find_or_create_by! methods Co-authored-by: 24c02 <163450896+24c02@users.noreply.github.com> --- README.md | 4 + lib/airctiverecord/scoping.rb | 8 ++ spec/find_or_create_by_spec.rb | 179 +++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 spec/find_or_create_by_spec.rb diff --git a/README.md b/README.md index ac80da3..3c3e903 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,10 @@ user = User.first user = User.find_by(email: "alice@example.com") user = User.find_by!(email: "alice@example.com") # raises if not found +# find or create +user = User.find_or_create_by(email: "alice@example.com") # finds or creates +user = User.find_or_create_by!(email: "alice@example.com") # raises on validation error + # update user.update(first_name: "Alicia") user.first_name = "Alicia" diff --git a/lib/airctiverecord/scoping.rb b/lib/airctiverecord/scoping.rb index ae7634b..e9fb9f0 100644 --- a/lib/airctiverecord/scoping.rb +++ b/lib/airctiverecord/scoping.rb @@ -36,6 +36,14 @@ def find_by(conditions) = all.find_by(conditions) def find_by!(conditions) = all.find_by!(conditions) + def find_or_create_by(conditions) + find_by(conditions) || create(conditions) + end + + def find_or_create_by!(conditions) + find_by(conditions) || create!(conditions) + end + def first(limit = nil) = all.first(limit) def last(limit = nil) = all.last(limit) diff --git a/spec/find_or_create_by_spec.rb b/spec/find_or_create_by_spec.rb new file mode 100644 index 0000000..772c539 --- /dev/null +++ b/spec/find_or_create_by_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "find_or_create_by" do + let(:model_class) do + build_test_model("TestUser") do + self.base_key = "appTest123" + self.table_name = "Users" + + field :email, "Email Address" + field :name, "Name" + field :age, "Age" + + validates :email, presence: true + validates :age, numericality: { greater_than: 0 }, allow_nil: true + + # Track created records + @created_records = [] + + def self.create(attributes) + record = new(attributes) + if record.valid? + record.instance_variable_set(:@id, "rec#{rand(100000)}") + record.instance_variable_set(:@new_record, false) + @created_records << record + record + else + record + end + end + + def self.create!(attributes) + record = new(attributes) + if record.valid? + record.instance_variable_set(:@id, "rec#{rand(100000)}") + record.instance_variable_set(:@new_record, false) + @created_records << record + record + else + raise AirctiveRecord::RecordInvalid, record.errors.full_messages.join(", ") + end + end + + def self.created_records + @created_records + end + + def self.reset_created_records + @created_records = [] + end + + def self.records(**params) + @last_params = params + @stub_records || [] + end + + class << self + attr_reader :last_params + attr_accessor :stub_records + end + end + end + + before do + model_class.reset_created_records + model_class.stub_records = [] + end + + describe "#find_or_create_by" do + context "when record exists" do + it "returns the existing record" do + existing_record = model_class.new(email: "test@example.com", name: "Alice") + existing_record.instance_variable_set(:@id, "rec123") + existing_record.instance_variable_set(:@new_record, false) + + model_class.stub_records = [existing_record] + + result = model_class.find_or_create_by(email: "test@example.com") + + expect(result).to eq(existing_record) + expect(model_class.created_records).to be_empty + end + end + + context "when record does not exist" do + it "creates a new record" do + model_class.stub_records = [] + + result = model_class.find_or_create_by(email: "new@example.com", name: "Bob") + + expect(result).to be_a(model_class) + expect(result.email).to eq("new@example.com") + expect(result.name).to eq("Bob") + expect(result.id).to be_present + expect(model_class.created_records.size).to eq(1) + end + + it "returns the created record even if invalid" do + model_class.stub_records = [] + + result = model_class.find_or_create_by(email: nil, name: "Invalid") + + expect(result).to be_a(model_class) + expect(result.valid?).to be false + expect(result.new_record?).to be true + end + end + + context "with field mappings" do + it "uses field mappings in both find and create" do + model_class.stub_records = [] + + result = model_class.find_or_create_by(email: "mapped@example.com", name: "Charlie") + + # Check that the find_by was called with conditions + expect(model_class.last_params[:filter]).to include("{Email Address} = 'mapped@example.com'") + + # Check record was created with mapped fields + expect(result.email).to eq("mapped@example.com") + expect(result.name).to eq("Charlie") + end + end + end + + describe "#find_or_create_by!" do + context "when record exists" do + it "returns the existing record" do + existing_record = model_class.new(email: "test@example.com", name: "Alice") + existing_record.instance_variable_set(:@id, "rec456") + existing_record.instance_variable_set(:@new_record, false) + + model_class.stub_records = [existing_record] + + result = model_class.find_or_create_by!(email: "test@example.com") + + expect(result).to eq(existing_record) + expect(model_class.created_records).to be_empty + end + end + + context "when record does not exist" do + it "creates a new record" do + model_class.stub_records = [] + + result = model_class.find_or_create_by!(email: "new@example.com", name: "Bob") + + expect(result).to be_a(model_class) + expect(result.email).to eq("new@example.com") + expect(result.name).to eq("Bob") + expect(result.id).to be_present + expect(model_class.created_records.size).to eq(1) + end + + it "raises RecordInvalid if validation fails" do + model_class.stub_records = [] + + expect do + model_class.find_or_create_by!(email: nil, name: "Invalid") + end.to raise_error(AirctiveRecord::RecordInvalid, /can't be blank/) + end + end + + context "with field mappings" do + it "uses field mappings in both find and create" do + model_class.stub_records = [] + + result = model_class.find_or_create_by!(email: "mapped2@example.com", name: "Diana") + + # Check that the find_by was called with conditions + expect(model_class.last_params[:filter]).to include("{Email Address} = 'mapped2@example.com'") + + # Check record was created with mapped fields + expect(result.email).to eq("mapped2@example.com") + expect(result.name).to eq("Diana") + end + end + end +end From 5712585b0477944d12687ed944632c5a9d6013cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 07:53:18 +0000 Subject: [PATCH 3/6] Add create and create! methods, refactor tests Co-authored-by: 24c02 <163450896+24c02@users.noreply.github.com> --- lib/airctiverecord/base.rb | 11 +++++ spec/find_or_create_by_spec.rb | 74 ++++++++++++---------------------- 2 files changed, 37 insertions(+), 48 deletions(-) diff --git a/lib/airctiverecord/base.rb b/lib/airctiverecord/base.rb index 1934561..c2dd35d 100644 --- a/lib/airctiverecord/base.rb +++ b/lib/airctiverecord/base.rb @@ -28,6 +28,17 @@ def relation_class end def relation_class_name = "#{name}::Relation" + + def create(attributes = {}) + record = new(attributes) + record.save ? record : record + end + + def create!(attributes = {}) + record = new(attributes) + record.save! + record + end end def initialize(attributes = {}, **kwargs) diff --git a/spec/find_or_create_by_spec.rb b/spec/find_or_create_by_spec.rb index 772c539..1f9819f 100644 --- a/spec/find_or_create_by_spec.rb +++ b/spec/find_or_create_by_spec.rb @@ -15,41 +15,6 @@ validates :email, presence: true validates :age, numericality: { greater_than: 0 }, allow_nil: true - # Track created records - @created_records = [] - - def self.create(attributes) - record = new(attributes) - if record.valid? - record.instance_variable_set(:@id, "rec#{rand(100000)}") - record.instance_variable_set(:@new_record, false) - @created_records << record - record - else - record - end - end - - def self.create!(attributes) - record = new(attributes) - if record.valid? - record.instance_variable_set(:@id, "rec#{rand(100000)}") - record.instance_variable_set(:@new_record, false) - @created_records << record - record - else - raise AirctiveRecord::RecordInvalid, record.errors.full_messages.join(", ") - end - end - - def self.created_records - @created_records - end - - def self.reset_created_records - @created_records = [] - end - def self.records(**params) @last_params = params @stub_records || [] @@ -63,7 +28,6 @@ class << self end before do - model_class.reset_created_records model_class.stub_records = [] end @@ -79,13 +43,18 @@ class << self result = model_class.find_or_create_by(email: "test@example.com") expect(result).to eq(existing_record) - expect(model_class.created_records).to be_empty + expect(result.id).to eq("rec123") end end context "when record does not exist" do it "creates a new record" do - model_class.stub_records = [] + # Mock save to return true and set id + allow_any_instance_of(model_class).to receive(:save) do |record| + record.instance_variable_set(:@id, "rec#{rand(100000)}") + record.instance_variable_set(:@new_record, false) + true + end result = model_class.find_or_create_by(email: "new@example.com", name: "Bob") @@ -93,12 +62,9 @@ class << self expect(result.email).to eq("new@example.com") expect(result.name).to eq("Bob") expect(result.id).to be_present - expect(model_class.created_records.size).to eq(1) end it "returns the created record even if invalid" do - model_class.stub_records = [] - result = model_class.find_or_create_by(email: nil, name: "Invalid") expect(result).to be_a(model_class) @@ -109,7 +75,12 @@ class << self context "with field mappings" do it "uses field mappings in both find and create" do - model_class.stub_records = [] + # Mock save to return true and set id + allow_any_instance_of(model_class).to receive(:save) do |record| + record.instance_variable_set(:@id, "rec#{rand(100000)}") + record.instance_variable_set(:@new_record, false) + true + end result = model_class.find_or_create_by(email: "mapped@example.com", name: "Charlie") @@ -135,13 +106,18 @@ class << self result = model_class.find_or_create_by!(email: "test@example.com") expect(result).to eq(existing_record) - expect(model_class.created_records).to be_empty + expect(result.id).to eq("rec456") end end context "when record does not exist" do it "creates a new record" do - model_class.stub_records = [] + # Mock save! to return true and set id + allow_any_instance_of(model_class).to receive(:save!) do |record| + record.instance_variable_set(:@id, "rec#{rand(100000)}") + record.instance_variable_set(:@new_record, false) + true + end result = model_class.find_or_create_by!(email: "new@example.com", name: "Bob") @@ -149,12 +125,9 @@ class << self expect(result.email).to eq("new@example.com") expect(result.name).to eq("Bob") expect(result.id).to be_present - expect(model_class.created_records.size).to eq(1) end it "raises RecordInvalid if validation fails" do - model_class.stub_records = [] - expect do model_class.find_or_create_by!(email: nil, name: "Invalid") end.to raise_error(AirctiveRecord::RecordInvalid, /can't be blank/) @@ -163,7 +136,12 @@ class << self context "with field mappings" do it "uses field mappings in both find and create" do - model_class.stub_records = [] + # Mock save! to return true and set id + allow_any_instance_of(model_class).to receive(:save!) do |record| + record.instance_variable_set(:@id, "rec#{rand(100000)}") + record.instance_variable_set(:@new_record, false) + true + end result = model_class.find_or_create_by!(email: "mapped2@example.com", name: "Diana") From 2cfef67a85ca9320f1385ac207c72ba14d14c893 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 07:54:22 +0000 Subject: [PATCH 4/6] Simplify create method to remove redundant ternary Co-authored-by: 24c02 <163450896+24c02@users.noreply.github.com> --- lib/airctiverecord/base.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/airctiverecord/base.rb b/lib/airctiverecord/base.rb index c2dd35d..52319d2 100644 --- a/lib/airctiverecord/base.rb +++ b/lib/airctiverecord/base.rb @@ -31,7 +31,8 @@ def relation_class_name = "#{name}::Relation" def create(attributes = {}) record = new(attributes) - record.save ? record : record + record.save + record end def create!(attributes = {}) From 68918bd6dc23889e78d44da247d2568dd5566ed1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 13:23:25 +0000 Subject: [PATCH 5/6] Add block yielding support to find_or_create_by methods Co-authored-by: 24c02 <163450896+24c02@users.noreply.github.com> --- README.md | 6 +++ lib/airctiverecord/base.rb | 6 ++- lib/airctiverecord/scoping.rb | 8 ++-- spec/find_or_create_by_spec.rb | 82 ++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3c3e903..13e4876 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,12 @@ user = User.find_by!(email: "alice@example.com") # raises if not found user = User.find_or_create_by(email: "alice@example.com") # finds or creates user = User.find_or_create_by!(email: "alice@example.com") # raises on validation error +# find or create with a block (block only runs if creating) +user = User.find_or_create_by(email: "alice@example.com") do |u| + u.first_name = "Alice" + u.last_name = "Smith" +end + # update user.update(first_name: "Alicia") user.first_name = "Alicia" diff --git a/lib/airctiverecord/base.rb b/lib/airctiverecord/base.rb index 52319d2..694d225 100644 --- a/lib/airctiverecord/base.rb +++ b/lib/airctiverecord/base.rb @@ -29,14 +29,16 @@ def relation_class def relation_class_name = "#{name}::Relation" - def create(attributes = {}) + def create(attributes = {}, &block) record = new(attributes) + yield(record) if block_given? record.save record end - def create!(attributes = {}) + def create!(attributes = {}, &block) record = new(attributes) + yield(record) if block_given? record.save! record end diff --git a/lib/airctiverecord/scoping.rb b/lib/airctiverecord/scoping.rb index e9fb9f0..a053f2a 100644 --- a/lib/airctiverecord/scoping.rb +++ b/lib/airctiverecord/scoping.rb @@ -36,12 +36,12 @@ def find_by(conditions) = all.find_by(conditions) def find_by!(conditions) = all.find_by!(conditions) - def find_or_create_by(conditions) - find_by(conditions) || create(conditions) + def find_or_create_by(conditions, &block) + find_by(conditions) || create(conditions, &block) end - def find_or_create_by!(conditions) - find_by(conditions) || create!(conditions) + def find_or_create_by!(conditions, &block) + find_by(conditions) || create!(conditions, &block) end def first(limit = nil) = all.first(limit) diff --git a/spec/find_or_create_by_spec.rb b/spec/find_or_create_by_spec.rb index 1f9819f..3032e07 100644 --- a/spec/find_or_create_by_spec.rb +++ b/spec/find_or_create_by_spec.rb @@ -92,6 +92,43 @@ class << self expect(result.name).to eq("Charlie") end end + + context "with a block" do + it "yields to the block when creating a new record" do + # Mock save to return true and set id + allow_any_instance_of(model_class).to receive(:save) do |record| + record.instance_variable_set(:@id, "rec#{rand(100000)}") + record.instance_variable_set(:@new_record, false) + true + end + + result = model_class.find_or_create_by(email: "block@example.com") do |user| + user.name = "Block User" + user.age = 25 + end + + expect(result.email).to eq("block@example.com") + expect(result.name).to eq("Block User") + expect(result.age).to eq(25) + end + + it "does not yield when record exists" do + existing_record = model_class.new(email: "exists@example.com", name: "Existing") + existing_record.instance_variable_set(:@id, "rec789") + existing_record.instance_variable_set(:@new_record, false) + + model_class.stub_records = [existing_record] + + block_called = false + result = model_class.find_or_create_by(email: "exists@example.com") do |user| + block_called = true + user.name = "Should Not Change" + end + + expect(block_called).to be false + expect(result.name).to eq("Existing") + end + end end describe "#find_or_create_by!" do @@ -153,5 +190,50 @@ class << self expect(result.name).to eq("Diana") end end + + context "with a block" do + it "yields to the block when creating a new record" do + # Mock save! to return true and set id + allow_any_instance_of(model_class).to receive(:save!) do |record| + record.instance_variable_set(:@id, "rec#{rand(100000)}") + record.instance_variable_set(:@new_record, false) + true + end + + result = model_class.find_or_create_by!(email: "block@example.com") do |user| + user.name = "Block User" + user.age = 30 + end + + expect(result.email).to eq("block@example.com") + expect(result.name).to eq("Block User") + expect(result.age).to eq(30) + end + + it "does not yield when record exists" do + existing_record = model_class.new(email: "exists@example.com", name: "Existing") + existing_record.instance_variable_set(:@id, "rec999") + existing_record.instance_variable_set(:@new_record, false) + + model_class.stub_records = [existing_record] + + block_called = false + result = model_class.find_or_create_by!(email: "exists@example.com") do |user| + block_called = true + user.name = "Should Not Change" + end + + expect(block_called).to be false + expect(result.name).to eq("Existing") + end + + it "raises RecordInvalid with block attributes if validation fails" do + expect do + model_class.find_or_create_by!(email: "invalid@example.com") do |user| + user.email = nil # Make it invalid + end + end.to raise_error(AirctiveRecord::RecordInvalid, /can't be blank/) + end + end end end From f0978509042ef110731fd6a5f974f54f758a47c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 9 Dec 2025 13:24:08 +0000 Subject: [PATCH 6/6] Bump version to 0.3.0 Co-authored-by: 24c02 <163450896+24c02@users.noreply.github.com> --- lib/airctiverecord/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/airctiverecord/version.rb b/lib/airctiverecord/version.rb index 8800d5f..34c6b29 100644 --- a/lib/airctiverecord/version.rb +++ b/lib/airctiverecord/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module AirctiveRecord - VERSION = "0.2.1" + VERSION = "0.3.0" end