Coverage for gws-app/gws/plugin/model_field/related_linked_feature_list/__init__.py: 97%

86 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Related linked feature list field. 

2 

3Represents an M:N relationship between two models via a link table ("associative entity"):: 

4 

5 +------------+ +-------------------------------+ +-----------+ 

6 | this model | | linkTableName | | toModel | 

7 +------------+ +-------------------------------+ +-----------+ 

8 | fromColumn |-------<<| linkFromColumn linkToColumn |>>-------| toColumn | 

9 +------------+ +-------------------------------+ +-----------+ 

10 

11The value of the field is the list of linked features of the related model. 

12``fromColumn`` and ``toColumn`` default to the primary keys. The link table 

13must be in the same database as this model. 

14 

15When a feature is written, the rows of the link table are synchronized with 

16the field value: missing links are inserted, links to features no longer in 

17the list are deleted. The linked features themselves are not changed. When a 

18related feature is created together with features of this model, links to 

19them are inserted. Without a configured widget, the field uses a 

20``featureList`` widget. 

21 

22Example:: 

23 

24 fields+ { 

25 name "tags" 

26 type "relatedLinkedFeatureList" 

27 toModel "model_tag" 

28 linkTableName "edit.poi_tag" 

29 linkFromColumn "poi_id" 

30 linkToColumn "tag_id" 

31 } 

32""" 

33 

34import gws 

35import gws.base.database.model 

36import gws.base.model 

37import gws.base.model.related_field as related_field 

38import gws.lib.sa as sa 

39 

40 

41@gws.ext.config.modelField('relatedLinkedFeatureList') 

42class Config(related_field.Config): 

43 """Field listing features of another model, related via a link table.""" 

44 

45 fromColumn: str = '' 

46 """Key column in this table, primary key by default.""" 

47 toModel: str 

48 """UID of the related model.""" 

49 toColumn: str = '' 

50 """Key column in the related table, primary key by default.""" 

51 linkTableName: str 

52 """Link table name.""" 

53 linkFromColumn: str 

54 """Column in the link table that refers to this model.""" 

55 linkToColumn: str 

56 """Column in the link table that refers to the related model.""" 

57 

58 

59@gws.ext.props.modelField('relatedLinkedFeatureList') 

60class Props(related_field.Props): 

61 pass 

62 

63 

64@gws.ext.object.modelField('relatedLinkedFeatureList') 

65class Object(related_field.Object): 

66 """Related linked feature list field object.""" 

67 

68 attributeType = gws.AttributeType.featurelist 

69 

70 def configure_relationship(self): 

71 dst_mod = self.get_model(self.cfg('toModel')) 

72 link_tab = self.model.db.table(self.cfg('linkTableName')) 

73 

74 self.rel = related_field.Relationship( 

75 src=related_field.RelRef( 

76 model=self.model, 

77 keyName=self.model.column(self.cfg('fromColumn') or self.model.uidName).name, 

78 ), 

79 dstList=[ 

80 related_field.RelRef( 

81 model=dst_mod, 

82 keyName=dst_mod.column(self.cfg('toColumn') or dst_mod.uidName).name, 

83 ) 

84 ], 

85 link=related_field.Link( 

86 tableName=self.cfg('linkTableName'), 

87 srcKeyName=self.model.db.column(link_tab, self.cfg('linkFromColumn')).name, 

88 dstKeyName=self.model.db.column(link_tab, self.cfg('linkToColumn')).name, 

89 ), 

90 ) 

91 self.rel.dst = self.rel.dstList[0] 

92 

93 ## 

94 

95 def after_select(self, features, mc): 

96 if not mc.user.can_read(self) or mc.relDepth >= mc.maxDepth: 

97 return 

98 

99 for f in features: 

100 f.set(self.name, []) 

101 

102 uid_to_f = {f.uid(): f for f in features} 

103 

104 sql = ( 

105 sa.select( 

106 self.rel.dst.model.uid_column(), 

107 self.rel.src.model.uid_column(), 

108 ) 

109 .select_from( 

110 self.rel.dst.model.table().join( 

111 self.link_table(), 

112 self.link_column(self.rel.link.dstKeyName) == self.key_column(self.rel.dst), 

113 ).join( 

114 self.rel.src.model.table(), 

115 self.link_column(self.rel.link.srcKeyName) == self.key_column(self.rel.src), 

116 ) 

117 ) 

118 .where( 

119 self.rel.src.model.uid_equals(uid_to_f), 

120 ) 

121 ) 

122 

123 r_to_uids = {} 

124 with self.model.db.connect() as conn: 

125 for r, u in conn.execute(sql): 

126 r_to_uids.setdefault(str(r), []).append(str(u)) 

127 

128 for dst_feature in self.rel.dst.model.get_features( 

129 r_to_uids, 

130 gws.base.model.secondary_context(mc), 

131 ): 

132 for uid in r_to_uids.get(dst_feature.uid(), []): 

133 feature = uid_to_f.get(uid) 

134 feature.get(self.name).append(dst_feature) 

135 

136 def after_create_related(self, dst_feature, mc): 

137 if dst_feature.model != self.rel.dst.model: 

138 return 

139 

140 right_key = self.key_for_uid( 

141 self.rel.dst.model, 

142 self.key_column(self.rel.dst), 

143 dst_feature.insertedPrimaryKey, 

144 mc, 

145 ) 

146 new_links = set() 

147 

148 for feature in dst_feature.createWithFeatures: 

149 if feature.model == self.model: 

150 key = self.key_for_uid( 

151 self.rel.src.model, 

152 self.key_column(self.rel.src), 

153 feature.uid(), 

154 mc, 

155 ) 

156 new_links.add((key, right_key)) 

157 

158 self.create_links(new_links, mc) 

159 

160 def after_create(self, feature, mc): 

161 key = self.key_for_uid( 

162 self.model, 

163 self.key_column(self.rel.src), 

164 feature.insertedPrimaryKey, 

165 mc, 

166 ) 

167 self.after_write(feature, key, mc) 

168 

169 def after_update(self, feature, mc): 

170 key = self.key_for_uid(self.model, self.key_column(self.rel.src), feature.uid(), mc) 

171 self.after_write(feature, key, mc) 

172 

173 def after_write(self, feature, key, mc: gws.ModelContext): 

174 """Synchronize the link table with the field value of a written feature. 

