Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NEW: Add/remove callbacks for relation lists #9572

Merged
merged 1 commit into from
Sep 18, 2020
Merged

NEW: Add/remove callbacks for relation lists #9572

merged 1 commit into from
Sep 18, 2020

Conversation

sminnee
Copy link
Member

@sminnee sminnee commented Jul 2, 2020

This provides a mechanism for adjusting the behaviour of these
relations when building more complex data models.

For example the following example has a status field incorporates a
Status field into the relationship:

function MyRelation() {
  $rel = $this->getManyManyComponents(‘MyRelation’);
  $rel = $rel->filter(‘Status’, ‘Active’);

  $rel->setAddCallback(function ($relation, $item, $extra) {
    $item->Status = ‘Active’;
    $item->write();
  });

  return $rel;
}

to do

  • refactor to allow multiple callbacks via sminnee/callbacklist

@kinglozzer
Copy link
Member

@sminnee I know @unclecheese is looking into “relation changed” hooks as part of some work on silverstripe-search-service: silverstripe/silverstripe-search-service#29 (comment). Do you think the same functionality you’re aiming for here could be achieved with those hooks + an extension?

src/ORM/HasManyList.php Outdated Show resolved Hide resolved
@sminnee
Copy link
Member Author

sminnee commented Jul 2, 2020

@sminnee I know @unclecheese is looking into “relation changed” hooks as part of some work on silverstripe-search-service: silverstripe/silverstripe-search-service#29 (comment). Do you think the same functionality you’re aiming for here could be achieved with those hooks + an extension?

At a minimum the two pieces of work should be considered together. My use-case is more "creating custom relations" than it is "responding to changes in relations", and while it's possible that the same extension points could be used for both, it's possible that they're not.

Notably, I very much don't want this use case to be driven by injecting event handlers system, wide, whereas I suspect that Aaron. does.

@sminnee sminnee marked this pull request as draft July 2, 2020 21:44
@sminnee
Copy link
Member Author

sminnee commented Jul 2, 2020

I've marked this as a draft until I talk to @unclecheese

@sminnee
Copy link
Member Author

sminnee commented Jul 2, 2020

Aaron's proposed DataObject::onRelationChange, which would hook into all the relations of a given dataobject without patching the relation methods.

This proposes a callback that you can apply to a specific RelationList object.

I think it's different, but perhaps a unified RelationList::executeOnAdd() method could call both parts:

  • Instance specific callback handlers
  • onRelationChanged events of the related DataObject

Any thoughts @unclecheese?

@sminnee sminnee requested a review from unclecheese July 2, 2020 22:58
@unclecheese
Copy link

With extensibility enhancements like this, I don't think we should worry about having too many options. I think the cases here have a bit of overlap, but not so much that I'm opposed to having both.

Sam, do you think we need to have callbacks for remove and setByIDList as well? I think the latter is the most consumed surface of the API.

@sminnee
Copy link
Member Author

sminnee commented Jul 2, 2020

I believe that setByIDList just calls add/remove internally, so I don't think it necessary and will risk creating bugs that appear only when you use setByIDList but not add.

Adding a remove callback would be consistent though.

@sminnee
Copy link
Member Author

sminnee commented Jul 2, 2020

A potential approach is that the feature in this PR is used to implement DataObject::onRelationChange(): DataObject, could insert a callback that calls the appropriate onRelationChange handler. This would meant that RelationList doesn't need to know about the semantics of DataObject::onRelationChange.

@ScopeyNZ
Copy link
Contributor

ScopeyNZ commented Jul 3, 2020

having too many options

I think we sometimes get pretty burned by our exhaustive extension API, and whether that falls into "public API" from a SemVer perspective.

If Sam's proposed API is more flexible, let's rely on that and use it in the search service rather than implementing two pieces of code that aim to achieve the same goal.

@sminnee
Copy link
Member Author

sminnee commented Jul 3, 2020

I don’t think the API I’ve proposed is a good fit for Aaron’s use-case, as he wants to be able to plug into any standard dataobject/relation. My suggestion was that the two APIs could be layered, however.

It would be possible (but clumsier) for me to achieve what needed to on this with DataObject::onRelationChange. My view is that both APIs have their use-case but I’m mindful of the risk of bloating the API surface.

