app/resto/core/RestoModel.php

Summary

Maintainability
F
4 days
Test Coverage
<?php
/*
 * Copyright 2018 Jérôme Gasperi
 *
 * Licensed under the Apache License, version 2.0 (the "License");
 * You may not use this file except in compliance with the License.
 * You may obtain a copy of the License at:
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations
 * under the License.
 */

/**
 * resto model
 */
abstract class RestoModel
{
    /*
     * Model options
     */
    public $options = array();

    /*
     * STAC extensions - override in child models
     *
     * [STAC 1.0.0] Desactivated due to https://github.com/stac-extensions/stac-extensions.github.io/issues/20
     * [TODO] How to deal with this constraint ?
     */
    public $stacExtensions = array(
        //'https://stac-extensions.github.io/processing/v1.0.0/schema.json',
    );

    /*
     * Mapping applied to input properties
     * It is used to convert input GeoJSON properties BEFORE being inserted in database
     */
    public $inputMapping = array();

    /*
     * STAC mapping is used to convert output property names to STAC properties names
     */
    public $stacMapping = array(

        /*
         * Common metadata
         * [TODO][WARNING] The "published" metadata is part of https://github.com/stac-extensions/timestamps so we should not replace it with "created"
         */
        'published' => array(
            'key' => 'created'
        ),

        /*
         * Processing Extension Specification
         * (https://stac-extensions.github.io/processing/v1.0.0/schema.json)
         */
        'processingLevel' => array(
            'key' => 'processing:level'
        )
    );

    /*
     * Facet hierarchy
     */
    public $facetCategories = array(
        array(
            'collection'
        ),
        array(
            'continent',
            'country',
            'region',
            'state'
        ),
        array(
            'year'
        ),
        array(
            'month'
        ),
        array(
            'day'
        )
    );

