Consider using only Drupal Entities and remove roomify/bat dependency

Created on 7 August 2020, over 4 years ago
Updated 8 March 2023, about 2 years ago

Problem/Motivation

First of all, I really like the module's entity model - it's very flexible and makes no assumption on the usage.

I also find the backend library vs Drupal decoupling a good idea and the data storage innovative - however, it also feels to me a bit over-engineered and hard to maintain - e.g. see the linked issue with timezone handling.

Note, for the context:

Currently I'm only using the following modules:

  • bat
  • bat_unit
  • bat_event
  • a modified / forked version of bat_facets

From the roomify/bat library I used only the IntervalValuator besides the basic classes/interfaces (Store, Unit, Event, Calendar), have not tested the Constraints.

Also, my site is in development, so I don't have real-time performance statistics yet.

Proposed resolution

  • Use only the Drupal entities, do not create separate / duplicated database tables and wrapper classes (Event, Unit).
  • For data handling use default entity methods and entity queries. Where the performance requires, use Drupal DB queries.
  • Bonus: It would be great if the base layer (entities, forms, access handling) and helper functionality (getEvents, getMatchingUnits) are separated into different modules (and be decoupled). That way one could install the base layer but use completely custom business logic.

Code ideas

For a start, I provide my solution for building event intervals (the transformEventsToIntervals() function is important here).

public function getEventIntervalsGrouped(\DateTime $start_date, \DateTime $end_date, $event_type, $unit_ids) {
  $events_by_unit = $this->getEventsGrouped($start_date, $end_date, $event_type, $unit_ids);

  $intervals_by_unit = [];
  foreach ($events_by_unit as $unit => $events) {
    $intervals_by_unit[$unit] = $this->transformEventsToIntervals($start_date, $end_date, $events);
  }

  return $intervals_by_unit;
}

public function getEventsGrouped(\DateTime $start_date, \DateTime $end_date, $event_type, $unit_ids) {
  $events = $this->getEvents($start_date, $end_date, $event_type, $unit_ids);

  $events_by_unit = [];
  foreach ($events as $event_id => $event) {
    $unit_id = $event->get('event_bat_unit_reference')->target_id;
    if (empty($events_by_unit[$unit_id])) { $events_by_unit[$unit_id] = []; };
    $events_by_unit[$unit_id][$event_id] = $event;
  }
  return $events_by_unit;
}

public function getEvents(\DateTime $start_date, \DateTime $end_date, $event_type, $unit_ids) {
  $query = $this->getBaseEventsQuery($start_date, $end_date, $event_type, $unit_ids);
  return Event::loadMultiple($query->execute());
}

protected function getBaseEventsQuery(\DateTime $start_date, \DateTime $end_date, $event_type, $unit_ids) {
  $query = \Drupal::entityQuery('bat_event');
  $query->condition('type', $event_type);
  $query->condition('event_bat_unit_reference.target_id', $unit_ids, 'IN');
  $query->condition('event_dates.value', $this->formatDateForQuery($end_date), '<');
  $query->condition('event_dates.end_value', $this->formatDateForQuery($start_date), '>');
  return $query;
}

protected function transformEventsToIntervals(\DateTime $start_date, \DateTime $end_date, $events) {
  // 'timestamp 1' => ['start' = [], 'end' = []]
  $cuts = [];
  $cuts[$start_date->getTimestamp()] = ['start' => [], 'end' => []];
  $cuts[$end_date->getTimestamp()] = ['start' => [], 'end' => []];

  foreach ($events as $event) {
    $start_timestamp = $event->getStartDate()->getTimestamp();
    $end_timestamp = $event->getEndDate()->getTimestamp();

    // Safety check: do not consider events with zero length
    if ($start_timestamp === $end_timestamp) {
      continue;
    }

    if (empty($cuts[$start_timestamp])) { $cuts[$start_timestamp] = ['start' => [], 'end' => []]; }
    if (empty($cuts[$end_timestamp])) { $cuts[$end_timestamp] = ['start' => [], 'end' => []]; }

    $cuts[$start_timestamp]['start'][] = $event->id();
    $cuts[$end_timestamp]['end'][] = $event->id();
  }

  // Sort cuts by key
  ksort($cuts);

  // 'timestamp 1' => ['start_date' => \DateTime, 'end_date' => \DateTime, 'events' => []]
  $intervals = [];
  $events_on = [];

  $start_date_index = 0;
  $end_date_index = 0;
  $prev_timestamp = NULL;
  foreach ($cuts as $timestamp => $cut) {
    if ($timestamp < $start_date->getTimestamp()) { $start_date_index++; }
    if ($timestamp < $end_date->getTimestamp()) { $end_date_index++; }

    // Close previous interval, see later
    if (!empty($prev_timestamp)) {
      $intervals[$prev_timestamp]['end_date'] = (new \DateTime())->setTimestamp($timestamp);
    }

    // Turn on events that start now
    $events_on = array_unique(array_merge($events_on, $cut['start']));
    // Turn off events that end now
    $events_on = array_diff($events_on, $cut['end']);

    $intervals[$timestamp] = [
      'start_date' => (new \DateTime())->setTimestamp($timestamp),
      'end_date' => NULL, // will be closed in the next iteration
      'events' => array_intersect_key($events, array_flip($events_on)),
    ];

    // Store the current timestamp so that the current interval can be closed in the next iteration.
    $prev_timestamp = $timestamp;
  }

  // Keep only the elements between start and end dates
  return array_slice($intervals, $start_date_index, $end_date_index - $start_date_index, TRUE);
}

Example:

  • One Price event is defined between 2020-08-08 8:00 and 2020-08-08 20:00
  • The getEventsGrouped() function queries the full 2020-08-08 day (from 00.00 to next day 00.00)

Price calculation (replacement of the IntervalValuator). Specific to my use-case, but shows the general approach.

public function calculatePriceForEvent($event) {
  $intervals_by_unit = $this->getEventIntervalsGrouped(
    $event->getStartDate(),
    $event->getEndDate(),
    'price',
    [$event->get('event_bat_unit_reference')->target_id]
  );
  // There's only one unit
  $intervals = reset($intervals_by_unit);

  $price = 0;
  foreach ($intervals as $interval) {
    // For safety, calulate the maximum price - but there should not be
    // multiple price events defined for a unit at the same time.
    $max_value = 0;
    foreach ($interval['events'] as $event) {
      $value = $event->get('field_price')->number;
      if (!empty($value) && $value > $max_value) { $max_value = $value; }
    }

    $time_diff = $interval['end_date']->getTimestamp() - $interval['start_date']->getTimestamp();
    // Using hourly price
    $price += $max_value * ($time_diff / 3600);
  }

  return $price;
}

Next steps

  • First of all, I would like to understand better why the database backend was developed how it was (day, hour, minute tables, etc.)? Is it for performance reasons, if yes, could you provide an example where it can mean a significant difference?
  • Get feedback on what your opinion is about my proposal / code ideas?

As I progress with the project I'm happy to provide patches, improvements for the module - but unfortunately, I cannot guarantee timely responses and full dedication as it is not my main job.

๐ŸŒฑ Plan
Status

Active

Version

1.0

Component

Code

Created by

๐Ÿ‡จ๐Ÿ‡ญSwitzerland DonAtt

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

Not all content is available!

It's likely this issue predates Contrib.social: some issue and comment data are missing.

Production build 0.71.5 2024