[PATCH 1 of 1] db: link relationship pairs that share a foreign key

橋本 憲一 hashimoto.kenichi at genetec.co.jp
Fri Nov 6 03:40:23 UTC 2026


# HG changeset patch
# User Hashimoto Kenichi <hashimoto.kenichi at genetec.co.jp>
# Date 1793872241 -32400
#      Thu Nov 05 18:50:41 2026 +0900
# Branch stable
# Node ID 2cdbbe5237e095b854cba2fe4e4d05708758cb4a
# Parent  4c5d75319b9eef45c7738d909d4f9e307280a4c7
db: link relationship pairs that share a foreign key

Both sides of many one-to-many relationships are declared independently.
SQLAlchemy 1.4 warns 35 times that they conflict ("relationship X will
copy column ... to column ..., which conflicts with relationship(s) ...").
Declare each pair as the two sides of one relationship with
back_populates.

Also set cascade_backrefs=False on them. That keeps the current session
behaviour, which is also the only behaviour of SQLAlchemy 2.0: an object
is not added to the session just because it is assigned to a
relationship of a persistent object through a backref. Without it,
PermissionModel.create_default_permissions() fails: it assigns
UserToPerm.user before UserToPerm.permission, and the Permission query
in between autoflushes the incomplete object (NOT NULL constraint
failed: user_to_perm.permission_id).

The warnings are not only noise: kallithea-cli ssh-serve writes them
(about 28 kB) to stderr at startup, and with Mercurial over ssh that can
fill the pipe before the client reads stderr, so the ssh session hangs.
The Hg ssh tests hang that way with SQLAlchemy 1.4.

This is a step towards SQLAlchemy 1.4; setup.py still requires < 1.4.

Assisted-by: Claude:claude-opus-5-5

diff -r 4c5d75319b9e -r 2cdbbe5237e0 kallithea/model/db.py
--- a/kallithea/model/db.py     Fri Apr 10 20:57:47 2026 +0200
+++ b/kallithea/model/db.py     Thu Nov 05 18:50:41 2026 +0900
@@ -405,27 +405,41 @@
     created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
     _user_data = Column("user_data", LargeBinary(), nullable=True)  # JSON data # FIXME: not nullable?

-    user_log = relationship('UserLog')
-    user_perms = relationship('UserToPerm', primaryjoin="User.user_id==UserToPerm.user_id", cascade='all')
+    user_log = relationship('UserLog',
+                            cascade_backrefs=False, back_populates='user')
+    user_perms = relationship('UserToPerm', primaryjoin="User.user_id==UserToPerm.user_id", cascade='all',
+                              cascade_backrefs=False, back_populates='user')

-    repositories = relationship('Repository')
-    repo_groups = relationship('RepoGroup')
-    user_groups = relationship('UserGroup')
-    user_followers = relationship('UserFollowing', primaryjoin='UserFollowing.follows_user_id==User.user_id', cascade='all')
-    followings = relationship('UserFollowing', primaryjoin='UserFollowing.user_id==User.user_id', cascade='all')
+    repositories = relationship('Repository',
+                                cascade_backrefs=False, back_populates='owner')
+    repo_groups = relationship('RepoGroup',
+                               cascade_backrefs=False, back_populates='owner')
+    user_groups = relationship('UserGroup',
+                               cascade_backrefs=False, back_populates='owner')
+    user_followers = relationship('UserFollowing', primaryjoin='UserFollowing.follows_user_id==User.user_id', cascade='all',
+                                  cascade_backrefs=False, back_populates='follows_user')
+    followings = relationship('UserFollowing', primaryjoin='UserFollowing.user_id==User.user_id', cascade='all',
+                              cascade_backrefs=False, back_populates='user')

-    repo_to_perm = relationship('UserRepoToPerm', primaryjoin='UserRepoToPerm.user_id==User.user_id', cascade='all')
-    repo_group_to_perm = relationship('UserRepoGroupToPerm', primaryjoin='UserRepoGroupToPerm.user_id==User.user_id', cascade='all')
+    repo_to_perm = relationship('UserRepoToPerm', primaryjoin='UserRepoToPerm.user_id==User.user_id', cascade='all',
+                                cascade_backrefs=False, back_populates='user')
+    repo_group_to_perm = relationship('UserRepoGroupToPerm', primaryjoin='UserRepoGroupToPerm.user_id==User.user_id', cascade='all',
+                                      cascade_backrefs=False, back_populates='user')

-    group_member = relationship('UserGroupMember', cascade='all')
+    group_member = relationship('UserGroupMember', cascade='all',
+                                cascade_backrefs=False, back_populates='user')

     # comments created by this user
-    user_comments = relationship('ChangesetComment', cascade='all')
+    user_comments = relationship('ChangesetComment', cascade='all',
+                                 cascade_backrefs=False, back_populates='author')
     # extra emails for this user
-    user_emails = relationship('UserEmailMap', cascade='all')
+    user_emails = relationship('UserEmailMap', cascade='all',
+                               cascade_backrefs=False, back_populates='user')
     # extra API keys
-    user_api_keys = relationship('UserApiKeys', cascade='all')
-    ssh_keys = relationship('UserSshKeys', cascade='all')
+    user_api_keys = relationship('UserApiKeys', cascade='all',
+                                 cascade_backrefs=False, back_populates='user')
+    ssh_keys = relationship('UserSshKeys', cascade='all',
+                            cascade_backrefs=False, back_populates='user')

     @hybrid_property
     def email(self):
@@ -668,7 +682,8 @@
     expires = Column(Float(53), nullable=False)
     created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)