    /**
     * OpenSearch search filters
     *
     *  'key' :
     *      *.feature column name
     *  'osKey' :
     *      OpenSearch property name in template urls
     *  'stacKey' :
     *      Search filter name equivalent in STAC
     *      [IMPORTANT] If not set then it is assumed that stacKey is the same as osKey
     *  'prefix' :
     *      (Optional) (for "keywords" operation only) Prefix systematically added to input value (i.e. prefix:value)
     *  'operation' :
     *      Type of operation applied to the filter ("in", "keywords", "intersects", "distance", "=", "<=", ">=")
     *  'queryable' :
     *      (Optional) The name of the underlying STAC/OAFeature property that is queryable (i.e. that will be displayed in the /queryables endpoint)
     *  '$ref' :
     *      (Optional) Displayed in relation with queryable property (see https://github.com/radiantearth/stac-api-spec/tree/master/fragments/filter#queryables)
     *
     *  Below properties follow the "Parameter extension" (http://www.opensearch.org/Specifications/OpenSearch/Extensions/Parameter/1.0/Draft_2)
     *
     *  'minimum' :
     *      Minimum number of times this parameter must be included in the search request (default 0)
     *  'maximum' :
     *      Maximum number of times this parameter must be included in the search request (default 1)
     *  'pattern' :
     *      Regular expression against which the parameter's value
     *      Pattern follows Javascript (http://www.ecma-international.org/publications/standards/Ecma-262.htm)
     *  'title' :
     *      Tooltip
     *  'minExclusive'
     *      Minimum value for the element that cannot be reached
     *  'maxExclusive'
     *      Maximum value for the element that cannot be reached
     *  'hidden'
     *      Do not display this search parameter in OpenSearch Description Document
     *  'options'
     *      List of possible values. Two ways
     *      1. Array of predefined value/label
     *          array(
     *              array(
     *                  'value'
     *                  'label'
     *              ),
     *              ...
     *          )
     *      2. 'auto'
     *         In this case will be computed from facets table
     */
    public $searchFilters = array(

        'filter' => array(
            'osKey' => 'filter',
            'operation' => 'cql2',
            'title' => 'Filter expression expressed in Common Query Lanbguage (CQL2)',
        ),

        'filter:lang' => array(
            'osKey' => 'filter-lang',
            'operation' => '=',
            'title' => 'The language of the filter expression. Only *cql2-text* is accepted',
            'pattern' => 'cql2-text'
        ),

        'filter:crs' => array(
            'osKey' => 'filter-crs',
            'operation' => '=',
            'title' => 'Coordinate reference system of geometries expressed in filter. Only *http://www.opengis.net/def/crs/OGC/1.3/CRS84* is accepted',
            'pattern' => 'http://www.opengis.net/def/crs/OGC/1.3/CRS84'
        ),

        'searchTerms' => array(
            'key' => 'normalized_hashtags',
            // [TODO] Remove type since it seems not be used anymore (TBC)
            'type' => 'array',
            'osKey' => 'q',
            'operation' => 'keywords',
            'title' => 'Free text search'
        ),

        'count' => array(
            'osKey' => 'limit',
            'minInclusive' => 1,
            'maxInclusive' => 500,
            'title' => 'The maximum number of results returned per page (default 10)'
        ),

        'startIndex' => array(
            'osKey' => 'startIndex',
            'minInclusive' => 1
        ),

        'startPage' => array(
            'osKey' => 'page',
            'minInclusive' => 1
        ),

        'language' => array(
            'osKey' => 'lang',
            'title' => 'Two letters language code according to ISO 639-1',
            'pattern' => '^[a-z]{2}$'
        ),

        'geo:uid' => array(
            'key' => 'id',
            'osKey' => 'ids',
            'stacKey' => 'id',
            'operation' => 'in',
            'title' => 'Array of item ids to return. All other filter parameters that further restrict the number of search results (except next and limit) are ignored',
            //'pattern' => '^[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}$',
            'queryable' => 'id',
            '$ref' => 'https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/id'
        ),

        'geo:geometry' => array(
            'key' => 'geom',
            'osKey' => 'intersects',
            'stacKey' => 'geometry',
            'operation' => 'intersects',
            'title' => 'Region of Interest defined in GeoJSON or in Well Known Text standard (WKT) with coordinates in decimal degrees (EPSG:4326)',
            'queryable' => 'geometry',
            '$ref' => 'https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/geometry'
        ),

        'geo:box' => array(
            'key' => 'geom',
            'osKey' => 'bbox',
            'operation' => 'intersects',
            'title' => 'Region of Interest defined by \'west,south,east,north\' coordinates of longitude, latitude, in decimal degrees (EPSG:4326). Note: Box3D are accepted as input but converted to 2D equivalent',
            'pattern' => '^[-]?[0-9]*\.?[0-9]+,[-]?[0-9]*\.?[0-9]+,[-]?[0-9]*\.?[0-9]+,[-]?[0-9]*\.?[0-9]+$|^[-]?[0-9]*\.?[0-9]+,[-]?[0-9]*\.?[0-9]+,[-]?[0-9]*\.?[0-9]+,[-]?[0-9]*\.?[0-9],[-]?[0-9]*\.?[0-9]+,[-]?[0-9]*\.?[0-9]+$'
            /*'pattern' => '^\[[-]?[0-9]*\.?[0-9]+,[-]?[0-9]*\.?[0-9]+,[-]?[0-9]*\.?[0-9]+,[-]?[0-9]*\.?[0-9]+\]$'*/
        ),

        'geo:name' => array(
            'key' => 'geom',
            'osKey' => 'name',
            'operation' => 'distance',
            'title' => 'Location string e.g. Paris, France or toponym identifier (i.e. geouid:xxxx)'
        ),

        'geo:lon' => array(
            'key' => 'geom',
            'osKey' => 'lon',
            'operation' => 'distance',
            'title' => 'Longitude expressed in decimal degrees (EPSG:4326) - should be used with geo:lat',
            'minInclusive' => -180,
            'maxInclusive' => 180
        ),

        'geo:lat' => array(
            'key' => 'geom',
            'osKey' => 'lat',
            'operation' => 'distance',
            'title' => 'Latitude expressed in decimal degrees (EPSG:4326) - should be used with geo:lon',
            'minInclusive' => -90,
            'maxInclusive' => 90
        ),

        'geo:radius' => array(
            'key' => 'geom',
            'osKey' => 'radius',
            'operation' => 'distance',
            'title' => 'Expressed in meters - should be used with geo:lon and geo:lat',
            'minInclusive' => 1
        ),

        'dc:date' => array(
            'key' => 'created',
            'osKey' => 'published',
            'title' => 'Metadata product publication date within database - must follow RFC3339 pattern',
            'operation' => '>=',
            'pattern' => '^([0-9]{4})-(0[1-9]|1[012])-(0[1-9]|[12][0-9]|3[01])[Tt]([01][0-9]|2[0-3]):([0-5][0-9]):([0-5][0-9]|60)(\.[0-9]+)?(([Zz])|([\+|\-]([01][0-9]|2[0-3]):[0-5][0-9]))$'
            /*'pattern' => '^[0-9]{4}-[0-9]{2}-[0-9]{2}(T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(|Z|[\+\-][0-9]{2}:[0-9]{2}))?$'*/
        ),

        'resto:collection' => array(
            'key' => 'collection',
            // This is used to have "collections" converted to "collection" in summaries without having a prefix
            'facetKey' => 'collection',
            'osKey' => 'collections',
            'stacKey' => 'collection',
            'title' => 'Comma separated list of collections name',
            /*'pattern' => '^[a-zA-Z0-9\-_\.\:]+$',*/
            'operation' => 'in',
            'hidden' => true,
            'options' => 'auto',
            'queryable' => 'collection',
            '$ref' => 'https://schemas.stacspec.org/v1.0.0/item-spec/json-schema/item.json#/collection'
        ),

        'resto:model' => array(
            'key' => 'model',
            'osKey' => 'model',
            'title' => 'Model name',
            'pattern' => '^[A-Za-z][a-zA-Z0-9]+$',
            'operation' => '='
        ),

        /*
         * Opposite to STAC "next" query parameter does not exist but could be named "prev"
         */
        'resto:gt' => array(
            'osKey' => 'prev',
            'title' => 'Cursor pagination - return result with sort key greater than sort value',
            'pattern' => "^[0-9\-]+$",
            'operation' => '>'
        ),

        /*
         * The default sort order is DESCENDING - so the STAC "next" query parameter is equivalent
         * to the "resto:lt" (lower than) filter
         */
        'resto:lt' => array(
            'osKey' => 'next',
            'title' => 'Cursor pagination - return result with sort key lower than sort value',
            'pattern' => "^[0-9\-]+$",
            'operation' => '<'
        ),

        'resto:pid' => array(
            'key' => 'productIdentifier',
            'osKey' => 'pid',
            'operation' => '=',
            'title' => 'Equal on productIdentifier'
        ),

        /*
         * The default sort order is DESCENDING - so the STAC "next" query parameter is equivalent
         */
        'resto:sort' => array(
            'osKey' => 'sortby',
            'pattern' => '^[a-zA-Z\-]*$',
            'title' => 'Sort results by property (startDate or created - Default is startDate). Sorting order is DESCENDING (ASCENDING if property is prefixed by minus sign)'
        ),

        'resto:owner' => array(
            'key' => 'owner',
            'osKey' => 'owner',
            'title' => 'Owner of features',
            'operation' => '='
        ),

        'resto:status' => array(
            'key' => 'status',
            'osKey' => 'status',
            'title' => 'Feature status',
            'operation' => '=',
            'pattern' => '^[0-9]+$'
        ),

        /*
         * Apply a filter on collections based on the DATABASE_TARGET_SCHEMA.collection keywords column
         * Equivalent to apply a collections filter with a list of collections
         */
        'resto:ckeywords' => array(
            'osKey' => 'ck',
            'title' => 'Stands for "collection keyword" - Limit search to collection containing the input keyword',
            'operation' => '='
        ),

    );

