Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions db/migrations/20260427115611_make_tokens_next_id_unique.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class MakeTokensNextIdUnique::V20260427115611 < Avram::Migrator::Migration::V1
def migrate
create_index table_for(Token), :next_id, unique: true
end

def rollback
drop_index table_for(Token), :next_id, if_exists: true
end
end
9 changes: 9 additions & 0 deletions db/migrations/20260427120304_make_users_name_unique.cr
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class MakeUsersNameUnique::V20260427120304 < Avram::Migrator::Migration::V1
def migrate
create_index table_for(Users), [:name, :nickname], unique: true
end

def rollback
drop_index table_for(Users), [:name, :nickname], if_exists: true
end
end
39 changes: 37 additions & 2 deletions spec/avram/insert_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,50 @@ describe Avram::Insert do
it "inserts with a hash of String" do
params = {:first_name => "Paul", :last_name => "Smith"}
insert = Avram::Insert.new(table: :users, params: params)
insert.statement.should eq %(insert into users("first_name", "last_name") values($1, $2) returning *)

insert.statement.should eq %(INSERT INTO users ("first_name", "last_name") \
VALUES ($1, $2) RETURNING *)

insert.args.should eq ["Paul", "Smith"]
end

it "inserts with a hash of Nil" do
params = {:first_name => nil}
insert = Avram::Insert.new(table: :users, params: params)
insert.statement.should eq %(insert into users("first_name") values($1) returning *)
insert.statement.should eq %(INSERT INTO users ("first_name") VALUES ($1) RETURNING *)
insert.args.should eq [nil]
end
end

describe "upserting" do
it "updates when keys conflict" do
params = {:first_name => "Paul", :last_name => "Smith"}

insert = Avram::Insert.new(
:users,
params,
conflict_action: :update,
conflict_keys: [:first_name, :last_name],
conflict_params: [:last_name]
)

insert.statement.should eq %(INSERT INTO users ("first_name", "last_name") \
VALUES ($1, $2) ON CONFLICT ("first_name", "last_name") DO UPDATE SET \
"last_name" = EXCLUDED."last_name" RETURNING *)
end

it "does nothing when keys conflict" do
params = {:first_name => "Paul", :last_name => "Smith"}

insert = Avram::Insert.new(
:users,
params,
conflict_action: :nothing,
conflict_keys: [:first_name, :last_name]
)

insert.statement.should eq %(INSERT INTO users ("first_name", "last_name") \
VALUES ($1, $2) ON CONFLICT ("first_name", "last_name") DO NOTHING RETURNING *)
end
end
end
94 changes: 63 additions & 31 deletions spec/avram/operations/save_operation_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,23 @@ private class ParamKeySaveOperation < ValueColumnModel::SaveOperation
end

private class UpsertUserOperation < User::SaveOperation
getter? after_commit_called = false

upsert_lookup_columns :name, :nickname

after_commit do |_user|
@after_commit_called = true
end
end

private class UpsertToken < Token::SaveOperation
getter? after_save_called = false

upsert_lookup_columns :next_id

after_save do |_user|
@after_save_called = true
end
end

private class OverrideDefaults < ModelWithDefaultValues::SaveOperation
Expand Down Expand Up @@ -217,22 +229,23 @@ describe "Avram::SaveOperation" do
describe "upsert upsert_lookup_columns" do
describe ".upsert" do
it "updates the existing record if one exists" do
existing_user = UserFactory.create &.name("Rich").nickname(nil).age(20)
existing_user = UserFactory.create &.name("Rich").nickname("Dolly").age(20)
joined_at = Time.utc.at_beginning_of_second

UpsertUserOperation.upsert(
name: "Rich",
nickname: nil,
nickname: "Dolly",
age: 30,
joined_at: joined_at
) do |operation, user|
operation.created?.should be_false
operation.updated?.should be_true
operation.upserted?.should be_true
operation.saved?.should be_true
operation.after_commit_called?.should be_true
UserQuery.new.select_count.should eq(1)
user = user.as(User)
user.id.should eq(existing_user.id)
user.name.should eq("Rich")
user.nickname.should be_nil
user.nickname.should eq("Dolly")
user.age.should eq(30)
user.joined_at.should eq(joined_at)
end
Expand All @@ -243,15 +256,15 @@ describe "Avram::SaveOperation" do