If we were to go with only one of these, it would probably be DataObject::onRelationChange. However the best bet will be to address both of them in a single PR.

@ScopeyNZ
Copy link
Contributor

ScopeyNZ commented Jul 3, 2020

Sure. I trust you to make a good judgment for what's required here considering I didn't read too much into it. I was just a little scared about the idea of "too many options" meaning two chunks of API that serve the same (approximate) purpose.

chillu added a commit to open-sausages/silverstripe-framework that referenced this pull request Jul 10, 2020
chillu added a commit to open-sausages/silverstripe-framework that referenced this pull request Jul 10, 2020
@chillu
Copy link
Member

chillu commented Jul 10, 2020

I've followed up with a removeCallback in #9588. Both these implementations have an issue with list identity: It's not stable between calls. Given these lists can modify their return values through where() etc, we can't cache them. So it's just a matter of clearly documenting how to use these callbacks (in relation getters only).

chillu added a commit to open-sausages/silverstripe-framework that referenced this pull request Jul 11, 2020
@chillu
Copy link
Member

chillu commented Jul 14, 2020

Given these lists can modify their return values through where() etc, we can't cache them.

Hmm, maybe we can cache them, they were designed as value objects (returning clones on modification). But I think this isn't a blocker for merging these two PRs, it just limits their usefulness (and adds a few more sharp edges)

@sminnee
Copy link
Member Author

sminnee commented Jul 14, 2020

Hmm, maybe we can cache them, they were designed as value objects (returning clones on modification). But I think this isn't a blocker for merging these two PRs, it just limits their usefulness (and adds a few more sharp edges).

Adding caching, presumably to getManyManyComponents, sounds like a can of worms unrelated to this change IMO. I'm not saying that it's a bad idea, but that is should be considered as more of a general thing and not in relation to making this change more useful. This use case relevant in 0.1% of the situations that a caching change would impactt.

@chillu
Copy link
Member

chillu commented Jul 27, 2020

Talked with Sam, and we decided to fold my follow up PR into this one here, since they're really two sides of the same coin, and should be discussed+merged together: #9588

@chillu chillu marked this pull request as ready for review July 29, 2020 02:31
@chillu
Copy link
Member

chillu commented Jul 29, 2020

OK this is ready for merge from my perspective. Since it's a composite PR from Sam and myself now, maybe @kinglozzer @ScopeyNZ or @dhensby could look at this?

@sminnee
Copy link
Member Author

sminnee commented Jul 29, 2020

One thing about this work: should it be pushed up to DataList instead?

A DataList with an addCallback and removeCallback applied could be used to build a "virtual relationship". For example:

// data object Item has a Status Active/Inactive field
function OpenItems() {
  $items = Item::get()->filter(['Status' => 'Active']);
  $items->setAddCallback(function ($relation, $item, $extra) {
     $item->Status = "Active";
  });
  $items->setRemoveCallback(function ($relation, $item, $extra) {
     $item->Status = "Inactive";
  });
}

It would be mean that other data lists could be placed into GridFields (e.g. the top GridField of a ModelAdmin). Right now:

  • add is a no-op
  • remove calls delete

So for this to work, DataList would also need a mechanism for disabling the default DataList::removeByID() behaviour. The advantage of doing this is that:

  • We could get RelationList to apply behaviour change: $this->deleteOnRemove = false or something.
  • The relation lists could then call parent::add() and parent::removeByID()
  • The callback functionality could be written in DataList, and the copy-pasta reduced.

@chillu
Copy link
Member

chillu commented Jul 30, 2020