    /*
     * Array of table names to store "model specific" properties for feature
     * Usually only numeric properties are stored (for search) since
     * string property are stored within metadata property of *.feature table
     * and indexed with normalized_hashtags property of the same table
     */
    public $tables = array();

    /*
     * Parameters to apply to database storage for products related to this model
     *
     *  - tablePrefix : all features belonging to a collection referencing this model will be stored in a dedicated table [tablePrefix]__feature instead of feature"
     *  - storeFacets = if true, facets are stored for model related products
     */
    public $dbParams = array(
        'tablePrefix' => '',
        'storeFacets' => true
    );

    /*
     * Tag add-on configuration:
     * [IMPORTANT] strategy values:
     *    - "merge" $tagConfig->taggers is merged with Tag add-on default taggers
     *    - "replace" $tagConfig->taggers replace Tag add-on default taggers
     *    - "none" Tag add-on is not used
     */
    public $tagConfig = array(
        'strategy' => 'merge',
        'taggers' => array()
    );

    /**
     * Constructor
     *
     * @param array $options
     */
    public function __construct($options = array())
    {
        $this->options = $options;

        if (isset($this->options['addons']['Social'])) {
            $this->searchFilters = array_merge($this->searchFilters, SocialAPI::$searchFilters);
        }
    }

    /**
     * Return the model name (i.e. the name of the Model class)
     */
    public function getName()
    {
        return get_class($this);
    }

    /**
     * Return model inheritance hierarchy stripping out RestoModel
     */
    public function getLineage()
    {
        return array_slice(
            array_merge(
                array_values(array_reverse(class_parents($this))),
                array($this->getName())
            ),
            1
        );
    }

    /**
     * Store several features within {collection}.features table following the class model
     *
     * @param RestoCollection $collection
     * @param array $body : HTTP body (MUST BE a GeoJSON "Feature" or "FeatureCollection" in abstract Model)
     * @param array $params
     *
     */
    public function storeFeatures($collection, $body, $params)
    {
        // Convert input to resto model
        $data = $this->inputToResto($body, $collection, $params);

        if (!isset($data) || !in_array($data['type'], array('Feature', 'FeatureCollection'))) {
            return RestoLogUtil::httpError(400, 'Invalid input type - only "Feature" and "FeatureCollection" are allowed');
        }

        // Extent
        $dates = array();
        $bboxes = array();

        $featuresInserted = array();
        $featuresInError = array();

        // Feature case
        if ($data['type'] === 'Feature') {
            $insert = $this->storeFeature($collection, $data, $params);

            if ($insert['result'] !== false) {
                $featuresInserted[] = array(
                    'featureId' => $insert['result']['id'],
                    'productIdentifier' => $insert['result']['productIdentifier'],
                    'facetsStored' => $insert['result']['facetsStored']
                );

                $dates[] = isset($insert['featureArray']['properties']) && isset($insert['featureArray']['properties']['startDate']) ? $insert['featureArray']['properties']['startDate'] : null;
                $bboxes[] = isset($insert['featureArray']['topologyAnalysis']) && isset($insert['featureArray']['topologyAnalysis']['bbox']) ? $insert['featureArray']['topologyAnalysis']['bbox'] : null;
            }
        }

        // FeatureCollection case
        else {
            for ($i = 0, $ii = count($data['features']); $i<$ii; $i++) {
                try {
                    $insert = $this->storeFeature($collection, $data['features'][$i], $params);
                    if ($insert['result'] !== false) {
                        $featuresInserted[] = array(
                            'featureId' => $insert['result']['id'],
                            'productIdentifier' => $insert['result']['productIdentifier'],
                            'facetsStored' => $insert['result']['facetsStored']
                        );

                        $dates[] = isset($insert['featureArray']['properties']) && isset($insert['featureArray']['properties']['startDate']) ? $insert['featureArray']['properties']['startDate'] : null;
                        $bboxes[] = isset($insert['featureArray']['topologyAnalysis']) && isset($insert['featureArray']['topologyAnalysis']['bbox']) ? $insert['featureArray']['topologyAnalysis']['bbox'] : null;
                    }
                } catch (Exception $e) {
                    $featuresInError[] = array(
                        'code' => $e->getCode(),
                        'error' => $e->getMessage()
                    );
                    continue;
                }
            }
        }

        /*
         * Update collection spatio temporal extent
         */
        (new CollectionsFunctions($collection->context->dbDriver))->updateExtent($collection, array(
            'dates' => $dates,
            'bboxes' => $bboxes
        ));

        return array(
            'inserted' => count($featuresInserted),
            'inError' => count($featuresInError),
            'features' => $featuresInserted,
            'errors' => $featuresInError
        );
    }

    /**
     * Update feature within {collection}.features table following the class model
     *
     * @param RestoFeature $feature
     * @param RestoCollection $collection
     * @param array $body
     * @param array $params
     *
     */
    public function updateFeature($feature, $collection, $body, $params)
    {
        return (new FeaturesFunctions($collection->context->dbDriver))->updateFeature(
            $feature,
            $collection,
            $this->prepareFeatureArray($collection, $this->inputToResto($body, $collection, $params), $params)
        );
    }

    /**
     * Get auto facet fields from model
     */
    public function getAutoFacetFields()
    {
        $facetFields = array();
        foreach (array_values($this->searchFilters) as $filter) {
            if (isset($filter['options']) && $filter['options'] === 'auto') {
                // [IMPORTANT] prefix has preseance over osKey
                $facetFields[] = $filter['prefix'] ?? $filter['facetKey'] ?? $filter['osKey'];
            }
        }
        return $facetFields;
    }

    /**
     * Get resto filters from input query parameters
     *
     *  - change input query keys to model parameter key including STAC conversion (i.e. input STAC query to resto model - e.g. processing:level => processingLevel)
     *  - check that filter value is valid regarding the model definition
     *
     * [IMPORTANT]CHANGE] Each unknown filter key that does not start with '_' is converted to hashtag with the following convention : "#<filterName>:value"
     *
     * @param array $query
     */
    public function getFiltersFromQuery($query)
    {
        $params = array();
        $unknowns = array();
        foreach ($query as $key => $value) {
            $filterKey = $this->getFilterName($key);
            if (isset($filterKey)) {
                // Special case geo:geometry also accept GeoJSON => convert it to WKT
                /* [TODO] Remove already done in RestoUtil::sanitize
                $params[$filterKey] = preg_replace('/<.+?>/', '', $filterKey === 'geo:geometry' ? RestoGeometryUtil::forceWKT($value) : $value); */
                $params[$filterKey] = $filterKey === 'geo:geometry' ? RestoGeometryUtil::forceWKT($value) : $value;
                $this->validateFilter($filterKey, $params[$filterKey]);
            }
            // Do not process query params starting with '_' or in the reserved list
            elseif (!in_array($key, array('collectionId', 'fields')) && substr($key, 0, 1) !== '_') {
                /* [TODO] Remove already done in RestoUtil::sanitize
                Protect against XSS injection
                $unknowns[] = '#' . $this->toHashTag($key, preg_replace('/<.+?>/', '', ltrim($value, '#'))); */
                $unknowns[] = '#' . $this->toHashTag($key, ltrim($value, '#'));
            }
        }

        // Convert unknowns input to hashtags
        if (count($unknowns) > 0) {
            $params['searchTerms'] = isset($params['searchTerms']) ? $params['searchTerms'] . ' ' . join(' ', $unknowns) : join(' ', $unknowns);
        }

        // [STAC]If "ids" filter is set, then discard every other filters except next and limit
        /*
         * [TODO] To be discard since STAC API 1.0.0-beta.1
        return isset($params['geo:uid']) ? array(
            'geo:uid' => $params['geo:uid'],
            'resto:lt' => $params['resto:lt'] ?? null,
            'limit' => $params['limit'] ?? null
        ) : $params;
        */

        return $params;
    }

    /**
     * Return resto internal property (i.e. OpenSearch based) from a STAC property
     *
     * @param string $stacProperty
     */
    public function getRestoPropertyFromSTAC($stacProperty)
    {
        foreach (array_keys($this->stacMapping) as $restoProperty) {
            if ($this->stacMapping[$restoProperty]['key'] === $stacProperty) {
                return $restoProperty;
            }
        }

        return $stacProperty;
    }

    /**
     * Return OpenSearch filter name from OpenSearch or STAC key
     *
     * @param string $osOrSTACKey
     */
    public function getFilterName($osOrSTACKey)
    {
        foreach (array_keys($this->searchFilters) as $filterKey) {
            if ($osOrSTACKey === $this->searchFilters[$filterKey]['osKey'] || (isset($this->searchFilters[$filterKey]['stacKey']) && $osOrSTACKey === $this->searchFilters[$filterKey]['stacKey'])) {
                return $filterKey;
            }
        }
        return null;
    }

    /**
     * Return OpenSearch filter name from prefix key or input prefix otherwise
     *
     * @param string $prefix
     */
    public function getOSKeyFromPrefix($prefix)
    {
        foreach (array_keys($this->searchFilters) as $filterKey) {
            if (isset($this->searchFilters[$filterKey]['prefix']) && $this->searchFilters[$filterKey]['prefix'] === $prefix) {
                return $this->searchFilters[$filterKey]['osKey'];
            }
        }
        return $prefix;
    }

    /**
     * Return STAC/OAFeatures queryables
     */
    public function getQueryables()
    {
        $queryables = array();
        foreach (array_keys($this->searchFilters) as $filterKey) {
            if (isset($this->searchFilters[$filterKey]['queryable'])) {
                $queryable = array(
                    'description' => $this->searchFilters[$filterKey]['title']
                );
                if (isset($this->searchFilters[$filterKey]['$ref'])) {
                    $queryable['$ref'] = $this->searchFilters[$filterKey]['$ref'];
                }
                $queryables[$this->searchFilters[$filterKey]['queryable']] = $queryable;
            }
        }

        return $queryables;
    }

    /**
     * Check if value is valid for a given filter regarding the model
     *
     * @param string $filterKey
     * @param string $value
     */
    public function validateFilter($filterKey, $value)
    {
        /*
         * Check pattern for string
         */
        if (isset($this->searchFilters[$filterKey]['pattern'])) {
            return $this->validateFilterString($filterKey, $value);
        }
        /*
         * Check pattern for number
         */
        elseif (isset($this->searchFilters[$filterKey]['minInclusive']) || isset($this->searchFilters[$filterKey]['maxInclusive'])) {
            return $this->validateFilterNumber($filterKey, $value);
        }

        return true;
    }

    /**
     * Rewrite input $featureArray for output.
     * This function can be superseeded in child Model
     *
     * @param array $featureArray
     * @param RestoCollection $collection
     * @return array
     */
    public function remap($featureArray, $collection)
    {
        /*
         * These properties are discarded from output
         */
        $discardedProperties = array(
            'id',
            'visibility',
            'owner',
            'sort_idx',
            '_keywords',
            'resto:internal'
        );

        $properties = array();

        foreach (array_keys($featureArray['properties']) as $key) {
            // Remove null and non public properties
            if (! isset($featureArray['properties'][$key]) || in_array($key, $discardedProperties)) {
                continue;
            }

            // [STAC] Eventually follows STAC mapping for properties names
            if (isset($this->stacMapping[$key])) {
                $properties[$this->stacMapping[$key]['key']] = $this->convertTo($featureArray['properties'][$key], $this->stacMapping[$key]['convertTo'] ?? null);
            } else {
                $properties[$key] = $featureArray['properties'][$key];
            }
        }

        return array_merge($featureArray, array(
            'properties' => $properties
        ));
    }