-    user = relationship('User')
+    user = relationship('User',
+                        cascade_backrefs=False, back_populates='user_api_keys')

     @hybrid_property
     def is_expired(self):
@@ -685,7 +700,8 @@
     email_id = Column(Integer(), primary_key=True)
     user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
     _email = Column("email", String(255), nullable=False, unique=True)
-    user = relationship('User')
+    user = relationship('User',
+                        cascade_backrefs=False, back_populates='user_emails')

     @validates('_email')
     def validate_email(self, key, email):
@@ -756,8 +772,10 @@
     def action_as_day(self):
         return datetime.date(*self.action_date.timetuple()[:3])

-    user = relationship('User')
-    repository = relationship('Repository', cascade='')
+    user = relationship('User',
+                        cascade_backrefs=False, back_populates='user_log')
+    repository = relationship('Repository', cascade='',
+                              cascade_backrefs=False, back_populates='logs')


 class UserGroup(meta.Base, BaseDbModel):
@@ -774,14 +792,21 @@
     created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
     _group_data = Column("group_data", LargeBinary(), nullable=True)  # JSON data # FIXME: not nullable?

-    members = relationship('UserGroupMember', cascade="all, delete-orphan")
-    users_group_to_perm = relationship('UserGroupToPerm', cascade='all')
-    users_group_repo_to_perm = relationship('UserGroupRepoToPerm', cascade='all')
-    users_group_repo_group_to_perm = relationship('UserGroupRepoGroupToPerm', cascade='all')
-    user_user_group_to_perm = relationship('UserUserGroupToPerm', cascade='all')
-    user_group_user_group_to_perm = relationship('UserGroupUserGroupToPerm', primaryjoin="UserGroupUserGroupToPerm.target_user_group_id==UserGroup.users_group_id", cascade='all')
+    members = relationship('UserGroupMember', cascade="all, delete-orphan",
+                           cascade_backrefs=False, back_populates='users_group')
+    users_group_to_perm = relationship('UserGroupToPerm', cascade='all',
+                                       cascade_backrefs=False, back_populates='users_group')
+    users_group_repo_to_perm = relationship('UserGroupRepoToPerm', cascade='all',
+                                            cascade_backrefs=False, back_populates='users_group')
+    users_group_repo_group_to_perm = relationship('UserGroupRepoGroupToPerm', cascade='all',
+                                                  cascade_backrefs=False, back_populates='users_group')
+    user_user_group_to_perm = relationship('UserUserGroupToPerm', cascade='all',
+                                           cascade_backrefs=False, back_populates='user_group')
+    user_group_user_group_to_perm = relationship('UserGroupUserGroupToPerm', primaryjoin="UserGroupUserGroupToPerm.target_user_group_id==UserGroup.users_group_id", cascade='all',
+                                                 cascade_backrefs=False, back_populates='target_user_group')

-    owner = relationship('User')
+    owner = relationship('User',
+                         cascade_backrefs=False, back_populates='user_groups')

     @hybrid_property
     def group_data(self):
@@ -851,8 +876,10 @@
     users_group_id = Column(Integer(), ForeignKey('users_groups.users_group_id'), nullable=False)
     user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)

-    user = relationship('User')
-    users_group = relationship('UserGroup')
+    user = relationship('User',
+                        cascade_backrefs=False, back_populates='group_member')
+    users_group = relationship('UserGroup',
+                               cascade_backrefs=False, back_populates='members')

     def __init__(self, gr_id='', u_id=''):
         self.users_group_id = gr_id
