modeler/database/postgres/Modeler.js

const EventEmitter = require('node:events');
const path = require('node:path');

const { _, isPlainObject, isEmpty, pushIntoBucket, get, naming, bin2Hex, suffixForDuplicate } = require('@kitmi/utils');
const { fs } = require('@kitmi/sys');

const XemlUtils = require('../../../lang/XemlUtils');
const XemlTypes = require('../../../lang/XemlTypes');
const { pluralize, isDotSeparateName, extractDotSeparateName, prefixNaming } = XemlUtils;
const Entity = require('../../../lang/Entity');
const Types = require('../../../lang/Types');

const UNSUPPORTED_DEFAULT_VALUE = new Set(['SERIAL', 'BIGSERIAL', 'VECTOR', 'TEXT', 'GEOMETRY']);
const MAX_NUMERIC_PRECISION = 1000;

const UNSIGNED_WARNING = 'Unsigned is not supported in postgres. You may use domain type to check integer range.';

/**
 * Xeml database modeler for postgres db.
 * @class
 */
class PostgresModeler {
    /**
     * @param {object} modelService
     * @param {Linker} linker - Xeml DSL linker
     * @param {Connector} connector - Generated script path
     * @param {object} dbOptions
     * @property {object} dbOptions.db
     * @property {object} dbOptions.table
     */
    constructor(modelService, linker, connector, dbOptions) {
        this.modelService = modelService;
        this.linker = linker;
        this.outputPath = modelService.config.migrationPath;
        this.connector = connector;

        this._events = new EventEmitter();

        this._dbOptions = dbOptions
            ? {
                  db: _.mapKeys(dbOptions.db, (value, key) => _.upperCase(key)),
                  table: _.mapKeys(dbOptions.table, (value, key) => _.upperCase(key)),
              }
            : {};

        this._references = {};
        this._relationEntities = {};
        this._processedRef = new Set();
        this._sequenceRestart = [];

        this.warnings = {};
    }

    modeling(schema, skipGeneration) {
        if (!skipGeneration) {
            this.linker.log('info', 'Generating postgres scripts for schema "' + schema.name + '"...');
        }

        let modelingSchema = schema.clone();

        this.linker.log('debug', 'Building relations...');

        let pendingEntities = Object.keys(modelingSchema.entities);

        while (pendingEntities.length > 0) {
            let entityName = pendingEntities.shift();
            let entity = modelingSchema.entities[entityName];

            if (!isEmpty(entity.info.associations)) {
                this.linker.log('debug', `Processing associations of entity "${entityName}"...`);

                let assocs = this._preProcessAssociations(entity);

                let assocNames = assocs.reduce((result, v) => {
                    result[v] = v;
                    return result;
                }, {});

                entity.info.associations.forEach((assoc) =>
                    this._processAssociation(modelingSchema, entity, assoc, assocNames, pendingEntities)
                );
            }
        }

        this._events.emit('afterRelationshipBuilding');

        // build SQL scripts
        // change from database name to schema name
        // let sqlFilesDir = path.join("postgres", this.connector.database);
        let sqlFilesDir = path.join('postgres', schema.name);
        let dbFilePath = path.join(sqlFilesDir, 'entities.sql');
        let fkFilePath = path.join(sqlFilesDir, 'relations.sql');
        let seqFilePath = path.join(sqlFilesDir, 'sequence.sql');

        let tableSQL = '',
            relationSQL = '',
            data = {};

        //let mapOfEntityNameToCodeName = {};
        const extraFunctions = [];
        const triggers = [];

        _.each(modelingSchema.entities, (entity, entityName) => {
            if (entityName !== entity.name) {
                throw new Error(
                    `Entity name "${entity.name}" does not match the entity name "${entityName}" in the schema.`
                );
            }
            //mapOfEntityNameToCodeName[entityName] = entity.code;

            entity.addIndexes();

            let result = this.complianceCheck(entity);
            if (result.errors.length) {
                let message = '';
                if (result.warnings.length > 0) {
                    message += 'Warnings: \n' + result.warnings.join('\n') + '\n';
                }
                message += result.errors.join('\n');

                throw new Error(message);
            }

            if (entity.features) {
                _.forOwn(entity.features, (f, featureName) => {
                    if (Array.isArray(f)) {
                        f.forEach((ff) => this._featureReducer(modelingSchema, entity, featureName, ff));
                    } else {
                        this._featureReducer(modelingSchema, entity, featureName, f);
                    }

                    if (featureName === 'updateTimestamp') {
                        const entitiAsPrefix = naming.snakeCase(entityName);
                        let tsField = f.field;
                        let callFunction = `update_timestamp`;
                        if (tsField !== 'updatedAt') {
                            // non-default field name
                            callFunction = `${entitiAsPrefix}_update_ts`;
                            extraFunctions.push(`CREATE OR REPLACE FUNCTION ${entitiAsPrefix}_update_ts()
RETURNS TRIGGER AS $$
BEGIN    
    NEW.${this.connector.escapeId(tsField)} = CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;`)
                        }
                        
                        triggers.push(`CREATE TRIGGER ${entitiAsPrefix}_set_update_ts
BEFORE UPDATE ON ${this.connector.escapeId(entityName)}
FOR EACH ROW
EXECUTE FUNCTION ${callFunction}();`)
                    }
                });
            }

            // add enum types
            _.each(entity.fields, (field, name) => {
                if (field.enum) {
                    let enumName = prefixNaming(entity.name, name);

                    if (schema.types[enumName]) {
                        this.warnings[
                            enumName
                        ] = `Enum type "${enumName}" already exists. The one in entity "${entity.name}" will be renamed.`;

                        enumName = suffixForDuplicate(enumName, (newName) => newName in schema.types);
                    }

                    schema.types[enumName] = _.omit(field, ['name']);
                    field.domain = enumName;

                    tableSQL += `-- Create Enum Type\nCREATE TYPE ${enumName} AS ENUM (${field.enum
                        .map((v) => `'${v}'`)
                        .join(', ')});\n\n`;
                }
            });

            if (!skipGeneration) {
                tableSQL += this._createTableStatement(entityName, entity /*, mapOfEntityNameToCodeName*/) + '\n';

                if (entity.info.data) {
                    entity.info.data.forEach(({ dataSet, runtimeEnv, records }) => {
                        //intiSQL += `-- Initial data for entity: ${entityName}\n`;

                        let entityData = [];

                        if (Array.isArray(records)) {
                            records.forEach((record) => {
                                if (!isPlainObject(record)) {
                                    let fields = Object.keys(entity.fields);
                                    if (fields.length !== 2) {
                                        throw new Error(
                                            `Invalid data syntax: entity "${entity.name}" has more than 2 fields.`
                                        );
                                    }

                                    let keyField = entity.fields[fields[0]];

                                    if (!keyField.auto && !keyField.autoByDb) {
                                        throw new Error(
                                            `The key field "${entity.name}" has no default value or auto-generated value.`
                                        );
                                    }

                                    record = { [fields[1]]: this.linker.translateXemlValue(entity.xemlModule, record) };
                                } else {
                                    record = this.linker.translateXemlValue(entity.xemlModule, record);
                                }

                                entityData.push(record);
                            });
                        } else {
                            _.forOwn(records, (record, key) => {
                                if (!isPlainObject(record)) {
                                    let fields = Object.keys(entity.fields);
                                    if (fields.length !== 2) {
                                        throw new Error(
                                            `Invalid data syntax: entity "${entity.name}" has more than 2 fields.`
                                        );
                                    }

                                    record = {
                                        [entity.key]: key,
                                        [fields[1]]: this.linker.translateXemlValue(entity.xemlModule, record),
                                    };
                                } else {
                                    record = Object.assign(
                                        { [entity.key]: key },
                                        this.linker.translateXemlValue(entity.xemlModule, record)
                                    );
                                }

                                entityData.push(record);
                                //intiSQL += 'INSERT INTO `' + entityName + '` SET ' + _.map(record, (v,k) => '`' + k + '` = ' + JSON.stringify(v)).join(', ') + ';\n';
                            });
                        }

                        if (!isEmpty(entityData)) {
                            dataSet || (dataSet = '_init');
                            runtimeEnv || (runtimeEnv = 'default');

                            let nodes = [dataSet, runtimeEnv];

                            nodes.push(entityName);

                            let key = nodes.join('.');

                            pushIntoBucket(data, key, entityData, true);
                        }
                    });

                    //intiSQL += '\n';
                }
            }
        });

        if (!skipGeneration) {
            if (!isEmpty(this._sequenceRestart)) {
                let seqSQL = this._sequenceRestart.join('\n\n');
                this._writeFile(path.join(this.outputPath, seqFilePath), seqSQL);
            }

            _.forOwn(this._references, (refs, srcEntityName) => {
                _.each(refs, (ref) => {
                    relationSQL +=
                        this._addForeignKeyStatement(
                            srcEntityName,
                            ref
                            /*, mapOfEntityNameToCodeName*/
                        ) + '\n';
                });
            });

            this._writeFile(path.join(this.outputPath, dbFilePath), tableSQL);
            this._writeFile(path.join(this.outputPath, fkFilePath), relationSQL);

            let initIdxFiles = {};

            if (!isEmpty(data)) {
                _.forOwn(data, (envData, dataSet) => {
                    _.forOwn(envData, (entitiesData, runtimeEnv) => {
                        _.forOwn(entitiesData, (records, entityName) => {
                            let initFileName = `0-${entityName}.json`;

                            let pathNodes = [sqlFilesDir, 'data', dataSet || '_init'];

                            if (runtimeEnv !== 'default') {
                                pathNodes.push(runtimeEnv);
                            }

                            let initFilePath = path.join(...pathNodes, initFileName);
                            let idxFilePath = path.join(...pathNodes, 'index.list');

                            pushIntoBucket(initIdxFiles, [idxFilePath], initFileName);

                            this._writeFile(
                                path.join(this.outputPath, initFilePath),
                                JSON.stringify({ [entityName]: records }, null, 4)
                            );
                        });
                    });
                });
            }

            //console.dir(initIdxFiles, {depth: 10});

            _.forOwn(initIdxFiles, (list, filePath) => {
                let idxFilePath = path.join(this.outputPath, filePath);

                let manual = [];

                if (fs.existsSync(idxFilePath)) {
                    let lines = fs.readFileSync(idxFilePath, 'utf8').split('\n');
                    lines.forEach((line) => {
                        if (!line.startsWith('0-')) {
                            manual.push(line);
                        }
                    });
                }

                this._writeFile(idxFilePath, list.concat(manual).join('\n'));
            });

            let funcSQL = '-- ON UPDATE SET CURRENT_TIMESTAMP\n\n';//'CREATE LANGUAGE plpgsql; \n\n';

            // update timestamp
            funcSQL += `CREATE OR REPLACE FUNCTION update_timestamp()
RETURNS TRIGGER AS $$
BEGIN    
    NEW."updatedAt" = CURRENT_TIMESTAMP;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;\n\n`;

            if (!isEmpty(extraFunctions)) {
                funcSQL += extraFunctions.join('\n\n') + '\n\n';
            }

            //process view
            /*
            _.each(modelingSchema.views, (view, viewName) => {
                view.inferTypeInfo(modelingSchema);

                funcSQL += `CREATE PROCEDURE ${dbService.getViewSPName(viewName)}(`;
                
                if (!isEmpty(view.params)) {
                    let paramSQLs = [];
                    view.params.forEach(param => {
                        paramSQLs.push(`p${_.upperFirst(param.name)} ${MySQLModeler.columnDefinition(param, true)}`);
                    });

                    funcSQL += paramSQLs.join(', ');
                }

                funcSQL += `)\nCOMMENT 'SP for view ${viewName}'\nREADS SQL DATA\nBEGIN\n`;

                funcSQL += this._viewDocumentToSQL(modelingSchema, view) + ';';

                funcSQL += '\nEND;\n\n';
            });
            */

            let spFilePath = path.join(sqlFilesDir, 'procedures.sql');
            this._writeFile(path.join(this.outputPath, spFilePath), funcSQL);

            if (!isEmpty(triggers)) {
                let triggerSQL = triggers.join('\n\n');
                let triggerFilePath = path.join(sqlFilesDir, 'triggers.sql');
                this._writeFile(path.join(this.outputPath, triggerFilePath), triggerSQL);
            }
        }

        return modelingSchema;
    }

    _toColumnReference(name) {
        return { $xr: 'Column', name };
    }

    _translateJoinCondition(context, localField, anchor, remoteField) {
        if (Array.isArray(remoteField)) {
            return remoteField.map((rf) => this._translateJoinCondition(context, localField, anchor, rf));
        }

        if (isPlainObject(remoteField)) {
            let ret = { [localField]: this._toColumnReference(anchor + '.' + remoteField.by) };
            let withExtra = this._xemlConditionToQueryCondition(context, remoteField.with);

            if (localField in withExtra) {
                return { $and: [ret, withExtra] };
            }

            return { ...ret, ...withExtra };
        }

        return { [localField]: this._toColumnReference(anchor + '.' + remoteField) };
    }

    _getAllRelatedFields(remoteField) {
        if (!remoteField) return undefined;

        if (Array.isArray(remoteField)) {
            return remoteField.map((rf) => this._getAllRelatedFields(rf));
        }

        if (isPlainObject(remoteField)) {
            return remoteField.by;
        }

        return remoteField;
    }

    _preProcessAssociations(entity) {
        return entity.info.associations.map((assoc) => {
            if (assoc.srcField) return assoc.srcField;

            if (assoc.type === 'hasMany') {
                return pluralize(assoc.destEntity);
            }

            return assoc.destEntity;
        });
    }

    /**
     * hasMany/hasOne - belongsTo
     * hasMany/hasOne - hasMany/hasOne [by] [with]
     * hasMany - semi connection
     * refersTo - semi connection
     *
     * remoteField:
     *   1. fieldName
     *   2. array of fieldName
     *   3. { by , with }
     *   4. array of fieldName and { by , with } mixed
     *
     * @param {*} schema
     * @param {*} entity
     * @param {*} assoc
     */
    _processAssociation(schema, entity, assoc, assocNames, pendingEntities) {
        let entityKeyField = entity.getKeyField();
        assert: !Array.isArray(entityKeyField);

        this.linker.log('debug', `Processing "${entity.name}" ${JSON.stringify(assoc)}`);

        let destEntityName = assoc.destEntity,
            destEntity,
            destEntityNameAsFieldName;

        if (isDotSeparateName(destEntityName)) {
            //cross db reference
            let [destSchemaName, actualDestEntityName] = extractDotSeparateName(destEntityName);

            let destSchema = schema.linker.schemas[destSchemaName];
            if (!destSchema.linked) {
                throw new Error(
                    `The destination schema ${destSchemaName} has not been linked yet. Currently only support one-way reference for cross db relation.`
                );
            }

            destEntity = destSchema.entities[actualDestEntityName];
            destEntityNameAsFieldName = actualDestEntityName;
        } else {
            destEntity = schema.ensureGetEntity(entity.xemlModule, destEntityName, pendingEntities);
            if (!destEntity) {
                throw new Error(`Entity "${entity.name}" references to an unexisting entity "${destEntityName}".`);
            }

            destEntityNameAsFieldName = destEntityName;
        }

        if (!destEntity) {
            throw new Error(`Entity "${entity.name}" references to an unexisting entity "${destEntityName}".`);
        }

        if (destEntity.info.abstract) {
            // ignore abstract entity
            return;
        }

        let destKeyField = destEntity.getKeyField();
        if (!destKeyField) {
            throw new Error(`Empty key field "${destEntity.keyField}". Dest entity: ${destEntityName}, current entity: ${entity.name}`);
        }            

        if (Array.isArray(destKeyField)) {
            throw new Error(`Destination entity "${destEntityName}" with combination primary key is not supported.`);
        }

        switch (assoc.type) {
            case 'hasOne':
            case 'hasMany':
                let includes;
                let excludes = {
                    types: ['refersTo'],
                    association: assoc,
                };

                if (assoc.by) {
                    excludes.types.push('belongsTo');
                    includes = {
                        by: (cb) => cb && cb.split('.')[0] === assoc.by.split('.')[0],
                    };

                    if (assoc.with) {
                        includes.with = assoc.with;
                    }
                } else {
                    let remoteFields = this._getAllRelatedFields(assoc.remoteField);

                    includes = {
                        srcField: (remoteField) => {
                            remoteField || (remoteField = entity.name);

                            return (
                                _.isNil(remoteFields) ||
                                (Array.isArray(remoteFields)
                                    ? remoteFields.indexOf(remoteField) > -1
                                    : remoteFields === remoteField)
                            );
                        },
                    };
                }

                let backRef = destEntity.getReferenceTo(entity.name, includes, excludes);
                if (backRef) {
                    if (backRef.type === 'hasMany' || backRef.type === 'hasOne') {
                        if (!assoc.by) {
                            throw new Error(
                                '"m2n" association requires "by" property. Entity: ' +
                                    entity.name +
                                    ' destination: ' +
                                    destEntityName
                            );
                        }

                        // one/many to one/many relation

                        let connectedByParts = assoc.by.split('.');
                        assert: connectedByParts.length <= 2;

                        // connected by field is usually a refersTo assoc
                        let connectedByField = (connectedByParts.length > 1 && connectedByParts[1]) || entity.name;
                        let connEntityName = XemlUtils.entityNaming(connectedByParts[0]);

                        assert: connEntityName;

                        let tag1 = `${entity.name}:${assoc.type === 'hasMany' ? 'm' : '1'}-${destEntityName}:${
                            backRef.type === 'hasMany' ? 'n' : '1'
                        } by ${connEntityName}`;
                        let tag2 = `${destEntityName}:${backRef.type === 'hasMany' ? 'm' : '1'}-${entity.name}:${
                            assoc.type === 'hasMany' ? 'n' : '1'
                        } by ${connEntityName}`;

                        if (assoc.srcField) {
                            tag1 += ' ' + assoc.srcField;
                        }

                        if (backRef.srcField) {
                            tag2 += ' ' + backRef.srcField;
                        }

                        if (this._processedRef.has(tag1) || this._processedRef.has(tag2)) {
                            //already processed, skip
                            return;
                        }

                        let connectedByParts2 = backRef.by.split('.');
                        let connectedByField2 =
                            (connectedByParts2.length > 1 && connectedByParts2[1]) || destEntityNameAsFieldName;

                        if (connectedByField === connectedByField2) {
                            throw new Error('Cannot use the same "by" field in a relation entity.');
                        }

                        let connEntity = schema.ensureGetEntity(entity.xemlModule, connEntityName, pendingEntities);
                        if (!connEntity) {
                            //create a
                            connEntity = this._addRelationEntity(
                                schema,
                                connEntityName,
                                entity.name,
                                destEntityName,
                                connectedByField,
                                connectedByField2
                            );
                            pendingEntities.push(connEntity.name);
                            this.linker.log('debug', `New entity "${connEntity.name}" added by association.`);
                        }

                        this._updateRelationEntity(
                            connEntity,
                            entity,
                            destEntity,
                            entity.name,
                            destEntityName,
                            connectedByField,
                            connectedByField2
                        );

                        let localFieldName = assoc.srcField || pluralize(destEntityNameAsFieldName);

                        entity.addAssociation(localFieldName, {
                            entity: connEntityName,
                            key: connEntity.key,
                            on: this._translateJoinCondition(
                                { ...assocNames, [connEntityName]: localFieldName },
                                entity.key,
                                localFieldName,
                                assoc.with
                                    ? {
                                          by: connectedByField,
                                          with: assoc.with,
                                      }
                                    : connectedByField
                            ),
                            field: connectedByField,
                            ...(assoc.type === 'hasMany' ? { list: true } : {}),
                            assoc: connectedByField2,
                        });

                        let remoteFieldName = backRef.srcField || pluralize(entity.name);

                        destEntity.addAssociation(remoteFieldName, {
                            entity: connEntityName,
                            key: connEntity.key,
                            on: this._translateJoinCondition(
                                { ...assocNames, [connEntityName]: remoteFieldName },
                                destEntity.key,
                                remoteFieldName,
                                backRef.with
                                    ? {
                                          by: connectedByField2,
                                          with: backRef.with,
                                      }
                                    : connectedByField2
                            ),
                            field: connectedByField2,
                            ...(backRef.type === 'hasMany' ? { list: true } : {}),
                            assoc: connectedByField,
                        });

                        this._processedRef.add(tag1);
                        this.linker.log('verbose', `Processed 2-way reference: ${tag1}`);

                        this._processedRef.add(tag2);
                        this.linker.log('verbose', `Processed 2-way reference: ${tag2}`);
                    } else if (backRef.type === 'belongsTo') {
                        if (assoc.by) {
                            throw new Error('todo: belongsTo by. entity: ' + entity.name);
                        } else {
                            //leave it to the referenced entity
                            let anchor =
                                assoc.srcField ||
                                (assoc.type === 'hasMany'
                                    ? pluralize(destEntityNameAsFieldName)
                                    : destEntityNameAsFieldName);
                            let remoteField = assoc.remoteField || backRef.srcField || entity.name;

                            //check if the target entity has logical deletion feature
                            if (destEntity.hasFeature('logicalDeletion')) {
                                let deletionCheck = {
                                    $xt: 'BinaryExpression',
                                    operator: '!=',
                                    left: {
                                        $xt: 'ObjectReference',
                                        name: `${destEntityName}.${destEntity.features['logicalDeletion'].field}`,
                                    },
                                    right: true,
                                };

                                if (isPlainObject(remoteField)) {
                                    remoteField.with = {
                                        $xt: 'LogicalExpression',
                                        operator: 'and',
                                        left: remoteField.with,
                                        right: deletionCheck,
                                    };
                                } else if (assoc.with) {
                                    assoc.with = {
                                        $xt: 'LogicalExpression',
                                        operator: 'and',
                                        left: assoc.with,
                                        right: deletionCheck,
                                    };
                                } else {
                                    assoc.with = deletionCheck;
                                }
                            }

                            entity.addAssociation(anchor, {
                                entity: destEntityName,
                                key: destEntity.key,
                                on: this._translateJoinCondition(
                                    { ...assocNames, [destEntityName]: anchor },
                                    entity.key,
                                    anchor,
                                    assoc.with
                                        ? {
                                              by: remoteField,
                                              with: assoc.with,
                                          }
                                        : remoteField
                                ),
                                ...(typeof remoteField === 'string' ? { field: remoteField } : {}),
                                ...(assoc.type === 'hasMany' ? { list: true } : {}),
                            });
                        }
                    } else {
                        throw new Error(
                            'Unexpected path. Entity: ' +
                                entity.name +
                                ', association: ' +
                                JSON.stringify(assoc, null, 2)
                        );
                    }
                } else {
                    // semi association

                    let connectedByParts = assoc.by
                        ? assoc.by.split('.')
                        : [XemlUtils.prefixNaming(entity.name, destEntityName)];
                    assert: connectedByParts.length <= 2;

                    let connectedByField = (connectedByParts.length > 1 && connectedByParts[1]) || entity.name;
                    let connEntityName = XemlUtils.entityNaming(connectedByParts[0]);

                    assert: connEntityName;

                    let tag1 = `${entity.name}:${
                        assoc.type === 'hasMany' ? 'm' : '1'
                    }-${destEntityName}:* by ${connEntityName}`;

                    if (assoc.srcField) {
                        tag1 += ' ' + assoc.srcField;
                    }

                    assert: !this._processedRef.has(tag1);

                    let connEntity = schema.ensureGetEntity(entity.xemlModule, connEntityName, pendingEntities);
                    if (!connEntity) {
                        //create a
                        connEntity = this._addRelationEntity(
                            schema,
                            connEntityName,
                            entity.name,
                            destEntityName,
                            connectedByField,
                            destEntityNameAsFieldName
                        );
                        pendingEntities.push(connEntity.name);
                        this.linker.log('debug', `New entity "${connEntity.name}" added by association.`);
                    }

                    //todo: get back ref from connection entity
                    let connBackRef1 = connEntity.getReferenceTo(entity.name, {
                        type: 'refersTo',
                        srcField: (f) => _.isNil(f) || f == connectedByField,
                    });

                    if (!connBackRef1) {
                        throw new Error(
                            `Cannot find back reference to "${entity.name}" from relation entity "${connEntityName}".`
                        );
                    }

                    let connBackRef2 = connEntity.getReferenceTo(
                        destEntityName,
                        { type: 'refersTo' },
                        { association: connBackRef1 }
                    );

                    if (!connBackRef2) {
                        throw new Error(
                            `Cannot find back reference to "${destEntityName}" from relation entity "${connEntityName}".`
                        );
                    }

                    let connectedByField2 = connBackRef2.srcField || destEntityNameAsFieldName;

                    if (connectedByField === connectedByField2) {
                        throw new Error(
                            'Cannot use the same "by" field in a relation entity. Detail: ' +
                                JSON.stringify({
                                    src: entity.name,
                                    dest: destEntityName,
                                    srcField: assoc.srcField,
                                    by: connectedByField,
                                })
                        );
                    }

                    this._updateRelationEntity(
                        connEntity,
                        entity,
                        destEntity,
                        entity.name,
                        destEntityName,
                        connectedByField,
                        connectedByField2
                    );

                    let localFieldName = assoc.srcField || pluralize(destEntityNameAsFieldName);

                    entity.addAssociation(localFieldName, {
                        entity: connEntityName,
                        key: connEntity.key,
                        on: this._translateJoinCondition(
                            {
                                ...assocNames,
                                [destEntityName]: localFieldName + '.' + connectedByField2,
                                [connEntityName]: localFieldName,
                            },
                            entity.key,
                            localFieldName,
                            assoc.with
                                ? {
                                      by: connectedByField,
                                      with: assoc.with,
                                  }
                                : connectedByField
                        ),
                        field: connectedByField,
                        ...(assoc.type === 'hasMany' ? { list: true } : {}),
                        assoc: connectedByField2,
                    });

                    this._processedRef.add(tag1);
                    this.linker.log('verbose', `Processed 1-way reference: ${tag1}`);
                }

                break;

            case 'refersTo':
            case 'belongsTo':
                let localField = assoc.srcField || destEntityNameAsFieldName;
                let destFieldName = destKeyField.name;
                let referencedField = destKeyField;

                if (assoc.type === 'refersTo') {
                    let tag = `${entity.name}:1-${destEntityName}:* ${localField}`;

                    if (assoc.destField) {
                        if (!destEntity.hasField(assoc.destField)) {
                            throw new Error(
                                `The field "${assoc.destField}" being referenced is not a field of entity "${destEntityName}".`
                            );
                        }

                        destFieldName = assoc.destField;
                        referencedField = destEntity.fields[destFieldName];
                    }

                    tag += '->' + assoc.destField;

                    if (this._processedRef.has(tag)) {
                        //already processed by connection, skip
                        return;
                    }

                    this._processedRef.add(tag);
                    this.linker.log('verbose', `Processed week reference: ${tag}`);
                }

                let joinOn = { [localField]: this._toColumnReference(localField + '.' + destFieldName) };

                if (assoc.with) {
                    Object.assign(
                        joinOn,
                        this._xemlConditionToQueryCondition({ ...assocNames, [destEntityName]: localField }, assoc.with)
                    );
                }

                if (!assoc.existingField) {
                    entity.addAssocField(localField, destEntity, referencedField, assoc.fieldProps);
                }

                entity.addAssociation(localField, {
                    type: assoc.type,
                    entity: destEntityName,
                    key: destEntity.key,
                    field: destFieldName,
                    on: joinOn,
                });

                //foreign key constraits
                let localFieldObj = entity.fields[localField];

                let constraints = {};

                if (localFieldObj.constraintOnUpdate) {
                    constraints.onUpdate = localFieldObj.constraintOnUpdate;
                }

                if (localFieldObj.constraintOnDelete) {
                    constraints.onDelete = localFieldObj.constraintOnDelete;
                }

                if (assoc.type === 'belongsTo') {
                    constraints.onUpdate || (constraints.onUpdate = 'CASCADE');
                    constraints.onDelete || (constraints.onDelete = 'CASCADE');
                } else if (localFieldObj.optional) {
                    constraints.onUpdate || (constraints.onUpdate = 'SET NULL');
                    constraints.onDelete || (constraints.onDelete = 'SET NULL');
                }

                constraints.onUpdate || (constraints.onUpdate = 'NO ACTION');
                constraints.onDelete || (constraints.onDelete = 'NO ACTION');

                this._addReference(entity.name, localField, destEntityName, destFieldName, constraints);
                break;
        }
    }

    _xemlConditionToQueryCondition(context, xemlCon) {
        if (!xemlCon.$xt) {
            throw new Error('Unknown syntax: ' + JSON.stringify(xemlCon));
        }

        if (xemlCon.$xt === 'BinaryExpression') {
            if (xemlCon.operator === '==') {
                let left = xemlCon.left;
                if (left.$xt && left.$xt === 'ObjectReference') {
                    left = this._translateReference(context, left.name, true);
                }

                let right = xemlCon.right;
                if (right.$xt && right.$xt === 'ObjectReference') {
                    right = this._translateReference(context, right.name);
                }

                return {
                    [left]: { $eq: right },
                };
            } else if (xemlCon.operator === '!=') {
                let left = xemlCon.left;
                if (left.$xt && left.$xt === 'ObjectReference') {
                    left = this._translateReference(context, left.name, true);
                }

                let right = xemlCon.right;
                if (right.$xt && right.$xt === 'ObjectReference') {
                    right = this._translateReference(context, right.name);
                }

                return {
                    [left]: { $ne: right },
                };
            }
        } else if (xemlCon.$xt === 'UnaryExpression') {
            let arg;

            switch (xemlCon.operator) {
                case 'is-null':
                    arg = xemlCon.argument;
                    if (arg.$xt && arg.$xt === 'ObjectReference') {
                        arg = this._translateReference(context, arg.name, true);
                    }

                    return {
                        [arg]: { $eq: null },
                    };

                case 'is-not-null':
                    arg = xemlCon.argument;
                    if (arg.$xt && arg.$xt === 'ObjectReference') {
                        arg = this._translateReference(context, arg.name, true);
                    }

                    return {
                        [arg]: { $ne: null },
                    };

                default:
                    throw new Error('Unknown UnaryExpression operator: ' + xemlCon.operator);
            }
        } else if (xemlCon.$xt === 'LogicalExpression') {
            switch (xemlCon.operator) {
                case 'and':
                    return {
                        $and: [
                            this._xemlConditionToQueryCondition(context, xemlCon.left),
                            this._xemlConditionToQueryCondition(context, xemlCon.right),
                        ],
                    };

                case 'or':
                    return {
                        $or: [
                            this._xemlConditionToQueryCondition(context, xemlCon.left),
                            this._xemlConditionToQueryCondition(context, xemlCon.right),
                        ],
                    };
            }
        }

        throw new Error('Unknown syntax: ' + JSON.stringify(xemlCon));
    }

    _translateReference(context, ref, asKey) {
        let [base, ...other] = ref.split('.');

        let translated = context[base];
        if (!translated) {
            console.log(context);
            throw new Error(`Referenced object "${ref}" not found in context.`);
        }

        let refName = [translated, ...other].join('.');

        if (asKey) {
            return refName;
        }

        return this._toColumnReference(refName);
    }

    _addReference(left, leftField, right, rightField, constraints) {
        if (Array.isArray(leftField)) {
            leftField.forEach((lf) => this._addReference(left, lf, right, rightField, constraints));
            return;
        }

        if (isPlainObject(leftField)) {
            this._addReference(left, leftField.by, right.rightField, constraints);
            return;
        }

        assert: typeof leftField === 'string';

        let refs4LeftEntity = this._references[left];
        if (!refs4LeftEntity) {
            refs4LeftEntity = [];
            this._references[left] = refs4LeftEntity;
        } else {
            let found = _.find(
                refs4LeftEntity,
                (item) => item.leftField === leftField && item.right === right && item.rightField === rightField
            );

            if (found) return;
        }

        refs4LeftEntity.push({ leftField, right, rightField, constraints });
    }

    _getReferenceOfField(left, leftField) {
        let refs4LeftEntity = this._references[left];
        if (!refs4LeftEntity) {
            return undefined;
        }

        let reference = _.find(refs4LeftEntity, (item) => item.leftField === leftField);

        if (!reference) {
            return undefined;
        }

        return reference;
    }

    _hasReferenceOfField(left, leftField) {
        let refs4LeftEntity = this._references[left];
        if (!refs4LeftEntity) return false;

        return undefined !== _.find(refs4LeftEntity, (item) => item.leftField === leftField);
    }

    _getReferenceBetween(left, right) {
        let refs4LeftEntity = this._references[left];
        if (!refs4LeftEntity) {
            return undefined;
        }

        let reference = _.find(refs4LeftEntity, (item) => item.right === right);

        if (!reference) {
            return undefined;
        }

        return reference;
    }

    _hasReferenceBetween(left, right) {
        let refs4LeftEntity = this._references[left];
        if (!refs4LeftEntity) return false;

        return undefined !== _.find(refs4LeftEntity, (item) => item.right === right);
    }

    _featureReducer(schema, entity, featureName, feature) {
        let field;

        switch (featureName) {
            case 'autoId':
                field = entity.fields[feature.field];

                if (field.type === 'integer' && !field.generator) {
                    field.autoIncrementId = true;
                    if ('startFrom' in feature) {
                        const seqId = `${entity.name}_${feature.field}_seq`;
                        this._sequenceRestart.push(
                            `ALTER SEQUENCE ${this.connector.escapeId(seqId)} RESTART WITH ${feature.startFrom};`
                        );
                    }
                }
                break;

            case 'createTimestamp':
                field = entity.fields[feature.field];
                field.isCreateTimestamp = true;
                break;

            case 'updateTimestamp':
                field = entity.fields[feature.field];
                field.isUpdateTimestamp = true;
                break;

            case 'userEditTracking':
                break;

            case 'logicalDeletion':
                break;

            case 'atLeastOneNotNull':
                break;

            case 'validateAllFieldsOnCreation':
                break;

            case 'stateTracking':
                break;

            case 'i18n':
                break;

            case 'changeLog':
                let changeLogSettings = get(schema.deploymentSettings, 'features.changeLog');

                if (!changeLogSettings) {
                    throw new Error(
                        `Missing "changeLog" feature settings in deployment config for schema [${schema.name}].`
                    );
                }

                if (!changeLogSettings.dataSource) {
                    throw new Error(`"changeLog.dataSource" is required. Schema: ${schema.name}`);
                }

                Object.assign(feature, changeLogSettings);
                break;

            case 'createBefore':
                break;

            case 'createAfter':
                break;

            case 'closureTable':
                break;

            default:
                throw new Error('Unsupported feature "' + featureName + '".');
        }
    }

    _writeFile(filePath, content) {
        fs.ensureFileSync(filePath);
        fs.writeFileSync(filePath, content);

        this.linker.log('info', 'Generated db script: ' + filePath);
    }

    _addRelationEntity(
        schema,
        relationEntityName,
        entity1Name /* for cross db */,
        entity2Name /* for cross db */,
        entity1RefField,
        entity2RefField
    ) {
        let entityInfo = {
            features: ['autoId', 'createTimestamp'],
            indexes: [
                {
                    fields: [entity1RefField, entity2RefField],
                    unique: true,
                },
            ],
            associations: [
                {
                    type: 'refersTo',
                    destEntity: entity1Name,
                    srcField: entity1RefField,
                },
                {
                    type: 'refersTo',
                    destEntity: entity2Name,
                    srcField: entity2RefField,
                },
            ],
        };

        let entity = new Entity(this.linker, relationEntityName, schema.xemlModule, entityInfo);
        entity.link();

        schema.addEntity(entity);

        return entity;
    }

    /**
     *
     * @param {*} relationEntity
     * @param {*} entity1
     * @param {*} entity2
     * @param {*} entity1Name
     * @param {*} entity2Name
     * @param {*} connectedByField
     * @param {*} connectedByField2
     */
    _updateRelationEntity(
        relationEntity,
        entity1,
        entity2,
        entity1Name /* for cross db */,
        entity2Name /* for cross db */,
        connectedByField,
        connectedByField2
    ) {
        let relationEntityName = relationEntity.name;

        this._relationEntities[relationEntityName] = true;

        if (relationEntity.info.associations) {
            // check if the relation entity has the refersTo both side of associations
            let hasRefToEntity1 = false,
                hasRefToEntity2 = false;

            _.each(relationEntity.info.associations, (assoc) => {
                if (
                    assoc.type === 'refersTo' &&
                    assoc.destEntity === entity1Name &&
                    (assoc.srcField || entity1Name) === connectedByField
                ) {
                    hasRefToEntity1 = true;
                }

                if (
                    assoc.type === 'refersTo' &&
                    assoc.destEntity === entity2Name &&
                    (assoc.srcField || entity2Name) === connectedByField2
                ) {
                    hasRefToEntity2 = true;
                }
            });

            if (hasRefToEntity1 && hasRefToEntity2) {
                //yes, don't need to add refersTo to the relation entity
                return;
            }
        }

        let tag1 = `${relationEntityName}:1-${entity1Name}:* ${connectedByField}`;
        let tag2 = `${relationEntityName}:1-${entity2Name}:* ${connectedByField2}`;

        if (this._processedRef.has(tag1)) {
            assert: this._processedRef.has(tag2);

            //already processed, skip
            return;
        }

        this._processedRef.add(tag1);
        this.linker.log('verbose', `Processed bridging reference: ${tag1}`);

        this._processedRef.add(tag2);
        this.linker.log('verbose', `Processed bridging reference: ${tag2}`);

        let keyEntity1 = entity1.getKeyField();
        if (Array.isArray(keyEntity1)) {
            throw new Error(`Combination primary key is not supported. Entity: ${entity1Name}`);
        }

        let keyEntity2 = entity2.getKeyField();
        if (Array.isArray(keyEntity2)) {
            throw new Error(`Combination primary key is not supported. Entity: ${entity2Name}`);
        }

        relationEntity.addAssocField(connectedByField, entity1, keyEntity1);
        relationEntity.addAssocField(connectedByField2, entity2, keyEntity2);

        relationEntity.addAssociation(connectedByField, { entity: entity1Name });
        relationEntity.addAssociation(connectedByField2, { entity: entity2Name });

        let allCascade = { onUpdate: 'RESTRICT', onDelete: 'RESTRICT' };

        this._addReference(relationEntityName, connectedByField, entity1Name, keyEntity1.name, allCascade);
        this._addReference(relationEntityName, connectedByField2, entity2Name, keyEntity2.name, allCascade);
    }

    xemlOpToSql(op) {
        switch (op) {
            case '=':
                return '=';

            default:
                throw new Error('xemlOpToSql to be implemented.');
        }
    }

    xemlToSql(schema, doc, xeml, params) {
        if (!xeml.$xt) {
            return xeml;
        }

        switch (xeml.$xt) {
            case 'BinaryExpression':
                let left, right;

                if (xeml.left.$xt) {
                    left = this.xemlToSql(schema, doc, xeml.left, params);
                } else {
                    left = xeml.left;
                }

                if (xeml.right.$xt) {
                    right = this.xemlToSql(schema, doc, xeml.right, params);
                } else {
                    right = xeml.right;
                }

                return left + ' ' + this.xemlOpToSql(xeml.operator) + ' ' + right;

            case 'ObjectReference':
                if (!XemlUtils.isMemberAccess(xeml.name)) {
                    if (params && _.find(params, (p) => p.name === xeml.name) !== -1) {
                        return 'p' + _.upperFirst(xeml.name);
                    }

                    throw new Error(`Referencing to a non-existing param "${xeml.name}".`);
                }

                let { entityNode, entity, field } = XemlUtils.parseReferenceInDocument(schema, doc, xeml.name);

                return entityNode.alias + '.' + this.quoteIdentifier(field.name);

            default:
                throw new Error('oolToSql to be implemented.');
        }
    }

    _orderByToSql(schema, doc, ool) {
        return this.xemlToSql(schema, doc, { $xt: 'ObjectReference', name: ool.field }) + (ool.ascend ? '' : ' DESC');
    }

    _viewDocumentToSQL(modelingSchema, view) {
        let sql = '  ';
        //console.log('view: ' + view.name);
        let doc = _.cloneDeep(view.getDocumentHierarchy(modelingSchema));
        //console.dir(doc, {depth: 8, colors: true});

        //let aliasMapping = {};
        let [colList, alias, joins] = this._buildViewSelect(modelingSchema, doc, 0);

        sql += 'SELECT ' + colList.join(', ') + ' FROM ' + this.quoteIdentifier(doc.entity) + ' AS ' + alias;

        if (!isEmpty(joins)) {
            sql += ' ' + joins.join(' ');
        }

        if (!isEmpty(view.selectBy)) {
            sql +=
                ' WHERE ' +
                view.selectBy.map((select) => this.xemlToSql(modelingSchema, doc, select, view.params)).join(' AND ');
        }

        if (!isEmpty(view.groupBy)) {
            sql += ' GROUP BY ' + view.groupBy.map((col) => this._orderByToSql(modelingSchema, doc, col)).join(', ');
        }

        if (!isEmpty(view.orderBy)) {
            sql += ' ORDER BY ' + view.orderBy.map((col) => this._orderByToSql(modelingSchema, doc, col)).join(', ');
        }

        let skip = view.skip || 0;
        if (view.limit) {
            sql +=
                ' LIMIT ' +
                this.xemlToSql(modelingSchema, doc, skip, view.params) +
                ', ' +
                this.xemlToSql(modelingSchema, doc, view.limit, view.params);
        } else if (view.skip) {
            sql += ' OFFSET ' + this.xemlToSql(modelingSchema, doc, view.skip, view.params);
        }

        return sql;
    }

    /*
    _buildViewSelect(schema, doc, startIndex) {
        let entity = schema.entities[doc.entity];
        let alias = ntol(startIndex++);
        doc.alias = alias;

        let colList = Object.keys(entity.fields).map(k => alias + '.' + MySQLModeler.quoteIdentifier(k));
        let joins = [];

        if (!isEmpty(doc.subDocuments)) {
            _.forOwn(doc.subDocuments, (doc, fieldName) => {
                let [ subColList, subAlias, subJoins, startIndex2 ] = this._buildViewSelect(schema, doc, startIndex);
                startIndex = startIndex2;
                colList = colList.concat(subColList);
                
                joins.push('LEFT JOIN ' + MySQLModeler.quoteIdentifier(doc.entity) + ' AS ' + subAlias
                    + ' ON ' + alias + '.' + MySQLModeler.quoteIdentifier(fieldName) + ' = ' +
                    subAlias + '.' + MySQLModeler.quoteIdentifier(doc.linkWithField));

                if (!isEmpty(subJoins)) {
                    joins = joins.concat(subJoins);
                }
            });
        }

        return [ colList, alias, joins, startIndex ];
    }*/

    _createTableStatement(entityName, entity /*, mapOfEntityNameToCodeName*/) {
        let sql = '-- Create Table\nCREATE TABLE IF NOT EXISTS ' + this.connector.escapeId(entityName) + ' (\n';

        //column definitions
        _.each(entity.fields, (field, name) => {
            sql += '  ' + this.quoteIdentifier(name) + ' ' + this.columnDefinition(entity, field) + ',\n';
        });

        const pkName = `${entityName}_pkey`;

        sql += '  CONSTRAINT "' + pkName + '" PRIMARY KEY (' + this.quoteIdListOrValue(entity.key) + '),\n';

        let lines = [];
        this._events.emit('beforeEndColumnDefinition:' + entityName, lines);
        if (lines.length > 0) {
            sql += '  ' + lines.join(',\n  ');
        } else {
            sql = sql.substring(0, sql.length - 2);
        }

        sql += '\n)';

        //table options
        let extraProps = {};
        this._events.emit('setTableOptions:' + entityName, extraProps);
        let props = Object.assign({}, this._dbOptions.table, extraProps);

        sql = _.reduce(
            props,
            function (result, value, key) {
                return result + ' ' + key + '=' + value;
            },
            sql
        );

        sql += ';\n\n';

        const outVars = {};

        this._events.emit('afterTableDefinition:' + entityName, outVars);

        if (outVars.sql) {
            sql += outVars.sql;
        }

        //other keys
        if (entity.indexes && entity.indexes.length > 0) {
            entity.indexes.forEach((index) => {
                sql += '-- Create Index\nCREATE ';
                if (index.unique) {
                    sql += 'UNIQUE ';
                }

                const indexName = `${entityName}_${index.fields.join('_')}_idx`;

                sql +=
                    'INDEX "' +
                    indexName +
                    '" ON "' +
                    entityName +
                    '"(' +
                    this.quoteIdListOrValue(index.fields) +
                    ');\n\n';
            });
        }

        if (entity.comment) {
            sql += `COMMENT ON TABLE "${entityName}" IS ${this.quoteString(entity.comment)};\n\n`;
        }

        //column definitions
        _.each(entity.fields, (field, name) => {
            if (field.comment) {
                sql += `COMMENT ON COLUMN "${entityName}"."${name}" IS ${this.quoteString(field.comment)};\n\n`;
            }
        });

        return sql;
    }

    _addForeignKeyStatement(entityName, relation /*, mapOfEntityNameToCodeName*/) {
        let refTable = relation.right;

        if (refTable.indexOf('.') > 0) {
            let [schemaName, refEntityName] = refTable.split('.');

            //let targetConnector = this.modelService.getConnector(schemaName);
            // todo: support multiple schema
            refTable = this.quoteIdentifier(refEntityName);
        } else {
            refTable = this.quoteIdentifier(refTable);
        }

        let keyName = `${entityName}_${relation.leftField}_fkey`;

        let sql =
            'ALTER TABLE ' +
            this.quoteIdentifier(entityName) +
            ' ADD CONSTRAINT ' +
            this.quoteIdentifier(keyName) +
            ' FOREIGN KEY (' +
            this.quoteIdentifier(relation.leftField) +
            ') ' +
            'REFERENCES ' +
            refTable +
            ' (' +
            this.quoteIdentifier(relation.rightField) +
            ') ';

        sql += `ON UPDATE ${relation.constraints.onUpdate} ON DELETE ${relation.constraints.onDelete};\n`;

        return sql;
    }

    static foreignKeyFieldNaming(entityName, entity) {
        let leftPart = naming.camelCase(entityName);
        let rightPart = naming.pascalCase(entity.key);

        if (_.endsWith(leftPart, rightPart)) {
            return leftPart;
        }

        return leftPart + rightPart;
    }

    quoteString(str) {
        return this.connector.escapeValue(str);
    }

    quoteIdentifier(str) {
        return this.connector.escapeId(str);
    }

    quoteIdListOrValue(obj) {
        return _.isArray(obj) ? obj.map((v) => this.quoteIdentifier(v)).join(', ') : this.quoteIdentifier(obj);
    }

    complianceCheck(entity) {
        let result = { errors: [], warnings: [] };

        if (!entity.key) {
            result.errors.push('Primary key is not specified.');
        }

        return result;
    }

    columnDefinition(entity, field, isProc, returnType) {
        let col;

        if (field.domain) {
            col = {
                sql: field.domain,
                type: 'DOMAIN',
            };
        } else {
            switch (field.type) {
                case 'array':
                    if (field.vector != null) {
                        col = this.vectorColumnDefinition(field);
                    } else if (field.element) {
                        col = this.arrayColumnDefinition(entity, field);
                    } else {
                        col = this.textColumnDefinition(field);
                    }
                    break;

                case 'bigint':
                    col = this.bigintColumnDefinition(field);
                    break;

                case 'binary':
                    col = this.binaryColumnDefinition(field);
                    break;

                case 'boolean':
                    col = this.boolColumnDefinition(field);
                    break;

                case 'datetime':
                    col = this.datetimeColumnDefinition(field);
                    break;

                case 'integer':
                    col = this.intColumnDefinition(field);
                    break;

                case 'number':
                    col = this.floatColumnDefinition(field);
                    break;

                case 'object':
                    //if (field.jsonb) {
                        col = this.jsonbColumnDefinition(field);
                    //} else {
                    //   col = this.textColumnDefinition(field);
                    //}
                    break;

                case 'text':
                    col = this.textColumnDefinition(field);
                    break;

                default:
                    throw new Error('Unsupported type "' + field.type + '". Field: ' + JSON.stringify(field));
            }
        }

        let { sql, type } = col;

        if (returnType) {
            return type;
        }

        if (!isProc) {
            sql += this.columnNullable(field);
            sql += this.defaultValue(entity, field, type);
        }

        return sql;
    }

    bigintColumnDefinition(info) {
        let sql, type;

        type = sql = info.autoIncrementId ? 'BIGSERIAL' : 'BIGINT';

        if (info.unsigned) {
            this.warnings['unsigned'] = UNSIGNED_WARNING;
        }

        return { sql, type };
    }

    intColumnDefinition(info) {
        let sql, type;

        if (info.bytes) {
            if (info.bytes === 8) {
                type = sql = info.autoIncrementId ? 'BIGSERIAL' : 'BIGINT';
            } else if (info.bytes === 4) {
                type = sql = info.autoIncrementId ? 'SERIAL' : 'INT';
            } else if (info.bytes === 1 || info.bytes === 2) {
                type = sql = info.autoIncrementId ? 'SERIAL' : 'SMALLINT';
            } else {
                throw new Error(`Unsupported "bytes" value (${info.bytes}) for "integer" type.`);
            }
        } else if (info.digits) {
            if (info.digits > 10) {
                type = sql = info.autoIncrementId ? 'BIGSERIAL' : 'BIGINT';
            } else if (info.digits > 4) {
                type = sql = info.autoIncrementId ? 'SERIAL' : 'INT';
            } else {
                type = sql = info.autoIncrementId ? 'SERIAL' : 'SMALLINT';
            }
        } else {
            type = sql = info.autoIncrementId ? 'SERIAL' : 'INT';
        }

        if (info.unsigned) {
            this.warnings['unsigned'] = UNSIGNED_WARNING;
        }

        return { sql, type };
    }

    floatColumnDefinition(info) {
        let sql = '',
            type;

        if (info.type == 'number' && info.exact) {
            type = sql = 'NUMERIC';

            if (info.totalDigits > MAX_NUMERIC_PRECISION) {
                throw new Error('Total digits exceed maximum limit.');
            }
        } else {
            type = sql = 'FLOAT';
            if (info.decimalDigits != null) {
                throw new Error('`decimalDigits` is not supported for `FLOAT` type.');
            }
        }

        if ('totalDigits' in info) {
            sql += '(' + info.totalDigits;
            if ('decimalDigits' in info) {
                sql += ', ' + info.decimalDigits;
            }
            sql += ')';
        } else {
            if ('decimalDigits' in info) {
                sql += '(' + info.decimalDigits + ', ' + info.decimalDigits + ')';
            }
        }

        return { sql, type };
    }

    arrayColumnDefinition(entity, info) {
        let sql = '',
            type;

        const subType = this.columnDefinition(entity, info.element, true, true);

        if (typeof info.fixedLength === 'number') {
            type = sql = subType + '[' + info.fixedLength + ']';
        } else {
            type = sql = subType + '[]';
        }

        return { sql, type };
    }

    vectorColumnDefinition(info) {
        let sql = '',
            type;

        if (typeof info.vector === 'number') {
            sql = 'VECTOR(' + info.vector + ')';
            type = 'VECTOR';
        }
        if (typeof info.fixedLength === 'number') {
            sql = 'VECTOR(' + info.fixedLength + ')';
            type = 'VECTOR';
        } else {
            throw new Error('`vector` length is required for `vector` type.');
        }

        return { sql, type };
    }

    jsonbColumnDefinition(info) {
        let sql = '',
            type;

        type = sql = 'JSONB';

        return { sql, type };
    }

    textColumnDefinition(info) {
        let sql = '',
            type;

        if (info.fixedLength) {
            sql = 'CHAR(' + info.fixedLength + ')';
            type = 'CHAR';
        } else if (info.maxLength) {
            type = sql = 'VARCHAR';
            sql += '(' + info.maxLength + ')';
        } else {
            type = sql = 'TEXT';
        }

        return { sql, type };
    }

    binaryColumnDefinition(info) {
        let sql = '',
            type;

        type = sql = 'BYTEA';

        return { sql, type };
    }

    boolColumnDefinition() {
        return { sql: 'BOOLEAN', type: 'BOOLEAN' };
    }

    datetimeColumnDefinition(info) {
        let sql, type;

        if (!info.range || info.range === 'datetime') {
            sql = 'TIMESTAMP(3)';
            type = 'TIMESTAMP';
        } else if (info.range === 'date') {
            type = sql = 'DATE';
        } else if (info.range === 'time') {
            type = sql = 'TIME';
        } else if (info.range === 'year') {
            throw new Error("range('year') for `datetime` type is not supported.");
        } else if (info.range === 'timestamp') {
            type = sql = 'TIMESTAMP';
        } else if (info.range === 'interval') {
            type = sql = 'INTERVAL';
        }

        return { sql, type };
    }

    columnNullable(info) {
        if (info.hasOwnProperty('optional') && info.optional) {
            return ' NULL';
        }

        return ' NOT NULL';
    }

    defaultValue(entity, info, type) {
        if (info.isCreateTimestamp) {
            info.autoByDb = true;
            return ' DEFAULT CURRENT_TIMESTAMP';
        }

        if (info.autoIncrementId) {
            info.autoByDb = true;
            return ''; // no ' AUTO_INCREMENT' in progres;
        }

        /*
        todo: replaced by trigger
        if (info.isUpdateTimestamp) {
            info.updateByDb = true;
            return ' ON UPDATE CURRENT_TIMESTAMP';
        }
        */

        let sql = '';

        if (!info.optional) {
            if (info.hasOwnProperty('default')) {
                let defaultValue = info['default'];

                if (typeof defaultValue === 'object' && defaultValue.$xt) {
                    if (defaultValue.$xt === XemlTypes.Lang.SYMBOL_TOKEN) {
                        const tokenName = defaultValue.name.toUpperCase();

                        switch (tokenName) {
                            case 'NOW':
                                sql += ' DEFAULT NOW()';
                                info.autoByDb = true;
                                return sql;

                            default:
                                throw new Error(`Unsupported symbol token "${tokenName}".`);
                        }
                    } else if (defaultValue.$xt === XemlTypes.Lang.CONST_REF) {
                        defaultValue = this.linker.translateXemlValue(entity.xemlModule, defaultValue);
                    } else {
                        throw new Error(`Unknown xeml object tag "${defaultValue.$xt}".`);
                    }
                } 
                
                if (info.enum) {
                    if (!info.enum.includes(defaultValue)) {
                        throw new Error(`Invalid default value "${defaultValue}" for enum field "${info.name}".`);
                    }
                    sql += ' DEFAULT ' + this.quoteString(defaultValue);
                } else {
                    switch (info.type) {
                        case 'boolean':
                            sql += ' DEFAULT ' + (Types.BOOLEAN.sanitize(defaultValue) ? 'TRUE' : 'FALSE');
                            break;

                        case 'bigint':
                        case 'integer':
                            if (_.isInteger(defaultValue)) {
                                sql += ' DEFAULT ' + defaultValue.toString();
                            } else {
                                sql += ' DEFAULT ' + parseInt(defaultValue).toString();
                            }
                            break;

                        case 'text':
                            sql += ' DEFAULT ' + defaultValue ? this.quoteString(defaultValue) : "''";
                            break;

                        case 'number':
                            if (_.isNumber(defaultValue)) {
                                sql += ' DEFAULT ' + defaultValue.toString();
                            } else {
                                sql += ' DEFAULT ' + parseFloat(defaultValue).toString();
                            }
                            break;

                        case 'binary':
                            sql += ' DEFAULT ' + bin2Hex(defaultValue);
                            break;

                        case 'datetime':
                            sql +=
                                ' DEFAULT ' +
                                this.quoteString(Types.DATETIME.sanitize(defaultValue).toSQL({ includeOffset: false }));
                            break;

                        case 'object':
                        case 'array':
                            sql += ' DEFAULT ' + this.quoteString(JSON.stringify(defaultValue));
                            break;

                        default:
                            throw new Error(`Invalid type "${info.type}"`);
                    }
                }
            } else if (!info.hasOwnProperty('auto')) {
                if (UNSUPPORTED_DEFAULT_VALUE.has(type)) {
                    return '';
                }

                if (info.type === 'boolean') {
                    sql += ' DEFAULT FALSE';
                } else if (info.type === 'bigint' || info.type === 'integer' || info.type === 'number') {
                    sql += ' DEFAULT 0';
                } else if (info.type === 'datetime') {
                    sql += ' DEFAULT CURRENT_TIMESTAMP';
                } else if (info.enum) {
                    sql += ' DEFAULT ' + this.quoteString(info.enum[0]);
                    info.autoByDb = true;
                } else {
                    sql += " DEFAULT ''";
                }

                //not explicit specified, will not treated as autoByDb
                //info.autoByDb = true;
            }
        }

        return sql;
    }

    static removeTableNamePrefix(entityName, removeTablePrefix) {
        if (removeTablePrefix) {
            entityName = _.trim(_.snakeCase(entityName));

            removeTablePrefix = _.trimEnd(_.snakeCase(removeTablePrefix), '_') + '_';

            if (_.startsWith(entityName, removeTablePrefix)) {
                entityName = entityName.substr(removeTablePrefix.length);
            }
        }

        return XemlUtils.entityNaming(entityName);
    }
}

module.exports = PostgresModeler;