You are here

ViewsJsonQuery.php in Views Json Source 1.x

Same filename and directory in other branches
  1. 8 src/Plugin/views/query/ViewsJsonQuery.php

File

src/Plugin/views/query/ViewsJsonQuery.php
View source
<?php

namespace Drupal\views_json_source\Plugin\views\query;

use Drupal\Core\File\FileSystemInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\views\ResultRow;
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\query\QueryPluginBase;

/**
 * Base query handler for views_json_source.
 *
 * @ViewsQuery(
 *   id = "views_json_source_query",
 *   title = @Translation("Views Json Source Query"),
 *   help = @Translation("Query against API(JSON).")
 * )
 */
class ViewsJsonQuery extends QueryPluginBase {

  /**
   * To store the contextual Filter info.
   *
   * @var array
   */
  protected $contextualFilter;

  /**
   * Generate a query from all of the information supplied to the object.
   *
   * @param bool $get_count
   *   Provide a countquery if this is true, otherwise provide a normal query.
   */
  public function query($get_count = FALSE) {
    $filters = [];
    if (isset($this->filter)) {
      foreach ($this->filter as $filter) {
        $filters[] = $filter
          ->generate();
      }
    }

    // @todo Add an option for the filters to be 'and' or 'or'.
    return $filters;
  }

  /**
   * Builds the necessary info to execute the query.
   */
  public function build(ViewExecutable $view) {
    $view
      ->initPager();

    // Let the pager modify the query to add limits.
    $view->pager
      ->query();
    $view->build_info['query'] = $this
      ->query();
    $view->build_info['query_args'] = [];
  }

  /**
   * Fetch file.
   */
  public function fetchFile($uri) {
    $parsed = parse_url($uri);

    // Check for local file.
    if (empty($parsed['host'])) {
      if (!file_exists($uri)) {
        throw new \Exception($this
          ->t('Local file not found.'));
      }
      return file_get_contents($uri);
    }
    $destination = 'public://views_json_source';
    if (!file_prepare_directory($destination, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS)) {
      throw new \Exception($this
        ->t('Files directory either cannot be created or is not writable.'));
    }
    $headers = [];
    $cache_file = 'views_json_source_' . md5($uri);
    if ($cache = \Drupal::cache()
      ->get($cache_file)) {
      $last_headers = $cache->data;
      if (!empty($last_headers['etag'])) {
        $headers['If-None-Match'] = $last_headers['etag'];
      }
      if (!empty($last_headers['last-modified'])) {
        $headers['If-Modified-Since'] = $last_headers['last-modified'];
      }
    }
    $result = \Drupal::httpClient()
      ->get($uri, [
      'headers' => $headers,
    ]);
    if (isset($result->error)) {
      $args = [
        '%error' => $result->error,
        '%uri' => $uri,
      ];
      $message = $this
        ->t('HTTP response: %error. URI: %uri', $args);
      throw new \Exception($message);
    }
    $cache_file_uri = "{$destination}/{$cache_file}";
    if ($result
      ->getStatusCode() == 304) {
      if (file_exists($cache_file_uri)) {
        return file_get_contents($cache_file_uri);
      }

      // We have the headers but no cache file. :(
      // Run it back.
      \Drupal::cache('bin')
        ->invalidate($cache_file);
      return $this
        ->fetchFile($uri);
    }

    // As learned from Feeds caching mechanism, save to file.
    file_save_data((string) $result
      ->getBody(), $cache_file_uri, FileSystemInterface::EXISTS_REPLACE);
    \Drupal::cache()
      ->set($cache_file, $result
      ->getHeaders());
    return (string) $result
      ->getBody();
  }

  /**
   * {@inheritdoc}
   */
  public function execute(ViewExecutable $view) {
    $start = microtime(TRUE);

    // Avoid notices about $view->execute_time being undefined if the query
    // doesn't finish.
    $view->execute_time = NULL;

    // Make sure that an xml file exists.
    // This could happen if you come from the add wizard to the actual views
    // edit page.
    if (empty($this->options['json_file'])) {
      return FALSE;
    }
    $data = new \stdClass();
    try {
      $data->contents = $this
        ->fetchFile($this->options['json_file']);
    } catch (\Exception $e) {
      \Drupal::messenger()
        ->addMessage($e
        ->getMessage(), 'error');
      return;
    }

    // When content is empty, parsing it is pointless.
    if (!$data->contents) {
      if ($this->options['show_errors']) {
        \Drupal::messenger()
          ->addMessage($this
          ->t('Views Json Backend: File is empty.'), 'warning');
      }
      return;
    }

    // Go!
    $ret = $this
      ->parse($view, $data);
    $view->execute_time = microtime(TRUE) - $start;
    if (!$ret) {
      if (version_compare(phpversion(), '5.3.0', '>=')) {
        $tmp = [
          JSON_ERROR_NONE => $this
            ->t('No error has occurred'),
          JSON_ERROR_DEPTH => $this
            ->t('The maximum stack depth has been exceeded'),
          JSON_ERROR_STATE_MISMATCH => $this
            ->t('Invalid or malformed JSON'),
          JSON_ERROR_CTRL_CHAR => $this
            ->t('Control character error, possibly incorrectly encoded'),
          JSON_ERROR_SYNTAX => $this
            ->t('Syntax error'),
          JSON_ERROR_UTF8 => $this
            ->t('Malformed UTF-8 characters, possibly incorrectly encoded'),
        ];
        $msg = $tmp[json_last_error()] . ' - ' . $this->options['json_file'];
        \Drupal::messenger()
          ->addMessage($msg, 'error');
      }
      else {
        \Drupal::messenger()
          ->addMessage($this
          ->t('Views Json Backend: Parse json error') . ' - ' . $this->options['json_file'], 'error');
      }
    }
  }

