arlac77/sorm

View on GitHub
src/module.js

Summary

Maintainability
F
3 days
Test Coverage
/* jslint node: true, esnext: true */

'use strict';

const crypto = require('crypto'),
  fs = require('fs'),
  path = require('path'),
  mkdirp = require('mkdirp'),
  async = require('async'),
  winston = require('winston');

  //import Table from './Table';
  import { quote, quoteIfNeeded, unquoteList, unquote } from './util';

Object.defineProperty(Object.prototype, 'spawn', {
  value: function (props) {
    const defs = {};
    for (const key in props) {
      if (props.hasOwnProperty(key)) {
        defs[key] = {
          value: props[key],
          enumerable: true
        };
      }
    }
    return Object.create(this, defs);
  }
});

const orderedConstraints = [];
const constraints = {};

const RootConstraint = {
  ddl_statement() {
      return this.name;
    },
    toJSON() {
      return {
        name: this.name
      };
    },
    toString() {
      return this.ddl_statement();
    }
};

function parse_constraints(ps, cs) {
  let gotSomething;
  let str = ps.input;

  if (str) {
    do {
      gotSomething = false;
      if (!str) break;

      for (const i in orderedConstraints) {
        const oc = orderedConstraints[i];
        const m = str.match(oc.regex);

        //winston.log(str + " <> " + oc.regex + " ::: "  + m);

        if (m) {
          gotSomething = true;
          if (oc.parse) {
            str = oc.parse(m, cs, oc);
          } else {
            str = m[1];
            cs.push(Constraint(oc));
          }

          break;
        }
      }
    }
    while (gotSomething);
  }

  ps.input = str;

  return gotSomething;
}

function create_constraint(definition) {
  const c = RootConstraint.spawn(definition);
  orderedConstraints.push(c);
  constraints[c.name] = c;
}

create_constraint({
  name: 'PRIMARY KEY',
  regex: /^primary\s+key(\s+(asc|desc))?\s*(\(([^/)]+)\))?(.*)/im,
  toJSON() {
    const o = {
      name: this.name
    };
    if (this.options) o.options = this.options.toUpperCase();
    if (this.attributes) o.attributes = this.attributes;
    return o;
  },
  ddl_statement() {
    let s = this.name;
    if (this.options) {
      s += ' ' + this.options.toUpperCase();
    }
    if (this.attributes) {
      s += '(' + this.attributes.join(',') + ')';
    }
    return s;
  },
  parse(matches, cs, constraint) {
    const properties = {};
    if (matches[2]) properties.options = matches[2];
    if (matches[3]) {
      properties.attributes = matches[4].split(/,/).map(a => unquote(a));
    }
    cs.push(Constraint(constraint, properties));
    return matches[5];
  }
});

create_constraint({
  name: 'NOT NULL',
  regex: /^not\s+null\s*(.*)/im
});

create_constraint({
  name: 'NULL',
  regex: /^null\s*(.*)/im
});