175 

176 Does nothing if the user may not write the field or the maximum relation 

177 depth is reached. 

178 

179 Args: 

180 feature: The created or updated feature. 

181 key: Value of the key column of the feature. 

182 mc: The model context. 

183 """ 

184 if not mc.user.can_write(self) or mc.relDepth >= mc.maxDepth: 

185 return 

186 

187 cur_links = self.get_links([key], mc) 

188 dst_uids = set(dst_feature.uid() for dst_feature in feature.get(self.name, [])) 

189 

190 sql = sa.select( 

191 self.rel.dst.model.uid_column(), 

192 self.key_column(self.rel.dst), 

193 ).where( 

194 self.rel.dst.model.uid_equals(dst_uids), 

195 ) 

196 with self.rel.dst.model.db.connect() as conn: 

197 r_uid_to_key = {str(u): k for u, k in conn.execute(sql)} 

198 

199 new_links = set() 

200 

201 for dst_feature in feature.get(self.name, []): 

202 right_key = r_uid_to_key.get(dst_feature.uid()) 

203 new_links.add((key, right_key)) 

204 

205 self.create_links(new_links - cur_links, mc) 

206 self.delete_links(cur_links - new_links, mc) 

207 

208 def get_links(self, left_keys, mc): 

209 """Read the links of the given keys from the link table. 

210 

211 Args: 

212 left_keys: Key values of features of this model. 

213 mc: The model context. 

214 

215 Returns: 

216 A set of ``(src_key, dst_key)`` tuples. 

217 """ 

218 sql = sa.select( 

219 self.link_column(self.rel.link.srcKeyName), 

220 self.link_column(self.rel.link.dstKeyName), 

221 ).where( 

222 self.link_column(self.rel.link.srcKeyName).in_(left_keys), 

223 ) 

224 with self.model.db.connect() as conn: 

225 return set((lk, rk) for lk, rk in conn.execute(sql)) 

226 

227 def create_links(self, links, mc): 

228 """Insert links into the link table. 

229 

230 Args: 

231 links: ``(src_key, dst_key)`` tuples. 

232 mc: The model context. 

233 """ 

234 sql = sa.insert(self.link_table()) 

235 # fmt: off 

236 values = [ 

237 {self.rel.link.srcKeyName: lk, self.rel.link.dstKeyName: rk} 

238 for lk, rk in links 

239 ] 

240 # fmt: on 

241 if values: 

242 with self.model.db.connect() as conn: 

243 conn.execute(sql, values) 

244 

245 def delete_links(self, links, mc): 

246 """Delete links from the link table. 

247 

248 Args: 

249 links: ``(src_key, dst_key)`` tuples. 

250 mc: The model context. 

251 """ 

252 with self.model.db.connect() as conn: 

253 for lk, rk in links: 

254 sql = sa.delete( 

255 self.link_table(), 

256 ).where( 

257 self.link_column(self.rel.link.srcKeyName) == lk, 

258 self.link_column(self.rel.link.dstKeyName) == rk, 

259 ) 

260 conn.execute(sql)