    /**
     * Remap input properties using inputMapping
     *
     * @param array $properties
     * @return array
     */
    public function remapInputProperties($properties)
    {
        if (empty($this->inputMapping)) {
            return $properties;
        }

        $newProperties = array();

        $rulesKeys = array_keys($this->inputMapping);
        foreach ($properties as $key => $value) {
            if (in_array($key, $rulesKeys)) {
                if ($this->inputMapping[$key]['key'] === null) {
                    continue;
                }
                $newProperties[$this->inputMapping[$key]['key']] = $this->convertTo($value, $this->inputMapping[$key]['convertTo'] ?? null);
            } else {
                $newProperties[$key] = $value;
            }
        }

        return $newProperties;
    }

    /**
     * Apply type converstion to value
     *
     * @param integer|float|string|object $value
     * @param string $type
     * @return array|integer|float|string|object
     */
    public function convertTo($value, $type)
    {
        switch ($type) {
            case 'array':
                return array(
                    $value
                );
            default:
                return $value;
        }
    }

    /**
     * Convert array of filter names to array of OpenSearch keys
     *
     * @param array $filterNames
     * @param boolean $processSearchTerms
     * @return array
     */
    public function toOSKeys($filterNames, $processSearchTerms = false)
    {
        $arr = array();
        foreach ($filterNames as $key => $obj) {
            if (isset($this->searchFilters[$key])) {
                // Special case => convert string of hashtags to individuals
                if ($key === 'searchTerms' && $processSearchTerms) {
                    $arr = array_merge($arr, $this->explodeSearchTerms($obj));
                    continue;
                }

                // Convert to STAC
                $osKey = $this->searchFilters[$key]['osKey'];
                $arr[isset($this->stacMapping[$osKey]) ? $this->stacMapping[$osKey]['key']: $osKey] = $obj;
            }
        }
        return $arr;
    }

    /**
     * Convert input data to resto model
     *
     * @param array $body : any input data
     * @param RestoCollection $collection
     * @param array $params
     *
     */
    protected function inputToResto($body, $collection, $params)
    {
        if (isset($body['properties'])) {
            $body['properties'] = $this->remapInputProperties($body['properties']);
        }
        return $body;
    }

    /**
     * Store individual feature within {collection}.features table following the class model
     *
     * @param RestoCollection $collection
     * @param array $data : array (MUST BE a GeoJSON "Feature" in abstract Model)
     * @param array $params
     *
     */
    private function storeFeature($collection, $data, $params)
    {
        /*
         * Input feature cannot have both an id and a productIdentifier
         */
        if (isset($data['id']) && isset($data['properties']['productIdentifier']) && $data['id'] !== $data['properties']['productIdentifier']) {
            return RestoLogUtil::httpError(400, 'Invalid input feature - found both "id" and "properties.productIdentifier"');
        }

        $productIdentifier = $data['id'] ?? $data['properties']['productIdentifier'] ?? null;
        $data['properties']['productIdentifier'] = $productIdentifier;

        /*
         * [WARNING] New in resto 7.x - if input id / productIdentifier is already a valid UUID use it directly
         * Correct issue #342 to be STAC compatible
         */
        $featureId = isset($productIdentifier) ? (RestoUtil::isValidUUID($productIdentifier) ? $productIdentifier : RestoUtil::toUUID($productIdentifier)) : RestoUtil::toUUID(md5(microtime().rand()));

        /*
         * First check if feature is already in database
         * [Note] Feature productIdentifier is UNIQUE
         *
         * (do this before getKeywords to avoid iTag process)
         */
        if (isset($productIdentifier) && (new FeaturesFunctions($collection->context->dbDriver))->featureExists($featureId, $collection->context->dbDriver->targetSchema . '.' . $collection->model->dbParams['tablePrefix'] . 'feature')) {
            RestoLogUtil::httpError(409, 'Feature ' . $featureId . ' (with productIdentifier=' . $productIdentifier . ') already in database');
        }

        /*
         * Compute featureArray
         */
        $featureArray = $this->prepareFeatureArray($collection, $data, $params);

        /*
         * Insert feature
         */
        return array(
            'featureArray' => $featureArray,
            'result' => (new FeaturesFunctions($collection->context->dbDriver))->storeFeature(
                $featureId,
                $collection,
                $featureArray
            )
        );
    }