I've tried to implement this but can't make it work consistently:

  • For DataList::remove(), you already have a "hook" with onBeforeDelete(), just not context dependant on the list
  • If we create DataList::$deleteOnRemove, we need to allow setting this through DataList->setDeleteOnRemove($bool) alongside DataList->setDeleteCallback(). You need to have $deleteOnRemove=false on a DataList in order to get any use out of DataList::$deleteCallback. Otherwise you're just getting an identifier to a record that no longer exists (at least in this versioned stage)
  • If we do create DataList->setDeleteOnRemove($bool), then it's unclear how that is reflected in RelationList subclasses. We can set the default to false, but what happens when you use RelationList->setDeleteOnRemove(true)? Should that throw a LogicException, or do we add actual DataObject->delete() calls to those implementations now?
  • Keeping the code DRY by calling parent::remove() isn't really feasible with an API surface of remove(), removeByID(), removeMany() and removeAll(). For example, RelationList->removeAll() collects a bunch of IDs it has removed. Does it call foreach ($ids as $id) parent::removeByID(), or parent::removeMany($ids) in order to trigger the callback without repeating it? Both options seem messy

@sminnee
Copy link
Member Author

sminnee commented Jul 30, 2020

OK that's good feedback, Ingo. Let's hold off merging this right now until we've given it a bit more thought. A few questions:

If we create DataList::$deleteOnRemove, we need to allow setting this through DataList->setDeleteOnRemove($bool) alongside DataList->setDeleteCallback()

  • Would it make more sense to have addCallback, removeCallback, and the delete-on-remove option all set in 1 3-argument configurator? This would break the API so we need to decide before merging the PR as-is.
  • Otherwise we might need to point out this weakness in the docs and recognise that this is an advanced feature

