roganjoshua

roganjoshua

I have a many_to_many relationship ( I guess there really isn’t such a thing) but a junction table nevertheless.

erDiagram
    USER_GAMES {
        int user_id
        int game_id
    }
    USER {
        int id
        string name
    }
    GAME {
        int id
        string name
    }
  schema "users" do
    field :email, :string
    many_to_many :games, Game, join_through: "users_games"
    timestamps(type: :utc_datetime)
  end
 schema "games" do
    field :name, :string
    field :state, :map
    field :started_at, :utc_datetime
    many_to_many :users, User, join_through: "users_games"
    timestamps(type: :utc_datetime)
  end

No schema for users_games but in the migration

  create table("users_games", primary_key: false) do
      add :user_id,
          references(:users, on_delete: :delete_all),
          null: false

      add :game_id,
          references(:games, on_delete: :delete_all),
          null: false

      timestamps()
    end

    create unique_index(:users_games, [:user_id, :game_id])
  end
end

The action happens here

    new_game = %Game{
      name: attrs.name,
      started_at: attrs.started_at
    }

    user =
      Accounts.get_user_with_games(user_id)
      |> Ecto.Changeset.change()
      |> Ecto.Changeset.put_assoc(:games, [new_game])
      |> Repo.update!()

where get_user_with_games is

    get_user!(user_id)
    |> Repo.preload(:games)

I realise right now I need to include any existing related games but I can get to that later.

I get this

