diff --git a/README.md b/README.md index ac80da3..13e4876 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,16 @@ 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 + +# 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 1934561..694d225 100644 --- a/lib/airctiverecord/base.rb +++ b/lib/airctiverecord/base.rb @@ -28,6 +28,20 @@ def relation_class end def relation_class_name = "#{name}::Relation" + + def create(attributes = {}, &block) + record = new(attributes) + yield(record) if block_given? + record.save + record + end + + def create!(attributes = {}, &block) + record = new(attributes) + yield(record) if block_given? + record.save! + record + end end def initialize(attributes = {}, **kwargs) diff --git a/lib/airctiverecord/scoping.rb b/lib/airctiverecord/scoping.rb index ae7634b..a053f2a 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, &block) + find_by(conditions) || create(conditions, &block) + end + + def find_or_create_by!(conditions, &block) + find_by(conditions) || create!(conditions, &block) + end + def first(limit = nil) = all.first(limit) def last(limit = nil) = all.last(limit) 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 diff --git a/spec/find_or_create_by_spec.rb b/spec/find_or_create_by_spec.rb new file mode 100644 index 0000000..3032e07 --- /dev/null +++ b/spec/find_or_create_by_spec.rb @@ -0,0 +1,239 @@ +# 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 + + 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.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(result.id).to eq("rec123") + end + end + + context "when record does not exist" do + it "creates 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: "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 + end + + it "returns the created record even if invalid" do + 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 + # 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") + + # 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 + + 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 + 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(result.id).to eq("rec456") + end + end + + context "when record does not exist" do + it "creates 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: "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 + end + + it "raises RecordInvalid if validation fails" do + 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 + # 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") + + # 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 + + 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