core-framework 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. core_framework/__init__.py +0 -0
  2. core_framework/alembic/comment/alembic/README +1 -0
  3. core_framework/alembic/comment/alembic/env.py +72 -0
  4. core_framework/alembic/comment/alembic/script.py.mako +28 -0
  5. core_framework/alembic/comment/alembic/versions/30334fd1347b_init.py +59 -0
  6. core_framework/alembic/comment/alembic/versions/a2b3c4d5e6f7_improve_comment_indexes.py +54 -0
  7. core_framework/alembic/comment/alembic/versions/bcc8e00cfc8b_add_extra_tables.py +64 -0
  8. core_framework/alembic/comment/alembic/versions/d1e2f3a4b5c6_add_comment_stats_dirty_table.py +29 -0
  9. core_framework/alembic/comment/alembic/versions/e3f4a5b6c7d8_cascade_delete_comment_descendants.py +49 -0
  10. core_framework/alembic/comment/alembic/versions/f7e6d5c4b3a2_comments_path_to_ltree.py +60 -0
  11. core_framework/alembic/comment/alembic.ini +52 -0
  12. core_framework/alembic/extension/alembic/README +1 -0
  13. core_framework/alembic/extension/alembic/env.py +98 -0
  14. core_framework/alembic/extension/alembic/script.py.mako +28 -0
  15. core_framework/alembic/extension/alembic/versions/0389226049cb_add_pg_trgm_extension.py +25 -0
  16. core_framework/alembic/extension/alembic/versions/5dc58b016cf5_add_citext_extension.py +25 -0
  17. core_framework/alembic/extension/alembic/versions/b0ba0d8a284e_add_pg_stat_statements_extension.py +25 -0
  18. core_framework/alembic/extension/alembic/versions/c9d0e1f2a3b4_add_ltree_extension.py +25 -0
  19. core_framework/alembic/extension/alembic.ini +147 -0
  20. core_framework/alembic/moderation/alembic/README +1 -0
  21. core_framework/alembic/moderation/alembic/env.py +98 -0
  22. core_framework/alembic/moderation/alembic/script.py.mako +28 -0
  23. core_framework/alembic/moderation/alembic/versions/085ba9021850_add_category_to_user_restrictions.py +93 -0
  24. core_framework/alembic/moderation/alembic/versions/5f9e4fc14a41_create_moderation_appeals_table.py +69 -0
  25. core_framework/alembic/moderation/alembic/versions/63e37381e73b_add_user_reports_table.py +33 -0
  26. core_framework/alembic/moderation/alembic/versions/6a2ae31b7ac6_add_moderation_actions_table.py +34 -0
  27. core_framework/alembic/moderation/alembic/versions/716aa1735c03_improve_indexes.py +36 -0
  28. core_framework/alembic/moderation/alembic/versions/7d243ddbfde1_add_post_reports_table.py +35 -0
  29. core_framework/alembic/moderation/alembic/versions/8fba1f72dd46_add_indexes.py +64 -0
  30. core_framework/alembic/moderation/alembic/versions/95cc35a51984_update_restriction_history.py +91 -0
  31. core_framework/alembic/moderation/alembic/versions/9ad79d0af730_add_unique_constraint_user_reports_.py +28 -0
  32. core_framework/alembic/moderation/alembic/versions/a5e569f5df1a_create_user_restrictions_table.py +38 -0
  33. core_framework/alembic/moderation/alembic/versions/b2c3d4e5f6a7_add_indexes.py +42 -0
  34. core_framework/alembic/moderation/alembic/versions/c3d4e5f6a7b8_improve_report_indexes.py +48 -0
  35. core_framework/alembic/moderation/alembic/versions/d4af74643ff5_add_internal_notes_table.py +38 -0
  36. core_framework/alembic/moderation/alembic/versions/db20f2fb7390_add_comment_reports_table.py +35 -0
  37. core_framework/alembic/moderation/alembic/versions/e66226952ea6_add_report_category_to_user_reports_.py +54 -0
  38. core_framework/alembic/moderation/alembic/versions/f5e8cb275c30_enforce_1_pending_appeal.py +29 -0
  39. core_framework/alembic/moderation/alembic/versions/fe1faad2832d_create_restriction_history_table.py +69 -0
  40. core_framework/alembic/moderation/alembic.ini +147 -0
  41. core_framework/alembic/post/alembic/README +1 -0
  42. core_framework/alembic/post/alembic/env.py +97 -0
  43. core_framework/alembic/post/alembic/script.py.mako +28 -0
  44. core_framework/alembic/post/alembic/versions/51542673f5c8_add_tables_for_muted_banned_users.py +41 -0
  45. core_framework/alembic/post/alembic/versions/5beeeae40a4a_add_post_views_table.py +45 -0
  46. core_framework/alembic/post/alembic/versions/620971509a8b_init.py +55 -0
  47. core_framework/alembic/post/alembic/versions/a1b2c3d4e5f6_add_indexes.py +44 -0
  48. core_framework/alembic/post/alembic/versions/c1d2e3f4a5b6_add_post_hashtags_table.py +36 -0
  49. core_framework/alembic/post/alembic/versions/e56723f2afff_add_post_stats_table.py +39 -0
  50. core_framework/alembic/post/alembic/versions/fbc723ac58cc_add_post_likes_table.py +32 -0
  51. core_framework/alembic/post/alembic.ini +149 -0
  52. core_framework/alembic/user/alembic/README +1 -0
  53. core_framework/alembic/user/alembic/env.py +98 -0
  54. core_framework/alembic/user/alembic/script.py.mako +28 -0
  55. core_framework/alembic/user/alembic/versions/1a8bb99726ed_remove_avatar_id_from_users.py +81 -0
  56. core_framework/alembic/user/alembic/versions/2ccacf455941_improve_indexes.py +34 -0
  57. core_framework/alembic/user/alembic/versions/47f47ce2110e_create_user_deletions_table.py +31 -0
  58. core_framework/alembic/user/alembic/versions/5976db3f0175_drop_user_states.py +26 -0
  59. core_framework/alembic/user/alembic/versions/62417002cf32_add_indexes.py +46 -0
  60. core_framework/alembic/user/alembic/versions/6f7ccf3c226b_refactor_user_login_events.py +66 -0
  61. core_framework/alembic/user/alembic/versions/73432817015b_add_user_preferences_table.py +33 -0
  62. core_framework/alembic/user/alembic/versions/765bc01a7a59_create_user_blocks_table.py +33 -0
  63. core_framework/alembic/user/alembic/versions/7a56631f9927_create_user_login_events_table.py +49 -0
  64. core_framework/alembic/user/alembic/versions/831611e589bc_create_user_state.py +31 -0
  65. core_framework/alembic/user/alembic/versions/83c98ab2a779_add_user_profiles_table.py +88 -0
  66. core_framework/alembic/user/alembic/versions/8a94362cad6d_create_user_role.py +31 -0
  67. core_framework/alembic/user/alembic/versions/94b973923895_add_user_change_history_table.py +97 -0
  68. core_framework/alembic/user/alembic/versions/cbc0f4efe84f_add_avatar_id_column_to_users_table.py +31 -0
  69. core_framework/alembic/user/alembic/versions/d8b98ac6b073_add_index_for_get_admin_user_ids_query.py +29 -0
  70. core_framework/alembic/user/alembic/versions/ddb70cc09d16_create_user_states_table.py +34 -0
  71. core_framework/alembic/user/alembic/versions/f9ba10815ecd_add_users_table.py +33 -0
  72. core_framework/alembic/user/alembic.ini +147 -0
  73. core_framework/api/__init__.py +0 -0
  74. core_framework/api/admin/__init__.py +0 -0
  75. core_framework/api/admin/comments/router.py +69 -0
  76. core_framework/api/admin/comments/schemas.py +53 -0
  77. core_framework/api/admin/moderation/__init__.py +0 -0
  78. core_framework/api/admin/moderation/router.py +205 -0
  79. core_framework/api/admin/moderation/schemas.py +110 -0
  80. core_framework/api/admin/posts/router.py +62 -0
  81. core_framework/api/admin/posts/schemas.py +29 -0
  82. core_framework/api/admin/router.py +17 -0
  83. core_framework/api/admin/users/__init__.py +0 -0
  84. core_framework/api/admin/users/router.py +181 -0
  85. core_framework/api/admin/users/schemas.py +137 -0
  86. core_framework/api/auth/__init__.py +0 -0
  87. core_framework/api/auth/router.py +21 -0
  88. core_framework/api/auth/schemas.py +28 -0
  89. core_framework/api/comments/authenticated/router.py +126 -0
  90. core_framework/api/comments/authenticated/schemas.py +27 -0
  91. core_framework/api/comments/public/router.py +103 -0
  92. core_framework/api/comments/public/schemas.py +36 -0
  93. core_framework/api/comments/router.py +9 -0
  94. core_framework/api/comments/schemas.py +17 -0
  95. core_framework/api/dependencies.py +168 -0
  96. core_framework/api/events/router.py +39 -0
  97. core_framework/api/events/schemas.py +20 -0
  98. core_framework/api/posts/authenticated/router.py +83 -0
  99. core_framework/api/posts/authenticated/schemas.py +37 -0
  100. core_framework/api/posts/public/router.py +100 -0
  101. core_framework/api/posts/public/schemas.py +39 -0
  102. core_framework/api/posts/router.py +9 -0
  103. core_framework/api/posts/schemas.py +39 -0
  104. core_framework/api/router.py +19 -0
  105. core_framework/api/schemas.py +9 -0
  106. core_framework/api/system/__init__.py +0 -0
  107. core_framework/api/system/router.py +108 -0
  108. core_framework/api/users/__init__.py +0 -0
  109. core_framework/api/users/authenticated/__init__.py +0 -0
  110. core_framework/api/users/authenticated/router.py +244 -0
  111. core_framework/api/users/authenticated/schemas.py +81 -0
  112. core_framework/api/users/public/__init__.py +0 -0
  113. core_framework/api/users/public/router.py +25 -0
  114. core_framework/api/users/public/schemas.py +7 -0
  115. core_framework/api/users/router.py +9 -0
  116. core_framework/api/users/shared/schemas.py +174 -0
  117. core_framework/application/__init__.py +0 -0
  118. core_framework/application/auth/__init__.py +0 -0
  119. core_framework/application/auth/access_service.py +26 -0
  120. core_framework/application/auth/auth_service.py +10 -0
  121. core_framework/application/auth/models.py +10 -0
  122. core_framework/application/bootstrap.py +19 -0
  123. core_framework/application/comments/admin_service.py +236 -0
  124. core_framework/application/comments/aggregation_service.py +28 -0
  125. core_framework/application/comments/authenticated_service.py +89 -0
  126. core_framework/application/comments/public_service.py +218 -0
  127. core_framework/application/events/README.md +26 -0
  128. core_framework/application/events/event_service.py +51 -0
  129. core_framework/application/events/event_token.py +46 -0
  130. core_framework/application/events/models.py +9 -0
  131. core_framework/application/moderation/__init__.py +0 -0
  132. core_framework/application/moderation/appeal_service.py +98 -0
  133. core_framework/application/moderation/moderator_service.py +46 -0
  134. core_framework/application/moderation/report_service.py +180 -0
  135. core_framework/application/moderation/scheduled_service.py +5 -0
  136. core_framework/application/moderation/user_service.py +180 -0
  137. core_framework/application/posts/admin_service.py +104 -0
  138. core_framework/application/posts/aggregation_service.py +28 -0
  139. core_framework/application/posts/authenticated_service.py +72 -0
  140. core_framework/application/posts/public_service.py +197 -0
  141. core_framework/application/shared/__init__.py +0 -0
  142. core_framework/application/shared/enums.py +16 -0
  143. core_framework/application/shared/exceptions.py +16 -0
  144. core_framework/application/shared/user_agent.py +24 -0
  145. core_framework/application/users/__init__.py +0 -0
  146. core_framework/application/users/admin_service.py +298 -0
  147. core_framework/application/users/authenticated_service.py +179 -0
  148. core_framework/application/users/public_service.py +7 -0
  149. core_framework/application/users/scheduled_service.py +5 -0
  150. core_framework/bundled_alembic.py +57 -0
  151. core_framework/core/__init__.py +37 -0
  152. core_framework/core/cache.py +234 -0
  153. core_framework/core/context.py +14 -0
  154. core_framework/core/database.py +111 -0
  155. core_framework/core/exception_handlers/__init__.py +3 -0
  156. core_framework/core/exception_handlers/comment.py +99 -0
  157. core_framework/core/exception_handlers/common.py +5 -0
  158. core_framework/core/exception_handlers/moderation.py +104 -0
  159. core_framework/core/exception_handlers/post.py +54 -0
  160. core_framework/core/exception_handlers/setup.py +80 -0
  161. core_framework/core/exception_handlers/user.py +72 -0
  162. core_framework/core/http_client.py +64 -0
  163. core_framework/core/logging.py +99 -0
  164. core_framework/core/middleware.py +64 -0
  165. core_framework/core/observability.py +36 -0
  166. core_framework/core/pagination.py +203 -0
  167. core_framework/core/redis.py +135 -0
  168. core_framework/core/runtime.py +66 -0
  169. core_framework/core/settings.py +189 -0
  170. core_framework/domains/__init__.py +0 -0
  171. core_framework/domains/comment/README.md +243 -0
  172. core_framework/domains/comment/__init__.py +25 -0
  173. core_framework/domains/comment/constants.py +3 -0
  174. core_framework/domains/comment/dependencies.py +29 -0
  175. core_framework/domains/comment/enums.py +11 -0
  176. core_framework/domains/comment/exceptions.py +31 -0
  177. core_framework/domains/comment/models.py +54 -0
  178. core_framework/domains/comment/repository.py +947 -0
  179. core_framework/domains/comment/service.py +259 -0
  180. core_framework/domains/moderation/README.md +138 -0
  181. core_framework/domains/moderation/__init__.py +47 -0
  182. core_framework/domains/moderation/dependencies.py +29 -0
  183. core_framework/domains/moderation/enums.py +62 -0
  184. core_framework/domains/moderation/exceptions.py +31 -0
  185. core_framework/domains/moderation/models.py +94 -0
  186. core_framework/domains/moderation/repository.py +828 -0
  187. core_framework/domains/moderation/service.py +334 -0
  188. core_framework/domains/post/README.md +182 -0
  189. core_framework/domains/post/__init__.py +22 -0
  190. core_framework/domains/post/constants.py +3 -0
  191. core_framework/domains/post/dependencies.py +29 -0
  192. core_framework/domains/post/enums.py +18 -0
  193. core_framework/domains/post/exceptions.py +21 -0
  194. core_framework/domains/post/models.py +53 -0
  195. core_framework/domains/post/repository.py +791 -0
  196. core_framework/domains/post/service.py +204 -0
  197. core_framework/domains/user/README.md +74 -0
  198. core_framework/domains/user/__init__.py +39 -0
  199. core_framework/domains/user/constants.py +8 -0
  200. core_framework/domains/user/dependencies.py +29 -0
  201. core_framework/domains/user/enums.py +19 -0
  202. core_framework/domains/user/exceptions.py +31 -0
  203. core_framework/domains/user/models.py +124 -0
  204. core_framework/domains/user/repository.py +612 -0
  205. core_framework/domains/user/service.py +257 -0
  206. core_framework/domains/user/utils.py +182 -0
  207. core_framework/main.py +104 -0
  208. core_framework/worker/__init__.py +0 -0
  209. core_framework/worker/main.py +56 -0
  210. core_framework/worker/schedules/__init__.py +35 -0
  211. core_framework/worker/schedules/schedule_aggregate_comment_stats.py +32 -0
  212. core_framework/worker/schedules/schedule_aggregate_post_view_counts.py +28 -0
  213. core_framework/worker/schedules/schedule_expired_account_deletions.py +24 -0
  214. core_framework/worker/schedules/schedule_expired_mute_lifts.py +24 -0
  215. core_framework/worker/tasks/__init__.py +11 -0
  216. core_framework/worker/tasks/process_account_deletion.py +13 -0
  217. core_framework/worker/tasks/process_aggregate_comment_stats.py +19 -0
  218. core_framework/worker/tasks/process_aggregate_post_stats.py +12 -0
  219. core_framework/worker/tasks/process_mute_lift.py +13 -0
  220. core_framework-0.3.0.dist-info/METADATA +22 -0
  221. core_framework-0.3.0.dist-info/RECORD +222 -0
  222. core_framework-0.3.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,947 @@
