How do I get an upsert to only update the entry when a condition is met?

Hello there,

I am trying to accomplish the following (apart from upserting a value depending on :apple_event_id which works)

  1. increment :version with every upsert (also works)
  2. only update the entry when a condition is met (:last_modified has changes) - does not work and throws error (see below)

This is the entity (everything not important has been removed):

defmodule PalmSync4Mac.Entity.EventKit.CalendarEvent do
  @moduledoc """
  Represents a calendar event in the Apple Calendar.
  """
  use Ash.Resource,
    domain: PalmSync4Mac.Entity.EventKit,
    data_layer: AshSqlite.DataLayer

  sqlite do
    table("calendar_event")
    repo(PalmSync4Mac.Repo)
  end

  identities do
    identity(
      :unique_event,
      [
        :apple_event_id
      ],
      eager_check?: true
    )
  end

  actions do
    defaults([:read, :destroy])

    create(:create_or_update) do
      upsert?(true)
      upsert_identity(:unique_event)

      change(set_attribute(:version, 0))
      change(atomic_update(:version, expr(version + 1)))

      upsert_condition(expr(^arg(:last_modified) > last_modified))

      accept([
        ...
        :last_modified,
       ...
      ])
    end
  end

  attributes do
    uuid_primary_key(:id)

    .
    .
    .

    attribute(:last_modified, :utc_datetime) do
      description("The last time the event was modified as stored by Apple")
      allow_nil?(false)
      public?(true)
    end

    attribute :version, :integer do
      description("Version of the calendar event. Automatically incremented on each update")
      allow_nil?(false)
      public?(true)
      writable?(false)
    end
  end
end

The function that reates the entry in the database:

defp sync_calendar(calendar, interval) do
    case PalmSync4Mac.EventKit.PortHandler.get_events(calendar, interval) do
      {:ok, data} ->
        Enum.each(data["events"], fn cal_date ->
          PalmSync4Mac.Entity.EventKit.CalendarEvent
          |> Ash.Changeset.for_create(:create_or_update, cal_date)
          |> Ash.create!()
        end)

      {:error, reason} ->
        Logger.error("Error syncing calendar events: #{inspect(reason)}")
    end
  end

As long as the upsert_condition is commented out everything works but updates the entry everytime because of a missing constraint

With the upsert_condition commented in this is the error that I get:

03:00:00.464 [error] GenServer PalmSync4Mac.EventKit.CalendarEventWorker terminating
** (Ash.Error.Unknown) 
Bread Crumbs:
  > Error returned from: PalmSync4Mac.Entity.EventKit.CalendarEvent.create_or_update
  

Unknown Error

* ** (UndefinedFunctionError) function :parameterized.type/0 is undefined (module :parameterized is not available)
  :parameterized.type()
  (elixir 1.18.3) lib/enum.ex:1840: Enum."-map_reduce/3-lists^mapfoldl/2-0-"/3
  (elixir 1.18.3) lib/enum.ex:2546: Enum."-reduce/3-lists^foldl/2-0-"/3
    (ash 3.5.12) lib/ash/error/unknown.ex:3: Ash.Error.Unknown."exception (overridable 2)"/1
    (ash 3.5.12) /Users/bsu/Development/Elixir/palm_sync_4_mac/deps/splode/lib/splode.ex:264: Ash.Error.to_class/2
    (ash 3.5.12) lib/ash/error/error.ex:108: Ash.Error.to_error_class/2
    (ash 3.5.12) lib/ash/actions/create/create.ex:161: Ash.Actions.Create.do_run/4
    (ash 3.5.12) lib/ash/actions/create/create.ex:50: Ash.Actions.Create.run/4
    (ash 3.5.12) lib/ash.ex:2272: Ash.create!/3
    (elixir 1.18.3) lib/enum.ex:987: Enum."-each/2-lists^foreach/1-0-"/2
Last message: {:"$gen_cast", {:sync, 1, []}}
State: %PalmSync4Mac.EventKit.CalendarEventWorker{interval: 13, calendars: []}

My dependencies:

...
 elixir: "~> 1.18",
 compilers: [:unifex, :bundlex] ++ Mix.compilers(),
...
 {:ash, "~> 3.5"},
 {:ash_sqlite, "~> 0.2"},
...

Another question is of course also: Is upsert_condition the correct function for this?

Thanks for you help :slight_smile:

FYI: I have also posted this on reddit: https://www.reddit.com/r/elixir/comments/1kw052j/ash_entity_and_upset_condition_i_dont_get_it/

