IbisFeatureToJson.java

/*
 * Copyright (C) 2015 B3Partners B.V.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package nl.b3p.viewer.util;

import java.io.IOException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import nl.b3p.viewer.config.app.ApplicationLayer;
import nl.b3p.viewer.config.app.ConfiguredAttribute;
import nl.b3p.viewer.config.services.AttributeDescriptor;
import nl.b3p.viewer.config.services.FeatureTypeRelation;
import nl.b3p.viewer.config.services.FeatureTypeRelationKey;
import nl.b3p.viewer.config.services.SimpleFeatureType;
import static nl.b3p.viewer.ibis.util.IbisConstants.ID_FIELDNAME;
import static nl.b3p.viewer.ibis.util.IbisConstants.MUTATIEDATUM_FIELDNAME;
import static nl.b3p.viewer.ibis.util.IbisConstants.WORKFLOW_FIELDNAME;
import nl.b3p.viewer.ibis.util.WorkflowStatus;
import static nl.b3p.viewer.stripes.FeatureInfoActionBean.FID;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.geotools.data.DataUtilities;
import org.geotools.data.FeatureSource;
import org.geotools.data.Query;
import org.geotools.data.simple.SimpleFeatureCollection;
import org.geotools.data.simple.SimpleFeatureIterator;
import org.geotools.factory.CommonFactoryFinder;
import org.geotools.feature.FeatureIterator;
import org.geotools.feature.collection.SortedSimpleFeatureCollection;
import org.geotools.filter.text.cql2.CQL;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.opengis.feature.simple.SimpleFeature;
import org.opengis.filter.Filter;
import org.opengis.filter.FilterFactory2;
import org.opengis.filter.expression.Function;
import org.opengis.filter.sort.SortBy;
import org.opengis.filter.sort.SortOrder;

/**
 * This is a custom version of {@link FeatureToJson}.
 *
 * @author Mark Prins
 */
public class IbisFeatureToJson {

    private static final Log LOG = LogFactory.getLog(IbisFeatureToJson.class);

    public static final int MAX_FEATURES = 1000;
    private boolean arrays = false;
    private boolean edit = false;
    private boolean graph = false;
    private boolean aliases = true;
    private List<Long> attributesToInclude = new ArrayList<>();
    private static final int TIMEOUT = 5000;

    public IbisFeatureToJson(boolean arrays, boolean edit, boolean graph, List<Long> attributesToInclude) {
        this.arrays = arrays;
        this.edit = edit;
        this.graph = graph;
        this.attributesToInclude = attributesToInclude;
    }

    public IbisFeatureToJson(boolean arrays, boolean edit, boolean graph, boolean aliases, List<Long> attributesToInclude) {
        this.arrays = arrays;
        this.edit = edit;
        this.graph = graph;
        this.attributesToInclude = attributesToInclude;
        this.aliases = aliases;
    }

