randytarampi/me

View on GitHub
packages/service/src/lib/postSearchParams.js

Summary

Maintainability
D
2 days
Test Coverage
A
99%
import {
    castDatePropertyToDateTime,
    compositeKeySeparator,
    convertLatLongToGeohash,
    Gallery,
    getGeohashesForBoundingBox,
    getGeohashesForRadiusAroundGeohash,
    getGeohashesForRadiusAroundPoint,
    getHaversineDistance,
    Photo,
    Post,
    POST_STATUS
} from "@randy.tarampi/js";
import {Big} from "big.js";
import {Record} from "immutable";
import _ from "lodash";
import {DateTime, Duration} from "luxon";

/**
 * @typedef {Object} searchParamsRecordDefinition
 * @type {{type: string, perPage: number, page: number, orderBy: string, orderOperator: string, orderComparator: string, orderComparatorType: string, relativeOrderComparatorAdjustmentOperator: string, relativeOrderComparatorAdjustment: string, relativeOrderComparatorAdjustmentType: string, width: number, height: number, crop: undefined, id: string, uid: string, source: string, all: boolean, beforeDate: DateTime, beforeId: string, afterId: string, continuationToken: string, tags: string, status: boolean}}
 * @property orderBy {String} One of `ascending` or `descending`.
 */
const searchParamsRecordDefinition = {
    // NOTE-RT: For either
    type: undefined,
    source: undefined,
    geohash: undefined,
    lat: undefined,
    long: undefined,
    south: undefined,
    west: undefined,
    north: undefined,
    east: undefined,
    geoRadius: undefined,
    geohashPrecision: undefined, // NOTE-RT: A `6` would be about 7km^2, er the table in http://www.movable-type.co.uk/scripts/geohash.html

    // NOTE-RT: For lists
    perPage: 100,
    page: 1,
    orderBy: "descending",
    orderOperator: undefined,
    orderComparator: undefined,
    orderComparatorType: undefined,
    relativeOrderComparatorAdjustmentOperator: undefined,
    relativeOrderComparatorAdjustment: undefined,
    relativeOrderComparatorAdjustmentType: undefined,
    relativeOrderComparatorBasis: undefined,
    relativeOrderComparatorBasisType: undefined,
    all: false,
    beforeDate: undefined,
    afterDate: undefined,
    beforeId: undefined,
    afterId: undefined,
    continuationToken: undefined,
    tags: undefined,
    status: POST_STATUS.visible,

    // NOTE-RT: For individual posts
    width: undefined,
    height: undefined,
    crop: undefined,
    id: undefined,
    uid: undefined
};
const PostSearchParamsRecord = Record(searchParamsRecordDefinition);

/**
 * Turn some generic search parameters into a query parameters for [Posts]{@link Post} for some services
 * @extends PostSearchParamsRecord
 */
class PostSearchParams extends PostSearchParamsRecord {
    constructor({beforeDate, width, height, page, perPage, lat, long, north, east, south, west, ...properties} = {}) {
        super({
            ...properties,
            beforeDate: castDatePropertyToDateTime(beforeDate),
            width: width && Number(width),
            height: height && Number(height),
            perPage: perPage ? Number(perPage) : 100,
            page: page ? Number(page) : 1,
            lat: lat && Number(lat),
            long: long && Number(long),
            north: north && Number(north),
            east: east && Number(east),
            south: south && Number(south),
            west: west && Number(west)
        });
    }

    get Flickr() {
        return {
            page: this.page,
            per_page: Math.min(this.perPage, 500),
            extras: "url_o, url_k, url_h, url_c, url_z, url_m, url_n, date_upload, date_taken, owner_name, path_alias, description, tags, machine_tags, geo"
        };
    }

    get Unsplash() {
        let order_by = "latest";

        switch (this.orderBy) {
            case "ascending":
                order_by = "oldest";
                break;

            case "descending":
                order_by = "latest";
                break;
        }

        return {
            page: this.page,
            per_page: this.perPage,
            order_by,
            width: this.width,
            height: this.height,
            crop: this.crop
        };
    }

    get Instagram() {
        const baseRequest = {
            after: (this.page - 1) * this.perPage,
            limit: this.perPage,
            fields: [
                "caption",
                "children",
                "id",
                "media_type",
                "media_url",
                "permalink",
                "thumbnail_url",
                "timestamp",
                "username",
            ]
        };

        const filterRequest = {};

        if (this.afterId) {
            filterRequest.after = this.afterId;
        }

        return {
            ...baseRequest,
            ...filterRequest
        };
    }