1 Like

Answered on reddit, but answering here too:

Can you update your ash_sqlite, and ash_sql versions? mix deps.update ash_sqlite ash_sql? This was a bug in ash_sqlite that was fixed very recently IIRC.

Hey, that was a quick answer :slight_smile:

Alright, did:

mix deps.clean --all
mix clean
mix deps.get
mix dips.compile
mix compile

And I have the following deps now:

  ash 3.5.12
  ash_sql 0.2.76
  ash_sqlite 0.2.6
  bunch 1.6.1
  bunch_native 0.5.0
  bundlex 1.5.4
  bunt 1.0.0
  cc_precompiler 0.1.10
  certifi 2.14.0
  combine 0.10.0
  credo 1.7.7
  db_connection 2.7.0
  decimal 2.3.0
  dialyxir 1.4.3
  earmark_parser 1.4.44
  ecto 3.12.5
  ecto_sql 3.12.1
  ecto_sqlite3 0.19.0
  elixir_make 0.9.0
  elixir_uuid 1.2.1
  enum_type 1.1.3
  erlex 0.2.6
  ets 0.9.0
  ex_doc 0.38.1
  expo 1.1.0
  exqlite 0.30.1
  file_system 1.0.0
  finch 0.19.0
  gettext 0.26.2
  hackney 1.23.0
  hpax 1.0.3
  idna 6.1.1
  iterex 0.1.2
  jason 1.4.4
  lens 1.0.0
  libgraph 0.16.0
  makeup 1.2.1
  makeup_elixir 1.0.1
  makeup_erlang 1.0.2
  metrics 1.0.1
  mime 2.0.7
  mimerl 1.3.0
  mint 1.7.1
  mox 1.2.0
  nimble_options 1.1.1
  nimble_ownership 1.0.1
  nimble_parsec 1.4.2
  nimble_pool 1.1.0
  owl 0.12.2
  parse_trans 3.4.1
  patch 0.15.0
  picosat_elixir 0.2.3
  qex 0.5.1
  reactor 0.15.3
  req 0.5.10
  shmex 0.5.1
  simple_enum 0.1.0
  spark 2.2.62
  splode 0.2.9
  ssl_verify_fun 1.1.7
  stream_data 1.2.0
  telemetry 1.3.0
  timex 3.7.11
  typed_struct 0.3.0
  typed_struct_lens 0.1.1
  typed_struct_nimble_options 0.1.1
  typedstruct 0.5.3
  tzdata 1.1.3
  unicode_util_compat 0.7.0
  unifex 1.2.1
  usb 0.2.1
  yamerl 0.10.0
  yaml_elixir 2.11.0
  ymlr 5.1.3
  zarex 1.0.5

Unfortunately the error stays the same.

EDIT: Do you still know which issue that was? Maybe I can try and reproduce some of the things in there?

Sorry! :person_facepalming: it wasn’t released yet. Fix released in 0.2.7.

1 Like

Ha, no worries. Then I will focus on my other topics in the meantime and check in again later when the version is released :flexed_biceps:

Thanks for your fast support. Remind me to buy you a coffee at the Berlin BEAM :wink:

Cheers,
Stefan

I just released it in 0.2.7 :smiley:

3 Likes

Noice! :fire:

now I only need to find out why

upsert_condition(expr(last_modified < ^actor(:last_modified)))

with:

