Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# orm/dynamic.py 

2# Copyright (C) 2005-2020 the SQLAlchemy authors and contributors 

3# <see AUTHORS file> 

4# 

5# This module is part of SQLAlchemy and is released under 

6# the MIT License: http://www.opensource.org/licenses/mit-license.php 

7 

8"""Dynamic collection API. 

9 

10Dynamic collections act like Query() objects for read operations and support 

11basic add/delete mutation. 

12 

13""" 

14 

15from . import attributes 

16from . import exc as orm_exc 

17from . import interfaces 

18from . import object_mapper 

19from . import object_session 

20from . import properties 

21from . import strategies 

22from . import util as orm_util 

23from .query import Query 

24from .. import exc 

25from .. import log 

26from .. import util 

27 

28 

29@log.class_logger 

30@properties.RelationshipProperty.strategy_for(lazy="dynamic") 

31class DynaLoader(strategies.AbstractRelationshipLoader): 

32 def init_class_attribute(self, mapper): 

33 self.is_class_level = True 

34 if not self.uselist: 

35 raise exc.InvalidRequestError( 

36 "On relationship %s, 'dynamic' loaders cannot be used with " 

37 "many-to-one/one-to-one relationships and/or " 

38 "uselist=False." % self.parent_property 

39 ) 

40 elif self.parent_property.direction not in ( 

41 interfaces.ONETOMANY, 

42 interfaces.MANYTOMANY, 

43 ): 

44 util.warn( 

45 "On relationship %s, 'dynamic' loaders cannot be used with " 

46 "many-to-one/one-to-one relationships and/or " 

47 "uselist=False. This warning will be an exception in a " 

48 "future release." % self.parent_property 

49 ) 

50 

51 strategies._register_attribute( 

52 self.parent_property, 

53 mapper, 

54 useobject=True, 

55 impl_class=DynamicAttributeImpl, 

56 target_mapper=self.parent_property.mapper, 

57 order_by=self.parent_property.order_by, 

58 query_class=self.parent_property.query_class, 

59 ) 

60 

61 

62class DynamicAttributeImpl(attributes.AttributeImpl): 

63 uses_objects = True 

64 default_accepts_scalar_loader = False 

65 supports_population = False 

66 collection = False 

67 dynamic = True 

68 

69 def __init__( 

70 self, 

71 class_, 

72 key, 

73 typecallable, 

74 dispatch, 

75 target_mapper, 

76 order_by, 

77 query_class=None, 

78 **kw 

79 ): 

80 super(DynamicAttributeImpl, self).__init__( 

81 class_, key, typecallable, dispatch, **kw 

82 ) 

83 self.target_mapper = target_mapper 

84 self.order_by = order_by 

85 if not query_class: 

86 self.query_class = AppenderQuery 

87 elif AppenderMixin in query_class.mro(): 

88 self.query_class = query_class 

89 else: 

90 self.query_class = mixin_user_query(query_class) 

91 

92 def get(self, state, dict_, passive=attributes.PASSIVE_OFF): 

93 if not passive & attributes.SQL_OK: 

94 return self._get_collection_history( 

95 state, attributes.PASSIVE_NO_INITIALIZE 

96 ).added_items 

97 else: 

98 return self.query_class(self, state) 

99 

100 def get_collection( 

101 self, 

102 state, 

103 dict_, 

104 user_data=None, 

105 passive=attributes.PASSIVE_NO_INITIALIZE, 

106 ): 

107 if not passive & attributes.SQL_OK: 

108 return self._get_collection_history(state, passive).added_items 

109 else: 

110 history = self._get_collection_history(state, passive) 

111 return history.added_plus_unchanged 

112 

113 @util.memoized_property 

114 def _append_token(self): 

115 return attributes.Event(self, attributes.OP_APPEND) 

116 

117 @util.memoized_property 

118 def _remove_token(self): 

119 return attributes.Event(self, attributes.OP_REMOVE) 

120 

121 def fire_append_event( 

122 self, state, dict_, value, initiator, collection_history=None 

123 ): 

124 if collection_history is None: 

125 collection_history = self._modified_event(state, dict_) 

126 

127 collection_history.add_added(value) 

128 

129 for fn in self.dispatch.append: 

130 value = fn(state, value, initiator or self._append_token) 

131 

132 if self.trackparent and value is not None: 

133 self.sethasparent(attributes.instance_state(value), state, True) 

134 

135 def fire_remove_event( 

136 self, state, dict_, value, initiator, collection_history=None 

137 ): 

138 if collection_history is None: 

139 collection_history = self._modified_event(state, dict_) 

140 

141 collection_history.add_removed(value) 

142 

143 if self.trackparent and value is not None: 

144 self.sethasparent(attributes.instance_state(value), state, False) 

145 

146 for fn in self.dispatch.remove: 

147 fn(state, value, initiator or self._remove_token) 

148 

149 def _modified_event(self, state, dict_): 