    get Tumblr() {
        const baseRequest = {
            id: this.id,
            limit: Math.min(this.perPage, 20)
        };

        switch (this.type) {
            case Post.type:
                baseRequest.type = "text";
                break;

            case Gallery.type:
            case Photo.type:
                baseRequest.type = "photo";
                break;

            default: {
                if (this.type) {
                    baseRequest.type = this.type.toLowerCase();
                }
            }
        }

        const filterRequest = {
            page: this.page,
            offset: baseRequest.limit * (this.page - 1)
        };

        if (this.beforeDate) {
            filterRequest.before = Math.round(this.beforeDate.toSeconds()) - 1;
        }

        return {
            ...baseRequest,
            ...filterRequest
        };
    }

    get Dynamoose() {
        const options = {
            descending: true,
            all: this.all
        };

        switch (this.orderBy) {
            case "descending":
                options.descending = true;
                if (this.status) {
                    options.indexName = "status-datePublished-index";
                }
                break;

            case "ascending":
                options.descending = false;
                if (this.status) {
                    options.indexName = "status-datePublished-index";
                }
                break;
        }

        let castOrderComparator = this.orderComparator;

        if (this.orderComparator instanceof DateTime) {
            castOrderComparator = castOrderComparator.toJSDate();
        }

        const filters = {
            status: this.status
        };

        if (Number.isFinite(this.perPage)) {
            options.limit = this.perPage;
        } else {
            options.all = true;
        }

        if (this.type) {
            filters.type = this.type;
        }

        if (this.source) {
            filters.source = this.source;
        }

        if (this.hasOrderingConditions) {
            filters[this.orderBy] = {[this.orderOperator]: castOrderComparator};
        }

        if (this.tags) { // FIXME-RT: Ideally this would do a filtered query on an index, but let's save that for when I blow this up and move the logic into db/models/post
            filters.tags = {
                contains: this.tags
                    .split(",")
                    .map(string => string.toLowerCase())
            };
        }

        if (this.uid) {
            return {
                _query: {
                    uid: {eq: this.uid}
                },
                _options: {
                    ...options,
                    indexName: "uid-index"
                },
                _filter: filters
            };
        }

        if (this.source && this.id) {
            return {
                _query: {
                    uid: {eq: `${this.source}${compositeKeySeparator}${this.id}`}
                },
                _options: {
                    ...options,
                    indexName: "uid-index"
                },
                _filter: filters
            };
        }

        if (this.type) {
            const typeIndiciesRangeKeys = ["datePublished", "geohash"];

            if (typeIndiciesRangeKeys.includes(this.orderBy)) {
                options.indexName = `type-${this.orderBy}-index`;
            }

            if (this.geohashQueries) {
                return this.geohashQueries.map(geohashQuery => {
                    return {
                        _query: {
                            type: {eq: this.type},
                            geohash: {begins_with: geohashQuery}
                        },
                        _options: {
                            ...options,
                            indexName: "type-geohash-index"
                        },
                        _filter: filters
                    };
                });
            }

            if (this.geohash) {
                return {
                    _query: {
                        type: {eq: this.type},
                        geohash: {begins_with: this.geohash}
                    },
                    _options: {
                        ...options,
                        indexName: "type-geohash-index"
                    },
                    _filter: filters
                };
            }

            if (typeIndiciesRangeKeys.includes(this.orderBy) && this.hasOrderingConditions) {
                return {
                    _query: {
                        type: {eq: this.type},
                        [this.orderBy]: {[this.orderOperator]: castOrderComparator}
                    },
                    _options: options,
                    _filter: filters
                };
            }

            return {
                _query: {
                    type: {eq: this.type}
                },
                _options: {
                    ...options,
                    indexName: "type-datePublished-index"
                },
                _filter: filters
            };
        }

        if (this.status) {
            const statusIndiciesRangeKeys = ["dateCreated", "datePublished", "geohash"];

            if (statusIndiciesRangeKeys.includes(this.orderBy)) {
                options.indexName = `status-${this.orderBy}-index`;
            }

            if (this.geohashQueries) {
                return this.geohashQueries.map(geohashQuery => {
                    return {
                        _query: {
                            status: {eq: this.status},
                            geohash: {begins_with: geohashQuery}
                        },
                        _options: {
                            ...options,
                            indexName: "status-geohash-index"
                        },
                        _filter: filters
                    };
                });
            }

            if (this.geohash) {
                return {
                    _query: {
                        status: {eq: this.status},
                        geohash: {begins_with: this.geohash}
                    },
                    _options: {
                        ...options,
                        indexName: "status-geohash-index"
                    },
                    _filter: filters
                };
            }

            if (statusIndiciesRangeKeys.includes(this.orderBy) && this.hasOrderingConditions) {
                return {
                    _query: {
                        status: {eq: this.status},
                        [this.orderBy]: {[this.orderOperator]: castOrderComparator}
                    },
                    _options: options,
                    _filter: filters
                };
            }

            return {
                _query: {
                    status: {eq: this.status}
                },
                _options: options,
                _filter: filters
            };
        }

        return { // NOTE-RT: Just scan the entire table until we know enough of what we'd want to scan (instead of query) for
            _filter: filters,
            _options: options
        };
    }