UpsertToken.upsert(next_id: 4, name: "Secret", scopes: ["red", "blue"]) do |operation, token|
operation.valid?.should eq(true)
operation.after_save_called?.should be_true
token.should_not eq(nil)
token.as(Token).name.should eq("Secret")
token.as(Token).scopes.should eq(["red", "blue"])
end
end

it "creates a new record if match one doesn't exist" do
user_with_different_nickname =
UserFactory.create &.name("Rich").nickname(nil).age(20)
it "creates a new record if one doesn't exist" do
existing_user = UserFactory.create &.name("Rich").nickname("Dolly").age(20)
joined_at = Time.utc.at_beginning_of_second

UpsertUserOperation.upsert(
Expand All @@ -260,15 +273,16 @@ describe "Avram::SaveOperation" do
age: 30,
joined_at: joined_at
) do |operation, user|
operation.created?.should be_true
operation.updated?.should be_false
operation.upserted?.should be_true
operation.saved?.should be_true
operation.after_commit_called?.should be_true
UserQuery.new.select_count.should eq(2)
# Keep existing user the same
user_with_different_nickname.age.should eq(20)
user_with_different_nickname.nickname.should eq(nil)
existing_user.age.should eq(20)
existing_user.nickname.should eq("Dolly")

user = user.as(User)
user.id.should_not eq(user_with_different_nickname.id)
user.id.should_not eq(existing_user.id)
user.name.should eq("Rich")
user.nickname.should eq("R.")
user.age.should eq(30)
Expand All @@ -277,25 +291,35 @@ describe "Avram::SaveOperation" do
end

it "allows updating nilable fields to nil" do
UserFactory.create(&.name("test").total_score(100))
UpsertUserOperation.upsert(name: "test", total_score: nil) do |operation, updated_user|
operation.updated?.should eq(true)
updated_user.should_not eq(nil)
user = updated_user.as(User)
user.name.should eq("test")
user.total_score.should eq(nil)
user = UserFactory.create &.name("test").nickname("sample").total_score(100)

UpsertUserOperation.upsert(
name: user.name,
nickname: user.nickname,
total_score: nil,
age: user.age,
joined_at: user.joined_at
) do |operation, saved_user|
operation.upserted?.should be_true
operation.saved?.should be_true
saved_user.should_not be_nil

saved_user.try do |_user|
_user.name.should eq("test")
_user.total_score.should be_nil
end
end
end
end

describe ".upsert!" do
it "updates the existing record if one exists" do
existing_user = UserFactory.create &.name("Rich").nickname(nil).age(20)
existing_user = UserFactory.create &.name("Rich").nickname("Dolly").age(20)
joined_at = Time.utc.at_beginning_of_second

user = UpsertUserOperation.upsert!(
name: "Rich",
nickname: nil,
nickname: "Dolly",
age: 30,
joined_at: joined_at
)
Expand All @@ -304,13 +328,13 @@ describe "Avram::SaveOperation" do
user = user.as(User)
user.id.should eq(existing_user.id)
user.name.should eq("Rich")
user.nickname.should be_nil
user.nickname.should eq("Dolly")
user.age.should eq(30)
user.joined_at.should eq(joined_at)
end

it "creates a new record if one doesn't exist" do
user_with_different_nickname = UserFactory.create &.name("Rich").nickname(nil).age(20)
existing_user = UserFactory.create &.name("Rich").nickname(nil).age(20)
joined_at = Time.utc.at_beginning_of_second

