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

Integrate with Sqlalchemy #141

Closed
botzill opened this issue Sep 16, 2016 · 10 comments
Closed

Integrate with Sqlalchemy #141

botzill opened this issue Sep 16, 2016 · 10 comments

Comments

@botzill
Copy link

botzill commented Sep 16, 2016

Hi.

I wanted to ask if you have any plans to make this work easily with Sqlalchemuy model? Let say we have a model which has a status which needs to be updated every time we change the status using the machine. Of course we can implement this at the moment by using the trigger callbacks and update the state manually. I'm just wandering if you have such plans for the future.

Thx.

@aleneum
Copy link
Member

aleneum commented Sep 16, 2016

Hi @botzill,

could you give an example of what behaviour you are interested in?

Right now, a machine will add a state attribute to the model which returns the name of the current state. There are two ways that come into my mind to set another field whenever Model.state changes: a) using after_state_change to set an attribute explicitly or b) using property setters and getters to achieve the same thing but without callbacks:

from transitions import Machine

states = ['A', 'B']
transitions = [['go', 'A', 'B']]

class Model(object):
    def __init__(self):
        self.statusA = self.statusB = 'A'

    def status_changed(self):
        self.statusA = self.state # version with callback

    @property
    def state(self):
        return self.statusB

    @state.setter
    def state(self, value):
        self.statusB = value # version without callback


model = Model()
machine = Machine(model, states=states, transitions=transitions, initial='A',
                                  after_state_change='status_changed')
print model.state # A
model.go()
print model.state # B
print model.statusA # B
print model.statusB # B

@botzill
Copy link
Author

botzill commented Sep 16, 2016

Hi @aleneum,

Here is a small example of what I mean:

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, scoped_session

from transitions import Machine
from sqlalchemy import Column, Integer, String

engine = create_engine('sqlite:///test.sqlite')
session = scoped_session(sessionmaker(bind=engine))

Base = declarative_base()


class User(Base):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    state = Column(String)
    name = Column(String)


if __name__ == '__main__':
    states = ['created', 'validated', 'active', 'inactive']

    transitions = [
        ['validate', 'created', 'validated'],
        ['enable', 'validated', 'active'],
        ['disable', 'active', 'inactive'],
    ]

    Base.metadata.create_all(engine)

    user = User(name="User1")
    machine = Machine(model=user, states=states, transitions=transitions, initial='created')

    print(user.state)
    # output: created
    # but not saved into DB

    user.validate()
    print(user.state)
    # output: validated
    # but not saved into DB

    user.enable()
    print(user.state)
    # output: active
    # but not saved into DB

    # only at this point we actually save the state into DB
    session.add(user)
    session.commit()

What would be great to have is for the Machine object to accept a session object and when the state is changed we save it into DB transparently(of course via a different Machine class).

Of course we can achieve this by methods you mentioned above, but having a standard support for this would be really nice.

Here we can also encounter scenarios like

status = Column(String, name="state")

so, the fields which holds the state can have different name in the DB. By having a sort of mapping would help to integrate the transitions into a bigger project easier.

p.s: I just discovered this awesome library today and maybe it already has some of the features implemented that I'm asking.

Thx.

@tyarkoni
Copy link
Member

While I can see the utility in what you're suggesting, @botzill, I worry that adding support for common ORMs could get messy pretty quickly (see also #142), and maintaining some uniformity in the API might take some effort. Given that @aleneum's suggestions are quite elegant and minimalistic, I think you could probably solve your problem with minimal code by subclassing Base and adding the property getter and setter. Then all of your models could just inherit from the state-supporting subclass.

I'm willing to reconsider adding support for this kind of thing if we get a lot of requests for it, but I think for that to happen I'd want to know that someone is willing to commit the time to maintaining and updating the various mixins in an ORM integration module, and I'm not sure that I or any of the other current maintainers want to take that on.

@botzill
Copy link
Author

botzill commented Sep 16, 2016

Thx @tyarkoni,

Of course adding support for this feature is pretty straight forward currently without any additional helpers. But it's always surprisingly good when you get such functionality out of the box :).

I will play with it and see what I can come up with.

Thx again for the great library which is not yet added in awesome python?

@aleneum
Copy link
Member

aleneum commented Sep 16, 2016

I am not sure about how picky sqlalchemy is when it comes to inheritance. If you want, you can give this a try:

class User(Base):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    # renamed state -> status to allow that getter/setter solution
    status = Column(String)
    name = Column(String)

    def __init__(self, session, *args, **kwargs):
        self._session = session
        self._session.add(self)
        super(User, self).__init__(*args, **kwargs)

    @property
    def state(self):
        return self.status

    @state.setter
    def state(self, value):
        self.status = value
        self._session.commit()

I tested it with your example code and it seems to work (as in 'No exceptions are thrown'). I have not checked if the content in test.sqlite is correct though.

@botzill
Copy link
Author