    /**
     * Prepare featureArray for store/update
     *
     * @param RestoCollection $collection
     * @param array $data : array (MUST BE GeoJSON in abstract Model)
     * @param array $params : optional options for ingestion
     *
     */
    private function prepareFeatureArray($collection, $data, $params = array())
    {
        /*
         * Assume input file or stream is a JSON Feature
         */
        $checkGeoJSON = RestoGeometryUtil::checkGeoJSONFeature($data);
        if (! $checkGeoJSON['isValid']) {
            RestoLogUtil::httpError(400, $checkGeoJSON['error']);
        }

        /*
         * Clean properties
         */
        $properties = RestoUtil::cleanAssociativeArray($data['properties']);

        /*
         * Convert datetime to startDate / completionDate
         */
        if (isset($properties['datetime'])) {
            $dates = explode('/', $properties['datetime']);
            if (isset($dates[0])) {
                $properties['startDate'] = $dates[0];
            }
            if (isset($dates[1])) {
                $properties['completionDate'] = $dates[1];
            }
            unset($properties['datetime']);
        }
        if (isset($properties['start_datetime'])) {
            $properties['startDate'] = $properties['start_datetime'];
            unset($properties['start_datetime']);
        }
        if (isset($properties['end_datetime'])) {
            $properties['completionDate'] = $properties['end_datetime'];
            unset($properties['end_datetime']);
        }

        /*
         * Add collection to $properties to initialize facet counts on collection
         * [WARNING] if properties['collection'] is already set, it is discarded and replaced by the current collection
         */
        $properties['collection']  = $collection->id;

        /*
         * Check geometry topology integrity
         */
        $topologyAnalysis = (new GeneralFunctions($collection->context->dbDriver))->getTopologyAnalysis($data['geometry'] ?? null, $params);
        if (!$topologyAnalysis['isValid']) {
            RestoLogUtil::httpError(400, $topologyAnalysis['error']);
        }

        /*
         * Return prepared data
         */
        return array(
            'topologyAnalysis' => $topologyAnalysis,
            'properties' => array_merge($properties, array(
                'keywords' => (new Tag($collection->context, $collection->user))->getKeywords($properties, $data['geometry'] ?? null, $collection->model, $this->getITagParams($collection))
            )),
            'assets' => $data['assets'] ?? null,
            'links' => $data['links'] ?? null
        );
    }

    /**
     * Return collection taggers associative array
     *
     * @param RestoCollection $collection
     * @return array
     */
    private function getITagParams($collection)
    {
        // iTag is not use because model strategy is 'none' or explicitely _useItag is set to false
        if ((isset($collection->context->query['_useItag']) && filter_var($collection->context->query['_useItag'], FILTER_VALIDATE_BOOLEAN) === false) || ($collection->model->tagConfig['strategy'] === 'none')) {
            return null;
        }

        $taggers = array();

        /*
         * Default is to convert array of string to associative array
         */
        if ($collection->context->addons['Tag']['options']['iTag']['taggers']) {
            for ($i = 0, $ii = count($collection->context->addons['Tag']['options']['iTag']['taggers']); $i < $ii; $i++) {
                $taggers[$collection->context->addons['Tag']['options']['iTag']['taggers'][$i]] = array();
            }
        }

        /*
         * Superseed default per collection (replace or merge)
         */
        if (isset($collection->model->tagConfig['taggers'])) {
            if ($collection->model->tagConfig['strategy'] === 'replace') {
                $taggers = $collection->model->tagConfig['taggers'];
            } elseif ($collection->model->tagConfig['strategy'] === 'merge') {
                $taggers = array_merge($taggers, $collection->model->tagConfig['taggers']);
            }
        }

        return array(
            'taggers' => $taggers,
            'planet' => $collection->getPlanet()
        );
    }

    /**
     * Check if value is valid for a given pattern filter regarding the model
     *
     * @param string $filterKey
     * @param string $value
     * @return boolean
     */
    private function validateFilterString($filterKey, $value)
    {
        /*
         * If operation = "in" then value is a comma separated list - check pattern for each element of the list
         */
        if (isset($this->searchFilters[$filterKey]['operation']) && $this->searchFilters[$filterKey]['operation'] === 'in') {
            $elements = array_map('trim', explode(',', $value));
            for ($i = count($elements); $i--;) {
                if (preg_match('\'' . $this->searchFilters[$filterKey]['pattern'] . '\'', $elements[$i]) !== 1) {
                    return RestoLogUtil::httpError(400, 'Comma separated list of "' . $this->searchFilters[$filterKey]['osKey'] . '" must follow the pattern ' . $this->searchFilters[$filterKey]['pattern']);
                }
            }
        } elseif (preg_match('\'' . $this->searchFilters[$filterKey]['pattern'] . '\'', $value) !== 1) {
            return RestoLogUtil::httpError(400, 'Value for "' . $this->searchFilters[$filterKey]['osKey'] . '" must follow the pattern ' . $this->searchFilters[$filterKey]['pattern']);
        }

        return true;
    }