@@ -877,7 +904,8 @@
     field_type = Column(String(255), nullable=False)
     created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)

-    repository = relationship('Repository')
+    repository = relationship('Repository',
+                              cascade_backrefs=False, back_populates='extra_fields')

     @property
     def field_key_prefixed(self):
@@ -930,29 +958,39 @@
     fork_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=True)
     group_id = Column(Integer(), ForeignKey('groups.group_id'), nullable=True)

-    owner = relationship('User')
+    owner = relationship('User',
+                         cascade_backrefs=False, back_populates='repositories')
     fork = relationship('Repository', remote_side=repo_id)
     group = relationship('RepoGroup')
-    repo_to_perm = relationship('UserRepoToPerm', cascade='all', order_by='UserRepoToPerm.repo_to_perm_id')
-    users_group_to_perm = relationship('UserGroupRepoToPerm', cascade='all')
-    stats = relationship('Statistics', cascade='all', uselist=False)
+    repo_to_perm = relationship('UserRepoToPerm', cascade='all', order_by='UserRepoToPerm.repo_to_perm_id',
+                                cascade_backrefs=False, back_populates='repository')
+    users_group_to_perm = relationship('UserGroupRepoToPerm', cascade='all',
+                                       cascade_backrefs=False, back_populates='repository')
+    stats = relationship('Statistics', cascade='all', uselist=False,
+                         cascade_backrefs=False, back_populates='repository')

     followers = relationship('UserFollowing',
                              primaryjoin='UserFollowing.follows_repository_id==Repository.repo_id',
-                             cascade='all')
+                             cascade='all',
+                             cascade_backrefs=False, back_populates='follows_repository')
     extra_fields = relationship('RepositoryField',
-                                cascade="all, delete-orphan")
+                                cascade="all, delete-orphan",
+                                cascade_backrefs=False, back_populates='repository')

-    logs = relationship('UserLog')
-    comments = relationship('ChangesetComment', cascade="all, delete-orphan")
+    logs = relationship('UserLog',
+                        cascade_backrefs=False, back_populates='repository')
+    comments = relationship('ChangesetComment', cascade="all, delete-orphan",
+                            cascade_backrefs=False, back_populates='repo')

     pull_requests_org = relationship('PullRequest',
                     primaryjoin='PullRequest.org_repo_id==Repository.repo_id',
-                    cascade="all, delete-orphan")
+                    cascade="all, delete-orphan",
+                    cascade_backrefs=False, back_populates='org_repo')

     pull_requests_other = relationship('PullRequest',
                     primaryjoin='PullRequest.other_repo_id==Repository.repo_id',
-                    cascade="all, delete-orphan")
+                    cascade="all, delete-orphan",
+                    cascade_backrefs=False, back_populates='other_repo')

     def __repr__(self):
         return "<%s %s: %r>" % (self.__class__.__name__,
@@ -1342,10 +1380,13 @@
     owner_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False)
     created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)

-    repo_group_to_perm = relationship('UserRepoGroupToPerm', cascade='all', order_by='UserRepoGroupToPerm.group_to_perm_id')
-    users_group_to_perm = relationship('UserGroupRepoGroupToPerm', cascade='all')
+    repo_group_to_perm = relationship('UserRepoGroupToPerm', cascade='all', order_by='UserRepoGroupToPerm.group_to_perm_id',
+                                      cascade_backrefs=False, back_populates='group')
+    users_group_to_perm = relationship('UserGroupRepoGroupToPerm', cascade='all',
+                                       cascade_backrefs=False, back_populates='group')
     parent_group = relationship('RepoGroup', remote_side=group_id)
-    owner = relationship('User')
+    owner = relationship('User',
+                         cascade_backrefs=False, back_populates='repo_groups')

     @classmethod
     def query(cls, sorted=False):
@@ -1640,8 +1681,10 @@
     permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)
     repository_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=False)

-    user = relationship('User')
-    repository = relationship('Repository')
+    user = relationship('User',
+                        cascade_backrefs=False, back_populates='repo_to_perm')
+    repository = relationship('Repository',
+                              cascade_backrefs=False, back_populates='repo_to_perm')
     permission = relationship('Permission')

     @classmethod
@@ -1671,7 +1714,8 @@
     user_group_id = Column(Integer(), ForeignKey('users_groups.users_group_id'), nullable=False)

     user = relationship('User')
-    user_group = relationship('UserGroup')
+    user_group = relationship('UserGroup',
+                              cascade_backrefs=False, back_populates='user_user_group_to_perm')
     permission = relationship('Permission')

     @classmethod