defp sync_calendar(calendar, interval) do
    case PalmSync4Mac.EventKit.PortHandler.get_events(interval, calendar) do
      {:ok, data} ->
        Enum.each(data["events"], fn cal_date ->
          Logger.info("Syncing calendar event: #{inspect(cal_date)}")

          PalmSync4Mac.Entity.EventKit.CalendarEvent
          |> Ash.Changeset.for_create(:create_or_update, cal_date, actor: cal_date)
          |> Ash.create!()
        end)

      {:error, reason} ->
        Logger.error("Error syncing calendar events: #{inspect(reason)}")
    end
  end`

leads to this error :thinking:

09:32:06.733 [info] Syncing calendar event: %{"apple_event_id" => "ABFBDB3D-20ED-4F84-A1F3-1DD3F72D23E2:49684653-FECF-4AF5-9BE5-62173868E059", "calendar_name" => "Test", "end_date" => "2025-05-27T17:30:00Z", "invitees" => [], "last_modified" => "2025-05-26T21:22:16Z", "location" => "Paramount Theatre\n911 Pine St, 
Seattle, WA 98101, United States", "notes" => "With a Note", "source" => :apple, "start_date" => "2025-05-27T16:30:00Z", "title" => "Another Test Event", "url" => ""}

09:32:06.734 [error] GenServer PalmSync4Mac.EventKit.CalendarEventWorker terminating
** (Ash.Error.Invalid) 
Bread Crumbs:
  > Error returned from: PalmSync4Mac.Entity.EventKit.CalendarEvent.create_or_update
  

Invalid Error

* actor is required
  (ash 3.5.12) lib/ash/error/changes/action_requires_actor.ex:4: Ash.Error.Changes.ActionRequiresActor.exception/1
  (ash 3.5.12) lib/ash/changeset/changeset.ex:6206: Ash.Changeset.filter/2
  (ash 3.5.12) lib/ash/changeset/changeset.ex:1624: Ash.Changeset.for_create/4
  (palm_sync_4_mac 0.1.0) lib/palmsync4mac/event_kit/calendar_event_worker.ex:51: anonymous fn/1 in PalmSync4Mac.EventKit.CalendarEventWorker.sync_calendar/2
  (elixir 1.18.3) lib/enum.ex:987: Enum."-each/2-lists^foreach/1-0-"/2
  (palm_sync_4_mac 0.1.0) lib/palmsync4mac/event_kit/calendar_event_worker.ex:36: PalmSync4Mac.EventKit.CalendarEventWorker.handle_cast/2
  (stdlib 5.2.3.3) gen_server.erl:1121: :gen_server.try_handle_cast/3
    (ash 3.5.12) lib/ash/error/invalid.ex:3: Ash.Error.Invalid.exception/1
    (ash 3.5.12) /Users/marbury/Projects/Private/palm_sync_4_mac/deps/splode/lib/splode.ex:264: Ash.Error.to_class/2
    (ash 3.5.12) lib/ash/error/error.ex:108: Ash.Error.to_error_class/2
    (ash 3.5.12) lib/ash/actions/create/create.ex:155: Ash.Actions.Create.do_run/4
    (ash 3.5.12) lib/ash/actions/create/create.ex:50: Ash.Actions.Create.run/4
    (ash 3.5.12) lib/ash.ex:2272: Ash.create!/3
    (elixir 1.18.3) lib/enum.ex:987: Enum."-each/2-lists^foreach/1-0-"/2
Last message: {:"$gen_cast", {:sync, ["Test"], 1}}
State: %PalmSync4Mac.EventKit.CalendarEventWorker{calendars: [], interval: 13}

I have also tried with ^ref

upsert_condition(last_modified < ^ref(:last_modified))

but the where clause then changes to this which feels wrong since c0 suggests that it compares the value with itself.

WHERE (CAST(CAST(c0."last_modified" AS TEXT) AS TEXT) < CAST(CAST(c0."last_modified" AS TEXT) AS TEXT))

Alright, I guess that I have fixed it?

I changed to this:

upsert_condition(expr(last_modified < ^arg(:new_last_modified)))

error_handler(fn
  _changeset, %StaleRecord{} ->
    InvalidChanges.exception(
        fields: [:last_modified],
        message: "Calendar event did not change. No update needed."
    )

  _changeset, other ->
    other
end)

and

PalmSync4Mac.Entity.EventKit.CalendarEvent
|> Ash.Changeset.new()
|> Ash.Changeset.set_argument(:new_last_modified, cal_date["last_modified"])
|> Ash.Changeset.for_create(:create_or_update, cal_date)
|> Ash.create!()
    create(:create_or_update) do
      accept([
        ...
        # :last_modified, remove from accept
       ...
      ])
      upsert?(true)
      upsert_identity(:unique_event)
      argument :last_modified, :utc_datetime_usec, allow_nil?: false

      change set_attribute(:last_modified_at, arg(:last_modified_at))
      change(set_attribute(:version, 0))
      change(atomic_update(:version, expr(version + 1)))

      upsert_condition(expr(^arg(:last_modified) > last_modified))
    end

Something like that should work, since it is an argument you can refer to it in expressions.

One thing to note is that in cases where the DSL gives you a headache or you ever feel that its ambiguous, you can always drop down to a functional place, something like this:

change fn changeset, _ -> 
  last_modified_at = Ash.Changeset.get_attribute(changeset, :last_modified_at)
  Ash.Changeset.filter(changeset, expr(last_modified_at < ^last_modified_at))
end
2 Likes

Perfect. Will play around with it. Thanks a bunch mate!

1 Like