What happens when you use RelationList->setDeleteOnRemove(true

  • I'd probably override and throw a logic exception?

Keeping the code DRY by calling parent::remove() isn't really feasible with an API surface of remove(), removeByID(), removeMany() and removeAll()

  • Perhaps these callbacks need to be configured to receive a list of IDs / objects rather than a single object? Then the single-record cases can just pass one item. This would break the API so we need to decide before merging the PR as-is.

I'm okay in principle with accepting that pushing the work to DataList is too hard, but if we do that we're unlikely to be able to loop back around and add it to DataList later. So we decide, one way or the other, now.

@chillu
Copy link
Member

chillu commented Jul 30, 2020

I've sketched this out in a new commit: 7684da5. Check the setAddCallback() docs to see what it'd look like. If we're OK with that LogicException, it feels workable.

Would it make more sense to have addCallback, removeCallback, and the delete-on-remove option all set in 1 3-argument configurator?

It'd make the relationship between the callbacks and delete-on-remove clearer for the DataList use case, but has the same issues on the RelationList subclasses.

Otherwise we might need to point out this weakness in the docs and recognise that this is an advanced feature

I'd prefer that to creating a weaker "composite API" with three args.

Perhaps these callbacks need to be configured to receive a list of IDs / objects rather than a single object? Then the single-record cases can just pass one item.

Yep, I'd rather switch addCallback($item) to addCallback(array $items), than switching removeCallback(array $ids) to removeCallback($id) - simply because of performance concerns on batch add/remove. My use case is to re-sync a denormalised relationship in those callbacks, which is fairly expensive. Definitely not something I'd want to execute synchronously 100x if you call removeAll() on a relationship with a hundred items. I could perform some queues with deduplication logic, or retain some state for a PHP shutdown function, but neither of these is straightforward.

Buuuut, that's also not possible with the current API, since you have DataList->addMany() call foreach ... add(). The only consistent place where you can fire addCallback() is the individual add() call on a single item (without changing the function signatures to add($item, $useCallback = true)). Note that this isn't an inherent issue with Sam's DataList suggestion, it also applies to RelationList.

Another use case for DataList->setDeleteOnRemove(false): Soft deletes without overwriting the controller layers (see https://github.com/lekoala/silverstripe-softdelete). Although this is probably a concern for the controller layer regardless, since it can affect presentation and user feedback. And less of an issue in a system where we favour use of versioning.

@sminnee
Copy link
Member Author

sminnee commented Jul 30, 2020

The only consistent place where you can fire addCallback() is the individual add() call on a single item (without changing the function signatures to add($item, $useCallback = true)).

You could avoid a 2nd argument by having a private property that is set/unset to modify the built-in behaviour. Nasty, but does the job? I dunno...

As an aside: This is exactly the kind of minor-detail-but-still-technically-an-API-change that I think a major release could allow. Especially if our code upgrading stuff was smart enough to highlight removeByID overrides in your module/project code.

@chillu
Copy link
Member

chillu commented Jul 30, 2020

You could avoid a 2nd argument by having a private property that is set/unset to modify the built-in behaviour. Nasty, but does the job?

It would have to be protected, since addMany() only exists on DataList, but add() calls are subclass specific. And it actually would become part of the API anyway: If you build your own subclass of DataList with custom add() behaviour, it now needs to respect this flag to avoid triggering duplicate callbacks. I think this is just in the "too hard" bucket for 4.x, with the assumption that addMany() isn't used frequently anyway - it's more common to have a foreach ... add() in your own code (or GridField etc). Some cases will be easier to throttle/debounce than others, it really depends on what's in your callback.

In conclusion, I've implemented Sam's suggestion to extend the scope to DataList, and there's no easy path to remove the remaining inconsistency of addCallback(DataObject $item) vs. removeCallback(array $ids). Since the types of the items will need to be different anyway (DataObject vs. int), I'd say that's the least worst option - compared to changing the API (add($item, $useCallback = true) or adding some leaking internals (protected DataList->useCallback property to guide add() and remove() callback invocations).

@sminnee
Copy link
Member Author

sminnee commented Aug 7, 2020

@chillu I've found back your change to extend this to DataList (I have it on a temp branch locally in case we need it back). I think it's too hard and we should focus only on the RelationList changes as originally designed.

@sminnee
Copy link
Member Author

sminnee commented Aug 26, 2020

Do you think that one callback is enough, or should we support a stack of callbacks? I'm imagining a situation where a module returns a ManyMany or ManyManyThrough list or something, and provides its own callback for adding and deleting records from it, but then user code can't extend this because we only allow a single callback. You'd need to get the existing one and merge it with your own. What do you think?

Yeah... fair point.

@sminnee sminnee changed the title NEW: Add setAddCallback() to relation lists. [WIP] NEW: Add setAddCallback() to relation lists. Aug 26, 2020
@dhensby dhensby marked this pull request as draft August 26, 2020 22:37
@chillu
Copy link
Member

chillu commented Aug 27, 2020

Callback stacks provide flexibility, and complicate APIs. We'd need an addAddCallback(), and/or change to setAddCallbacks() which doubles as a way to clear them. And add getAddCallbacks(). And then do it all again for the removal operations. If effectively doubles the API surface in RelationList.

If we added getters for the single callbacks, you could wrap those callables in your own callable, which would satisfy the use case Robbie outlines. I don't feel strongly about this, but it makes me sad that this lead to such an API surface explosion.

@sminnee
Copy link
Member Author

sminnee commented Aug 27, 2020

Yeah, Ingo's comment about complexity is valid; my only hesitation with merging a simple API is that adding the complexity later would be harder with BC.

If we did want to allow chainable callbacks I would probably introduce a simple CallbackList object, so that the API shifts slightly to his:

// Rest the list to a single closure
$list->addCallbacks()->set($closure);
// Add one on the end of the list
$list->addCallbacks()->add($closure);
// Clear the list
$list->addCallbacks()->clear();
// Get an array of callbacks
$list->addCallbacks()->getAll();
// Add one with a string identifier
$list->addCallbacks()->add($closure, 'samsOne');
// Get one by an identifier
$list->addCallbacks()->get('samsOne');

For this specific use-case, adding CallbackList would likely be overkill, but are there other places where we make use of a list of callbacks?

@robbieaverill
Copy link
Contributor

Can you use an ArrayList instead of adding a CallbackList?

@sminnee
Copy link
Member Author

sminnee commented Aug 27, 2020

It's got quite a lot of other stuff and is primarily design to manage arrays of data for template population rather than callbacks. It also lacks the ability to do removal / lookup options by named identifiers, which is more of a nice to have. Also prevents me from providing a method on the class that calls all the callbacks. So I don't the trade-off for reuse there quite stacks up. The piece of ArrayList that you would benefit from is basically:

class Minimal {
  public $calls = [];
  function push($item) { $this->calls[] = $item; }
}

If we did make a CallbackList, not sure where it would belong. I'm tempted to release it as a micropackage :-P

@sminnee
Copy link
Member Author

sminnee commented Aug 28, 2020

Only half joking :-P https://github.com/sminnee/callbacklist

But yeah if we prefer these can be pulled into somewhere in framework.

@chillu
Copy link
Member

chillu commented Aug 28, 2020

Hah, of course you've written a micropackage for it ;) I like the approach of externalising the management into it's own object, but then we should adapt that as a pattern. Done a quick survey in core, not a lot of precent at least on the surface:

grep -E -r -i --include \*.php  'function .*callback.*\(' vendor/silverstripe/
silverstripe//subsites/tests/php/InitStateMiddlewareTest.php:    protected function getCallback()
silverstripe//framework/tests/php/Forms/GridField/GridFieldDetailFormTest.php:    public function testItemEditFormCallback()
silverstripe//framework/tests/php/Dev/FixtureBlueprintTest.php:    public function testCallbackOnBeforeCreate()
silverstripe//framework/tests/php/Dev/FixtureBlueprintTest.php:    public function testCallbackOnAfterCreate()
silverstripe//framework/tests/php/ORM/ListDecoratorTest.php:    public function testFilterByCallbackThrowsExceptionWhenGivenNonCallable()
silverstripe//framework/tests/php/ORM/ListDecoratorTest.php:    public function testFilterByCallback()
silverstripe//framework/tests/php/ORM/DataListTest.php:    public function testFilterByCallback()
silverstripe//framework/tests/php/ORM/ArrayListTest.php:    public function testFilterByCallback()
silverstripe//framework/tests/php/Control/PjaxResponseNegotiatorTest.php:    public function testDefaultCallbacks()
silverstripe//framework/src/Forms/GridField/GridFieldDetailForm.php:    public function setItemEditFormCallback(Closure $cb)
silverstripe//framework/src/Forms/GridField/GridFieldDetailForm.php:    public function getItemEditFormCallback()
silverstripe//framework/src/Forms/Form.php:    public function getValidationResponseCallback()
silverstripe//framework/src/Forms/Form.php:    public function setValidationResponseCallback($callback)
silverstripe//framework/src/Core/CustomMethods.php:    protected function registerExtraMethodCallback($name, $callback)
silverstripe//framework/src/Core/CustomMethods.php:    protected function addCallbackMethod($method, $callback)
silverstripe//framework/src/Dev/FixtureBlueprint.php:    public function addCallback($type, $callback)
silverstripe//framework/src/Dev/FixtureBlueprint.php:    public function removeCallback($type, $callback)
silverstripe//framework/src/Dev/FixtureBlueprint.php:    protected function invokeCallbacks($type, $args = [])
silverstripe//framework/src/ORM/ArrayList.php:    public function filterByCallback($callback)
silverstripe//framework/src/ORM/Filterable.php:    public function filterByCallback($callback);
silverstripe//framework/src/ORM/DataList.php:    public function filterByCallback($callback)
silverstripe//framework/src/ORM/ListDecorator.php:    public function filterByCallback($callback)
silverstripe//framework/src/Control/PjaxResponseNegotiator.php:    public function setCallback($fragment, $callback)
silverstripe//graphql/src/Scaffolding/Util/OperationList.php:    public function removeItemByCallback($callback)
silverstripe//graphql/src/Scaffolding/Util/OperationList.php:    public function findItemByCallback($callback)
silverstripe//assets/src/Flysystem/FlysystemAssetStore.php:    protected function writeWithCallback($callback, $filename, $hash, $variant = null, $config = [])
silverstripe//controllerpolicy/tests/CachingPolicyTest.php:    public function testCallbackOverride()

Before writing my answer, I also looked into https://symfony.com/doc/current/components/event_dispatcher.html, which @unclecheese has been using for versioned-snapshots already. I think if we go the route of pulling this out of the API, we should give this component a serious look.

<?php
class MyObject extends DataObject
{
	private static $many_many = ['Things' => 'Thing'];

	public function getThings()
	{
		$list = $this->manyManyComponent('Things')
		$list->getDispatcher()->addListener('RelationList.add', function (Event $event) {
			// ...
		});

		return $list;
	}
}

@sminnee
Copy link
Member Author

sminnee commented Aug 28, 2020

Yeah the event dispatcher is another way of looking at this, and it could be useful as long as we keep the following separate:

  • event listeners injected into preexisting relations, activated every time that relation is called
  • event listeners added directly to a single instance

The latter case is what this PR covers. The former case is what is typically thought of in "event system" discussions.

@chillu
Copy link
Member

chillu commented Aug 28, 2020

I was wondering if this can be solved by scoping the event on a more global dispatcher, e.g. fire both relationlist.add and relationlist.myobject.things.add. But DataList (and RelationList) don't know about the relationship context, just their data class and the query to assemble the list (incl. details like $foreignKey and $join_table). You could argue that's by design, but also that those lists are already coupled to their highlevel relationships through those query fields. So at the moment, we could use relationlist.add with a type check conditional on the object in the $event data, but we wouldn't know if that's happening on the MyObject.Things relationship. That would actually be good enough for Aaron's search reindex use case, as well as the use case for which we've originally introduced this PR (refreshing denormalised state)

@sminnee
Copy link
Member Author

sminnee commented Aug 28, 2020

Yeah having looked into this a little more I think the event dispatchers and what this patch does are too different to be unified into the same subsystem.

As I understand it you would need to do the following to replicate the example in the original ticket, using event-emitters.

MyClass.php

function MyRelation() {
  $rel = $this->getManyManyComponents(‘MyRelation’);
  $rel = $rel->filter(‘Status’, ‘Active’);

  // Something like this necessary to hook into a new event type
  $rel->setRelationName('MyRelation');

  return $rel;
}

MyRelationStatusUpdater.php

class MyRelationStatusUpdater implements EventHandlerInterface  {
 public function fire(EventContextInterface $context): void
    {
        $id = $context->get('id');
        $item = MyClass::get()->byID($id);
        $item->Status = ‘Active’;
        $item->write();
    }
}

event-config.yml

SilverStripe\Core\Injector\Injector:
  SilverStripe\EventDispatcher\Dispatch\Dispatcher:
    properties:
      handlers:
        # Arbitrary key. Allows other config to override.
        orders:
          on: [ MyRelation.relationAdd ]
          handler: %$MyRelationStatusUpdater

YUCK! This is a good approach for tidying loosely coupled parts of a system together, but not for writing a single piece of cohesive code as outlined in the callback-based example.

The core issue is that the dispatcher is a singleton, from everything I read in Aaron's and the Symphony docs.

@sminnee
Copy link
Member Author

sminnee commented Sep 1, 2020

@unclecheese is an instance-level dispatcher beyond the scope of what the event system is really designed for? My initial read is that the event system isn't fit-for-purpose for this use case (which is fine, it can't be everything to everyone) but I'd like to check that it's not a misunderstanding on my part before boxing on with this PR.

@chillu
Copy link
Member

chillu commented Sep 2, 2020

You're right Sam, my prior example with an inlined getDispatcher()->addListener() would cause duplicate listeners on each invocation of the relationship if we use the dispatcher as a singleton. Technically it doesn't require to be used this way, but it get quite confusing to mix up the concept of global and local dispatchers around core (if we adopted this more widely).

Overall, your code example looks very similar to what you'd need for an onBeforeWrite() through a DataExtension though. I see this more as an indictment on how we chose to do DI and config in core than something unique to this example. Compare this with Laravel Events: Event::listen('event.*', function (..., and their procedural config merging. We could simplify this as a wider effort to untangle the numerous responsibilities of Extension and DataExtension ... slight scope creep of course.

I think we've explored the space enough to vote.

Option A: Keep single setCallback(), deal with edge cases through callback nesting - original PR
Option B: Expand API surface to multiple callbacks via `addAddCallback() etc - #9572 (comment)
Option C: Support multiple callbacks through https://github.com/sminnee/callbacklist - #9572 (comment)
Option D: EventDispatcher singleton with scoped config-based callback - #9572 (comment)

My vote goes for Option C, and picking up the EventDispatcher discussion at a later point without blocking this PR.

@sminnee
Copy link
Member Author

sminnee commented Sep 3, 2020

Yeah I'd go with C too. Callbacks for cohesive code, DI and events for loosely coupled code. They're two different things.

@sminnee
Copy link
Member Author

sminnee commented Sep 3, 2020

My only question on option C is - leave it as a micropackage or inline the class?

I think a micropackage works because is got a very clear boundary of responsibility. It's one more composer download but shouldn't change much so will be cached in most setups. I'd probably tag it as 0.1.0 for the purposes of the PR. It's under my name so responsibility for the maintenance of the package would fall to me in the first instance.

But happy either way.

@chillu
Copy link
Member

chillu commented Sep 4, 2020

Yep happy with a micropackage. It'll become part of our API surface (through RelationList->addCallbacks()` etc). But that seems kind of unavoidable, even if we created a class interface for it, since the micropackage would need to implement that interface, and you'd end up with a two-way dependency. Given Sam as the maintainer, I'm not too worried about the micropackage status, and a cursory search on packagist also didn't turn up anything similar.