    /**
     * Get the features as JSONArray with the given params.
     *
     * @param al The application layer(if there is a application layer)
     * @param ft The featuretype that must be used to get the features
     * @param fs The featureSource
     * @param q The query
     * @return JSONArray with features.
     * @throws IOException if any
     * @throws JSONException if any
     * @throws Exception if any
     */
    public JSONArray getWorkflowJSONFeatures(ApplicationLayer al, SimpleFeatureType ft, FeatureSource fs, Query q) throws IOException, JSONException, Exception {
        LOG.debug("Ophalen workflow json features met query: " + q);
        // query aanpassen met extra filter zodat archief/afgevoerd percelen niet meekomenvoor edit en verhogen van max met factor 2
        Filter f = q.getFilter();
        FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2();
        f = ff.and(f, ff.or(
                ff.equal(ff.property(WORKFLOW_FIELDNAME), ff.literal(WorkflowStatus.definitief.name()), false),
                ff.equal(ff.property(WORKFLOW_FIELDNAME), ff.literal(WorkflowStatus.bewerkt.name()), false)
        ));
        q.setFilter(f);
        q.setMaxFeatures(2 * q.getMaxFeatures());
        LOG.debug("Ophalen workflow json features met aangepaste query: " + q);

        Map<String, String> attributeAliases = new HashMap<>();
        if (!edit) {
            for (AttributeDescriptor ad : ft.getAttributes()) {
                if (ad.getAlias() != null) {
                    attributeAliases.put(ad.getName(), ad.getAlias());
                }
            }
        }
        List<String> propertyNames;
        if (al != null) {
            propertyNames = this.setPropertyNames(al, q, ft, edit);
        } else {
            propertyNames = new ArrayList<>();
            for (AttributeDescriptor ad : ft.getAttributes()) {
                propertyNames.add(ad.getName());
            }
        }
        boolean shouldRemoveID_FIELDbeforeJSONify = false;
        if (!propertyNames.contains(ID_FIELDNAME)) {
            shouldRemoveID_FIELDbeforeJSONify = propertyNames.add(ID_FIELDNAME);
        }
        q.setPropertyNames(propertyNames);

        Integer start = q.getStartIndex();
        if (start == null) {
            start = 0;
        }
        boolean offsetSupported = fs.getQueryCapabilities().isOffsetSupported();
        //if offSet is not supported, get more features (start + the wanted features)
        if (!offsetSupported && q.getMaxFeatures() < MAX_FEATURES) {
            q.setMaxFeatures(q.getMaxFeatures() + start);
        }

        JSONArray features = new JSONArray();
        try {
            // workflow handling
            SimpleFeatureCollection feats = (SimpleFeatureCollection) fs.getFeatures(q);
            // get a list of unique ID_FIELDNAME
            Function uniq = ff.function("Collection_Unique", ff.property(ID_FIELDNAME));
            Set<Object> idlist = (Set<Object>) uniq.evaluate(feats);

            SimpleFeatureCollection inMem = DataUtilities.collection(feats);
            List<SimpleFeature> actueel = new ArrayList<>();

            // this works as follows:
            //  - filter out all definitief and bewerkt for a certain id
            //  - sort that set ascending by WORKFLOW_FIELDNAME
            //  - sort that set descending by MUTATIEDATUM_FIELDNAME
            //  - get the first feature from the collection
            // which should be the youngest bewerkt or definitief
            SimpleFeature actFeat;
            SortedSimpleFeatureCollection sorted;
            Filter filter;
            SortBy[] sortBy = new SortBy[]{
                ff.sort(WORKFLOW_FIELDNAME, SortOrder.ASCENDING),
                ff.sort(MUTATIEDATUM_FIELDNAME, SortOrder.DESCENDING)
            };
            if (idlist != null && idlist.size() > 0) {
                for (Object id : idlist) {
                    filter = ff.and(
                            ff.equals(ff.property(ID_FIELDNAME), ff.literal(id)),
                            ff.or(
                                    ff.equal(ff.property(WORKFLOW_FIELDNAME), ff.literal(WorkflowStatus.definitief.name()), false),
                                    ff.equal(ff.property(WORKFLOW_FIELDNAME), ff.literal(WorkflowStatus.bewerkt.name()), false)
                            ));
                    sorted = new SortedSimpleFeatureCollection(inMem.subCollection(filter), sortBy);
                    LOG.debug("aantal gevonden: " + sorted.size());
                    if (LOG.isDebugEnabled()) {
                        SimpleFeatureIterator sfi = sorted.features();
                        while (sfi.hasNext()) {
                            LOG.debug("gevonden feature: " + sfi.next());
                        }
                    }
                    actFeat = DataUtilities.first(sorted);
                    LOG.debug("actuele feature: " + actFeat);
                    if (actFeat != null) {
                        actueel.add(actFeat);
                    }
                }

                int featureIndex = 0;

                if (shouldRemoveID_FIELDbeforeJSONify) {
                    propertyNames.remove(ID_FIELDNAME);
                }

                for (SimpleFeature feature : actueel) {
                    /* if offset not supported and there are more features returned then
                     * only get the features after index >= start*/
                    if (offsetSupported || featureIndex >= start) {
                        JSONObject j = this.toJSONFeature(new JSONObject(), feature, ft, al, propertyNames, attributeAliases, 0);
                        features.put(j);
                    }
                    featureIndex++;
                }
            }
        } finally {
            fs.getDataStore().dispose();
        }
        return features;
    }