  /**
   * Fetch data in array according to apath.
   *
   * @param string $apath
   *   Something like '1/name/0'.
   * @param array $array
   *   The json document content.
   *
   * @return array
   *   The json document matching the path.
   */
  public function apath($apath, array $array) {
    $r =& $array;
    $paths = explode('/', trim($apath, '//'));
    foreach ($paths as $path) {
      if ($path == '%') {

        // Replace with the contextual filter value.
        $key = $this
          ->getCurrentContextualFilter();
        $r = $r[$key];
      }
      elseif (is_array($r) && isset($r[$path])) {
        $r =& $r[$path];
      }
      elseif (is_object($r)) {
        $r =& $r->{$path};
      }
      else {
        break;
      }
    }
    return $r;
  }

  /**
   * Define ops for using in filter.
   */
  public function ops($op, $l, $r) {
    $table = [
      '=' => function ($l, $r) {
        return $l === $r;
      },
      '!=' => function ($l, $r) {
        return $l !== $r;
      },
      'contains' => function ($l, $r) {
        return strpos($l, $r) !== FALSE;
      },
      '!contains' => function ($l, $r) {
        return strpos($l, $r) === FALSE;
      },
      'shorterthan' => function ($l, $r) {
        return strlen($l) < $r;
      },
      'longerthan' => function ($l, $r) {
        return strlen($l) > $r;
      },
    ];
    return call_user_func_array($table[$op], [
      $l,
      $r,
    ]);
  }

  /**
   * Parse.
   */
  public function parse(ViewExecutable &$view, $data) {
    $ret = json_decode($data->contents, TRUE);
    if (!$ret) {
      return FALSE;
    }

    // Get rows.
    $ret = $this
      ->apath($this->options['row_apath'], $ret);

    // Filter.
    foreach ($ret as $k => $row) {
      $check = TRUE;
      foreach ($view->build_info['query'] as $filter) {

        // Filter only when value is present.
        if (!empty($filter[0])) {
          $l = $row[$filter[0]];
          $check = $this
            ->ops($filter[1], $l, $filter[2]);
          if (!$check) {
            break;
          }
        }
      }
      if (!$check) {
        unset($ret[$k]);
      }
    }
    try {
      if ($view->pager
        ->useCountQuery() || !empty($view->get_total_rows)) {

        // Hackish execute_count_query implementation.
        $view->pager->total_items = count($ret);
        if (!empty($view->pager->options['offset'])) {
          $view->pager->total_items -= $view->pager->options['offset'];
        }
        $view->pager
          ->updatePageInfo();
      }
      if (!empty($this->orderby)) {

        // Array reverse, because the most specific are first.
        foreach (array_reverse($this->orderby) as $orderby) {
          $this
            ->sort($ret, $orderby['field'], $orderby['order']);
        }
      }

      // Deal with offset & limit.
      $offset = !empty($this->offset) ? intval($this->offset) : 0;
      $limit = !empty($this->limit) ? intval($this->limit) : 0;
      $ret = $limit ? array_slice($ret, $offset, $limit) : array_slice($ret, $offset);
      $result = [];
      foreach ($ret as $row) {
        $new_row = $this
          ->parseRow(NULL, $row, $row);
        $result[] = $new_row;
      }
      foreach ($result as $row) {
        $view->result[] = new ResultRow($row);
      }

      // Re-index array.
      $index = 0;
      foreach ($view->result as &$row) {
        $row->index = $index++;
      }
      $view->total_rows = count($result);
      $view->pager
        ->postExecute($view->result);
      return TRUE;
    } catch (\Exception $e) {
      $view->result = [];
      if (!empty($view->live_preview)) {
        \Drupal::messenger()
          ->addMessage(time());
        \Drupal::messenger()
          ->addMessage($e
          ->getMessage(), 'error');
      }
      else {
        \Drupal::messenger()
          ->addMessage($e
          ->getMessage());
      }
    }
  }