@sminnee sminnee self-assigned this Sep 4, 2020
@sminnee
Copy link
Member Author

sminnee commented Sep 4, 2020

Cool, I'll sign up for that.

If @silverstripe/core-team have concerns with this approach, let me know. :)

@chillu chillu marked this pull request as ready for review September 17, 2020 22:03
@chillu chillu changed the title [WIP] NEW: Add setAddCallback() to relation lists. NEW: Add/remove callbacks for relation lists Sep 17, 2020
@chillu
Copy link
Member

chillu commented Sep 17, 2020

@sminnee OK I've refactored this to use your micropackage, and added a few more tests. Could you release a 0.1.0 of the package at least? Ideally we'd have downstream packages as 1.0.0 though. Seems like the API is pretty uncontroversial :)

I've also noticed that the callbacks inherit some API messiness from the list APIs: The add(DataObject|int $item) signature flows through to the callbacks, so within the callback implementations you need to check which type you're getting. I think that's on balance better than creating a different API there, because those callbacks should be as lightweight as possible on batch additions - so shouldn't cause a database query either in the list or in each callback.

@sminnee
Copy link
Member Author

sminnee commented Sep 17, 2020

Could you release a 0.1.0 of the package at least? Ideally we'd have downstream packages as 1.0.0 though. Seems like the API is pretty uncontroversial.