1
+ from datetime import datetime
2
+ from typing import Any, Literal
3
+
4
+ from asyncpg import Record
5
+
6
+ from core_framework.core.database import Postgres
7
+ from core_framework.domains.comment.constants import REDACTED_CONTENT_PLACEHOLDER
8
+ from core_framework.domains.comment.enums import CommentStatus, CommentSubjectType
9
+ from core_framework.domains.comment.exceptions import (
10
+ BaseCommentException,
11
+ CommentEditLimitReachedException,
12
+ CommentNotFoundException,
13
+ CommentUpdateNotFoundException,
14
+ MaxReplyDepthException,
15
+ ParentCommentNotFoundException,
16
+ )
17
+ from core_framework.domains.comment.models import Comment, CommentPreview, CommentStats, CommentWithMetadata
18
+ from core_framework.domains.user import REDACTED_AUTHOR_ID
19
+
20
+
21
+ class CommentRepository:
22
+ def __init__(self, write_database: Postgres, read_database: Postgres, strong_read_database: Postgres):
23
+ self.write_database = write_database
24
+ self.read_database = read_database
25
+ self.strong_read_database = strong_read_database
26
+
27
+ async def insert_comment(
28
+ self,
29
+ *,
30
+ comment_id: str,
31
+ author_id: str,
32
+ content: str,
33
+ subject_type: CommentSubjectType,
34
+ subject_id: str,
35
+ ) -> None:
36
+ insert_root_comment_query = """
37
+ insert into "comment".comments (
38
+ id,
39
+ author_id,
40
+ content,
41
+ subject_type,
42
+ subject_id,
43
+ path,
44
+ level
45
+ )
46
+ values (
47
+ $3,
48
+ $4,
49
+ $5,
50
+ $1::"comment".comment_subject_type,
51
+ $2,
52
+ text2ltree($6::text),
53
+ 0
54
+ )
55
+ """
56
+ insert_comment_stats_query = """
57
+ insert into "comment".comment_stats (comment_id)
58
+ values ($1)
59
+ """
60
+ async with self.write_database.get_transaction() as connection:
61
+ await connection.execute(
62
+ insert_root_comment_query,
63
+ subject_type.value,
64
+ subject_id,
65
+ comment_id,
66
+ author_id,
67
+ content,
68
+ comment_id,
69
+ )
70
+ await connection.execute(insert_comment_stats_query, comment_id)
71
+
72
+ async def insert_reply(
73
+ self,
74
+ *,
75
+ comment_id: str,
76
+ author_id: str,
77
+ content: str,
78
+ parent_comment_id: str,
79
+ max_reply_level: int,
80
+ ) -> None:
81
+ insert_reply_query = """
82
+ with parent_check as (
83
+ select id, status, level, path, subject_type, subject_id
84
+ from "comment".comments
85
+ where id = $1
86
+ ),
87
+ outcome as (
88
+ select
89
+ case
90
+ when not exists (select 1 from parent_check) then 'parent_not_found'
91
+ when (select status from parent_check limit 1) != 'active' then 'parent_not_found'
92
+ when (select level from parent_check limit 1) >= $2 then 'max_depth'
93
+ else 'ok'
94
+ end as result
95
+ ),
96
+ valid_parent as (
97
+ select path, level, subject_type, subject_id
98
+ from parent_check
99
+ where status = 'active' and level < $2
100
+ ),
101
+ insert_result as (
102
+ insert into "comment".comments (
103
+ id,
104
+ author_id,
105
+ content,
106
+ subject_type,
107
+ subject_id,
108
+ parent_comment_id,
109
+ path,
110
+ level
111
+ )
112
+ select
113
+ $3,
114
+ $4,
115
+ $5,
116
+ p.subject_type,
117
+ p.subject_id,
118
+ $1,
119
+ p.path || $6::text,
120
+ p.level + 1
121
+ from valid_parent p
122
+ returning id
123
+ )
124
+ select
125
+ (select id from insert_result limit 1) as inserted_id,
126
+ coalesce(
127
+ (select 'ok' from insert_result limit 1),
128
+ (select result from outcome)
129
+ ) as outcome
130
+ """
131
+ insert_comment_stats_query = """
132
+ insert into "comment".comment_stats (comment_id)
133
+ values ($1)
134
+ """
135
+ async with self.write_database.get_transaction() as connection:
136
+ row = await connection.fetchrow(
137
+ insert_reply_query,
138
+ parent_comment_id,
139
+ max_reply_level,
140
+ comment_id,
141
+ author_id,
142
+ content,
143
+ comment_id,
144
+ )
145
+ match row["outcome"]:
146
+ case "ok":
147
+ await connection.execute(insert_comment_stats_query, comment_id)
148
+ return
149
+ case "max_depth":
150
+ raise MaxReplyDepthException()
151
+ case "parent_not_found":
152
+ raise ParentCommentNotFoundException()
153
+ case _:
154
+ raise BaseCommentException("Unexpected reply insert outcome")
155
+
156
+ async def insert_user_restriction_lookup(
157
+ self,
158
+ *,
159
+ user_id: str,
160
+ restriction_type: Literal["muted", "banned"],
161
+ ) -> None:
162
+ query = """
163
+ merge into "comment".user_restrictions_lookup as target
164
+ using (values ($1, $2)) as source(user_id, restriction_type)
165
+ on target.user_id = source.user_id
166
+ when matched then
167
+ update set restriction_type = source.restriction_type
168
+ when not matched then
169
+ insert (user_id, restriction_type)
170
+ values (source.user_id, source.restriction_type)
171
+ """
172
+ async with self.write_database.get_connection() as connection:
173
+ await connection.execute(query, user_id, restriction_type)
174
+
175
+ async def delete_user_restriction_lookup(self, *, user_id: str) -> None:
176
+ query = """
177
+ delete from "comment".user_restrictions_lookup
178
+ where true
179
+ and user_id = $1
180
+ """
181
+ async with self.write_database.get_connection() as connection:
182
+ await connection.execute(query, user_id)
183
+
184
+ async def insert_user_block_lookup(self, *, blocker_id: str, blocked_id: str) -> None:
185
+ query = """
186
+ merge into "comment".user_blocks_lookup as target
187
+ using (values ($1, $2)) as source(blocker_id, blocked_id)
188
+ on target.blocker_id = source.blocker_id
189
+ and target.blocked_id = source.blocked_id
190
+ when not matched then
191
+ insert (blocker_id, blocked_id)
192
+ values (source.blocker_id, source.blocked_id)
193
+ """
194
+ async with self.write_database.get_connection() as connection:
195
+ await connection.execute(query, blocker_id, blocked_id)
196
+
197
+ async def delete_user_block_lookup(self, *, blocker_id: str, blocked_id: str) -> None:
198
+ query = """
199
+ delete from "comment".user_blocks_lookup
200
+ where true
201
+ and blocker_id = $1
202
+ and blocked_id = $2
203
+ """
204
+ async with self.write_database.get_connection() as connection:
205
+ await connection.execute(query, blocker_id, blocked_id)
206
+
207
+ async def delete_user(self, *, user_id: str) -> None:
208
+ redact_comments_query = """
209
+ update "comment".comments
210
+ set
211
+ content = $2,
212
+ author_id = $3,
213
+ updated_at = now()
214
+ where true
215
+ and author_id = $1
216
+ """
217
+ delete_restrictions_query = """
218
+ delete from "comment".user_restrictions_lookup
219
+ where true
220
+ and user_id = $1
221
+ """
222
+ delete_blocks_query = """
223
+ delete from "comment".user_blocks_lookup
224
+ where true
225
+ and (
226
+ blocker_id = $1
227
+ or blocked_id = $1
228
+ )
229
+ """
230
+ async with self.write_database.get_transaction() as connection:
231
+ await connection.execute(
232
+ redact_comments_query,
233
+ user_id,
234
+ REDACTED_CONTENT_PLACEHOLDER,
235
+ REDACTED_AUTHOR_ID,
236
+ )
237
+ await connection.execute(delete_restrictions_query, user_id)
238
+ await connection.execute(delete_blocks_query, user_id)
239
+
240
+ async def insert_comment_like(self, *, comment_id: str, liker_id: str) -> None:
241
+ query = """
242
+ insert into "comment".comment_likes (comment_id, liker_id)
243
+ select
244
+ $1::varchar,
245
+ $2::varchar
246
+ where exists (
247
+ select 1
248
+ from "comment".comments
249
+ where true
250
+ and id = $1::varchar
251
+ and status = 'active'::"comment".comment_status
252
+ )
253
+ on conflict (comment_id, liker_id) do nothing
254
+ """
255
+ async with self.write_database.get_connection() as connection:
256
+ await connection.execute(query, comment_id, liker_id)
257
+
258
+ async def delete_comment_like(self, *, comment_id: str, liker_id: str) -> None:
259
+ query = """
260
+ delete from "comment".comment_likes
261
+ where true
262
+ and comment_id = $1
263
+ and liker_id = $2
264
+ """
265
+ async with self.write_database.get_connection() as connection:
266
+ await connection.execute(query, comment_id, liker_id)
267
+
268
+ async def update_comment(
269
+ self,
270
+ *,
271
+ comment_id: str,
272
+ author_id: str,
273
+ comment_updates: dict[str, Any],
274
+ max_edit_count: int,
275
+ ) -> None:
276
+ content = comment_updates.get("content")
277
+ if content is None:
278
+ return
279
+
280
+ query = """
281
+ with comment_check as (
282
+ select
283
+ id,
284
+ edited_count
285
+ from "comment".comments
286
+ where true
287
+ and id = $1
288
+ and author_id = $2
289
+ and status = 'active'::"comment".comment_status
290
+ ),
291
+ update_result as (
292
+ update "comment".comments c
293
+ set
294
+ content = $3,
295
+ edited_count = case when c.content is distinct from $3
296
+ then c.edited_count + 1 else c.edited_count end,
297
+ edited_at = case when c.content is distinct from $3
298
+ then now() else c.edited_at end,
299
+ updated_at = now()
300
+ from comment_check cc
301
+ where true
302
+ and c.id = cc.id
303
+ and cc.edited_count < $4
304
+ returning 1
305
+ )
306
+ select
307
+ (select count(*) from comment_check) as found,
308
+ (select count(*) from update_result) as rows_updated
309
+ """
310
+ async with self.write_database.get_connection() as connection:
311
+ row = await connection.fetchrow(
312
+ query,
313
+ comment_id,
314
+ author_id,
315
+ content,
316
+ max_edit_count,
317
+ )
318
+ if row["found"] == 0:
319
+ raise CommentUpdateNotFoundException()
320
+ if row["rows_updated"] == 0:
321
+ raise CommentEditLimitReachedException()
322
+
323
+ async def update_comment_status_by_id_and_author(
324
+ self,
325
+ *,
326
+ comment_id: str,
327
+ author_id: str,
328
+ status: CommentStatus,
329
+ ) -> str | None:
330
+ query = """
331
+ with upd as (
332
+ update "comment".comments
333
+ set
334
+ status = $3::"comment".comment_status,
335
+ updated_at = now()
336
+ where true
337
+ and id = $1
338
+ and author_id = $2
339
+ and status != $3::"comment".comment_status
340
+ returning id, parent_comment_id
341
+ ),
342
+ removed_dirty as (
343
+ delete from "comment".comment_stats_dirty d
344
+ using upd
345
+ where d.comment_id = upd.id
346
+ )
347
+ select parent_comment_id from upd
348
+ """
349
+ async with self.write_database.get_connection() as connection:
350
+ row = await connection.fetchrow(query, comment_id, author_id, status.value)
351
+ return row["parent_comment_id"] if row else None
352
+
353
+ async def update_comment_status_by_id(
354
+ self,
355
+ *,
356
+ comment_id: str,
357
+ status: CommentStatus,
358
+ ) -> str | None:
359
+ query = """
360
+ with upd as (
361
+ update "comment".comments
362
+ set
363
+ status = $2::"comment".comment_status,
364
+ updated_at = now()
365
+ where id = $1
366
+ returning id, parent_comment_id
367
+ ),
368
+ removed_dirty as (
369
+ delete from "comment".comment_stats_dirty d
370
+ using upd
371
+ where d.comment_id = upd.id
372
+ )
373
+ select parent_comment_id from upd
374
+ """
375
+ async with self.write_database.get_connection() as connection:
376
+ row = await connection.fetchrow(query, comment_id, status.value)
377
+ if row is None:
378
+ raise CommentNotFoundException()
379
+ return row["parent_comment_id"]
380
+
381
+ async def select_comment_ids_by_subject_strong(
382
+ self,
383
+ *,
384
+ subject_type: CommentSubjectType,
385
+ subject_id: str,
386
+ ) -> set[str]:
387
+ query = """
388
+ select id
389
+ from "comment".comments
390
+ where true
391
+ and subject_type = $1::"comment".comment_subject_type
392
+ and subject_id = $2
393
+ """
394
+ async with self.strong_read_database.get_connection() as connection:
395
+ rows = await connection.fetch(query, subject_type.value, subject_id)
396
+ return {row["id"] for row in rows}
397
+
398
+ async def select_active_comment_count_for_post(self, *, post_id: str) -> int:
399
+ # Active rows only if no soft-deleted ancestor on this post (a.path @> c.path).
400
+ query = """
401
+ select count(*)
402
+ from "comment".comments c
403
+ where true
404
+ and c.subject_type = 'post'::"comment".comment_subject_type
405
+ and c.subject_id = $1
406
+ and c.status = 'active'::"comment".comment_status
407
+ and not exists (
408
+ select 1
409
+ from "comment".comments a
410
+ where true
411
+ and a.subject_type = 'post'::"comment".comment_subject_type
412
+ and a.subject_id = $1
413
+ and a.path @> c.path
414
+ and a.status = 'deleted'::"comment".comment_status
415
+ )
416
+ """
417
+ async with self.read_database.get_connection() as connection:
418
+ value = await connection.fetchval(query, post_id)
419
+ return int(value or 0)
420
+
421
+ async def select_comment_subtree_ids_strong(self, *, root_comment_id: str) -> set[str]:
422
+ query = """
423
+ with root_comment as (
424
+ select subject_type, subject_id, path
425
+ from "comment".comments
426
+ where id = $1
427
+ )
428
+ select c.id
429
+ from "comment".comments c
430
+ join root_comment r
431
+ on c.subject_type = r.subject_type
432
+ and c.subject_id = r.subject_id
433
+ and c.path <@ r.path
434
+ """
435
+ async with self.strong_read_database.get_connection() as connection:
436
+ rows = await connection.fetch(query, root_comment_id)
437
+ return {row["id"] for row in rows}
438
+
439
+ async def select_parent_comment_id_strong(self, *, comment_id: str) -> str | None:
440
+ query = """
441
+ select parent_comment_id
442
+ from "comment".comments
443
+ where id = $1
444
+ """
445
+ async with self.strong_read_database.get_connection() as connection:
446
+ return await connection.fetchval(query, comment_id)
447
+
448
+ async def delete_comment_subtree(self, *, root_comment_id: str) -> None:
449
+ query = """
450
+ delete from "comment".comments
451
+ where id = $1
452
+ """
453
+ async with self.write_database.get_connection() as connection:
454
+ await connection.execute(query, root_comment_id)
455
+
456
+ async def delete_comments_by_subject(
457
+ self,
458
+ *,
459
+ subject_type: CommentSubjectType,
460
+ subject_id: str,
461
+ ) -> None:
462
+ query = """
463
+ delete from "comment".comments
464
+ where true
465
+ and subject_type = $1::"comment".comment_subject_type
466
+ and subject_id = $2
467
+ """
468
+ async with self.write_database.get_connection() as connection:
469
+ await connection.execute(query, subject_type.value, subject_id)
470
+
471
+ async def select_comment_ids_liked_by_user(self, *, liker_id: str, comment_ids: set[str]) -> list[str]:
472
+ if not comment_ids:
473
+ return []
474
+ query = """
475
+ select comment_id
476
+ from "comment".comment_likes
477
+ where true
478
+ and liker_id = $1
479
+ and comment_id = any($2::varchar[])
480
+ """
481
+ async with self.read_database.get_connection() as connection:
482
+ rows = await connection.fetch(query, liker_id, list(comment_ids))
483
+ return [row["comment_id"] for row in rows]
484
+
485
+ async def select_comment_content_mapping(self, *, comment_ids: set[str]) -> dict[str, str]:
486
+ if not comment_ids:
487
+ return {}
488
+ query = """
489
+ select id, content
490
+ from "comment".comments
491
+ where id = any($1::varchar[])
492
+ """
493
+ async with self.read_database.get_connection() as connection:
494
+ result = await connection.fetch(query, list(comment_ids))
495
+ return {row["id"]: row["content"] or "" for row in result}
496
+
497
+ async def select_comment_stats_mapping(self, *, comment_ids: set[str]) -> dict[str, CommentStats]:
498
+ if not comment_ids:
499
+ return {}
500
+ query = """
501
+ select
502
+ comment_id,
503
+ like_count,
504
+ reply_count,
505
+ report_count
506
+ from "comment".comment_stats
507
+ where comment_id = any($1::varchar[])
508
+ """
509
+ async with self.read_database.get_connection() as connection:
510
+ result = await connection.fetch(query, list(comment_ids))
511
+ return {
512
+ row["comment_id"]: CommentStats(
513
+ like_count=row["like_count"],
514
+ reply_count=row["reply_count"],
515
+ report_count=row["report_count"],
516
+ )
517
+ for row in result
518
+ }
519
+
520
+ async def insert_comment_stats_dirty(self, *, comment_id: str) -> None:
521
+ query = """
522
+ insert into "comment".comment_stats_dirty (comment_id)
523
+ values ($1)
524
+ on conflict (comment_id) do nothing
525
+ """
526
+ async with self.write_database.get_connection() as connection:
527
+ await connection.execute(query, comment_id)
528
+
529
+ async def claim_comment_ids_dirty(self) -> list[str]:
530
+ query = """
531
+ delete from "comment".comment_stats_dirty
532
+ returning comment_id
533
+ """
534
+ async with self.write_database.get_transaction() as connection:
535
+ rows = await connection.fetch(query)
536
+ return [row["comment_id"] for row in rows]
537
+
538
+ async def delete_comment_stats_dirty(self, *, comment_ids: set[str]) -> None:
539
+ if not comment_ids:
540
+ return
541
+ query = """
542
+ delete from "comment".comment_stats_dirty
543
+ where comment_id = any($1::varchar[])
544
+ """
545
+ async with self.write_database.get_connection() as connection:
546
+ await connection.execute(query, list(comment_ids))
547
+
548
+ async def update_comment_stats(
549
+ self,
550
+ *,
551
+ comment_id: str,
552
+ like_count: int,
553
+ reply_count: int,
554
+ report_count: int,
555
+ ) -> None:
556
+ query = """
557
+ update "comment".comment_stats
558
+ set
559
+ like_count = $2,
560
+ reply_count = $3,
561
+ report_count = $4,
562
+ updated_at = now()
563
+ where comment_id = $1
564
+ """
565
+ async with self.write_database.get_connection() as connection:
566
+ await connection.execute(
567
+ query,
568
+ comment_id,
569
+ like_count,
570
+ reply_count,
571
+ report_count,
572
+ )
573
+
574
+ async def select_comment_like_count(self, *, comment_id: str) -> int:
575
+ query = """
576
+ select count(*)
577
+ from "comment".comment_likes
578
+ where comment_id = $1
579
+ """
580
+ async with self.read_database.get_connection() as connection:
581
+ return await connection.fetchval(query, comment_id) or 0
582
+
583
+ async def select_comment_reply_count(self, *, comment_id: str) -> int:
584
+ query = """
585
+ select count(*)
586
+ from "comment".comments
587
+ where true
588
+ and parent_comment_id = $1
589
+ and status = 'active'::"comment".comment_status
590
+ """
591
+ async with self.read_database.get_connection() as connection:
592
+ return await connection.fetchval(query, comment_id) or 0
593
+
594
+ async def select_comments_with_metadata(
595
+ self,
596
+ *,
597
+ cursor: datetime,
598
+ limit: int,
599
+ ) -> list[CommentWithMetadata]:
600
+ query = """
601
+ select
602
+ c.id,
603
+ c.author_id,
604
+ c.content,
605
+ c.subject_type,
606
+ c.subject_id,
607
+ c.parent_comment_id,
608
+ c.status,
609
+ c.edited_count,
610
+ c.edited_at,
611
+ c.created_at
612
+ from "comment".comments c
613
+ where true
614
+ and c.created_at <= $1
615
+ order by c.created_at desc
616
+ limit $2
617
+ """
618
+ async with self.read_database.get_connection() as connection:
619
+ result = await connection.fetch(query, cursor, limit)
620
+ return [self._map_comment_with_metadata_row(row) for row in result]
621
+
622
+ async def select_comment_with_metadata_by_id(self, *, comment_id: str) -> CommentWithMetadata:
623
+ query = """
624
+ select
625
+ c.id,
626
+ c.author_id,
627
+ c.content,
628
+ c.subject_type,
629
+ c.subject_id,
630
+ c.parent_comment_id,
631
+ c.status,
632
+ c.edited_count,
633
+ c.edited_at,
634
+ c.created_at
635
+ from "comment".comments c
636
+ where c.id = $1
637
+ """
638
+ async with self.read_database.get_connection() as connection:
639
+ row = await connection.fetchrow(query, comment_id)
640
+ if row is None:
641
+ raise CommentNotFoundException()
642
+ return self._map_comment_with_metadata_row(row)
643
+
644
+ async def select_comment_preview_mapping(self, *, comment_ids: set[str]) -> dict[str, CommentPreview]:
645
+ if not comment_ids:
646
+ return {}
647
+ query = """
648
+ select
649
+ id,
650
+ author_id,
651
+ content,
652
+ status
653
+ from "comment".comments
654
+ where id = any($1::varchar[])
655
+ """
656
+ async with self.read_database.get_connection() as connection:
657
+ result = await connection.fetch(query, list(comment_ids))
658
+ return {row["id"]: self._map_comment_preview_row(row) for row in result}
659
+
660
+ async def select_comments_by_subject(
661
+ self,
662
+ *,
663
+ subject_type: CommentSubjectType,
664
+ subject_id: str,
665
+ cursor: datetime,
666
+ limit: int,
667
+ viewer_id: str | None,
668
+ ) -> list[Comment]:
669
+ query = """
670
+ with viewer_context as (
671
+ select exists(
672
+ select 1
673
+ from "comment".user_restrictions_lookup
674
+ where true
675
+ and user_id = $3
676
+ and restriction_type = 'muted'
677
+ ) as is_viewer_muted
678
+ )
679
+ select
680
+ c.id,
681
+ c.author_id,
682
+ c.content,
683
+ c.edited_count,
684
+ c.edited_at,
685
+ c.level,
686
+ c.created_at
687
+ from "comment".comments c
688
+ left join "comment".user_restrictions_lookup r
689
+ on r.user_id = c.author_id
690
+ cross join viewer_context vc
691
+ where true
692
+ and c.subject_type = $1::"comment".comment_subject_type
693
+ and c.subject_id = $2
694
+ and c.status = 'active'::"comment".comment_status
695
+ and c.created_at <= $4
696
+ and (
697
+ r.user_id is null
698
+ or (
699
+ r.restriction_type = 'muted'
700
+ and $3 is not null
701
+ and vc.is_viewer_muted
702
+ and c.author_id = $3
703
+ )
704
+ )
705
+ and (
706
+ $3 is null
707
+ or not exists(
708
+ select 1
709
+ from "comment".user_blocks_lookup b
710
+ where true
711
+ and b.blocker_id = $3
712
+ and b.blocked_id = c.author_id
713
+ )
714
+ )
715
+ order by c.created_at desc
716
+ limit $5
717
+ """
718
+ async with self.read_database.get_connection() as connection:
719
+ result = await connection.fetch(
720
+ query,
721
+ subject_type.value,
722
+ subject_id,
723
+ viewer_id,
724
+ cursor,
725
+ limit,
726
+ )
727
+ return [self._map_comment_row(row) for row in result]
728
+
729
+ async def select_comments_by_author_id(
730
+ self,
731
+ *,
732
+ author_id: str,
733
+ cursor: datetime,
734
+ limit: int,
735
+ viewer_id: str | None,
736
+ ) -> list[Comment]:
737
+ query = """
738
+ with viewer_context as (
739
+ select exists(
740
+ select 1
741
+ from "comment".user_restrictions_lookup
742
+ where true
743
+ and user_id = $2
744
+ and restriction_type = 'muted'
745
+ ) as is_viewer_muted
746
+ )
747
+ select
748
+ c.id,
749
+ c.author_id,
750
+ c.content,
751
+ c.edited_count,
752
+ c.edited_at,
753
+ c.level,
754
+ c.created_at
755
+ from "comment".comments c
756
+ left join "comment".user_restrictions_lookup r
757
+ on r.user_id = c.author_id
758
+ cross join viewer_context vc
759
+ where true
760
+ and c.author_id = $1
761
+ and c.status = 'active'::"comment".comment_status
762
+ and c.created_at <= $3
763
+ and (
764
+ r.user_id is null
765
+ or (
766
+ r.restriction_type = 'muted'
767
+ and $2 is not null
768
+ and vc.is_viewer_muted
769
+ and c.author_id = $2
770
+ )
771
+ )
772
+ and (
773
+ $2 is null
774
+ or not exists(
775
+ select 1
776
+ from "comment".user_blocks_lookup b
777
+ where true
778
+ and b.blocker_id = $2
779
+ and b.blocked_id = c.author_id
780
+ )
781
+ )
782
+ order by c.created_at desc
783
+ limit $4
784
+ """
785
+ async with self.read_database.get_connection() as connection:
786
+ result = await connection.fetch(
787
+ query,
788
+ author_id,
789
+ viewer_id,
790
+ cursor,
791
+ limit,
792
+ )
793
+ return [self._map_comment_row(row) for row in result]
794
+
795
+ async def select_replies_by_parent_id(
796
+ self,
797
+ *,
798
+ parent_comment_id: str,
799
+ cursor: datetime,
800
+ limit: int,
801
+ viewer_id: str | None,
802
+ ) -> list[Comment]:
803
+ query = """
804
+ with viewer_context as (
805
+ select exists(
806
+ select 1
807
+ from "comment".user_restrictions_lookup
808
+ where true
809
+ and user_id = $2
810
+ and restriction_type = 'muted'
811
+ ) as is_viewer_muted
812
+ )
813
+ select
814
+ c.id,
815
+ c.author_id,
816
+ c.content,
817
+ c.edited_count,
818
+ c.edited_at,
819
+ c.level,
820
+ c.created_at
821
+ from "comment".comments c
822
+ left join "comment".user_restrictions_lookup r
823
+ on r.user_id = c.author_id
824
+ cross join viewer_context vc
825
+ where true
826
+ and c.parent_comment_id = $1
827
+ and c.status = 'active'::"comment".comment_status
828
+ and c.created_at <= $3
829
+ and (
830
+ r.user_id is null
831
+ or (
832
+ r.restriction_type = 'muted'
833
+ and $2 is not null
834
+ and vc.is_viewer_muted
835
+ and c.author_id = $2
836
+ )
837
+ )
838
+ and (
839
+ $2 is null
840
+ or not exists(
841
+ select 1
842
+ from "comment".user_blocks_lookup b
843
+ where true
844
+ and b.blocker_id = $2
845
+ and b.blocked_id = c.author_id
846
+ )
847
+ )
848
+ order by c.created_at desc
849
+ limit $4
850
+ """
851
+ async with self.read_database.get_connection() as connection:
852
+ result = await connection.fetch(
853
+ query,
854
+ parent_comment_id,
855
+ viewer_id,
856
+ cursor,
857
+ limit,
858
+ )
859
+ return [self._map_comment_row(row) for row in result]
860
+
861
+ async def select_comment_by_id(
862
+ self,
863
+ *,
864
+ comment_id: str,
865
+ viewer_id: str | None,
866
+ ) -> Comment | None:
867
+ query = """
868
+ with viewer_context as (
869
+ select exists(
870
+ select 1
871
+ from "comment".user_restrictions_lookup
872
+ where true
873
+ and user_id = $2
874
+ and restriction_type = 'muted'
875
+ ) as is_viewer_muted
876
+ )
877
+ select
878
+ c.id,
879
+ c.author_id,
880
+ c.content,
881
+ c.edited_count,
882
+ c.edited_at,
883
+ c.level,
884
+ c.created_at
885
+ from "comment".comments c
886
+ left join "comment".user_restrictions_lookup r
887
+ on r.user_id = c.author_id
888
+ cross join viewer_context vc
889
+ where true
890
+ and c.id = $1
891
+ and c.status = 'active'::"comment".comment_status
892
+ and (
893
+ r.user_id is null
894
+ or (
895
+ r.restriction_type = 'muted'
896
+ and $2 is not null
897
+ and vc.is_viewer_muted
898
+ and c.author_id = $2
899
+ )
900
+ )
901
+ and (
902
+ $2 is null
903
+ or not exists(
904
+ select 1
905
+ from "comment".user_blocks_lookup b
906
+ where true
907
+ and b.blocker_id = $2
908
+ and b.blocked_id = c.author_id
909
+ )
910
+ )
911
+ """
912
+ async with self.read_database.get_connection() as connection:
913
+ row = await connection.fetchrow(query, comment_id, viewer_id)
914
+ return self._map_comment_row(row) if row is not None else None
915
+
916
+ def _map_comment_row(self, row: Record) -> Comment:
917
+ return Comment(
918
+ id=row["id"],
919
+ author_id=row["author_id"],
920
+ content=row["content"],
921
+ edited_count=row["edited_count"],
922
+ edited_at=row["edited_at"],
923
+ level=row["level"],
924
+ created_at=row["created_at"],
925
+ )
926
+
927
+ def _map_comment_with_metadata_row(self, row: Record) -> CommentWithMetadata:
928
+ return CommentWithMetadata(
929
+ id=row["id"],
930
+ author_id=row["author_id"],
931
+ content=row["content"],
932
+ subject_type=CommentSubjectType(row["subject_type"]),
933
+ subject_id=row["subject_id"],
934
+ parent_comment_id=row["parent_comment_id"],
935
+ status=CommentStatus(row["status"]),
936
+ edited_count=row["edited_count"],
937
+ edited_at=row["edited_at"],
938
+ created_at=row["created_at"],
939
+ )
940
+
941
+ def _map_comment_preview_row(self, row: Record) -> CommentPreview:
942
+ return CommentPreview(
943
+ id=row["id"],
944
+ author_id=row["author_id"],
945
+ content=row["content"] or "",
946
+ status=CommentStatus(row["status"]),
947
+ )