botzill commented Sep 17, 2016

Thx @aleneum,

You solution is good. I made a small change so that we don't need to pass in the session every time we create a new User object:

Base._session = scoped_session(sessionmaker(bind=engine))


class User(Base):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    status = Column(String)
    name = Column(String)

    def __init__(self, *args, **kwargs):
        super(User, self).__init__(*args, **kwargs)
        self._session.add(self)

    @property
    def state(self):
        return self.status

    @state.setter
    def state(self, value):
        self.status = value
        self._session.commit()

Another solution is to use a mixin, here is a working example:

Base = declarative_base()
Base._session = scoped_session(sessionmaker(bind=engine))


class StatusMixin(object):
    @declared_attr
    def __tablename__(cls):
        return cls.__name__.lower()

    @declared_attr
    def status(cls):
        return Column(String())

    @property
    def state(self):
        return self.status

    @state.setter
    def state(self, value):
        if self.status != value:
            self.status = value
            self._session.add(self)
            self._session.commit()


class User(Base, StatusMixin):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    name = Column(String)

This way we can add state to an existing model easily.
To be honest I'm not sure if the way I did is the right way(taking into account sqlalchemy philosophy, optimization and other), but it's working :).

Thx.

@aleneum
Copy link
Member

aleneum commented Sep 17, 2016

Nice to hear that you have found a way that suits your needs :).
Thank you for leaving your solution here. In case transitions gets a model mixin extension module, this might be part of it.

I will close this issue for now. Feel free to reopen it if you have further feedback or face any issues with transitions.

Best regards!

@aleneum aleneum closed this as completed Sep 17, 2016
@botzill
Copy link
Author

botzill commented Sep 19, 2016

OK, just a small update here. The examples above works OK when we initialize the model object ourself(and __init__ is called). But when we make a query then sqlachemy will initialize the model object itself and __init__ is not called(which mean that machine is not initialized). The solution I found so far is to use the sqlachemy events, here is a complete example:

from sqlalchemy import Column, Integer, String
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, scoped_session
from transitions import Machine
from sqlalchemy.ext.declarative import declared_attr
from sqlalchemy import event

engine = create_engine('sqlite:///test.sqlite')

Base = declarative_base()
session = scoped_session(sessionmaker(bind=engine))
Base._session = session


class StateMixin(object):
    @declared_attr
    def __tablename__(cls):
        return cls.__name__.lower()

    @declared_attr
    def status(cls):
        return Column(String())

    @property
    def state(self):
        return self.status

    @state.setter
    def state(self, value):
        if self.status != value:
            self.status = value

    def after_state_change(self):
        self._session.add(self)
        self._session.commit()

    @classmethod
    def init_state_machine(cls, obj, *args, **kwargs):
        # when we load data from the DB(via query) we need to set the proper initial state
        initial = obj.status or 'created'

        machine = Machine(model=obj, states=states, transitions=transitions, initial=initial,
                          after_state_change='after_state_change')

        # in case that we need to have machine obj in model obj
        setattr(obj, 'machine', machine)


class User(Base, StateMixin):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    name = Column(String)

event.listen(User, 'init', User.init_state_machine)
event.listen(User, 'load', User.init_state_machine)

if __name__ == '__main__':
    states = ['created', 'validated', 'active', 'inactive']

    transitions = [
        ['validated', 'created', 'validated'],
        ['enable', ['validated', 'created'], 'active'],
        ['disable', 'active', 'inactive'],
    ]

    # Base.metadata.drop_all(engine)
    Base.metadata.create_all(engine)

    user = User(name="User1")

    user.validated()

    print(user.state)
    # output: validated

    # extract object from DB and init state machine
    user_db = session.query(User).first()

    print(user_db.state)
    # output: validated

    user_db.enable()
    print(user.state)
    # output: active

@aleneum
Copy link
Member

aleneum commented Sep 19, 2016

The documentation suggests a @orm.reconstructor decorator. This might also be a solution.

@averypmc
Copy link

I found the @botzill's answer super helpful! I'm now using that pattern throughout my app to drive model state changes off of state machine transitions. Thanks a ton!

For those who come after and are curious about exactly how/why this code works, I added a bit of commenting. Assume that on our core model, Model, we have a field which tracks the machine state, and call that field model_state.

class StateMachineMixins:

      # When we access the state property, we want to be shown the value of the model_state
      # field, not the state of the state machine. This ensures we're always driving changes
      # through the state machine, and reading out state through the model_state field.
      @property
      def state(self):
           return self.model_state
          
      # When the state machine writes to the state field (a side-effect that occurs when you've called
      # a transition and the machine was instantiated with a reference to the model), have it update
      # the underlying model_state field.
      @state.setter
      def state(self, value):
          if self.model_state != value:
              self.model_state = value
           

The rest is fairly self-explanatory. After a state change, actually persist the changes to the database, etc. Thanks again @botzill!

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

No branches or pull requests

4 participants