create_constraint({
  name: 'DEFAULT',
  regex: /^default\s+(('[^']*')|("[^"]*")|(\d+)|(null))(.*)/im,
  toJSON() {
    return {
      name: this.name,
      value: this.value
    };
  },
  ddl_statement() {
    let v = 'NULL';
    if (typeof this.value === 'string') v = quote(this.value);
    else if (typeof this.value === 'number') v = this.value;
    return this.name + ' ' + v;
  },
  parse(matches, cs, constraint) {
    const properties = {};
    if (matches[2]) properties.value = unquote(matches[2]);
    if (matches[3]) properties.value = unquote(matches[3]);
    if (matches[4]) properties.value = parseInt(matches[4]);
    cs.push(Constraint(constraint, properties));
    return matches[6];
  }
});

create_constraint({
  name: 'FOREIGN KEY',
  regex: /^CONSTRAINT\s+((\'[^\']+\')|(\"[^\"]+\")|([a-z][a-z_0-9]*))\s+FOREIGN\s+KEY\s*\(([^\)]+)\)\s*REFERENCES\s*((\'[^\']+\')|(\"[^\"]+\")|([a-z][a-z_0-9]*))\s*\(([^\)]+)\)(.*)/im,
  toJSON() {
    return {
      name: this.name,
      id: this.id,
      attributes: this.attributes,
      foreign_table: this.foreign_table,
      foreign_attributes: this.foreign_attributes
    };
  },
  ddl_statement() {
    return 'CONSTRAINT ' + quoteIfNeeded(this.id) +
      ' FOREIGN KEY(' + this.attributes.join(',') + ') REFERENCES ' +
      quoteIfNeeded(this.foreign_table) + '(' + this.foreign_attributes.join(',') + ')';
  },
  parse(matches, cs, constraint) {
    cs.push(Constraint(constraint, {
      id: unquote(matches[1]),
      attributes: unquoteList(matches[5]),
      foreign_table: unquote(matches[6]),
      foreign_attributes: unquoteList(matches[10])
    }));
    return matches[11];
  }
});

/**
 * Creates an instance of Constraint.
 *
 * @constructor
 * @this {Constraint}
 * @param {options} either a string or a object with name and attributes.
 */
export function Constraint(type, properties) {

  if (typeof type === 'string') {
    let c = constraints[type.toUpperCase()];
    if (c) {
      return c;
    }

    const cs = [];

    parse_constraints({
      input: type
    }, cs);

    c = cs[0];

    if (!c) {
      winston.warn('Unknown constraint', type);
      return undefined;
    }
    return c;
  } else if (properties === undefined) {
    properties = {};
    for (const key in type) {
      if (key !== 'name')
        properties[key] = type[key];
    }

    type = constraints[type.name.toUpperCase()];
  }

  return type.spawn(properties);
}

const RootAttribute = {
  ddl_statement() {
      const c = [this.name, this.type];
      for (const i in this.constraints)
        c.push(this.constraints[i].ddl_statement());

      return c.join(' ');
    },
    toJSON() {
      const o = {
        name: this.name,
        type: this.type
      };
      if (this.constraints.length > 0) o.constraints = this.constraints;
      return o;
    },
    toString() {
      return this.ddl_statement();
    }
};

/**
 * Creates an instance of Attribute.
 *
 * @constructor
 * @this {Attribute}
 * @param {name} attribute name.
 * @param {type} attribute type.
 * @param {constraints} Array of Constraints.
 */
export function Attribute(name, type, cs) {

  const constraints = [];

  if (cs instanceof Array) {
    for (const i in cs) {
      const csi = cs[i];
      constraints[i] = csi instanceof Constraint ? csi : Constraint(csi);
    }
  } else {
    parse_constraints({
      input: cs
    }, constraints);
  }

  return RootAttribute.spawn({
    name: name,
    type: type,
    constraints: constraints
  });
}


const RootTable = {
  ddl_statement() {
      const atts = this.attributes.map(a => a.ddl_statement());

      for (const i in this.constraints) {
        atts.push(this.constraints[i].ddl_statement());
      }

      return 'CREATE TABLE ' + this.name + '(' + atts.join(',') + ')';
    },
    toJSON() {
      const o = {
        attributes: this.attributes
      };
      if (this.constraints && this.constraints.length > 0) o.constraints = this.constraints;
      return o;
    },
    toString() {
      return this.ddl_statement();
    },
    attribute_names() {
      const names = [];
      for (const i in this.attributes) names.push(this.attributes[i].name);
      return names;
    },
    insert_sql(attributes) {
      const qm = [];

      for (const k in attributes) qm.push('?');

      return 'INSERT INTO ' + this.name + '(' + attributes.join(',') + ') VALUES(' + qm.join(',') + ')';
    },
    update_sql(attributes) {
      const j = this.pk().length;
      const a = [];
      for (const i in attributes) {
        if (i >= j)
          a.push(attributes[i] + '=?');
      }

      if (a.length === 0) return undefined;
      return 'UPDATE ' + this.name + ' SET ' + a.join(',') + ' WHERE ' + this.pk_predicate();
    },
    attribute(name) {
      for (const i in this.attributes) {
        const a = this.attributes[i];
        if (a.name === name) return a;
      }
      return undefined;
    },
    pk() {
      const pk = [];

      for (const i in this.attributes) {
        const a = this.attributes[i];
        for (const j in a.constraints) {
          const c = a.constraints[j];
          if (c.name.search(/PRIMARY KEY/) >= 0) pk.push(a);
        }
      }

      for (const i in this.constraints) {
        const c = this.constraints[i];

        if (c.name.search(/PRIMARY KEY/) >= 0) {
          for (const j in c.attributes) pk.push(this.attribute(c.attributes[j]));
        }
      }

      return pk;
    },
    pk_predicate(alias, values) {
      const ps = [];
      const pk = this.pk();

      for (const i in pk) {
        ps.push((alias ? [alias, pk[i].name].join('.') : pk[i].name) + '=?');
      }

      return ps.join(' AND ');
    },
    next_pk(db, callback) {
      db.get('SELECT MAX(' + this.pk()[0].name + ') + 1 FROM ' + this.name, callback);
    }
};

/**
 * Creates an instance of Table.
 *
 * @constructor
 * @this {Table}
 * @param {name} attribute name.
 * @param {type} attribute type.
 * @param {constraints} Array of Constraints.
 */
export function Table(name, attributes, constraints) {

  for (const i in attributes) {
    const a = attributes[i];
    if (!(a instanceof Attribute))
      attributes[i] = Attribute(a.name, a.type, a.constraints);
  }

  for (const i in constraints) {
    const c = constraints[i];
    if (!(c instanceof Constraint))
      constraints[i] = Constraint(c);
  }

  return RootTable.spawn({
    name: name,
    attributes: attributes,
    constraints: constraints
  });
}


function tables_from_db(db, callback) {
  db.all("SELECT name,sql FROM sqlite_master WHERE type='table'", (error, rows) => {
    if (error) {
      callback(error);
      return;
    }

    let tables = {};
    for (const i in rows) {
      const row = rows[i];
      const sql = row.sql.split(/\n/).join(' ');
      //console.log("input          : " + sql);

      const m = sql.match(/CREATE\s+TABLE\s+((\"([^\"]+)\")|([a-z][a-z0-9_]*))\s*\((.*)/im);
      if (m) {
        const attributes = [];
        const constraints = [];
        const name = m[3] ? m[3] : m[4];
        const ps = {
          input: m[5]
        };

        do {
          if (parse_constraints(ps, constraints)) {
            //console.log("after constra A: " + ps.input);
          } else {
            const m = ps.input.match(
              /^\s*((\"[^\"]+\")|([a-z][a-z_0-9]*))\s+([a-z][a-z0-9_]*(\([^\)]+\))?)[\s,]+(.*)/i);
            if (m) {
              const aname = unquote(m[1]);
              const type = m[4];
              ps.input = m[6];

              const cs = [];
              parse_constraints(ps, cs);

              const m2 = ps.input.match(/^\s*,\s*(.*)/);
              if (m2) {
                ps.input = m2[1];
              }

              attributes.push(Attribute(aname, type, cs));
            } else if (ps.input === ')') {
              break;
            } else {
              winston.error('Unknown table ddl content', {
                input: ps.input
              });
              break;
            }
          }
        }
        while (ps.input.length > 0);

        tables[name] = Table(name, attributes, constraints);
      }
    }

    callback(null, tables);
  });
}

const RootSchema = Object.create({
  migrations: {},
  load(cb) {
    fs.readFile(this.schema_json_file, (error, data) => {
      if (error) {
        if (cb) cb(error);
        return;
      }
      this.load_from_object(JSON.parse(data));
      if (cb) cb(undefined, this);
    });
  },
  save(callback) {
    mkdirp(t.basedir, '0755', error => fs.writeFile(this.schema_json_file, JSON.stringify(t, undefined, '\t'),
      callback));
  },
  load_from_object(object) {
    const tables = {};

    for (const i in object.tables) {
      const t = object.tables[i];
      tables[i] = Table(i, t.attributes, t.constraints);
    }

    for (const j in object) {
      this[j] = object[j];
    }

    this.tables = tables;

    if (!this.versions) {
      this.versions = {};
      this.versions[this.schemaHash] = {
        tag: 1
      };
    }
  },
  load_ddl_from_db(db, callback) {
    tables_from_db(db, (error, tables) => {
      if (error) {
        callback(error);
        return;
      }
      this.tables = tables;

      if (!this.versions) {
        this.versions = {};
        this.versions[this.schemaHash] = {
          tag: 1
        };
      }

      callback(error, this);
    });
  },
  toJSON() {
    return {
      versions: this.versions,
      migrations: this.migrations,
      tables: this.tables
    };
  },

  exec_ddl(db, createOptions, callback) {

    if (!createOptions)
      createOptions = {
        'load_data': true
      };

    const create_tables = {};
    const alter_tables = [];

    for (const j in this.tables) {
      const t = this.tables[j];
      create_tables[t.name] = t;
    }

    const presentHash = crypto.createHash('sha1');
    const basedir = this.basedir;
    const schema = this;

    tables_from_db(db, (error, present_tables) => {
      if (error) {
        callback(error);
        return;
      }

      winston.info('present tables', Object.keys(present_tables));
      winston.info('desired tables', Object.keys(create_tables));

      const steps = [];
      const pre_steps = [];
      const post_steps = [];

      for (const i in present_tables) {
        const pt = present_tables[i];

        presentHash.update(pt.ddl_statement());

        const t = create_tables[pt.name];
        if (t) {
          const csql = t.ddl_statement();

          if (csql === pt.ddl_statement()) {
            delete create_tables[t.name];
          } else {
            // for now simply skip already existing table
            delete create_tables[t.name];

            //var backup_name = t.name + '_' + (schema.version - 1);
            //pre_steps.push("ALTER TABLE " + t.name + " RENAME TO " + backup_name);
            // TODO select all former attributes
            //post_steps.push("INSERT INTO " + t.name + "(" + t.attribute_names().join(',') + ") SELECT " + t.attribute_names().join(',') + " FROM " + backup_name);
            //post_steps.push("DROP TABLE " + backup_name);
          }
        }
      }

      for (const j in create_tables) {
        const t = create_tables[j];
        winston.info('ddl', {
          sql: t.ddl_statement()
        });
        steps.push(t.ddl_statement());
      }

      /*
        this.presentSchemaVersion = presentHash.digest("hex");

        var desiredSchemaVersion = schema.schemaHash;

        if(this.presentSchemaVersion !== desiredSchemaVersion) {
            winston.log("currentSchemaVersion: " + this.presentSchemaVersion);
            winston.log("desiredSchemaVersion: " + desiredSchemaVersion);

            if(schema.migrations) {
                var mig = schema.migrations[this.presentSchemaVersion];
                if(mig) {
                    winston.log("migrations: " + mig.statements.length);

                for(var i in mig.statements)
                    post_steps.push(mig.statements[i]);
                }
            }
            else
                callback("schema migration required: " + this.presentSchemaVersion + " -> " + desiredSchemaVersion,this,db);
        }
        */

      async.map(pre_steps, (sql, cb) => db.run(sql, cb),
        error => {
          if (error) {
            callback(error, this);
            return;
          }

          async.map(steps.concat(post_steps), (sql, cb) => db.run(sql, cb),
            error => {
              if (error) {
                callback(error, this);
                return;
              }

              if (createOptions.load_data) {
                const djs = path.join(basedir, 'data.json');
                fs.stat(djs, (error, stats) => {
                  if (error) return;
                  schema.migrate_data(db, JSON.parse(fs.readFileSync(djs)), callback);
                });
              } else {
                callback(undefined, schema, db);
                return;
              }
            });
        });
    });
  },
  migrate_data(db, data_sets, callback) {
    const update = function (table, sql, values) {
      const v = [];
      const l = table.pk().length;

      for (let i = l; i < values.length; i++) v.push(values[i]);
      for (let i = 0; i < l; i++) v.push(values[i]);

      db.get(sql, v, error => {
        if (error) {
          winston.error(error, {
            sql: sql + ' : ' + v.join(',')
          });
        }
      });
    };

    const insert = function (table, sql1, sql2, values) {
      db.get(sql1, values, error => {
        if (error) {
          if (sql2 !== undefined) update(table, sql2, values);
        }
      });
    };

    for (const i in data_sets) {
      const data = data_sets[i];
      const t = this.table(data.table);

      const updateSql = t.update_sql(data.attributes);
      const insertSql = t.insert_sql(data.attributes);

      for (const j in data.values) {
        insert(t, insertSql, updateSql, data.values[j]);
      }
    }
    callback(undefined, this, db);
  }
}, {
  schema_json_file: {
    get: function () {
      return path.join(this.basedir, 'schema.json');
    }
  },
  schemaHash: {
    get: function () {
      const hash = crypto.createHash('sha1');
      for (const j in this.tables) {
        hash.update(this.tables[j].ddl_statement());
      }
      return hash.digest('hex');
    },
    tables: {
      value: {},
      writeable: true,
      enumerable: true,
      configurable: true
    }
  }
});

/**
 * Creates an instance of Schema.
 *
 * @constructor
 * @this {Schema}
 * @param {options} string value holding schmema fs directory.
 */
export function Schema(options, callback) {

  if (typeof options === 'string') {
    const schema = RootSchema.spawn({
      basedir: options
    });
    schema.load(callback);
    return schema;
  } else {
    const schema = RootSchema.spawn({});

    schema.load_from_object(options);

    if (callback) {
      callback(undefined, schema);
    }

    return schema;
  }
}