    /**
     * Get the features as JSONArray with the given params
     *
     * @param al The application layer(if there is a application layer)
     * @param ft The featuretype that must be used to get the features
     * @param fs The featureSource
     * @param q The query
     * @return JSONArray with features.
     * @throws IOException if any
     * @throws JSONException if any
     * @throws Exception if any
     */
    public JSONArray getDefinitiefJSONFeatures(ApplicationLayer al, SimpleFeatureType ft, FeatureSource fs, Query q)
            throws IOException, JSONException, Exception {
        LOG.debug("Ophalen definitief json features met: " + q);
        Map<String, String> attributeAliases = new HashMap<>();
        if (!edit) {
            for (AttributeDescriptor ad : ft.getAttributes()) {
                if (ad.getAlias() != null) {
                    attributeAliases.put(ad.getName(), ad.getAlias());
                }
            }
        }
        List<String> propertyNames;
        if (al != null) {
            propertyNames = this.setPropertyNames(al, q, ft, edit);
        } else {
            propertyNames = new ArrayList<>();
            for (AttributeDescriptor ad : ft.getAttributes()) {
                propertyNames.add(ad.getName());
            }
        }

        Integer start = q.getStartIndex();
        if (start == null) {
            start = 0;
        }
        boolean offsetSupported = fs.getQueryCapabilities().isOffsetSupported();
        //if offSet is not supported, get more features (start + the wanted features)
        if (!offsetSupported && q.getMaxFeatures() < MAX_FEATURES) {
            q.setMaxFeatures(q.getMaxFeatures() + start);
        }

        JSONArray features = new JSONArray();
        try {
            // only get 'definitief'
            SimpleFeatureCollection feats = (SimpleFeatureCollection) fs.getFeatures(q);
            FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2();
            Filter definitief = ff.equal(ff.property(WORKFLOW_FIELDNAME), ff.literal(WorkflowStatus.definitief.name()), false);
            SimpleFeatureCollection defSFC = DataUtilities.collection(feats.subCollection(definitief));

            int featureIndex = 0;
            SimpleFeature feature;
            try (SimpleFeatureIterator defs = defSFC.features()) {
                while (defs.hasNext()) {
                    feature = defs.next();
                    /* if offset not supported and there are more features returned then
                     * only get the features after index >= start*/
                    if (offsetSupported || featureIndex >= start) {
                        JSONObject j = this.toJSONFeature(new JSONObject(), feature, ft, al, propertyNames, attributeAliases, 0);
                        features.put(j);
                    }
                    featureIndex++;
                }
            }
        } finally {
            fs.getDataStore().dispose();
        }
        return features;
    }

    /**
     * Get the features as JSONArray with the given params
     *
     * @param al The application layer(if there is a application layer)
     * @param ft The featuretype that must be used to get the features
     * @param fs The featureSource
     * @param q The query
     * @return JSONArray with features.
     * @throws IOException if any
     * @throws JSONException if any
     * @throws Exception if any
     */
    public JSONArray getHistorischeJSONFeatures(ApplicationLayer al, SimpleFeatureType ft, FeatureSource fs, Query q)
            throws IOException, JSONException, Exception {
        LOG.debug("Ophalen archief json features met: " + q);
        Map<String, String> attributeAliases = new HashMap<>();
        if (!edit) {
            for (AttributeDescriptor ad : ft.getAttributes()) {
                if (ad.getAlias() != null) {
                    attributeAliases.put(ad.getName(), ad.getAlias());
                }
            }
        }
        List<String> propertyNames;
        if (al != null) {
            propertyNames = this.setPropertyNames(al, q, ft, edit);
        } else {
            propertyNames = new ArrayList<>();
            for (AttributeDescriptor ad : ft.getAttributes()) {
                propertyNames.add(ad.getName());
            }
        }

        Integer start = q.getStartIndex();
        if (start == null) {
            start = 0;
        }
        boolean offsetSupported = fs.getQueryCapabilities().isOffsetSupported();
        //if offSet is not supported, get more features (start + the wanted features)
        if (!offsetSupported && q.getMaxFeatures() < MAX_FEATURES) {
            q.setMaxFeatures(q.getMaxFeatures() + start);
        }

        JSONArray features = new JSONArray();
        try {
            FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2();
            // 'archief' + afgevoerd, sort by date
            q.setSortBy(new SortBy[]{ff.sort(MUTATIEDATUM_FIELDNAME,SortOrder.ASCENDING)});
            Filter archief = ff.or(
                    ff.equal(ff.property(WORKFLOW_FIELDNAME), ff.literal(WorkflowStatus.archief.name()), false),
                    ff.equal(ff.property(WORKFLOW_FIELDNAME), ff.literal(WorkflowStatus.afgevoerd.name()), false)
            );
            SimpleFeatureCollection feats = (SimpleFeatureCollection) fs.getFeatures(q);
            SimpleFeatureCollection defSFC = DataUtilities.collection(feats.subCollection(archief));

            int featureIndex = 0;
            SimpleFeature feature;
            try (SimpleFeatureIterator defs = defSFC.features()) {
                while (defs.hasNext()) {
                    feature = defs.next();
                    /* if offset not supported and there are more features returned then
                     * only get the features after index >= start*/
                    if (offsetSupported || featureIndex >= start) {
                        JSONObject j = this.toJSONFeature(new JSONObject(), feature, ft, al, propertyNames, attributeAliases, 0);
                        features.put(j);
                    }
                    featureIndex++;
                }
            }
        } finally {
            fs.getDataStore().dispose();
        }
        return features;
    }

