Skip to content

Commit 5c5d4e3

Browse files
authored
Refetch single links when syncing (#911)
If a single link is changed as a result of a sync insert or update, the resulting link was always None. If the refetched link target is not passed as a sync argument, the refetch will now put an object with only `id` set.
1 parent d0328e1 commit 5c5d4e3

File tree

4 files changed

+267
-118
lines changed

4 files changed

+267
-118
lines changed

gel/_internal/_qbmodel/_abstract/_descriptors.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -771,7 +771,11 @@ def reconcile_link(
771771
else:
772772
return next(iter(link_to))
773773

774-
return new_objects[obj_id]
774+
if obj_id in new_objects:
775+
return new_objects[obj_id]
776+
777+
# The refetched object appeared out of nowhere.
778+
return refetched
775779

776780

777781
def reconcile_proxy_link(
@@ -799,8 +803,11 @@ def reconcile_proxy_link(
799803
link_to = _link_to
800804
else:
801805
link_to = next(iter(_link_to))
802-
else:
806+
elif obj_id in new_objects:
803807
link_to = new_objects[obj_id]
808+
else:
809+
# The refetched object appeared out of nowhere.
810+
link_to = refetched.without_linkprops()
804811

805812
# Make sure we create a new proxy model that
806813
# would wrap either an object that already exists

gel/_internal/_save.py

Lines changed: 135 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -640,12 +640,18 @@ def _add_refetch_shape(
640640
ref_shape.fields[field_name] = ptr_info
641641

642642
if obj.__gel_new__:
643-
# New objects should additionally refetch any computed properties
644643
for ptr_name, ptr_info in tp_pointers.items():
645-
if not ptr_info.computed or ptr_info.kind != PointerKind.Property:
646-
continue
644+
# New objects should refetch computed properties
645+
if ptr_info.kind == PointerKind.Property and ptr_info.computed:
646+
ref_shape.fields[ptr_name] = ptr_info
647647

648-
ref_shape.fields[ptr_name] = ptr_info
648+
# New objects should refetch single links
649+
if (
650+
ptr_info.kind == PointerKind.Link
651+
and not ptr_info.cardinality.is_multi()
652+
and ptr_name != "__type__"
653+
):
654+
ref_shape.fields[ptr_name] = ptr_info
649655

650656

651657
def push_refetch_new(
@@ -1347,13 +1353,17 @@ def _compile_refetch(
13471353
link_arg_order.append(ptr.name)
13481354
link_num = len(link_arg_order) - 1
13491355

1356+
link_shape_elems: list[str] = []
1357+
if not ptr.cardinality.is_multi():
1358+
link_shape_elems += ["id"]
13501359
if ptr.properties:
1351-
props = ",".join(
1360+
link_shape_elems += [
13521361
f"@{quote_ident(p)}" for p in ptr.properties
1353-
)
1354-
props = f"{{ {props} }}"
1355-
else:
1356-
props = ""
1362+
]
1363+
1364+
link_shape = ""
1365+
if link_shape_elems:
1366+
link_shape = f"{{ {','.join(link_shape_elems)} }}"
13571367

13581368
maybe_assert = maybe_assert_end = ""
13591369

@@ -1365,25 +1375,34 @@ def _compile_refetch(
13651375
if ptr.computed:
13661376
computed_filter = "or .id in __existing"
13671377

1368-
# The logic of the `filter` clause is:
1369-
# - first test if the link was fetched for this object
1370-
# at all - if not - we don't need to update it
1371-
# - if the link was fetched for this particular GelModel --
1372-
# filter it by all existing objects prior to sync() in
1373-
# this link PLUS all new objects that were inserted
1374-
# during sync().
1375-
select_shape.append(f"""
1376-
{quote_ident(ptr.name)} := {maybe_assert}(
1377-
with
1378-
__link := __obj_data.1[{link_num}]
1379-
select .{quote_ident(ptr.name)} {props}
1380-
filter __link.0 and (
1381-
.id in array_unpack(__link.1)
1382-
or .id in __new
1383-
{computed_filter}
1384-
)
1385-
){maybe_assert_end}
1386-
""")
1378+
if ptr.cardinality.is_multi():
1379+
# The logic of the `filter` clause is:
1380+
# - first test if the link was fetched for this object
1381+
# at all - if not - we don't need to update it
1382+
# - if the link was fetched for this particular
1383+
# GelModel -- filter it by all existing objects prior
1384+
# to sync() in this link PLUS all new objects that
1385+
# were inserted during sync().
1386+
select_shape.append(f"""
1387+
{quote_ident(ptr.name)} := {maybe_assert}(
1388+
with
1389+
__link := __obj_data.1[{link_num}]
1390+
select .{quote_ident(ptr.name)} {link_shape}
1391+
filter __link.0 and (
1392+
.id in array_unpack(__link.1)
1393+
or .id in __new
1394+
{computed_filter}
1395+
)
1396+
){maybe_assert_end}
1397+
""")
1398+
1399+
else:
1400+
# Single links should always refetch the link
1401+
select_shape.append(f"""
1402+
{quote_ident(ptr.name)} := {maybe_assert}(
1403+
.{quote_ident(ptr.name)} {link_shape}
1404+
){maybe_assert_end}
1405+
""")
13871406

13881407
else:
13891408
select_shape.append(quote_ident(ptr.name))
@@ -1568,6 +1587,8 @@ def _apply_refetched_data_shape(
15681587

15691588
model_or_models: GelModel | IDTracker[GelModel, None]
15701589
if gel_id in self.new_objects:
1590+
# For new objects, update the data we got back from the batch
1591+
# queries.
15711592
model_or_models = self.new_objects[gel_id]
15721593
else:
15731594
model_or_models = self.existing_objects[gel_id]
@@ -1593,66 +1614,86 @@ def _apply_refetched_data_shape(
15931614
continue
15941615

15951616
shape_field = shape.fields[field]
1596-
is_multi = shape_field.cardinality.is_multi()
1597-
is_link = shape_field.kind.is_link()
15981617

15991618
for model in models:
16001619
assert model.id == gel_id
1601-
model_dict = model.__dict__
1602-
1603-
model.__pydantic_fields_set__.add(field)
1604-
1605-
if is_link and is_multi:
1606-
link = model_dict.get(field)
1607-
if link is None:
1608-
# This instance never had this link, but
1609-
# now it will.
1610-
# TODO: this needs to be optimized.
1611-
link = copy.copy(new_value)
1612-
model_dict[field] = link
1613-
link.__gel_replace_with_empty__()
1614-
1615-
assert is_link_abstract_dlist(link)
1616-
assert type(new_value) is type(link)
1617-
model_dict[field] = link.__gel_reconcile__(
1618-
# no need to copy `new_value`,
1619-
# it will only be iterated over
1620-
new_value,
1621-
self.existing_objects, # type: ignore [arg-type]
1622-
self.new_objects, # type: ignore [arg-type]
1623-
)
1624-
1625-
elif is_link:
1626-
if new_value is None:
1627-
model_dict[field] = new_value
1628-
elif shape_field.properties:
1629-
model_dict[field] = reconcile_proxy_link(
1630-
existing=model_dict.get(field),
1631-
refetched=new_value, # pyright: ignore [reportArgumentType]
1632-
existing_objects=self.existing_objects, # type: ignore [arg-type]
1633-
new_objects=self.new_objects,
1634-
)
1635-
else:
1636-
model_dict[field] = reconcile_link(
1637-
existing=model_dict.get(field),
1638-
refetched=new_value,
1639-
existing_objects=self.existing_objects, # type: ignore [arg-type]
1640-
new_objects=self.new_objects,
1641-
)
16421620

1643-
elif not is_link:
1644-
# could be a multi prop, could be a single prop
1645-
# with an array value
1646-
if shape_field.mutable:
1647-
model_dict[field] = deepcopy(new_value)
1648-
else:
1649-
model_dict[field] = new_value
1621+
self._apply_refetched_field_to_model(
1622+
shape_field, new_value, model, deepcopy
1623+
)
16501624

16511625
# Let's be extra cautious and help GC a bit here. `obj` can
16521626
# have recursive references to other objects via links and
16531627
# computed backlinks.
16541628
obj.__dict__.clear()
16551629

1630+
def _apply_refetched_field_to_model(
1631+
self,
1632+
shape_field: GelPointerReflection,
1633+
new_value: Any,
1634+
model: GelModel,
1635+
deepcopy: Callable[[T], T],
1636+
) -> None:
1637+
field = shape_field.name
1638+
is_multi = shape_field.cardinality.is_multi()
1639+
is_link = shape_field.kind.is_link()
1640+
1641+
model_dict = model.__dict__
1642+
1643+
model.__pydantic_fields_set__.add(field)
1644+
1645+
if is_link and is_multi:
1646+
link = model_dict.get(field)
1647+
assert isinstance(new_value, AbstractCollection)
1648+
if link is None:
1649+
# This instance never had this link, but
1650+
# now it will.
1651+
# TODO: this needs to be optimized.
1652+
link = copy.copy(new_value)
1653+
model_dict[field] = link
1654+
link.__gel_replace_with_empty__()
1655+
1656+
assert is_link_abstract_dlist(link)
1657+
assert type(new_value) is type(link)
1658+
model_dict[field] = link.__gel_reconcile__(
1659+
# no need to copy `new_value`,
1660+
# it will only be iterated over
1661+
new_value, # type: ignore [arg-type]
1662+
self.existing_objects, # type: ignore [arg-type]
1663+
self.new_objects, # type: ignore [arg-type]
1664+
)
1665+
1666+
elif is_link:
1667+
existing = model_dict.get(field)
1668+
if (
1669+
new_value is None
1670+
or existing is None
1671+
or existing is DEFAULT_VALUE
1672+
):
1673+
model_dict[field] = new_value
1674+
elif shape_field.properties:
1675+
model_dict[field] = reconcile_proxy_link(
1676+
existing=existing,
1677+
refetched=new_value, # pyright: ignore [reportArgumentType]
1678+
existing_objects=self.existing_objects, # type: ignore [arg-type]
1679+
new_objects=self.new_objects,
1680+
)
1681+
else:
1682+
model_dict[field] = reconcile_link(
1683+
existing=existing,
1684+
refetched=new_value,
1685+
existing_objects=self.existing_objects, # type: ignore [arg-type]
1686+
new_objects=self.new_objects,
1687+
)
1688+
1689+
elif not is_link:
1690+
# could be a multi prop, could be a single prop
1691+
# with an array value
1692+
if shape_field.mutable:
1693+
model_dict[field] = deepcopy(new_value)
1694+
else:
1695+
model_dict[field] = new_value
1696+
16561697
def _commit(self) -> None:
16571698
for obj, new_id in self.new_object_ids.items():
16581699
assert new_id is not None
@@ -1662,25 +1703,36 @@ def _commit(self) -> None:
16621703
if self.refetch:
16631704
self._apply_refetched_data()
16641705

1706+
# Apply refetched data to new objects.
1707+
#
1708+
# There are 3 instances of a newly synced object:
1709+
# - The user instance
1710+
# - The batch instance
1711+
# - The refetch instance
1712+
#
1713+
# In _apply_refetched_data, the batch instance will be updated
1714+
# using data from the refetech instance. Here, we finally update
1715+
# the user's copy.
16651716
for obj, new_id in self.new_object_ids.items():
16661717
assert new_id is not None
16671718

16681719
updated = self.new_objects[new_id]
1669-
pydantic_set_fields = obj.__pydantic_fields_set__
16701720

16711721
for prop in get_pointers(type(obj)):
16721722
if (
16731723
# id is already set and should never change.
16741724
prop.name == "id"
16751725
# prop not refetched
16761726
or prop.name not in updated.__dict__
1677-
# TODO: Refetching links for new objects
1678-
or prop.kind == PointerKind.Link
16791727
):
16801728
continue
16811729

1682-
obj.__dict__[prop.name] = updated.__dict__[prop.name]
1683-
pydantic_set_fields.add(prop.name)
1730+
self._apply_refetched_field_to_model(
1731+
prop,
1732+
updated.__dict__[prop.name],
1733+
obj,
1734+
_identity_func,
1735+
)
16841736

16851737
self._commit_recursive()
16861738

tests/test_model_generator.py

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3916,26 +3916,18 @@ def test_modelgen_save_reload_links_04(self):
39163916

39173917
self.client.sync(tsl)
39183918

3919-
# Computed links should not be set -- we never fetched them
3920-
with self.assertRaisesRegex(
3921-
AttributeError, "'comp_req_wprop_friend' is not set"
3922-
):
3923-
assert tsl.comp_req_wprop_friend is not None # access field
3919+
# Computed single links should be refetched, but the ids
3920+
self.assertEqual(tsl.comp_req_wprop_friend, alice)
3921+
self.assertFalse(hasattr(tsl.comp_req_wprop_friend, "name"))
39243922

3925-
with self.assertRaisesRegex(
3926-
AttributeError, "'comp_req_friend' is not set"
3927-
):
3928-
assert tsl.comp_req_friend is not None # access field
3923+
self.assertEqual(tsl.comp_req_friend, alice)
3924+
self.assertFalse(hasattr(tsl.comp_req_friend, "name"))
39293925

3930-
with self.assertRaisesRegex(
3931-
AttributeError, "'comp_opt_friend' is not set"
3932-
):
3933-
assert tsl.comp_opt_friend is not None # access field
3926+
self.assertEqual(tsl.comp_opt_friend, alice)
3927+
self.assertFalse(hasattr(tsl.comp_opt_friend, "name"))
39343928

3935-
with self.assertRaisesRegex(
3936-
AttributeError, "'comp_opt_wprop_friend' is not set"
3937-
):
3938-
assert tsl.comp_opt_wprop_friend is not None # access field
3929+
self.assertEqual(tsl.comp_opt_wprop_friend, alice)
3930+
self.assertFalse(hasattr(tsl.comp_opt_wprop_friend, "name"))
39393931

39403932
# Make mypy happy
39413933
assert tsl.opt_friend is not None
@@ -4203,8 +4195,6 @@ def test_modelgen_save_reload_links_08(self):
42034195
)
42044196
self.assertEqual({gr.id for gr in alice.groups}, orig_groups | {g.id})
42054197

4206-
@tb.xfail
4207-
# link props aren't reloaded after save
42084198
def test_modelgen_save_reload_linkprops_01(self):
42094199
from models.orm import default
42104200

0 commit comments

Comments
 (0)