Skip to content

Commit

Permalink
Merge pull request #24 from thread/label-updated
Browse files Browse the repository at this point in the history
Label updated times
  • Loading branch information
danpalmer authored Jan 16, 2018
2 parents aa13991 + e9f015a commit 725f6f1
Show file tree
Hide file tree
Showing 8 changed files with 313 additions and 12 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ State machines as a service.

(The _master_ of _routes_ through a state machine.)

Routemaster targets Python 3.6 and above.
Routemaster targets Python 3.6 and above, and requires Postgres.


##### Useful Links
Expand Down
3 changes: 2 additions & 1 deletion docs/getting_started.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
You'll need to create a database for developing against and for running tests
against. This can be done by running the `scripts/database/create_databases.sh`
script. Full details of how the database, models & migrations are handled can be
found in the [migrations docs](docs/migrations.md).
found in the [migrations docs](docs/migrations.md). Routemaster requires
Postgres.

#### Tox

Expand Down
3 changes: 2 additions & 1 deletion docs/migrations.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Migrations setup

Routemaster uses [`alembic`][alembic] for its migrations.
Routemaster uses [`alembic`][alembic] for its migrations, and supports
Postgres for its data storage.

## I need to set up my database up for the first time

Expand Down
59 changes: 50 additions & 9 deletions routemaster/db/model.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,47 @@
"""Database model definition."""
import datetime
import functools

import dateutil.tz
from sqlalchemy import Column as NullableColumn
from sqlalchemy import (
DDL,
Table,
Column,
String,
Boolean,
Integer,
DateTime,
MetaData,
ForeignKey,
FetchedValue,
ForeignKeyConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB

metadata = MetaData()

Column = functools.partial(NullableColumn, nullable=False)

sync_label_updated_column = DDL(
'''
CREATE OR REPLACE FUNCTION sync_label_updated_column_fn()
RETURNS TRIGGER AS
$$
BEGIN
NEW.updated = now() AT TIME ZONE 'UTC';
RETURN NEW;
END;
$$
LANGUAGE PLPGSQL;
CREATE TRIGGER sync_label_updated_column
BEFORE UPDATE ON labels
FOR EACH ROW
EXECUTE PROCEDURE sync_label_updated_column_fn();
''',
)


"""The representation of the state of a label."""
labels = Table(
Expand All @@ -26,6 +52,15 @@
Column('metadata', JSONB),
Column('metadata_triggers_processed', Boolean, default=True),
Column('deleted', Boolean, default=False),
Column(
'updated',
DateTime(timezone=True),
server_default=func.now(),
server_onupdate=FetchedValue(),
),
listeners=[
('after_create', sync_label_updated_column),
],
)


Expand All @@ -42,15 +77,21 @@
['labels.name', 'labels.state_machine'],
),

Column('created', DateTime, default=datetime.datetime.utcnow),
Column(
'created',
DateTime(timezone=True),
default=lambda: datetime.datetime.now(dateutil.tz.tzutc()),
),

# `forced = True` represents a manual transition that may not be in
# accordance with the state machine logic.
Column('forced', Boolean, default=False),

# Null indicates starting a state machine
Column('old_state', String, nullable=True),
Column('new_state', String),
NullableColumn('old_state', String),

# Null indicates being deleted from a state machine
NullableColumn('new_state', String),
)