    private JSONObject toJSONFeature(JSONObject j, SimpleFeature f, SimpleFeatureType ft, ApplicationLayer al, List<String> propertyNames, Map<String, String> attributeAliases, int index)
            throws JSONException, Exception {
        if (arrays) {
            for (String name : propertyNames) {
                Object value = f.getAttribute(name);
                j.put("c" + index++, formatValue(value));
            }
        } else {
            for (String name : propertyNames) {
                if (!aliases) {
                    j.put(name, formatValue(f.getAttribute(name)));
                } else {
                    String alias = null;
                    if (attributeAliases != null) {
                        alias = attributeAliases.get(name);
                    }
                    j.put(alias != null ? alias : name, formatValue(f.getAttribute(name)));
                }
            }
        }
        //if edit and not yet set
        // removed check for edit variable here because we need to compare features in edit component and feature info attributes
        // was if(edit && j.optString(FID,null)==null) {
        if (j.optString(FID, null) == null) {
            String id = f.getID();
            j.put(FID, id);
        }
        if (ft.hasRelations()) {
            j = populateWithRelatedFeatures(j, f, ft, al, index);
        }
        return j;
    }

    /**
     * Populate the json object with related featues
     */
    private JSONObject populateWithRelatedFeatures(JSONObject j, SimpleFeature feature, SimpleFeatureType ft, ApplicationLayer al, int index) throws Exception {
        if (ft.hasRelations()) {
            JSONArray related_featuretypes = new JSONArray();
            for (FeatureTypeRelation rel : ft.getRelations()) {
                boolean isJoin = rel.getType().equals(FeatureTypeRelation.JOIN);
                if (isJoin) {
                    FeatureSource foreignFs = rel.getForeignFeatureType().openGeoToolsFeatureSource(TIMEOUT);
                    FeatureIterator<SimpleFeature> foreignIt = null;
                    try {
                        Query foreignQ = new Query(foreignFs.getName().toString());
                        //create filter
                        Filter filter = createFilter(feature, rel);
                        if (filter == null) {
                            continue;
                        }
                        //if join only get 1 feature
                        foreignQ.setMaxFeatures(1);
                        foreignQ.setFilter(filter);
                        //set propertynames
                        List<String> propertyNames;
                        if (al != null) {
                            propertyNames = setPropertyNames(al, foreignQ, rel.getForeignFeatureType(), edit);
                        } else {
                            propertyNames = new ArrayList<>();
                            for (AttributeDescriptor ad : rel.getForeignFeatureType().getAttributes()) {
                                propertyNames.add(ad.getName());
                            }
                        }
                        if (propertyNames.isEmpty()) {
                            // if there are no properties to retrieve just get out
                            continue;
                        }
                        //get aliases
                        Map<String, String> attributeAliases = new HashMap<>();
                        if (!edit) {
                            for (AttributeDescriptor ad : rel.getForeignFeatureType().getAttributes()) {
                                if (ad.getAlias() != null) {
                                    attributeAliases.put(ad.getName(), ad.getAlias());
                                }
                            }
                        }
                        //Get Feature and populate JSON object with the values.
                        foreignIt = foreignFs.getFeatures(foreignQ).features();
                        while (foreignIt.hasNext()) {
                            SimpleFeature foreignFeature = foreignIt.next();
                            //join it in the same json
                            j = toJSONFeature(j, foreignFeature, rel.getForeignFeatureType(), al, propertyNames, attributeAliases, index);
                        }
                    } finally {
                        if (foreignIt != null) {
                            foreignIt.close();
                        }
                        foreignFs.getDataStore().dispose();
                    }
                } else {
                    Filter filter = createFilter(feature, rel);
                    if (filter == null) {
                        continue;
                    }
                    JSONObject related_ft = new JSONObject();
                    related_ft.put("filter", CQL.toCQL(filter));
                    related_ft.put("id", rel.getForeignFeatureType().getId());
                    related_featuretypes.put(related_ft);
                }
            }
            if (related_featuretypes.length() > 0) {
                j.put("related_featuretypes", related_featuretypes);
            }
        }
        return j;
    }

