From 44a65bb3c7142e27f15f15a69d146ff6230be9d8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=C3=89loi=20Rivard?= <eloi@yaal.coop>
Date: Sat, 15 Mar 2025 11:35:57 +0100
Subject: [PATCH] fix: order SQL relationship tables

---
 CHANGES.rst                                   |  1 +
 .../1742033764_ordered_relationships.py       | 79 +++++++++++++++++++
 .../backends/sql/migrations/script.py.mako    |  1 +
 canaille/backends/sql/models.py               | 13 ++-
 4 files changed, 92 insertions(+), 2 deletions(-)
 create mode 100644 canaille/backends/sql/migrations/1742033764_ordered_relationships.py

diff --git a/CHANGES.rst b/CHANGES.rst
index 85078639..eabba9a1 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -13,6 +13,7 @@ Fixed
 - Prevent clients from registering with fragment components in their redirect uri :issue:`235`
 - Ensure there is a `redirect_uri` in authorization requests from clients. :issue:`232`
 - Display client TOS uri and policy uri in authorization page if set during client registration
+- User group membership is ordered with the SQL backend. :issue:`169`
 
 [0.0.64] - 2025-02-12
 ---------------------
diff --git a/canaille/backends/sql/migrations/1742033764_ordered_relationships.py b/canaille/backends/sql/migrations/1742033764_ordered_relationships.py
new file mode 100644
index 00000000..1f27c47f
--- /dev/null
+++ b/canaille/backends/sql/migrations/1742033764_ordered_relationships.py
@@ -0,0 +1,79 @@
+"""Ordered relationships.
+
+Revision ID: 1742033764
+Revises: 1740765703
+Create Date: 2025-03-15 11:16:04.256771
+
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "1742033764"
+down_revision: str | None = "1740765703"
+branch_labels: str | Sequence[str] | None = ()
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.add_column(
+        "client_audience_association_table",
+        sa.Column("index", sa.Integer(), nullable=True),
+    )
+    op.execute("""
+        UPDATE client_audience_association_table
+        SET 'index' = s.row_num
+        FROM (
+            SELECT audience_id, client_id, ROW_NUMBER() OVER (ORDER BY audience_id, client_id) as row_num
+            FROM client_audience_association_table
+        ) s
+        WHERE client_audience_association_table.audience_id = s.audience_id AND client_audience_association_table.client_id = s.client_id;
+    """)
+    with op.batch_alter_table("client_audience_association_table") as batch_op:
+        batch_op.alter_column("index", existing_type=sa.Integer(), autoincrement=True)
+
+    op.add_column(
+        "membership_association_table",
+        sa.Column("index", sa.Integer(), autoincrement=True, nullable=True),
+    )
+    op.execute("""
+        UPDATE membership_association_table
+        SET 'index' = s.row_num
+        FROM (
+            SELECT user_id, group_id, ROW_NUMBER() OVER (ORDER BY user_id, group_id) as row_num
+            FROM membership_association_table
+        ) s
+        WHERE membership_association_table.user_id = s.user_id AND membership_association_table.group_id = s.group_id;
+    """)
+    with op.batch_alter_table("membership_association_table") as batch_op:
+        batch_op.alter_column("index", existing_type=sa.Integer(), autoincrement=True)
+
+    op.add_column(
+        "token_audience_association_table",
+        sa.Column("index", sa.Integer(), autoincrement=True, nullable=True),
+    )
+    op.execute("""
+        UPDATE token_audience_association_table
+        SET 'index' = s.row_num
+        FROM (
+            SELECT token_id, client_id, ROW_NUMBER() OVER (ORDER BY token_id, client_id) as row_num
+            FROM token_audience_association_table
+        ) s
+        WHERE token_audience_association_table.token_id = s.token_id AND token_audience_association_table.client_id = s.client_id;
+    """)
+    with op.batch_alter_table("token_audience_association_table") as batch_op:
+        batch_op.alter_column("index", existing_type=sa.Integer(), autoincrement=True)
+
+    # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+    # ### commands auto generated by Alembic - please adjust! ###
+    op.drop_column("token_audience_association_table", "index")
+    op.drop_column("membership_association_table", "index")
+    op.drop_column("client_audience_association_table", "index")
+    # ### end Alembic commands ###
diff --git a/canaille/backends/sql/migrations/script.py.mako b/canaille/backends/sql/migrations/script.py.mako
index 669173c0..dd852bfa 100644
--- a/canaille/backends/sql/migrations/script.py.mako
+++ b/canaille/backends/sql/migrations/script.py.mako
@@ -6,6 +6,7 @@ Create Date: ${create_date}
 
 """
 from collections.abc import Sequence