@@ -1699,7 +1743,8 @@
     user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
     permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)

-    user = relationship('User')
+    user = relationship('User',
+                        cascade_backrefs=False, back_populates='user_perms')
     permission = relationship('Permission')

     def __repr__(self):
@@ -1719,9 +1764,11 @@
     permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)
     repository_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=False)

-    users_group = relationship('UserGroup')
+    users_group = relationship('UserGroup',
+                               cascade_backrefs=False, back_populates='users_group_repo_to_perm')
     permission = relationship('Permission')
-    repository = relationship('Repository')
+    repository = relationship('Repository',
+                              cascade_backrefs=False, back_populates='users_group_to_perm')

     @classmethod
     def create(cls, users_group, repository, permission):
@@ -1749,7 +1796,8 @@
     permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)
     user_group_id = Column(Integer(), ForeignKey('users_groups.users_group_id'), nullable=False)

-    target_user_group = relationship('UserGroup', primaryjoin='UserGroupUserGroupToPerm.target_user_group_id==UserGroup.users_group_id')
+    target_user_group = relationship('UserGroup', primaryjoin='UserGroupUserGroupToPerm.target_user_group_id==UserGroup.users_group_id',
+                                     cascade_backrefs=False, back_populates='user_group_user_group_to_perm')
     user_group = relationship('UserGroup', primaryjoin='UserGroupUserGroupToPerm.user_group_id==UserGroup.users_group_id')
     permission = relationship('Permission')

@@ -1778,7 +1826,8 @@
     users_group_id = Column(Integer(), ForeignKey('users_groups.users_group_id'), nullable=False)
     permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)

-    users_group = relationship('UserGroup')
+    users_group = relationship('UserGroup',
+                               cascade_backrefs=False, back_populates='users_group_to_perm')
     permission = relationship('Permission')


@@ -1794,8 +1843,10 @@
     group_id = Column(Integer(), ForeignKey('groups.group_id'), nullable=False)
     permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)

-    user = relationship('User')
-    group = relationship('RepoGroup')
+    user = relationship('User',
+                        cascade_backrefs=False, back_populates='repo_group_to_perm')
+    group = relationship('RepoGroup',
+                         cascade_backrefs=False, back_populates='repo_group_to_perm')
     permission = relationship('Permission')

     @classmethod
@@ -1820,9 +1871,11 @@
     group_id = Column(Integer(), ForeignKey('groups.group_id'), nullable=False)
     permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)

-    users_group = relationship('UserGroup')
+    users_group = relationship('UserGroup',
+                               cascade_backrefs=False, back_populates='users_group_repo_group_to_perm')
     permission = relationship('Permission')
-    group = relationship('RepoGroup')
+    group = relationship('RepoGroup',
+                         cascade_backrefs=False, back_populates='users_group_to_perm')

     @classmethod
     def create(cls, user_group, repository_group, permission):
@@ -1847,7 +1900,8 @@
     commit_activity_combined = Column(LargeBinary(), nullable=False) # JSON data
     languages = Column(LargeBinary(1000000), nullable=False) # JSON data

-    repository = relationship('Repository', single_parent=True)
+    repository = relationship('Repository', single_parent=True,
+                              cascade_backrefs=False, back_populates='stats')


 class UserFollowing(meta.Base, BaseDbModel):
@@ -1864,10 +1918,13 @@
     follows_user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=True)
     follows_from = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)

-    user = relationship('User', primaryjoin='User.user_id==UserFollowing.user_id')
+    user = relationship('User', primaryjoin='User.user_id==UserFollowing.user_id',
+                        cascade_backrefs=False, back_populates='followings')

-    follows_user = relationship('User', primaryjoin='User.user_id==UserFollowing.follows_user_id')
-    follows_repository = relationship('Repository', order_by=lambda: sqlalchemy.func.lower(Repository.repo_name))
+    follows_user = relationship('User', primaryjoin='User.user_id==UserFollowing.follows_user_id',
+                                cascade_backrefs=False, back_populates='user_followers')
+    follows_repository = relationship('Repository', order_by=lambda: sqlalchemy.func.lower(Repository.repo_name),
+                                      cascade_backrefs=False, back_populates='followers')

     @classmethod
     def get_repo_followers(cls, repo_id):
@@ -1893,14 +1950,18 @@
     created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
     modified_at = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)