    get S3() {
        const baseRequest = {
            Bucket: process.env.SERVICE_POSTS_S3_BUCKET_NAME
        };

        if (this.id) {
            return {
                ...baseRequest,
                Key: this.id
            };
        }

        const filterRequest = {};

        if (this.beforeId) {
            filterRequest.StartAfter = this.beforeId;
        }
        if (this.continuationToken) {
            filterRequest.ContinuationToken = this.continuationToken;
        }

        return {
            ...baseRequest,
            ...filterRequest,
            MaxKeys: Math.min(this.perPage, 1000)
        };
    }

    get Twitter() {
        if (this.id) {
            return {
                id: this.id
            };
        }

        const twitterQuery = {};

        if (this.perPage) {
            twitterQuery.count = this.perPage;
        }

        if (this.beforeId) {
            twitterQuery.max_id = this.beforeId && new Big(this.beforeId).minus(1).toString();
        }

        if (this.afterId) {
            twitterQuery.since_id = this.afterId;
        }

        return twitterQuery;
    }

    get Facebook() {
        const facebookQuery = {
            fields: "attachments,backdated_time,caption,created_time,description,from,full_picture,icon,message,message_tags,name,object_id,permalink_url,place,privacy,properties,source,type"
        };

        if (this.perPage) {
            facebookQuery.count = this.perPage;
        }

        if (this.hasOrderingConditions) {
            switch (this.orderBy) {
                case "datePublished": {
                    switch (this.orderOperator) {
                        case "lt":
                            facebookQuery.until = Math.round(this.orderComparator.toSeconds()) - 1;
                            break;

                        case "lte":
                            facebookQuery.until = Math.round(this.orderComparator.toSeconds());
                            break;

                        case "gt":
                        case "gte":
                            facebookQuery.since = Math.round(this.orderComparator.toSeconds());
                            break;
                    }

                    break;
                }
            }
        }

        if (this.beforeDate) {
            facebookQuery.until = Math.round(this.beforeDate.toSeconds()) - 1;
        }

        if (this.afterDate) {
            facebookQuery.since = Math.round(this.afterDate.toSeconds());
        }

        return facebookQuery;
    }

    get geoRadius() {
        if (this.get("geoRadius")) {
            return this.get("geoRadius");
        }

        if ([this.north, this.east, this.south, this.west].every(Number.isFinite)) {
            return getHaversineDistance(this.north, this.east, this.south, this.west) / 2;
        }

        return undefined;
    }

    get geohash() {
        if (this.get("geohash")) {
            return this.get("geohash");
        }

        if ([this.lat, this.long].every(Number.isFinite)) {
            return convertLatLongToGeohash(this.lat, this.long, this.geohashPrecision);
        }

        return undefined;
    }

    get geohashPrecision() {
        const geohashPrecision = this.get("geohashPrecision");

        if (geohashPrecision) {
            return geohashPrecision;
        }

        if (this.get("geohash")) {
            return this.get("geohash").length;
        }

        return undefined;
    }

    get geohashQueries() {
        const geoRadius = this.geoRadius;
        const geohashQuery = this.geohash;
        const geohashQueryPrecision = this.get("geohashPrecision");

        if ([this.north, this.east, this.south, this.west].every(Number.isFinite)) {
            return getGeohashesForBoundingBox(this.north, this.east, this.south, this.west, geohashQueryPrecision);
        }

        if (geoRadius) {
            if ([this.lat, this.long].every(Number.isFinite)) {
                return getGeohashesForRadiusAroundPoint(this.lat, this.long, geoRadius, geohashQueryPrecision);
            } else if (geohashQuery) {
                return getGeohashesForRadiusAroundGeohash(geohashQuery, geoRadius, geohashQueryPrecision);
            }
        }

        return undefined;
    }