+from typing import Union
 
 import sqlalchemy as sa
 import sqlalchemy_utils.types.password
diff --git a/canaille/backends/sql/models.py b/canaille/backends/sql/models.py
index 34bfa01d..491a4d5b 100644
--- a/canaille/backends/sql/models.py
+++ b/canaille/backends/sql/models.py
@@ -55,6 +55,7 @@ class SqlAlchemyModel(BackendModel):
 membership_association_table = Table(
     "membership_association_table",
     Base.metadata,
+    Column("index", Integer, autoincrement=True),
     Column("user_id", ForeignKey("user.id"), primary_key=True),
     Column("group_id", ForeignKey("group.id"), primary_key=True),
 )
@@ -111,7 +112,9 @@ class User(canaille.core.models.User, Base, SqlAlchemyModel):
     title: Mapped[str] = mapped_column(String, nullable=True)
     organization: Mapped[str] = mapped_column(String, nullable=True)
     groups: Mapped[list["Group"]] = relationship(
-        secondary=membership_association_table, back_populates="members"
+        secondary=membership_association_table,
+        back_populates="members",
+        order_by=membership_association_table.c.index,
     )
     lock_date: Mapped[datetime.datetime] = mapped_column(
         TZDateTime(timezone=True), nullable=True
@@ -159,13 +162,16 @@ class Group(canaille.core.models.Group, Base, SqlAlchemyModel):
     display_name: Mapped[str] = mapped_column(String)
     description: Mapped[str] = mapped_column(String, nullable=True)
     members: Mapped[list["User"]] = relationship(
-        secondary=membership_association_table, back_populates="groups"
+        secondary=membership_association_table,
+        back_populates="groups",
+        order_by=membership_association_table.c.index,
     )
 
 
 client_audience_association_table = Table(
     "client_audience_association_table",
     Base.metadata,
+    Column("index", Integer, autoincrement=True),
     Column("audience_id", ForeignKey("client.id"), primary_key=True, nullable=True),
     Column("client_id", ForeignKey("client.id"), primary_key=True, nullable=True),
 )
@@ -194,6 +200,7 @@ class Client(canaille.oidc.models.Client, Base, SqlAlchemyModel):
         secondary=client_audience_association_table,
         primaryjoin=id == client_audience_association_table.c.client_id,
         secondaryjoin=id == client_audience_association_table.c.audience_id,
+        order_by=client_audience_association_table.c.index,
     )
     client_id: Mapped[str] = mapped_column(String, nullable=True)
     client_secret: Mapped[str] = mapped_column(String, nullable=True)
@@ -257,6 +264,7 @@ class AuthorizationCode(canaille.oidc.models.AuthorizationCode, Base, SqlAlchemy
 token_audience_association_table = Table(
     "token_audience_association_table",
     Base.metadata,
+    Column("index", Integer, autoincrement=True),
     Column("token_id", ForeignKey("token.id"), primary_key=True, nullable=True),
     Column("client_id", ForeignKey("client.id"), primary_key=True, nullable=True),
 )
@@ -296,6 +304,7 @@ class Token(canaille.oidc.models.Token, Base, SqlAlchemyModel):
         secondary=token_audience_association_table,
         primaryjoin=id == token_audience_association_table.c.token_id,
         secondaryjoin=Client.id == token_audience_association_table.c.client_id,
+        order_by=token_audience_association_table.c.index,
     )
 
 
-- 
GitLab