150 

151 if self.key not in state.committed_state: 

152 state.committed_state[self.key] = CollectionHistory(self, state) 

153 

154 state._modified_event(dict_, self, attributes.NEVER_SET) 

155 

156 # this is a hack to allow the fixtures.ComparableEntity fixture 

157 # to work 

158 dict_[self.key] = True 

159 return state.committed_state[self.key] 

160 

161 def set( 

162 self, 

163 state, 

164 dict_, 

165 value, 

166 initiator=None, 

167 passive=attributes.PASSIVE_OFF, 

168 check_old=None, 

169 pop=False, 

170 _adapt=True, 

171 ): 

172 if initiator and initiator.parent_token is self.parent_token: 

173 return 

174 

175 if pop and value is None: 

176 return 

177 

178 iterable = value 

179 new_values = list(iterable) 

180 if state.has_identity: 

181 old_collection = util.IdentitySet(self.get(state, dict_)) 

182 

183 collection_history = self._modified_event(state, dict_) 

184 if not state.has_identity: 

185 old_collection = collection_history.added_items 

186 else: 

187 old_collection = old_collection.union( 

188 collection_history.added_items 

189 ) 

190 

191 idset = util.IdentitySet 

192 constants = old_collection.intersection(new_values) 

193 additions = idset(new_values).difference(constants) 

194 removals = old_collection.difference(constants) 

195 

196 for member in new_values: 

197 if member in additions: 

198 self.fire_append_event( 

199 state, 

200 dict_, 

201 member, 

202 None, 

203 collection_history=collection_history, 

204 ) 

205 

206 for member in removals: 

207 self.fire_remove_event( 

208 state, 

209 dict_, 

210 member, 

211 None, 

212 collection_history=collection_history, 

213 ) 

214 

215 def delete(self, *args, **kwargs): 

216 raise NotImplementedError() 

217 

218 def set_committed_value(self, state, dict_, value): 

219 raise NotImplementedError( 

220 "Dynamic attributes don't support " "collection population." 

221 ) 

222 

223 def get_history(self, state, dict_, passive=attributes.PASSIVE_OFF): 

224 c = self._get_collection_history(state, passive) 

225 return c.as_history() 

226 

227 def get_all_pending( 

228 self, state, dict_, passive=attributes.PASSIVE_NO_INITIALIZE 

229 ): 

230 c = self._get_collection_history(state, passive) 

231 return [(attributes.instance_state(x), x) for x in c.all_items] 

232 

233 def _get_collection_history(self, state, passive=attributes.PASSIVE_OFF): 

234 if self.key in state.committed_state: 

235 c = state.committed_state[self.key] 

236 else: 

237 c = CollectionHistory(self, state) 

238 

239 if state.has_identity and (passive & attributes.INIT_OK): 

240 return CollectionHistory(self, state, apply_to=c) 

241 else: 

242 return c 

243 

244 def append( 

245 self, state, dict_, value, initiator, passive=attributes.PASSIVE_OFF 

246 ): 

247 if initiator is not self: 

248 self.fire_append_event(state, dict_, value, initiator) 

249 

250 def remove( 

251 self, state, dict_, value, initiator, passive=attributes.PASSIVE_OFF 

252 ): 

253 if initiator is not self: 

254 self.fire_remove_event(state, dict_, value, initiator) 

255 

256 def pop( 

257 self, state, dict_, value, initiator, passive=attributes.PASSIVE_OFF 

258 ): 

259 self.remove(state, dict_, value, initiator, passive=passive) 

260 

261 

262class AppenderMixin(object): 

263 query_class = None 

264 

265 def __init__(self, attr, state): 

266 super(AppenderMixin, self).__init__(attr.target_mapper, None) 

267 self.instance = instance = state.obj() 

268 self.attr = attr 

269 

270 mapper = object_mapper(instance) 

271 prop = mapper._props[self.attr.key] 

272 

273 if prop.secondary is not None: 

274 # this is a hack right now. The Query only knows how to 

275 # make subsequent joins() without a given left-hand side 

276 # from self._from_obj[0]. We need to ensure prop.secondary 

277 # is in the FROM. So we purposly put the mapper selectable 

278 # in _from_obj[0] to ensure a user-defined join() later on 

279 # doesn't fail, and secondary is then in _from_obj[1]. 

280 self._from_obj = (prop.mapper.selectable, prop.secondary) 

281 

282 self._criterion = prop._with_parent(instance, alias_secondary=False) 

283 

284 if self.attr.order_by: 

285 self._order_by = self.attr.order_by 

286 

287 def session(self): 

288 sess = object_session(self.instance) 

289 if ( 

290 sess is not None 

291 and self.autoflush 

292 and sess.autoflush 

293 and self.instance in sess 

294 ): 

295 sess.flush() 

296 if not orm_util.has_identity(self.instance): 

297 return None 

298 else: 

299 return sess 