    /**
     * Check if value is valid for a given number filter regarding the model
     *
     * @param string $filterKey
     * @param string $value
     * @return boolean
     */
    private function validateFilterNumber($filterKey, $value)
    {
        if (!is_numeric($value)) {
            RestoLogUtil::httpError(400, 'Value for "' . $this->searchFilters[$filterKey]['osKey'] . '" must be numeric');
        }
        if (isset($this->searchFilters[$filterKey]['minInclusive']) && $value < $this->searchFilters[$filterKey]['minInclusive']) {
            RestoLogUtil::httpError(400, 'Value for "' . $this->searchFilters[$filterKey]['osKey'] . '" must be greater than ' . ($this->searchFilters[$filterKey]['minInclusive'] - 1));
        }
        // [STAC] Special case for count - accept value even if higher than maxInclusive
        if ($filterKey !== 'count' && isset($this->searchFilters[$filterKey]['maxInclusive']) && $value > $this->searchFilters[$filterKey]['maxInclusive']) {
            RestoLogUtil::httpError(400, 'Value for "' . $this->searchFilters[$filterKey]['osKey'] . '" must be lower than ' . ($this->searchFilters[$filterKey]['maxInclusive'] + 1));
        }
        return true;
    }

    /**
     * Convert input value to hashtag with the following convention : "#<filterName>:value"
     * [WARNING] Exception if filterName = 'hashtag' then "#value" is returned (i.e. discard 'hashtag' prefix)
     *
     * @param string $key
     * @param string $value
     * @return string
     */
    private function toHashTag($filterName, $value)
    {
        // Special case for ',' (AND) and '|' (OR)
        $splitter = '';
        if (strpos($value, ',') !== false) {
            $splitter = ',';
            $exploded = explode(',', $value);
        } elseif (strpos($value, ',') !== false) {
            $splitter = '|';
            $exploded = explode('|', $value);
        } else {
            $exploded = array($value);
        }

        for ($i = 0, $ii = count($exploded); $i < $ii; $i++) {
            $exploded[$i] = $filterName === 'hashtag' ? $exploded[$i] : $filterName . RestoConstants::TAG_SEPARATOR . $exploded[$i];
        }

        return join($splitter, $exploded);
    }

    /**
     * Explode a searchTerms string (e.g. "#location:coastal #year:2003 #instrument:PHR,NIR #thisisanormalahashtag")
     * into an array of filters (i.e. {"location":"coastal","year":2003,"instruments":"PHR,NIR","q":"#thisisnormalhashtagh"})
     *
     * @param array $obj
     */
    private function explodeSearchTerms($obj)
    {
        $hashtags = [];
        $output = [];
        
        /*
         * Process each searchTerm
         */
        if (is_string($obj)) {
            $obj = array(
                'value' => $obj,
                'operation' => '='
            );
        }
        $searchTerms = RestoUtil::splitString($obj['value']);
        for ($i = 0, $l = count($searchTerms); $i < $l; $i++) {
            $splitted = explode(RestoConstants::TAG_SEPARATOR, $searchTerms[$i]);

            // This is a regular hashtag
            if (count($splitted) === 1) {
                $hashtags[] = $searchTerms[$i];
                continue;
            }

            // Concatenate splitted into prefix and value
            $key = array_shift($splitted);
            $value = join(RestoConstants::TAG_SEPARATOR, $splitted);

            /*
             * Hashtags start with "#" or with "-#" (equivalent to "NOT #")
             */
            if (substr($key, 0, 1) === '#') {
                $osKey = $this->getOSKeyFromPrefix(ltrim($key, '#'));
                $output[isset($this->stacMapping[$osKey]) ? $this->stacMapping[$osKey]['key']: $osKey] = array(
                    'value' => $value,
                    'operation' =>  $obj['operation']
                );
            } elseif (substr($key, 0, 2) === '-#') {
                $osKey = $this->getOSKeyFromPrefix(ltrim($key, '-#'));
                $output[isset($this->stacMapping[$osKey]) ? $this->stacMapping[$osKey]['key']: $osKey] = array(
                    'value' => '-' . $value,
                    'operation' =>  $obj['operation']
                );
            } else {
                $hashtags[] = $searchTerms[$i];
            }
        }

        if (count($hashtags) > 0) {
            $output['searchTerms'] = array(
                'value' => join(' ', $hashtags),
                'operation' =>  $obj['operation']
            );
        }

        return $output;
    }

}