  /**
   * Parse row.
   *
   * A recursive function to flatten the json object.
   * Example:
   * {person:{name:{first_name:"John", last_name:"Doe"}}}
   * becomes:
   * $row->person/name/first_name = "John",
   * $row->person/name/last_name = "Doe"
   */
  public function parseRow($parent_key, $parent_row, &$row) {
    foreach ($parent_row as $key => $value) {
      if (is_array($value)) {
        unset($row[$key]);
        $this
          ->parseRow(is_null($parent_key) ? $key : $parent_key . '/' . $key, $value, $row);
      }
      else {
        if ($parent_key) {
          $new_key = $parent_key . '/' . $key;
          $row[$new_key] = $value;
        }
        else {
          $row[$key] = $value;
        }
      }
    }
    return $row;
  }

  /**
   * Option definition.
   */
  public function defineOptions() {
    $options = parent::defineOptions();
    $options['json_file'] = [
      'default' => '',
    ];
    $options['row_apath'] = [
      'default' => '',
    ];
    $options['show_errors'] = [
      'default' => TRUE,
    ];
    return $options;
  }

  /**
   * Options form.
   */
  public function buildOptionsForm(&$form, FormStateInterface $form_state) {
    $form['json_file'] = [
      '#type' => 'textfield',
      '#title' => $this
        ->t('Json File'),
      '#default_value' => $this->options['json_file'],
      '#description' => $this
        ->t("The URL or path to the Json file."),
      '#maxlength' => 1024,
    ];
    $form['row_apath'] = [
      '#type' => 'textfield',
      '#title' => $this
        ->t('Row Apath'),
      '#default_value' => $this->options['row_apath'],
      '#description' => $this
        ->t("Apath to records.<br />Apath is just a simple array item find method. Ex:<br /><pre>['data' => \n\t['records' => \n\t\t[\n\t\t\t['firstname' => 'abc', 'lastname' => 'pqr'],\n\t\t\t['firstname' => 'xyz', 'lastname' => 'aaa']\n\t\t]\n\t]\n]</pre><br />You want 'records', so Apath could be set to 'data/records'. <br />Notice: Use '%' as wildcard to get the child contents - EG: '%/records', Also add the contextual filter to replace the wildcard('%') with 'data'."),
      '#required' => TRUE,
    ];
    $form['show_errors'] = [
      '#type' => 'checkbox',
      '#title' => $this
        ->t('Show Json errors'),
      '#default_value' => $this->options['show_errors'],
      '#description' => $this
        ->t('If there were any errors during Json parsing, display them. It is recommended to leave this on during development.'),
      '#required' => FALSE,
    ];
  }

  /**
   * {@inheritDoc}
   */
  public function addField($table, $field, $alias = '', $params = []) {
    $alias = $field;

    // Add field info array.
    if (empty($this->fields[$field])) {
      $this->fields[$field] = [
        'field' => $field,
        'table' => $table,
        'alias' => $alias,
      ] + $params;
    }
    return $field;
  }

  /**
   * Add Order By.
   */
  public function addOrderBy($table, $field = NULL, $orderby = 'ASC') {
    $this->orderby[] = [
      'field' => $field,
      'order' => $orderby,
    ];
  }

  /**
   * Add Filter.
   */
  public function addFilter($filter) {
    $this->filter[] = $filter;
  }

  /**
   * Sort.
   */
  public function sort(&$result, $field, $order) {
    if (strtolower($order) == 'asc') {
      usort($result, $this
        ->sortAsc($field));
    }
    else {
      usort($result, $this
        ->sortDesc($field));
    }
  }

  /**
   * Sort Ascending.
   */
  public function sortAsc($key) {
    return function ($a, $b) use ($key) {
      $a_value = $a[$key] ?? '';
      $b_value = $b[$key] ?? '';
      return strnatcasecmp($a_value, $b_value);
    };
  }

  /**
   * Sort Descending.
   */
  public function sortDesc($key) {
    return function ($a, $b) use ($key) {
      $a_value = $a[$key] ?? '';
      $b_value = $b[$key] ?? '';
      return -strnatcasecmp($a_value, $b_value);
    };
  }

  /**
   * To store the filter values required to pick the node from the json.
   */
  public function addContextualFilter($filter) {
    $this->contextualFilter[] = $filter;
  }

  /**
   * To get the next filter value to pick the node from the json.
   */
  public function getCurrentContextualFilter() {
    $filter = current($this->contextualFilter);
    next($this->contextualFilter);
    return $filter;
  }

}

Classes

Namesort descending Description
ViewsJsonQuery Base query handler for views_json_source.