    HashMap<Long, List<String>> propertyNamesQueryCache = new HashMap<>();
    HashMap<Long, Boolean> haveInvisiblePropertiesCache = new HashMap<>();
    HashMap<Long, List<String>> propertyNamesReturnCache = new HashMap<>();

    /**
     * Get the propertynames and add the needed propertynames to the query.
     */
    private List<String> setPropertyNames(ApplicationLayer appLayer, Query q, SimpleFeatureType sft, boolean edit) {
        List<String> propertyNames = new ArrayList<>();
        boolean haveInvisibleProperties = false;
        if (propertyNamesQueryCache.containsKey(sft.getId())) {
            haveInvisibleProperties = haveInvisiblePropertiesCache.get(sft.getId());
            if (haveInvisibleProperties) {
                q.setPropertyNames(propertyNamesQueryCache.get(sft.getId()));
            }
            return propertyNamesReturnCache.get(sft.getId());
        } else {
            for (ConfiguredAttribute ca : appLayer.getAttributes(sft)) {
                if ((!edit && !graph && ca.isVisible()) || (edit && ca.isEditable()) || (graph && attributesToInclude.contains(ca.getId()))) {
                    propertyNames.add(ca.getAttributeName());
                } else {
                    haveInvisibleProperties = true;
                }
            }
            haveInvisiblePropertiesCache.put(sft.getId(), haveInvisibleProperties);
            propertyNamesReturnCache.put(sft.getId(), propertyNames);
            propertyNamesQueryCache.put(sft.getId(), propertyNames);
            if (haveInvisibleProperties) {
                // By default Query retrieves Query.ALL_NAMES
                // Query.NO_NAMES is an empty String array
                q.setPropertyNames(propertyNames);
                // If any related featuretypes are set, add the leftside names in the query
                // don't add them to propertynames, maybe they are not visible
                if (sft.getRelations() != null) {
                    List<String> withRelations = new ArrayList<>();
                    withRelations.addAll(propertyNames);
                    for (FeatureTypeRelation ftr : sft.getRelations()) {
                        if (ftr.getRelationKeys() != null) {
                            for (FeatureTypeRelationKey key : ftr.getRelationKeys()) {
                                if (!withRelations.contains(key.getLeftSide().getName())) {
                                    withRelations.add(key.getLeftSide().getName());
                                }
                            }
                        }
                    }
                    propertyNamesQueryCache.put(sft.getId(), withRelations);
                    q.setPropertyNames(withRelations);
                }
            }
            propertyNamesReturnCache.put(sft.getId(), propertyNames);
            return propertyNames;
        }
    }

    public static final DateFormat dateFormat = new SimpleDateFormat("dd-MM-yyyy HH:mm:ss");

    private Object formatValue(Object value) {
        if (value instanceof Date) {
            // JSON has no date type so format the date as it is used for
            // display, not calculation
            return dateFormat.format((Date) value);
        } else {
            return value;
        }
    }

    private Filter createFilter(SimpleFeature feature, FeatureTypeRelation rel) {
        FilterFactory2 ff = CommonFactoryFinder.getFilterFactory2();
        List<Filter> filters = new ArrayList<>();
        for (FeatureTypeRelationKey key : rel.getRelationKeys()) {
            AttributeDescriptor rightSide = key.getRightSide();
            AttributeDescriptor leftSide = key.getLeftSide();
            Object value = feature.getAttribute(leftSide.getName());
            if (value == null) {
                continue;
            }
            if (AttributeDescriptor.GEOMETRY_TYPES.contains(rightSide.getType())
                    && AttributeDescriptor.GEOMETRY_TYPES.contains(leftSide.getType())) {
                filters.add(ff.not(ff.isNull(ff.property(rightSide.getName()))));
                filters.add(ff.intersects(ff.property(rightSide.getName()), ff.literal(value)));
            } else {
                filters.add(ff.equals(ff.property(rightSide.getName()), ff.literal(value)));
            }
        }
        if (filters.size() > 1) {
            return ff.and(filters);
        } else if (filters.size() == 1) {
            return filters.get(0);
        } else {
            return null;
        }
    }
}