Expand Down Expand Up @@ -92,11 +133,11 @@
edges = Table(
'edges',
metadata,
Column('state_machine', String, primary_key=True, nullable=False),
Column('from_state', String, primary_key=True, nullable=False),
Column('to_state', String, primary_key=True, nullable=False),
Column('deprecated', Boolean, default=False, nullable=False),
Column('updated', DateTime, nullable=False),
Column('state_machine', String, primary_key=True),
Column('from_state', String, primary_key=True),
Column('to_state', String, primary_key=True),
Column('deprecated', Boolean, default=False),
Column('updated', DateTime),
ForeignKeyConstraint(
columns=('state_machine', 'from_state'),
refcolumns=(states.c.state_machine, states.c.name),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""
create trigger to sync updated field
Revision ID: 3eb4f3b419c6
Revises: 814a6b555eb9
"""
import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = '3eb4f3b419c6'
down_revision = '814a6b555eb9'
branch_labels = None
depends_on = None


def upgrade():
op.execute(
'''
CREATE OR REPLACE FUNCTION sync_label_updated_column_fn()
RETURNS TRIGGER AS
$$
BEGIN
NEW.updated = now() AT TIME ZONE 'UTC';
RETURN NEW;
END;
$$
LANGUAGE PLPGSQL;
CREATE TRIGGER sync_label_updated_column
BEFORE UPDATE ON labels
FOR EACH ROW
EXECUTE PROCEDURE sync_label_updated_column_fn();
''',
)


def downgrade():
op.execute('DROP TRIGGER sync_label_updated_column')
op.execute('DROP FUNCTION sync_label_updated_column_fn')
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""
make fields non-nullable by default
Revision ID: 814a6b555eb9
Revises: e7d5ad06c0d1
"""
import sqlalchemy as sa

from alembic import op

from sqlalchemy.dialects import postgresql

# revision identifiers, used by Alembic.
revision = '814a6b555eb9'
down_revision = 'e7d5ad06c0d1'
branch_labels = None
depends_on = None


def upgrade():
op.alter_column(
'history',
'label_name',
existing_type=sa.VARCHAR(),
nullable=False,
)
op.alter_column(
'history',
'label_state_machine',
existing_type=sa.VARCHAR(),
nullable=False,
)

def set_not_null(table, column, existing_type, server_default):
op.alter_column(
table,
column,
existing_type=existing_type,
server_default=server_default,
)
op.execute(
f"UPDATE {table} SET {column} = {server_default} "
f"WHERE {column} IS NULL",
)
op.alter_column(
table,
column,
existing_type=existing_type,
nullable=False,
)

set_not_null(
'history',
'created',
existing_type=postgresql.TIMESTAMP(),
server_default=sa.func.now(),
)
set_not_null(
'history',
'forced',
existing_type=sa.BOOLEAN(),
server_default=sa.false(),
)
set_not_null(
'labels',
'metadata',
existing_type=postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::json"), # noqa
)
set_not_null(
'labels',
'metadata_triggers_processed',
existing_type=sa.BOOLEAN(),
server_default=sa.true(),
)
set_not_null(
'state_machines',
'updated',
existing_type=postgresql.TIMESTAMP(),
server_default=sa.func.now(),
)
set_not_null(
'states',
'deprecated',
existing_type=sa.BOOLEAN(),
server_default=sa.false(),
)
set_not_null(
'states',
'updated',
existing_type=postgresql.TIMESTAMP(),
server_default=sa.func.now(),
)


def downgrade():
op.alter_column(
'states',
'updated',
existing_type=postgresql.TIMESTAMP(),
nullable=True,
)
op.alter_column(
'states',
'deprecated',
existing_type=sa.BOOLEAN(),
nullable=True,
)
op.alter_column(
'state_machines',
'updated',
existing_type=postgresql.TIMESTAMP(),
nullable=True,
)
op.alter_column(
'labels',
'metadata_triggers_processed',
existing_type=sa.BOOLEAN(),
nullable=True,
)
op.alter_column(
'labels',
'metadata',
existing_type=postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
)
op.alter_column(
'history',
'label_state_machine',
existing_type=sa.VARCHAR(),
nullable=True,
)
op.alter_column(
'history',
'label_name',
existing_type=sa.VARCHAR(),
nullable=True,
)
op.alter_column(
'history',
'forced',
existing_type=sa.BOOLEAN(),
nullable=True,
)
op.alter_column(
'history',
'created',
existing_type=postgresql.TIMESTAMP(),
nullable=True,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""
add updated field to label
Revision ID: e7d5ad06c0d1
Revises: e1fec9622785
"""
import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision = 'e7d5ad06c0d1'
down_revision = 'e1fec9622785'
branch_labels = None
depends_on = None


def upgrade():
op.add_column(
'labels',
sa.Column(
'updated',
sa.DateTime(),
nullable=False,
server_default=sa.func.now(),
),
)


def downgrade():
op.drop_column('labels', 'updated')
36 changes: 36 additions & 0 deletions routemaster/state_machine/tests/test_state_machine.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,39 @@ def test_metadata_update_gate_evaluations_dont_race_processing_subsequent_metada
assert_history([
(None, 'gate_1'),
])


def test_maintains_updated_field_on_label(app_config, mock_test_feed):
label = LabelRef('foo', 'test_machine')

with mock_test_feed():
state_machine.create_label(
app_config,
label,
{},
)

with app_config.db.begin() as conn:
first_updated = conn.scalar(
select([labels.c.updated]).where(and_(
labels.c.name == label.name,
labels.c.state_machine == label.state_machine,
)),
)

with mock_test_feed():
state_machine.update_metadata_for_label(
app_config,
label,
{'foo': 'bar'},
)

with app_config.db.begin() as conn:
second_updated = conn.scalar(
select([labels.c.updated]).where(and_(
labels.c.name == label.name,
labels.c.state_machine == label.state_machine,
)),
)

assert second_updated > first_updated

0 comments on commit 725f6f1

Please sign in to comment.