300 

301 session = property(session, lambda s, x: None) 

302 

303 def __iter__(self): 

304 sess = self.session 

305 if sess is None: 

306 return iter( 

307 self.attr._get_collection_history( 

308 attributes.instance_state(self.instance), 

309 attributes.PASSIVE_NO_INITIALIZE, 

310 ).added_items 

311 ) 

312 else: 

313 return iter(self._clone(sess)) 

314 

315 def __getitem__(self, index): 

316 sess = self.session 

317 if sess is None: 

318 return self.attr._get_collection_history( 

319 attributes.instance_state(self.instance), 

320 attributes.PASSIVE_NO_INITIALIZE, 

321 ).indexed(index) 

322 else: 

323 return self._clone(sess).__getitem__(index) 

324 

325 def count(self): 

326 sess = self.session 

327 if sess is None: 

328 return len( 

329 self.attr._get_collection_history( 

330 attributes.instance_state(self.instance), 

331 attributes.PASSIVE_NO_INITIALIZE, 

332 ).added_items 

333 ) 

334 else: 

335 return self._clone(sess).count() 

336 

337 def _clone(self, sess=None): 

338 # note we're returning an entirely new Query class instance 

339 # here without any assignment capabilities; the class of this 

340 # query is determined by the session. 

341 instance = self.instance 

342 if sess is None: 

343 sess = object_session(instance) 

344 if sess is None: 

345 raise orm_exc.DetachedInstanceError( 

346 "Parent instance %s is not bound to a Session, and no " 

347 "contextual session is established; lazy load operation " 

348 "of attribute '%s' cannot proceed" 

349 % (orm_util.instance_str(instance), self.attr.key) 

350 ) 

351 

352 if self.query_class: 

353 query = self.query_class(self.attr.target_mapper, session=sess) 

354 else: 

355 query = sess.query(self.attr.target_mapper) 

356 

357 query._criterion = self._criterion 

358 query._from_obj = self._from_obj 

359 query._order_by = self._order_by 

360 

361 return query 

362 

363 def extend(self, iterator): 

364 for item in iterator: 

365 self.attr.append( 

366 attributes.instance_state(self.instance), 

367 attributes.instance_dict(self.instance), 

368 item, 

369 None, 

370 ) 

371 

372 def append(self, item): 

373 self.attr.append( 

374 attributes.instance_state(self.instance), 

375 attributes.instance_dict(self.instance), 

376 item, 

377 None, 

378 ) 

379 

380 def remove(self, item): 

381 self.attr.remove( 

382 attributes.instance_state(self.instance), 

383 attributes.instance_dict(self.instance), 

384 item, 

385 None, 

386 ) 

387 

388 

389class AppenderQuery(AppenderMixin, Query): 

390 """A dynamic query that supports basic collection storage operations.""" 

391 

392 

393def mixin_user_query(cls): 

394 """Return a new class with AppenderQuery functionality layered over.""" 

395 name = "Appender" + cls.__name__ 

396 return type(name, (AppenderMixin, cls), {"query_class": cls}) 

397 

398 

399class CollectionHistory(object): 

400 """Overrides AttributeHistory to receive append/remove events directly.""" 

401 

402 def __init__(self, attr, state, apply_to=None): 

403 if apply_to: 

404 coll = AppenderQuery(attr, state).autoflush(False) 

405 self.unchanged_items = util.OrderedIdentitySet(coll) 

406 self.added_items = apply_to.added_items 

407 self.deleted_items = apply_to.deleted_items 

408 self._reconcile_collection = True 

409 else: 

410 self.deleted_items = util.OrderedIdentitySet() 

411 self.added_items = util.OrderedIdentitySet() 

412 self.unchanged_items = util.OrderedIdentitySet() 

413 self._reconcile_collection = False 

414 

415 @property 

416 def added_plus_unchanged(self): 

417 return list(self.added_items.union(self.unchanged_items)) 

418 

419 @property 

420 def all_items(self): 

421 return list( 

422 self.added_items.union(self.unchanged_items).union( 

423 self.deleted_items 

424 ) 

425 ) 

426 

427 def as_history(self): 

428 if self._reconcile_collection: 

429 added = self.added_items.difference(self.unchanged_items) 

430 deleted = self.deleted_items.intersection(self.unchanged_items) 

431 unchanged = self.unchanged_items.difference(deleted) 

432 else: 

433 added, unchanged, deleted = ( 

434 self.added_items, 

435 self.unchanged_items, 

436 self.deleted_items, 

437 ) 

438 return attributes.History(list(added), list(unchanged), list(deleted)) 

439 

440 def indexed(self, index): 

441 return list(self.added_items)[index] 

442 

443 def add_added(self, value): 

444 self.added_items.add(value) 

445 

446 def add_removed(self, value): 

447 if value in self.added_items: 

448 self.added_items.remove(value) 

449 else: 

450 self.deleted_items.add(value)