-    author = relationship('User')
-    repo = relationship('Repository')
+    author = relationship('User',
+                          cascade_backrefs=False, back_populates='user_comments')
+    repo = relationship('Repository',
+                        cascade_backrefs=False, back_populates='comments')
     # status_change is frequently used directly in templates - make it a lazy
     # join to avoid fetching each related ChangesetStatus on demand.
     # There will only be one ChangesetStatus referencing each comment so the join will not explode.
     status_change = relationship('ChangesetStatus',
-                                 cascade="all, delete-orphan", lazy='joined')
-    pull_request = relationship('PullRequest')
+                                 cascade="all, delete-orphan", lazy='joined',
+                                 cascade_backrefs=False, back_populates='comment')
+    pull_request = relationship('PullRequest',
+                                cascade_backrefs=False, back_populates='comments')

     def url(self):
         anchor = "comment-%s" % self.comment_id
@@ -1959,8 +2020,10 @@

     author = relationship('User')
     repo = relationship('Repository')
-    comment = relationship('ChangesetComment')
-    pull_request = relationship('PullRequest')
+    comment = relationship('ChangesetComment',
+                           cascade_backrefs=False, back_populates='status_change')
+    pull_request = relationship('PullRequest',
+                                cascade_backrefs=False, back_populates='statuses')

     def __repr__(self):
         return "<%s %r by %r>" % (
@@ -2027,12 +2090,17 @@

     owner = relationship('User')
     reviewers = relationship('PullRequestReviewer',
-                             cascade="all, delete-orphan")
-    org_repo = relationship('Repository', primaryjoin='PullRequest.org_repo_id==Repository.repo_id')
-    other_repo = relationship('Repository', primaryjoin='PullRequest.other_repo_id==Repository.repo_id')
-    statuses = relationship('ChangesetStatus', order_by='ChangesetStatus.changeset_status_id')
+                             cascade="all, delete-orphan",
+                             cascade_backrefs=False, back_populates='pull_request')
+    org_repo = relationship('Repository', primaryjoin='PullRequest.org_repo_id==Repository.repo_id',
+                            cascade_backrefs=False, back_populates='pull_requests_org')
+    other_repo = relationship('Repository', primaryjoin='PullRequest.other_repo_id==Repository.repo_id',
+                              cascade_backrefs=False, back_populates='pull_requests_other')
+    statuses = relationship('ChangesetStatus', order_by='ChangesetStatus.changeset_status_id',
+                            cascade_backrefs=False, back_populates='pull_request')
     comments = relationship('ChangesetComment', order_by='ChangesetComment.comment_id',
-                             cascade="all, delete-orphan")
+                             cascade="all, delete-orphan",
+                             cascade_backrefs=False, back_populates='pull_request')

     @classmethod
     def query(cls, reviewer_id=None, include_closed=True, sorted=False):
@@ -2142,7 +2210,8 @@
     user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)

     user = relationship('User')
-    pull_request = relationship('PullRequest')
+    pull_request = relationship('PullRequest',
+                                cascade_backrefs=False, back_populates='reviewers')

     def __json__(self):
         return dict(
@@ -2262,7 +2331,8 @@
     created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
     last_seen = Column(DateTime(timezone=False), nullable=True)

-    user = relationship('User')
+    user = relationship('User',
+                        cascade_backrefs=False, back_populates='ssh_keys')

     @property
     def public_key(self):

________________________________________
差出人: 橋本 憲一 <hashimoto.kenichi at genetec.co.jp>
送信日時: 2026年11月6日 12:37
宛先: kallithea-general at sfconservancy.org
件名: [PATCH 0 of 1] db: link relationship pairs that share a foreign key

SQLAlchemy 1.4 prints 35 relationship overlap warnings when the models are
configured. This patch links each pair with back_populates so they go away.
It also fixes a hang of the Hg ssh tests on 1.4; the commit message has the
details.

Tested with the full test suite on stable 4c5d75319b9e, Python 3.11.15,
NetBSD 11.0:

                       stable                  with this patch
  SQLAlchemy 1.4.54    35 warnings             0 warnings
                       ssh tests hang          ssh tests pass
                       (20 tests deselected)
  SQLAlchemy 1.3.24    0 warnings              0 warnings

1.3 does not emit these warnings, so it is there to check that nothing
changes. I did not test 1.2.

In all four runs, the same 14 tests failed and the same 3 tests had
errors, with and without the patch. Two of the failures are the
markup_renderer doctests, which fail with bleach 6 (I sent a separate
patch for that). The others are 500 responses and fixture errors in the
summary, fork and admin repo tests. They fail the same way on unpatched
stable in this environment.



More information about the kallithea-general mailing list