[debug] QUERY OK db=1.0ms idle=895.3ms
begin []
↳ ScrabbleWeb.GameLive.Form.save_game/3, at: lib/scrabble_web/live/game_live/form.ex:87
[debug] QUERY OK source="games" db=1.6ms
INSERT INTO "games" ("name","started_at","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" ["ABC", ~U[2026-02-12 19:12:43Z], ~U[2026-02-12 19:12:43Z], ~U[2026-02-12 19:12:43Z]]
↳ ScrabbleWeb.GameLive.Form.save_game/3, at: lib/scrabble_web/live/game_live/form.ex:87
[debug] QUERY ERROR source="users_games" db=0.5ms
INSERT INTO "users_games" ("user_id","game_id") VALUES ($1,$2) [1, 6]
↳ ScrabbleWeb.GameLive.Form.save_game/3, at: lib/scrabble_web/live/game_live/form.ex:87
[debug] QUERY OK db=0.2ms
rollback []
↳ ScrabbleWeb.GameLive.Form.save_game/3, at: lib/scrabble_web/live/game_live/form.ex:87
[error] GenServer #PID<0.1178.0> terminating
** (Postgrex.Error) ERROR 23502 (not_null_violation) null value in column "inserted_at" of relation "users_games" violates not-null constraint

    table: users_games
    column: inserted_at

Which looks so close…it creates a transaction creates the game but when it is inserting into users_games it omits the timestamps.

What am I missing?

It looks like building the query

INSERT INTO "users_games" ("user_id","game_id") VALUES ($1,$2) [1, 6]

should know there are timestamp fields that could be populated for this.

Should I sack the many to many and create an intermediate entity and and have two one_to_many relationships and do it manually?

Showing Posts 1 to 9

jdiago

jdiago

Do you need the timestamp on the join table? If not, delete it.

If you do need it, I think doing the many-to-many through a join schema should work. Associations — Ecto v3.14.0

edit: also, this section of the guide talks about the differences between having a join schema and not. Polymorphic associations with many to many — Ecto v3.14.0

linusdm

linusdm

Since you’re using the :join_through option with a string value, there is nothing more that Ecto knows about this join table. It knows the two columns with the foreign keys (using the _id suffix). But it knows nothing about your timestamp columns.

Read the documentation on Ecto.Schema.many_to_many again, and you’ll see they make a distinction between passing in just a string, and passing in a schema.

From the docs:

However, if your :join_through is a schema, like MyApp.PostTag, your join table may be structured as any other table in your codebase, including timestamps

and:

Because :join_through contains a schema, in such cases, autogenerated values and primary keys will be automatically handled by Ecto.

There are many other ways to model many-to-many relations in Ecto. But if the missing timestamp is your only issue right now, I’d define the schema, and you’re all set.

You could also define a default value (NOW()) for a inserted_at column in your migration. Then the database handles setting this value for you, instead of Ecto. But you would still need to model a schema for the join table if you need the timestamp in your application somewhere.

roganjoshua

roganjoshua OP

This looks precise, logical and excellent.
I missed that bit of the documentation and look forward to trying it tomorrow.

roganjoshua

roganjoshua OP

I did get this working in the end.

I added a schema for the M2M relation

Note: there is an auto primary key (id)

defmodule MyApp.UsersGames do 
 schema "users_games" do
    belongs_to(:user, User)
    belongs_to(:game, Game)
    timestamps()
  end
end

the relations on the other schemas now reference the above schema

for example,

    many_to_many :users, User, join_through: UsersGames

Then to create the relation and the game

    new_game = %Game{
      name: attrs.name,
      started_at: attrs.started_at
    }

    user =  get_user!(user_id)
    |> Repo.preload(:games)

    games = user.games

    user
    |> Ecto.Changeset.change()
    |> Ecto.Changeset.put_assoc(:games, [new_game | games])
    |> Repo.update!()

It is essential as this is a put to collect the games already belonging to the user and add them to list [new_game | games].

Sub-optimal in terms of performance as the users_games since there is a overhead for the id column and the use of put.

It not entirely intuitive and the documentation suggests the intermediate relation schema isn’t necessary.

LostKobrakai

LostKobrakai

You don’t need that if you configure the schema to not use an :id column. Once you’re using a schema you can use it to provide all manner of additional information to ecto.

Where exactly do you read it like that. Imo the documentation has always been very explicit that no-schema means no additional columns – especially given in the past there was no option for providing a schema at all, so this really needed to be explicit.

roganjoshua

roganjoshua OP

The documentation for Many to Many

Through a join table as an alternative to Through a join schema only include the migration and no schema for the join.

create table("users_organizations", primary_key: false) do
      add :user_id,
          references(:users, on_delete: :delete_all),
          null: false

      add :organization_id,
          references(:organizations, on_delete: :delete_all),
          null: false

      timestamps()
    end

    create unique_index(:users_organizations, [:user_id, :organization_id])

Using a join schema

 @primary_key false
  schema "users_organizations" do
    belongs_to :user, User
    belongs_to :organization, Organization
    timestamps()
  end

also sets primary key to be false which didn’t work for me as the query was returning an id which didn’t exist and threw an error.

LostKobrakai

LostKobrakai

Ok, those cheatsheets are quite a bit newer. That’s something to be fixed then.

You might also need to declare the foreign key columns to be the primary keys, but granted I never actually tried that.

@primary_key false
schema "users_organizations" do
  field :user_id, :id, primary_key: true
  belongs_to :user, User, define_field: false

  field :organization_id, :id, primary_key: true
  belongs_to :organization, Organization, define_field: false

  timestamps()
end

Edit: I setup an issue for fixing the docs: Cheatsheet many-to-many migration incorrect · Issue #4703 · elixir-ecto/ecto · GitHub

roganjoshua

roganjoshua OP

I have update the schema and migration according to your suggestions

   @primary_key false
  schema "users_games" do
    field :user_id, :id, primary_key: true
    belongs_to :user, User, define_field: false

    field :game_id, :id, primary_key: true
    belongs_to :game, Game, define_field: false
    timestamps()
  end

and

    create table(:users_games, primary_key: false) do
      add :user_id,
          references(:users, on_delete: :delete_all),
          null: false,
          primary_key: true

      add :game_id,
          references(:games, on_delete: :delete_all),
          null: false,
          primary_key: true

      timestamps()
    end

And this now works without the id column

— All posts loaded —

Where Next? Top

Trending in Questions Top

RSP87
I’m working on a project that simulates the bumbl example in the programming phoenix book. It acts almost like an email client. We have a...
New
RemyXRenard
I’m seeing that a list inside a Kino.DataTable will be interpreted as a charlist, even if the Kino.configure() is set to charlists: :as_l...
New
nseaSeb
Hello, I know there is an approach for handling lists that allows for optimized traversal, but I can’t recall the specific method (somet...
New
velrest
So my question is quite simple and i have found no conclusive answer on forum, google or AI. Should we use :erlang.float for Integer to ...
New
samoloth
Hi, I’ve just set up an application with ash_authentication. There is only magic link strategy for now, so there is no confirmation add o...
New
FlyingNoodle
If a change or preparation module uses Ash.Changeset.get_argument/2 or Ash.Query.get_argument/2 (or any of the other get_argument functio...
New
ryanwinchester
apply_graft/2 doesn’t rewrite an add_many sub-workflow’s deps on an add step. Grafted jobs cancel with “upstream job was deleted” Version...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Dmk
Xamal is a deployment tool for Elixir apps that deploys native releases to bare metal servers over SSH. It’s a port of GitHub - basecamp/...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge &amp; Solve. They are GUI (Emerge) and State management (S...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews