"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const knex_1 = require("knex");
const datasource_1 = require("../../definitions/datasource");
const utils_1 = require("../utils");
const sqlTable_1 = __importDefault(require("./sqlTable"));
const environment_1 = __importDefault(require("../../environment"));
const envLimit = environment_1.default.SQL_MAX_ROWS
    ? parseInt(environment_1.default.SQL_MAX_ROWS)
    : null;
const BASE_LIMIT = envLimit || 5000;
// these are invalid dates sent by the client, need to convert them to a real max date
const MIN_ISO_DATE = "0000-00-00T00:00:00.000Z";
const MAX_ISO_DATE = "9999-00-00T00:00:00.000Z";
function likeKey(client, key) {
    if (!key.includes(" ")) {
        return key;
    }
    let start, end;
    switch (client) {
        case utils_1.SqlClients.MY_SQL:
            start = end = "`";
            break;
        case utils_1.SqlClients.ORACLE:
        case utils_1.SqlClients.POSTGRES:
            start = end = '"';
            break;
        case utils_1.SqlClients.MS_SQL:
            start = "[";
            end = "]";
            break;
        default:
            throw "Unknown client";
    }
    const parts = key.split(".");
    key = parts.map(part => `${start}${part}${end}`).join(".");
    return key;
}
function parse(input) {
    if (Array.isArray(input)) {
        return JSON.stringify(input);
    }
    if (input == undefined) {
        return null;
    }
    if (typeof input !== "string") {
        return input;
    }
    if (input === MAX_ISO_DATE || input === MIN_ISO_DATE) {
        return null;
    }
    if ((0, utils_1.isIsoDateString)(input)) {
        return new Date(input);
    }
    return input;
}
function parseBody(body) {
    for (let [key, value] of Object.entries(body)) {
        body[key] = parse(value);
    }
    return body;
}
function parseFilters(filters) {
    if (!filters) {
        return {};
    }
    for (let [key, value] of Object.entries(filters)) {
        let parsed;
        if (typeof value === "object") {
            parsed = parseFilters(value);
        }
        else {
            parsed = parse(value);
        }
        // @ts-ignore
        filters[key] = parsed;
    }
    return filters;
}
function generateSelectStatement(json, knex) {
    var _a;
    const { resource, meta } = json;
    const schema = (_a = meta === null || meta === void 0 ? void 0 : meta.table) === null || _a === void 0 ? void 0 : _a.schema;
    return resource.fields.map(field => {
        const fieldNames = field.split(/\./g);
        const tableName = fieldNames[0];
        const columnName = fieldNames[1];
        if (columnName &&
            (schema === null || schema === void 0 ? void 0 : schema[columnName]) &&
            knex.client.config.client === utils_1.SqlClients.POSTGRES) {
            const externalType = schema[columnName].externalType;
            if (externalType === null || externalType === void 0 ? void 0 : externalType.includes("money")) {
                return knex.raw(`"${tableName}"."${columnName}"::money::numeric as "${field}"`);
            }
        }
        return `${field} as ${field}`;
    });
}
class InternalBuilder {
    constructor(client) {
        this.client = client;
    }
    // right now we only do filters on the specific table being queried
    addFilters(query, filters, opts) {
        function iterate(structure, fn) {
            for (let [key, value] of Object.entries(structure)) {
                const isRelationshipField = key.includes(".");
                if (!opts.relationship && !isRelationshipField) {
                    fn(`${opts.tableName}.${key}`, value);
                }
                if (opts.relationship && isRelationshipField) {
                    fn(key, value);
                }
            }
        }
        const like = (key, value) => {
            const fnc = allOr ? "orWhere" : "where";
            // postgres supports ilike, nothing else does
            if (this.client === utils_1.SqlClients.POSTGRES) {
                query = query[fnc](key, "ilike", `%${value}%`);
            }
            else {
                const rawFnc = `${fnc}Raw`;
                // @ts-ignore
                query = query[rawFnc](`LOWER(${likeKey(this.client, key)}) LIKE ?`, [
                    `%${value}%`,
                ]);
            }
        };
        if (!filters) {
            return query;
        }
        filters = parseFilters(filters);
        // if all or specified in filters, then everything is an or
        const allOr = filters.allOr;
        if (filters.oneOf) {
            iterate(filters.oneOf, (key, array) => {
                const fnc = allOr ? "orWhereIn" : "whereIn";
                query = query[fnc](key, Array.isArray(array) ? array : [array]);
            });
        }
        if (filters.string) {
            iterate(filters.string, (key, value) => {
                const fnc = allOr ? "orWhere" : "where";
                // postgres supports ilike, nothing else does
                if (this.client === utils_1.SqlClients.POSTGRES) {
                    query = query[fnc](key, "ilike", `${value}%`);
                }
                else {
                    const rawFnc = `${fnc}Raw`;
                    // @ts-ignore
                    query = query[rawFnc](`LOWER(${key}) LIKE ?`, [`${value}%`]);
                }
            });
        }
        if (filters.fuzzy) {
            iterate(filters.fuzzy, like);
        }
        if (filters.range) {
            iterate(filters.range, (key, value) => {
                if (value.low && value.high) {
                    // Use a between operator if we have 2 valid range values
                    const fnc = allOr ? "orWhereBetween" : "whereBetween";
                    query = query[fnc](key, [value.low, value.high]);
                }
                else if (value.low) {
                    // Use just a single greater than operator if we only have a low
                    const fnc = allOr ? "orWhere" : "where";
                    query = query[fnc](key, ">", value.low);
                }
                else if (value.high) {
                    // Use just a single less than operator if we only have a high
                    const fnc = allOr ? "orWhere" : "where";
                    query = query[fnc](key, "<", value.high);
                }
            });
        }
        if (filters.equal) {
            iterate(filters.equal, (key, value) => {
                const fnc = allOr ? "orWhere" : "where";
                query = query[fnc]({ [key]: value });
            });
        }
        if (filters.notEqual) {
            iterate(filters.notEqual, (key, value) => {
                const fnc = allOr ? "orWhereNot" : "whereNot";
                query = query[fnc]({ [key]: value });
            });
        }
        if (filters.empty) {
            iterate(filters.empty, key => {
                const fnc = allOr ? "orWhereNull" : "whereNull";
                query = query[fnc](key);
            });
        }
        if (filters.notEmpty) {
            iterate(filters.notEmpty, key => {
                const fnc = allOr ? "orWhereNotNull" : "whereNotNull";
                query = query[fnc](key);
            });
        }
        if (filters.contains) {
            const fnc = allOr ? "orWhere" : "where";
            const rawFnc = `${fnc}Raw`;
            if (this.client === utils_1.SqlClients.POSTGRES) {
                iterate(filters.contains, (key, value) => {
                    const fieldNames = key.split(/\./g);
                    const tableName = fieldNames[0];
                    const columnName = fieldNames[1];
                    if (typeof value === "string") {
                        value = `"${value}"`;
                    }
                    // @ts-ignore
                    query = query[rawFnc](`"${tableName}"."${columnName}"::jsonb @> '[${value}]'`);
                });
            }
            else if (this.client === utils_1.SqlClients.MY_SQL) {
                iterate(filters.contains, (key, value) => {
                    if (typeof value === "string") {
                        value = `"${value}"`;
                    }
                    // @ts-ignore
                    query = query[rawFnc](`JSON_CONTAINS(${key}, '${value}')`);
                });
            }
            else {
                iterate(filters.contains, like);
            }
        }
        return query;
    }
    addSorting(query, json) {
        var _a;
        let { sort, paginate } = json;
        const table = (_a = json.meta) === null || _a === void 0 ? void 0 : _a.table;
        if (sort) {
            for (let [key, value] of Object.entries(sort)) {
                const direction = value === datasource_1.SortDirection.ASCENDING ? "asc" : "desc";
                query = query.orderBy(`${table === null || table === void 0 ? void 0 : table.name}.${key}`, direction);
            }
        }
        else if (this.client === utils_1.SqlClients.MS_SQL && (paginate === null || paginate === void 0 ? void 0 : paginate.limit)) {
            // @ts-ignore
            query = query.orderBy(`${table === null || table === void 0 ? void 0 : table.name}.${table === null || table === void 0 ? void 0 : table.primary[0]}`);
        }
        return query;
    }
    addRelationships(query, fromTable, relationships) {
        if (!relationships) {
            return query;
        }
        const tableSets = {};
        // aggregate into table sets (all the same to tables)
        for (let relationship of relationships) {
            const keyObj = {
                toTable: relationship.tableName,
                throughTable: undefined,
            };
            if (relationship.through) {
                keyObj.throughTable = relationship.through;
            }
            const key = JSON.stringify(keyObj);
            if (tableSets[key]) {
                tableSets[key].push(relationship);
            }
            else {
                tableSets[key] = [relationship];
            }
        }
        for (let [key, relationships] of Object.entries(tableSets)) {
            const { toTable, throughTable } = JSON.parse(key);
            if (!throughTable) {
                // @ts-ignore
                query = query.leftJoin(toTable, function () {
                    for (let relationship of relationships) {
                        const from = relationship.from, to = relationship.to;
                        // @ts-ignore
                        this.orOn(`${fromTable}.${from}`, "=", `${toTable}.${to}`);
                    }
                });
            }
            else {
                query = query
                    // @ts-ignore
                    .leftJoin(throughTable, function () {
                    for (let relationship of relationships) {
                        const fromPrimary = relationship.fromPrimary;
                        const from = relationship.from;
                        // @ts-ignore
                        this.orOn(`${fromTable}.${fromPrimary}`, "=", `${throughTable}.${from}`);
                    }
                })
                    .leftJoin(toTable, function () {
                    for (let relationship of relationships) {
                        const toPrimary = relationship.toPrimary;
                        const to = relationship.to;
                        // @ts-ignore
                        this.orOn(`${toTable}.${toPrimary}`, `${throughTable}.${to}`);
                    }
                });
            }
        }
        return query.limit(BASE_LIMIT);
    }
    create(knex, json, opts) {
        const { endpoint, body } = json;
        let query = knex(endpoint.entityId);
        if (endpoint.schema) {
            query = query.withSchema(endpoint.schema);
        }
        const parsedBody = parseBody(body);
        // make sure no null values in body for creation
        for (let [key, value] of Object.entries(parsedBody)) {
            if (value == null) {
                delete parsedBody[key];
            }
        }
        // mysql can't use returning
        if (opts.disableReturning) {
            return query.insert(parsedBody);
        }
        else {
            return query.insert(parsedBody).returning("*");
        }
    }
    bulkCreate(knex, json) {
        const { endpoint, body } = json;
        let query = knex(endpoint.entityId);
        if (endpoint.schema) {
            query = query.withSchema(endpoint.schema);
        }
        if (!Array.isArray(body)) {
            return query;
        }
        const parsedBody = body.map(row => parseBody(row));
        return query.insert(parsedBody);
    }
    read(knex, json, limit) {
        let { endpoint, resource, filters, paginate, relationships } = json;
        const tableName = endpoint.entityId;
        // select all if not specified
        if (!resource) {
            resource = { fields: [] };
        }
        let selectStatement = "*";
        // handle select
        if (resource.fields && resource.fields.length > 0) {
            // select the resources as the format "table.columnName" - this is what is provided
            // by the resource builder further up
            selectStatement = generateSelectStatement(json, knex);
        }
        let foundLimit = limit || BASE_LIMIT;
        // handle pagination
        let foundOffset = null;
        if (paginate && paginate.page && paginate.limit) {
            // @ts-ignore
            const page = paginate.page <= 1 ? 0 : paginate.page - 1;
            const offset = page * paginate.limit;
            foundLimit = paginate.limit;
            foundOffset = offset;
        }
        else if (paginate && paginate.limit) {
            foundLimit = paginate.limit;
        }
        // start building the query
        let query = knex(tableName).limit(foundLimit);
        if (endpoint.schema) {
            query = query.withSchema(endpoint.schema);
        }
        if (foundOffset) {
            query = query.offset(foundOffset);
        }
        query = this.addFilters(query, filters, { tableName });
        // add sorting to pre-query
        query = this.addSorting(query, json);
        // @ts-ignore
        let preQuery = knex({
            // @ts-ignore
            [tableName]: query,
        }).select(selectStatement);
        // have to add after as well (this breaks MS-SQL)
        if (this.client !== utils_1.SqlClients.MS_SQL) {
            preQuery = this.addSorting(preQuery, json);
        }
        // handle joins
        query = this.addRelationships(preQuery, tableName, relationships);
        return this.addFilters(query, filters, { relationship: true });
    }
    update(knex, json, opts) {
        const { endpoint, body, filters } = json;
        let query = knex(endpoint.entityId);
        if (endpoint.schema) {
            query = query.withSchema(endpoint.schema);
        }
        const parsedBody = parseBody(body);
        query = this.addFilters(query, filters, { tableName: endpoint.entityId });
        // mysql can't use returning
        if (opts.disableReturning) {
            return query.update(parsedBody);
        }
        else {
            return query.update(parsedBody).returning("*");
        }
    }
    delete(knex, json, opts) {
        const { endpoint, filters } = json;
        let query = knex(endpoint.entityId);
        if (endpoint.schema) {
            query = query.withSchema(endpoint.schema);
        }
        query = this.addFilters(query, filters, { tableName: endpoint.entityId });
        // mysql can't use returning
        if (opts.disableReturning) {
            return query.delete();
        }
        else {
            return query.delete().returning("*");
        }
    }
}
class SqlQueryBuilder extends sqlTable_1.default {
    // pass through client to get flavour of SQL
    constructor(client, limit = BASE_LIMIT) {
        super(client);
        this.limit = limit;
    }
    /**
     * @param json The JSON query DSL which is to be converted to SQL.
     * @param opts extra options which are to be passed into the query builder, e.g. disableReturning
     * which for the sake of mySQL stops adding the returning statement to inserts, updates and deletes.
     * @return {{ sql: string, bindings: object }} the query ready to be passed to the driver.
     */
    _query(json, opts = {}) {
        const sqlClient = this.getSqlClient();
        const client = (0, knex_1.knex)({ client: sqlClient });
        let query;
        const builder = new InternalBuilder(sqlClient);
        switch (this._operation(json)) {
            case datasource_1.Operation.CREATE:
                query = builder.create(client, json, opts);
                break;
            case datasource_1.Operation.READ:
                query = builder.read(client, json, this.limit);
                break;
            case datasource_1.Operation.UPDATE:
                query = builder.update(client, json, opts);
                break;
            case datasource_1.Operation.DELETE:
                query = builder.delete(client, json, opts);
                break;
            case datasource_1.Operation.BULK_CREATE:
                query = builder.bulkCreate(client, json);
                break;
            case datasource_1.Operation.CREATE_TABLE:
            case datasource_1.Operation.UPDATE_TABLE:
            case datasource_1.Operation.DELETE_TABLE:
                return this._tableQuery(json);
            default:
                throw `Operation type is not supported by SQL query builder`;
        }
        // @ts-ignore
        return query.toSQL().toNative();
    }
    getReturningRow(queryFn, json) {
        return __awaiter(this, void 0, void 0, function* () {
            if (!json.extra || !json.extra.idFilter) {
                return {};
            }
            const input = this._query({
                endpoint: Object.assign(Object.assign({}, json.endpoint), { operation: datasource_1.Operation.READ }),
                resource: {
                    fields: [],
                },
                filters: json.extra.idFilter,
                paginate: {
                    limit: 1,
                },
                meta: json.meta,
            });
            return queryFn(input, datasource_1.Operation.READ);
        });
    }
    // when creating if an ID has been inserted need to make sure
    // the id filter is enriched with it before trying to retrieve the row
    checkLookupKeys(id, json) {
        var _a, _b;
        if (!id || !((_a = json.meta) === null || _a === void 0 ? void 0 : _a.table) || !json.meta.table.primary) {
            return json;
        }
        const primaryKey = (_b = json.meta.table.primary) === null || _b === void 0 ? void 0 : _b[0];
        json.extra = {
            idFilter: {
                equal: {
                    [primaryKey]: id,
                },
            },
        };
        return json;
    }
    // this function recreates the returning functionality of postgres
    queryWithReturning(json, queryFn, processFn = (result) => result) {
        return __awaiter(this, void 0, void 0, function* () {
            const sqlClient = this.getSqlClient();
            const operation = this._operation(json);
            const input = this._query(json, { disableReturning: true });
            if (Array.isArray(input)) {
                const responses = [];
                for (let query of input) {
                    responses.push(yield queryFn(query, operation));
                }
                return responses;
            }
            let row;
            // need to manage returning, a feature mySQL can't do
            if (operation === datasource_1.Operation.DELETE) {
                row = processFn(yield this.getReturningRow(queryFn, json));
            }
            const response = yield queryFn(input, operation);
            const results = processFn(response);
            // same as delete, manage returning
            if (operation === datasource_1.Operation.CREATE || operation === datasource_1.Operation.UPDATE) {
                let id;
                if (sqlClient === utils_1.SqlClients.MS_SQL) {
                    id = results === null || results === void 0 ? void 0 : results[0].id;
                }
                else if (sqlClient === utils_1.SqlClients.MY_SQL) {
                    id = results === null || results === void 0 ? void 0 : results.insertId;
                }
                row = processFn(yield this.getReturningRow(queryFn, this.checkLookupKeys(id, json)));
            }
            if (operation !== datasource_1.Operation.READ) {
                return row;
            }
            return results.length ? results : [{ [operation.toLowerCase()]: true }];
        });
    }
}
exports.default = SqlQueryBuilder;
module.exports = SqlQueryBuilder;
