Permalink
Browse files

Route scheduling, part two: electric boogaloo (#174)

* Create SQL function to determine if a route is active based on its schedule

* retrieve active intervals

* Retrieve schedule by route ID

* Re-enable Travis email notifications

* Add schedules on route creation

* Allow modifying schedules on existing routes

* Enforce schedule ending after its start time

* Comparisons lol

* Don't place shuttles on inactive routes

* Stop shadowing err

* Set route active field on create; simplify is_route_active()

* Initial (failing) tests

* Fix route_is_active() if no schedules defined

* Fix var shadowing

* Add schedule modification test

* Add more than one schedule

* more tests

* remove dead code

* ...and remove the inverted Docker ignore rule

* fix day of week checks

* Add note about route schedule active intervals not wrapping
  • Loading branch information...
kochman authored and garoller committed Oct 27, 2018
1 parent f936435 commit 6210c9df72a4c8036e88340285100d6cbfc2283f
Showing with 495 additions and 225 deletions.
  1. +0 −1 .dockerignore
  2. +0 −3 .travis.yml
  3. +2 −0 api/routes.go
  4. +177 −11 postgres/route.go
  5. +290 −0 postgres/route_test.go
  6. +25 −10 route.go
  7. +0 −81 time/time.go
  8. +0 −118 time/time_test.go
  9. +1 −1 updater/updater.go
View
@@ -7,7 +7,6 @@
!mock
!postgres
!static
!time
!updater
!vendor/vendor.json
!admin.html
View
@@ -45,6 +45,3 @@ cache:
before_cache:
- rm -rf $GOPATH/src/github.com/wtg/shuttletracker/*
- rm -rf $GOPATH/pkg/**/github.com/wtg/shuttletracker/*
notifications:
email: false
View
@@ -76,8 +76,10 @@ func (api *API) RoutesEditHandler(w http.ResponseWriter, r *http.Request) {
return
}
en := route.Enabled
sched := route.Schedule
route, err = api.ms.Route(route.ID)
route.Enabled = en
route.Schedule = sched
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
View
@@ -40,7 +40,62 @@ CREATE TABLE IF NOT EXISTS routes_stops (
stop_id integer REFERENCES stops NOT NULL,
"order" integer NOT NULL,
UNIQUE (route_id, "order")
);`
);
CREATE TABLE IF NOT EXISTS route_schedules (
id serial PRIMARY KEY,
route_id integer REFERENCES routes ON DELETE CASCADE NOT NULL,
start_day smallint NOT NULL CHECK (start_day >= 0 AND start_day < 7),
start_time time with time zone NOT NULL,
end_day smallint NOT NULL CHECK (end_day >= 0 AND end_day < 7),
end_time time with time zone NOT NULL,
-- Note: active intervals for route schedules for a route cannot wrap around
-- the week boundary. This is for simplicity of implementation in the
-- route_is_active() function.
CHECK (
(start_day = end_day AND start_time < end_time) OR (start_day < end_day)
)
);
CREATE OR REPLACE FUNCTION route_is_active(route_id integer) RETURNS boolean STABLE AS $$
SELECT exists(
SELECT true FROM
(
SELECT route_schedules.route_id,
make_timestamptz(
extract(year from (current_date - extract(dow from current_date)::int) + start_day)::int,
extract(month from (current_date - extract(dow from current_date)::int) + start_day)::int,
extract(day from (current_date - extract(dow from current_date)::int) + start_day)::int,
extract(hour from start_time)::int,
extract(minute from start_time)::int,
extract(sec from start_time)
) as start,
make_timestamptz(
extract(year from (current_date - extract(dow from current_date)::int) + end_day)::int,
extract(month from (current_date - extract(dow from current_date)::int) + end_day)::int,
extract(day from (current_date - extract(dow from current_date)::int) + end_day)::int,
extract(hour from end_time)::int,
extract(minute from end_time)::int,
extract(sec from end_time)
) as end
FROM route_schedules
) AS timestamps
RIGHT OUTER JOIN routes ON routes.id = timestamps.route_id
WHERE
timestamps.route_id = route_is_active.route_id
AND now() >= timestamps.start
AND now() <= timestamps.end
OR (
EXISTS (
SELECT 1 from routes
WHERE routes.id = route_is_active.route_id
) AND NOT EXISTS (
SELECT 1 from route_schedules
WHERE route_schedules.route_id = route_is_active.route_id
)
)
);
$$ LANGUAGE sql;
`
_, err := rs.db.Exec(schema)
return err
}
@@ -81,44 +136,118 @@ func (p *scanPoints) Scan(src interface{}) error {
// Routes returns all Routes in the database.
func (rs *RouteService) Routes() ([]*shuttletracker.Route, error) {
tx, err := rs.db.Begin()
if err != nil {
return nil, err
}
// We can't really do anything if rolling back a transaction fails.
// nolint: errcheck
defer tx.Rollback()
routes := []*shuttletracker.Route{}
query := "SELECT r.id, r.name, r.created, r.updated, r.enabled, r.width, r.color, r.points," +
" array_remove(array_agg(rs.stop_id ORDER BY rs.order ASC), NULL) as stop_ids" +
" FROM routes r LEFT JOIN routes_stops rs" +
" ON r.id = rs.route_id GROUP BY r.id;"
rows, err := rs.db.Query(query)
// This allows us to do faster lookups when retrieving schedule data.
idsToRoute := map[int64]*shuttletracker.Route{}
query := `
SELECT r.id, r.name, r.created, r.updated, r.enabled, r.width, r.color, r.points,
array_remove(array_agg(rs.stop_id ORDER BY rs.order ASC), NULL) as stop_ids,
route_is_active(r.id) as active
FROM
routes r
LEFT JOIN routes_stops rs ON r.id = rs.route_id
GROUP BY r.id;
`
rows, err := tx.Query(query)
if err != nil {
return nil, err
}
for rows.Next() {
r := &shuttletracker.Route{}
p := scanPoints{}
err := rows.Scan(&r.ID, &r.Name, &r.Created, &r.Updated, &r.Enabled, &r.Width, &r.Color, &p, pq.Array(&r.StopIDs))
err = rows.Scan(&r.ID, &r.Name, &r.Created, &r.Updated, &r.Enabled, &r.Width, &r.Color, &p, pq.Array(&r.StopIDs), &r.Active)
if err != nil {
return nil, err
}
r.Points = p.points
r.Schedule = shuttletracker.RouteSchedule{}
routes = append(routes, r)
idsToRoute[r.ID] = r
}
query = "SELECT s.id, s.route_id, s.start_day, s.start_time, s.end_day, s.end_time FROM route_schedules s;"
rows, err = tx.Query(query)
if err != nil {
return nil, err
}
for rows.Next() {
interval := shuttletracker.RouteActiveInterval{}
err = rows.Scan(&interval.ID, &interval.RouteID, &interval.StartDay, &interval.StartTime, &interval.EndDay, &interval.EndTime)
if err != nil {
return nil, err
}
route, ok := idsToRoute[interval.RouteID]
if !ok {
return nil, shuttletracker.ErrRouteNotFound
}
route.Schedule = append(route.Schedule, interval)
}
err = tx.Commit()
if err != nil {
return nil, err
}
return routes, nil
}
// Route returns the Route with the provided ID.
func (rs *RouteService) Route(id int64) (*shuttletracker.Route, error) {
tx, err := rs.db.Begin()
if err != nil {
return nil, err
}
// We can't really do anything if rolling back a transaction fails.
// nolint: errcheck
defer tx.Rollback()
query := "SELECT r.name, r.created, r.updated, r.enabled, r.width, r.color, r.points," +
" array_remove(array_agg(rs.stop_id ORDER BY rs.order ASC), NULL) as stop_ids" +
" array_remove(array_agg(rs.stop_id ORDER BY rs.order ASC), NULL) as stop_ids," +
" route_is_active(r.id) as active" +
" FROM routes r LEFT JOIN routes_stops rs" +
" ON r.id = rs.route_id WHERE r.id = $1 GROUP BY r.id;"
row := rs.db.QueryRow(query, id)
row := tx.QueryRow(query, id)
r := &shuttletracker.Route{
ID: id,
ID: id,
Schedule: shuttletracker.RouteSchedule{},
}
p := scanPoints{}
err := row.Scan(&r.Name, &r.Created, &r.Updated, &r.Enabled, &r.Width, &r.Color, &p, pq.Array(&r.StopIDs))
err = row.Scan(&r.Name, &r.Created, &r.Updated, &r.Enabled, &r.Width, &r.Color, &p, pq.Array(&r.StopIDs), &r.Active)
if err != nil {
return nil, err
}
r.Points = p.points
query = "SELECT s.id, s.start_day, s.start_time, s.end_day, s.end_time" +
" FROM route_schedules s WHERE s.route_id = $1;"
rows, err := tx.Query(query, id)
if err != nil {
return nil, err
}
for rows.Next() {
interval := shuttletracker.RouteActiveInterval{
RouteID: id,
}
err = rows.Scan(&interval.ID, &interval.StartDay, &interval.StartTime, &interval.EndDay, &interval.EndTime)
if err != nil {
return nil, err
}
r.Schedule = append(r.Schedule, interval)
}
err = tx.Commit()
if err != nil {
return nil, err
}
return r, nil
}
@@ -184,6 +313,25 @@ func (rs *RouteService) CreateRoute(route *shuttletracker.Route) error {
return err
}
// insert route schedule
for _, interval := range route.Schedule {
statement = "INSERT INTO route_schedules (route_id, start_day, start_time, end_day, end_time)" +
" VALUES ($1, $2, $3, $4, $5) RETURNING id;"
row = tx.QueryRow(statement, route.ID, interval.StartDay, interval.StartTime, interval.EndDay, interval.EndTime)
err = row.Scan(&interval.ID)
if err != nil {
return err
}
interval.RouteID = route.ID
}
// Determine if route is active. Must happen after inserting the route schedule.
row = tx.QueryRow("SELECT route_is_active($1);", route.ID)
err = row.Scan(&route.Active)
if err != nil {
return err
}
return tx.Commit()
}
@@ -240,5 +388,23 @@ func (rs *RouteService) ModifyRoute(route *shuttletracker.Route) error {
return err
}
// remove existing route schedule
_, err = tx.Exec("DELETE FROM route_schedules WHERE route_id = $1;", route.ID)
if err != nil {
return err
}
// insert route schedule
for _, interval := range route.Schedule {
statement = "INSERT INTO route_schedules (route_id, start_day, start_time, end_day, end_time)" +
" VALUES ($1, $2, $3, $4, $5) RETURNING id;"
row := tx.QueryRow(statement, route.ID, interval.StartDay, interval.StartTime, interval.EndDay, interval.EndTime)
err = row.Scan(&interval.ID)
if err != nil {
return err
}
interval.RouteID = route.ID
}
return tx.Commit()
}
Oops, something went wrong.

0 comments on commit 6210c9d

Please sign in to comment.