user = UpsertUserOperation.upsert!(
Expand All @@ -322,22 +346,30 @@ describe "Avram::SaveOperation" do

UserQuery.new.select_count.should eq(2)
# Keep existing user the same
user_with_different_nickname.age.should eq(20)
user_with_different_nickname.nickname.should eq(nil)
existing_user.age.should eq(20)
existing_user.nickname.should eq(nil)

user = user.as(User)
user.id.should_not eq(user_with_different_nickname.id)
user.id.should_not eq(existing_user.id)
user.name.should eq("Rich")
user.nickname.should eq("R.")
user.age.should eq(30)
user.joined_at.should eq(joined_at)
end

it "allows updating nilable fields to nil" do
UserFactory.create(&.name("test").total_score(100))
user = UpsertUserOperation.upsert!(name: "test", total_score: nil)
user.name.should eq("test")
user.total_score.should eq(nil)
user = UserFactory.create &.name("test").nickname("sample").total_score(100)

saved_user = UpsertUserOperation.upsert!(
name: user.name,
nickname: user.nickname,
total_score: nil,
age: user.age,
joined_at: user.joined_at
)

saved_user.name.should eq("test")
saved_user.total_score.should be_nil
end

it "raises if the record is invalid" do
Expand Down
6 changes: 3 additions & 3 deletions spec/avram/view_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ describe "views" do
end

it "works without a primary key" do
UserFactory.new.nickname("Johnny").create
UserFactory.new.nickname("Johnny").create
UserFactory.new.nickname("Johnny").create
UserFactory.new.name("Walker").nickname("Johnny").create
UserFactory.new.name("Wilson").nickname("Johnny").create
UserFactory.new.name("Wonker").nickname("Johnny").create
nickname_info = NicknameInfo::BaseQuery.first

nickname_info.nickname.should eq "Johnny"
Expand Down
91 changes: 82 additions & 9 deletions src/avram/insert.cr
Original file line number Diff line number Diff line change
@@ -1,32 +1,105 @@
class Avram::Insert
alias Params = Hash(Symbol, String) | Hash(Symbol, String?) | Hash(Symbol, Nil)

def initialize(@table : TableName, @params : Params, @column_names : Array(Symbol) = [] of Symbol)
enum ConflictAction
Nothing
Update

def to_s(io : IO)
case self
in .nothing?
io << "NOTHING"
in .update?
io << "UPDATE"
end
end
end

def initialize(
@table : TableName,
@params : Params,
@column_names = [] of Symbol,
@conflict_action : ConflictAction? = nil,
@conflict_keys = [] of Symbol,
@conflict_params = [] of Symbol
)
end

def statement : String
"insert into #{@table}(#{fields}) values(#{values_placeholders}) returning #{returning}"
String.build do |io|
io << "INSERT INTO "
io << @table
io << " ("
fields(io)
io << ')'
io << " VALUES ("
values_placeholders(io)
io << ')'

if @conflict_action && !@conflict_keys.empty?
io << " ON CONFLICT ("
conflict_keys(io)
io << ") DO "
io << @conflict_action

if @conflict_action.try(&.update?)
io << " SET "
excluded_params(io)
end
end

io << " RETURNING "
returning(io)
end
end

private def returning : String
private def returning(io)
if @column_names.empty?
"*"
io << '*'
else
@column_names.join(", ") { |column| %("#{@table}"."#{column}") }
@column_names.join(io, ", ") do |column, _io|
_io << '"'
_io << @table
_io << %(".")
_io << column
_io << '"'
end
end
end

def args
@params.values
end

private def fields : String
@params.keys.join(", ") { |col| %("#{col}") }
private def fields(io)
@params.join(io, ", ") do |(col, _value), _io|
_io << '"'
_io << col
_io << '"'
end
end

private def values_placeholders : String
private def values_placeholders(io)
@params.values.map_with_index do |_value, index|
"$#{index + 1}"
end.join(", ")
end.join(io, ", ")
end

private def conflict_keys(io)
@conflict_keys.join(io, ", ") do |col, _io|
_io << '"'
_io << col
_io << '"'
end
end

private def excluded_params(io)
@conflict_params.join(io, ", ") do |key, _io|
_io << '"'
_io << key
_io << %(" = EXCLUDED.")
_io << key
_io << '"'
end
end
end
Loading
Loading