My preference would be that we 0.1.0. I feel like "Ideally we'd have downstream packages as 1.0.0 though" is the mistake we made with GraphQL and I'd rather not make it again.

If the API is stable for 6-12 months and I release 1.0 without breakages, it will be easy to update framework to support ^0.1 || ^1 then.

In the meantime tying to a 0.1 release says 'this API is new, watch out' which I think is appropriate.

If we start getting pen-test reports complaining about it or something, we can always look to revise that decision, even in a framework patch release.

Does that rationale seem reasonable to you?

TL;DR: tagging 0.1.0 now.

@sminnee
Copy link
Member Author

sminnee commented Sep 17, 2020

FYI before tagging 0.1.0 I added a couple of things that I had intended to do. They don't really relate to this use-case:

  • It returns an array of all the callbacks' return values
  • It implements __invoke() so can act as a callable itself

@sminnee
Copy link
Member Author

sminnee commented Sep 17, 2020

Copy link
Member Author

@sminnee sminnee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I approve this even though it was originally mine :-P

This provides a mechanism for adjusting the behaviour of these
relations when building more complex data models.

For example the following example has a status field incorporates a
Status field into the relationship:

```php
function MyRelation() {
  $rel = $this->getManyManyComponents(‘MyRelation’);
  $rel = $rel->filter(‘Status’, ‘Active’);

  $rel->addCallbacks()->add(function ($relation, $item, $extra) {
    $item->Status = ‘Active’;
    $item->write();
  });
}
```

Introduces a new library dependency: http://github.com/sminnee/callbacklist
@chillu
Copy link
Member

chillu commented Sep 18, 2020

Sweet, yeah 0.1.0 is fine. Changed constraint to ^0.1, notably not using ~ so we don't automatically get a 0.2 release. Squashed commits, force pushed, just waiting for builds to pass before merging.

@chillu chillu merged commit ecb0356 into silverstripe:4 Sep 18, 2020
@chillu chillu deleted the pulls/manymanylist-add-callback branch September 18, 2020 04:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

8 participants