    get orderComparator() {
        if (this.hasRelativeOrderComparator) {
            return computeOrderComparatorFromRelativeOrderComparatorAdjustment(this.relativeOrderComparatorAdjustmentOperator, this.relativeOrderComparatorBasis, this.relativeOrderComparatorAdjustment);
        }

        return castOrderComparator(this.get("orderComparator"), this.orderComparatorType);
    }

    get relativeOrderComparatorBasis() {
        return castOrderComparator(this.get("relativeOrderComparatorBasis"), this.relativeOrderComparatorBasisType);
    }

    get relativeOrderComparatorAdjustment() {
        return castOrderComparator(this.get("relativeOrderComparatorAdjustment"), this.relativeOrderComparatorAdjustmentType);
    }

    get hasOrderingConditions() {
        return this.orderBy
            && !_.isUndefined(this.orderOperator)
            && !_.isUndefined(this.orderComparator);
    }

    get hasRelativeOrderComparator() {
        return !_.isUndefined(this.relativeOrderComparatorAdjustmentOperator)
            && !_.isUndefined(this.relativeOrderComparatorAdjustment)
            && !_.isUndefined(this.relativeOrderComparatorBasis);
    }

    computeOrderingComparison(leftSideComparator) {
        return computeOrderingComparison(this.orderOperator, leftSideComparator, this.orderComparator);
    }

    computeOrderingComparisonForEntity(leftSideComparatorEntity) {
        return this.computeOrderingComparison(leftSideComparatorEntity[this.orderBy]);
    }

    static parsePropertiesFromJs(js) {
        return {
            orderBy: "descending",
            all: false,
            ...js
        };
    }

    static fromJS(js) {
        return new PostSearchParams(PostSearchParams.parsePropertiesFromJs(js));
    }

    static parsePropertiesFromJson(json) {
        return {
            orderBy: "descending",
            all: false,
            ...json
        };
    }

    static fromJSON(json) {
        return new PostSearchParams(PostSearchParams.parsePropertiesFromJson(json));
    }
}

/**
 * Cast an `orderComparator` to the proper JS object as specified by `orderComparatorType`
 * @param orderComparator {*}
 * @param orderComparatorType {string} Defines the intended JS object for `orderComparator`
 * @returns {*}
 */
export const castOrderComparator = (orderComparator, orderComparatorType) => {
    switch (orderComparatorType) {
        case "String":
            return orderComparator && orderComparator.toString();

        case "DateTime": {
            switch (orderComparator) {
                case "now":
                    return DateTime.local();

                default:
                    return orderComparator && DateTime.fromISO(orderComparator);
            }
        }

        case "Duration":
            return orderComparator && Duration.fromISO(orderComparator);

        case "Number":
        default:
            return !Number.isNaN(orderComparator) && Number(orderComparator);
    }
};

/**
 * Compute the comparison of two comparators for a given `orderOperator`
 * @param orderOperator {string} Defines the comparison operation for the comparators
 * @param leftSideComparator {*}
 * @param rightSideComparator {*}
 * @returns {boolean}
 */
export const computeOrderingComparison = (orderOperator, leftSideComparator, rightSideComparator) => {
    switch (orderOperator) {
        case "lt":
            return leftSideComparator < rightSideComparator;

        case "le":
            return leftSideComparator <= rightSideComparator;

        case "eq":
            return leftSideComparator === rightSideComparator;

        case "ge":
            return leftSideComparator >= rightSideComparator;

        case "gt":
            return leftSideComparator > rightSideComparator;
    }
};

/**
 * Compute an `orderComparator` from a `relativeOrderComparatorBasis` and `relativeOrderComparatorAdjustmentOperator`
 * @param relativeOrderComparatorAdjustmentOperator {string} Defines the operation to apply to the `relativeOrderComparatorAdjustment`
 * @param relativeOrderComparatorBasis {*}
 * @param relativeOrderComparatorAdjustment {*}
 * @returns {*}
 */
export const computeOrderComparatorFromRelativeOrderComparatorAdjustment = (relativeOrderComparatorAdjustmentOperator, relativeOrderComparatorBasis, relativeOrderComparatorAdjustment) => {
    switch (relativeOrderComparatorAdjustmentOperator) {
        case "DateTime.minus":
            return relativeOrderComparatorBasis.minus(relativeOrderComparatorAdjustment);

        case "DateTime.plus":
            return relativeOrderComparatorBasis.plus(relativeOrderComparatorAdjustment);
    }
};

export default PostSearchParams;