// @flow
// This class handles schema validation, persistence, and modification.
//
// Each individual Schema object should be immutable. The helpers to
// do things with the Schema just return a new schema when the schema
// is changed.
//
// The canonical place to store this Schema is in the database itself,
// in a _SCHEMA collection. This is not the right way to do it for an
// open source framework, but it's backward compatible, so we're
// keeping it this way for now.
//
// In API-handling code, you should only use the Schema class via the
// DatabaseController. This will let us replace the schema logic for
// different databases.
// TODO: hide all schema logic inside the database adapter.
// @flow-disable-next
const Parse = require('parse/node').Parse;
import { StorageAdapter } from '../Adapters/Storage/StorageAdapter';
import SchemaCache from '../Adapters/Cache/SchemaCache';
import DatabaseController from './DatabaseController';
import Config from '../Config';
import { createSanitizedError } from '../Error';
// @flow-disable-next
import deepcopy from 'deepcopy';
import type {
  Schema,
  SchemaFields,
  ClassLevelPermissions,
  SchemaField,
  LoadSchemaOptions,
} from './types';

const defaultColumns: { [string]: SchemaFields } = Object.freeze({
  // Contain the default columns for every parse object type (except _Join collection)
  _Default: {
    objectId: { type: 'String' },
    createdAt: { type: 'Date' },
    updatedAt: { type: 'Date' },
    ACL: { type: 'ACL' },
  },
  // The additional default columns for the _User collection (in addition to DefaultCols)
  _User: {
    username: { type: 'String' },
    password: { type: 'String' },
    email: { type: 'String' },
    emailVerified: { type: 'Boolean' },
    authData: { type: 'Object' },
  },
  // The additional default columns for the _Installation collection (in addition to DefaultCols)
  _Installation: {
    installationId: { type: 'String' },
    deviceToken: { type: 'String' },
    channels: { type: 'Array' },
    deviceType: { type: 'String' },
    pushType: { type: 'String' },
    GCMSenderId: { type: 'String' },
    timeZone: { type: 'String' },
    localeIdentifier: { type: 'String' },
    badge: { type: 'Number' },
    appVersion: { type: 'String' },
    appName: { type: 'String' },
    appIdentifier: { type: 'String' },
    parseVersion: { type: 'String' },
  },
  // The additional default columns for the _Role collection (in addition to DefaultCols)
  _Role: {
    name: { type: 'String' },
    users: { type: 'Relation', targetClass: '_User' },
    roles: { type: 'Relation', targetClass: '_Role' },
  },
  // The additional default columns for the _Session collection (in addition to DefaultCols)
  _Session: {
    user: { type: 'Pointer', targetClass: '_User' },
    installationId: { type: 'String' },
    sessionToken: { type: 'String' },
    expiresAt: { type: 'Date' },
    createdWith: { type: 'Object' },
  },
  _Product: {
    productIdentifier: { type: 'String' },
    download: { type: 'File' },
    downloadName: { type: 'String' },
    icon: { type: 'File' },
    order: { type: 'Number' },
    title: { type: 'String' },
    subtitle: { type: 'String' },
  },
  _PushStatus: {
    pushTime: { type: 'String' },
    source: { type: 'String' }, // rest or webui
    query: { type: 'String' }, // the stringified JSON query
    payload: { type: 'String' }, // the stringified JSON payload,
    title: { type: 'String' },
    expiry: { type: 'Number' },
    expiration_interval: { type: 'Number' },
    status: { type: 'String' },
    numSent: { type: 'Number' },
    numFailed: { type: 'Number' },
    pushHash: { type: 'String' },
    errorMessage: { type: 'Object' },
    sentPerType: { type: 'Object' },
    failedPerType: { type: 'Object' },
    sentPerUTCOffset: { type: 'Object' },
    failedPerUTCOffset: { type: 'Object' },
    count: { type: 'Number' }, // tracks # of batches queued and pending
  },
  _JobStatus: {
    jobName: { type: 'String' },
    source: { type: 'String' },
    status: { type: 'String' },
    message: { type: 'String' },
    params: { type: 'Object' }, // params received when calling the job
    finishedAt: { type: 'Date' },
  },
  _JobSchedule: {
    jobName: { type: 'String' },
    description: { type: 'String' },
    params: { type: 'String' },
    startAfter: { type: 'String' },
    daysOfWeek: { type: 'Array' },
    timeOfDay: { type: 'String' },
    lastRun: { type: 'Number' },
    repeatMinutes: { type: 'Number' },
  },
  _Hooks: {
    functionName: { type: 'String' },
    className: { type: 'String' },
    triggerName: { type: 'String' },
    url: { type: 'String' },
  },
  _GlobalConfig: {
    objectId: { type: 'String' },
    params: { type: 'Object' },
    masterKeyOnly: { type: 'Object' },
  },
  _GraphQLConfig: {
    objectId: { type: 'String' },
    config: { type: 'Object' },
  },
  _Audience: {
    objectId: { type: 'String' },
    name: { type: 'String' },
    query: { type: 'String' }, //storing query as JSON string to prevent "Nested keys should not contain the '$' or '.' characters" error
    lastUsed: { type: 'Date' },
    timesUsed: { type: 'Number' },
  },
  _Idempotency: {
    reqId: { type: 'String' },
    expire: { type: 'Date' },
  },
});

// fields required for read or write operations on their respective classes.
const requiredColumns = Object.freeze({
  read: {
    _User: ['username'],
  },
  write: {
    _Product: ['productIdentifier', 'icon', 'order', 'title', 'subtitle'],
    _Role: ['name', 'ACL'],
  },
});

const invalidColumns = ['length'];

const systemClasses = Object.freeze([
  '_User',
  '_Installation',
  '_Role',
  '_Session',
  '_Product',
  '_PushStatus',
  '_JobStatus',
  '_JobSchedule',
  '_Audience',
  '_Idempotency',
]);

const volatileClasses = Object.freeze([
  '_JobStatus',
  '_PushStatus',
  '_Hooks',
  '_GlobalConfig',
  '_GraphQLConfig',
  '_JobSchedule',
  '_Audience',
  '_Idempotency',
]);

// Anything that start with role
const roleRegex = /^role:.*/;
// Anything that starts with userField (allowed for protected fields only)
const protectedFieldsPointerRegex = /^userField:.*/;
// * permission
const publicRegex = /^\*$/;

const authenticatedRegex = /^authenticated$/;

const requiresAuthenticationRegex = /^requiresAuthentication$/;

const clpPointerRegex = /^pointerFields$/;

// regex for validating entities in protectedFields object
const protectedFieldsRegex = Object.freeze([
  protectedFieldsPointerRegex,
  publicRegex,
  authenticatedRegex,
  roleRegex,
]);

// clp regex
const clpFieldsRegex = Object.freeze([
  clpPointerRegex,
  publicRegex,
  requiresAuthenticationRegex,
  roleRegex,
]);

function validatePermissionKey(key, userIdRegExp) {
  let matchesSome = false;
  for (const regEx of clpFieldsRegex) {
    if (key.match(regEx) !== null) {
      matchesSome = true;
      break;
    }
  }

  // userId depends on startup options so it's dynamic
  const valid = matchesSome || key.match(userIdRegExp) !== null;
  if (!valid) {
    throw new Parse.Error(
      Parse.Error.INVALID_JSON,
      `'${key}' is not a valid key for class level permissions`
    );
  }
}

function validateProtectedFieldsKey(key, userIdRegExp) {
  let matchesSome = false;
  for (const regEx of protectedFieldsRegex) {
    if (key.match(regEx) !== null) {
      matchesSome = true;
      break;
    }
  }

  // userId regex depends on launch options so it's dynamic
  const valid = matchesSome || key.match(userIdRegExp) !== null;
  if (!valid) {
    throw new Parse.Error(
      Parse.Error.INVALID_JSON,
      `'${key}' is not a valid key for class level permissions`
    );
  }
}

const CLPValidKeys = Object.freeze([
  'ACL',
  'find',
  'count',
  'get',
  'create',
  'update',
  'delete',
  'addField',
  'readUserFields',
  'writeUserFields',
  'protectedFields',
]);

// validation before setting class-level permissions on collection
function validateCLP(perms: ClassLevelPermissions, fields: SchemaFields, userIdRegExp: RegExp) {
  if (!perms) {
    return;
  }
  for (const operationKey in perms) {
    if (CLPValidKeys.indexOf(operationKey) == -1) {
      throw new Parse.Error(
        Parse.Error.INVALID_JSON,
        `${operationKey} is not a valid operation for class level permissions`
      );
    }

    const operation = perms[operationKey];
    // proceed with next operationKey

    // throws when root fields are of wrong type
    validateCLPjson(operation, operationKey);

    if (operationKey === 'readUserFields' || operationKey === 'writeUserFields') {
      // validate grouped pointer permissions
      // must be an array with field names
      for (const fieldName of operation) {
        validatePointerPermission(fieldName, fields, operationKey);
      }
      // readUserFields and writerUserFields do not have nesdted fields
      // proceed with next operationKey
      continue;
    }

    // validate protected fields
    if (operationKey === 'protectedFields') {
      for (const entity in operation) {
        // throws on unexpected key
        validateProtectedFieldsKey(entity, userIdRegExp);

        const protectedFields = operation[entity];

        if (!Array.isArray(protectedFields)) {
          throw new Parse.Error(
            Parse.Error.INVALID_JSON,
            `'${protectedFields}' is not a valid value for protectedFields[${entity}] - expected an array.`
          );
        }

        // if the field is in form of array
        for (const field of protectedFields) {
          // do not alloow to protect default fields
          if (defaultColumns._Default[field]) {
            throw new Parse.Error(
              Parse.Error.INVALID_JSON,
              `Default field '${field}' can not be protected`
            );
          }
          // field should exist on collection
          if (!Object.prototype.hasOwnProperty.call(fields, field)) {
            throw new Parse.Error(
              Parse.Error.INVALID_JSON,
              `Field '${field}' in protectedFields:${entity} does not exist`
            );
          }
        }
      }
      // proceed with next operationKey
      continue;
    }

    // validate other fields
    // Entity can be:
    // "*" - Public,
    // "requiresAuthentication" - authenticated users,
    // "objectId" - _User id,
    // "role:rolename",
    // "pointerFields" - array of field names containing pointers to users
    for (const entity in operation) {
      // throws on unexpected key
      validatePermissionKey(entity, userIdRegExp);

      // entity can be either:
      // "pointerFields": string[]
      if (entity === 'pointerFields') {
        const pointerFields = operation[entity];

        if (Array.isArray(pointerFields)) {
          for (const pointerField of pointerFields) {
            validatePointerPermission(pointerField, fields, operation);
          }
        } else {
          throw new Parse.Error(
            Parse.Error.INVALID_JSON,
            `'${pointerFields}' is not a valid value for ${operationKey}[${entity}] - expected an array.`
          );
        }
        // proceed with next entity key
        continue;
      }

      const permit = operation[entity];

      if (operationKey === 'ACL') {
        if (Object.prototype.toString.call(permit) !== '[object Object]') {
          throw new Parse.Error(
            Parse.Error.INVALID_JSON,
            `'${permit}' is not a valid value for class level permissions acl`
          );
        }
        const invalidKeys = Object.keys(permit).filter(key => !['read', 'write'].includes(key));
        const invalidValues = Object.values(permit).filter(key => typeof key !== 'boolean');
        if (invalidKeys.length) {
          throw new Parse.Error(
            Parse.Error.INVALID_JSON,
            `'${invalidKeys.join(',')}' is not a valid key for class level permissions acl`
          );
        }

        if (invalidValues.length) {
          throw new Parse.Error(
            Parse.Error.INVALID_JSON,
            `'${invalidValues.join(',')}' is not a valid value for class level permissions acl`
          );
        }
      } else if (permit !== true) {
        throw new Parse.Error(
          Parse.Error.INVALID_JSON,
          `'${permit}' is not a valid value for class level permissions acl ${operationKey}:${entity}`
        );
      }
    }
  }
}

function validateCLPjson(operation: any, operationKey: string) {
  if (operationKey === 'readUserFields' || operationKey === 'writeUserFields') {
    if (!Array.isArray(operation)) {
      throw new Parse.Error(
        Parse.Error.INVALID_JSON,
        `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an array`
      );
    }
  } else {
    if (typeof operation === 'object' && operation !== null) {
      // ok to proceed
      return;
    } else {
      throw new Parse.Error(
        Parse.Error.INVALID_JSON,
        `'${operation}' is not a valid value for class level permissions ${operationKey} - must be an object`
      );
    }
  }
}

function validatePointerPermission(fieldName: string, fields: Object, operation: string) {
  // Uses collection schema to ensure the field is of type:
  // - Pointer<_User> (pointers)
  // - Array
  //
  //    It's not possible to enforce type on Array's items in schema
  //  so we accept any Array field, and later when applying permissions
  //  only items that are pointers to _User are considered.
  if (
    !(
      fields[fieldName] &&
      ((fields[fieldName].type == 'Pointer' && fields[fieldName].targetClass == '_User') ||
        fields[fieldName].type == 'Array')
    )
  ) {
    throw new Parse.Error(
      Parse.Error.INVALID_JSON,
      `'${fieldName}' is not a valid column for class level pointer permissions ${operation}`
    );
  }
}

const joinClassRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/;
const classAndFieldRegex = /^[A-Za-z][A-Za-z0-9_]*$/;
function classNameIsValid(className: string): boolean {
  // Valid classes must:
  return (
    // Be one of _User, _Installation, _Role, _Session OR
    systemClasses.indexOf(className) > -1 ||
    // Be a join table OR
    joinClassRegex.test(className) ||
    // Include only alpha-numeric and underscores, and not start with an underscore or number
    fieldNameIsValid(className, className)
  );
}

// Valid fields must be alpha-numeric, and not start with an underscore or number
// must not be a reserved key
function fieldNameIsValid(fieldName: string, className: string): boolean {
  if (className && className !== '_Hooks') {
    if (fieldName === 'className') {
      return false;
    }
  }
  return classAndFieldRegex.test(fieldName) && !invalidColumns.includes(fieldName);
}

// Checks that it's not trying to clobber one of the default fields of the class.
function fieldNameIsValidForClass(fieldName: string, className: string): boolean {
  if (!fieldNameIsValid(fieldName, className)) {
    return false;
  }
  if (defaultColumns._Default[fieldName]) {
    return false;
  }
  if (defaultColumns[className] && defaultColumns[className][fieldName]) {
    return false;
  }
  return true;
}

function invalidClassNameMessage(className: string): string {
  return (
    'Invalid classname: ' +
    className +
    ', classnames can only have alphanumeric characters and _, and must start with an alpha character '
  );
}

const invalidJsonError = new Parse.Error(Parse.Error.INVALID_JSON, 'invalid JSON');
const validNonRelationOrPointerTypes = [
  'Number',
  'String',
  'Boolean',
  'Date',
  'Object',
  'Array',
  'GeoPoint',
  'File',
  'Bytes',
  'Polygon',
];
// Returns an error suitable for throwing if the type is invalid
const fieldTypeIsInvalid = ({ type, targetClass }) => {
  if (['Pointer', 'Relation'].indexOf(type) >= 0) {
    if (!targetClass) {
      return new Parse.Error(135, `type ${type} needs a class name`);
    } else if (typeof targetClass !== 'string') {
      return invalidJsonError;
    } else if (!classNameIsValid(targetClass)) {
      return new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(targetClass));
    } else {
      return undefined;
    }
  }
  if (typeof type !== 'string') {
    return invalidJsonError;
  }
  if (validNonRelationOrPointerTypes.indexOf(type) < 0) {
    return new Parse.Error(Parse.Error.INCORRECT_TYPE, `invalid field type: ${type}`);
  }
  return undefined;
};

const convertSchemaToAdapterSchema = (schema: any) => {
  schema = injectDefaultSchema(schema);
  delete schema.fields.ACL;
  schema.fields._rperm = { type: 'Array' };
  schema.fields._wperm = { type: 'Array' };

  if (schema.className === '_User') {
    delete schema.fields.password;
    schema.fields._hashed_password = { type: 'String' };
  }

  return schema;
};

const convertAdapterSchemaToParseSchema = ({ ...schema }) => {
  delete schema.fields._rperm;
  delete schema.fields._wperm;

  schema.fields.ACL = { type: 'ACL' };

  if (schema.className === '_User') {
    delete schema.fields.authData; //Auth data is implicit
    delete schema.fields._hashed_password;
    schema.fields.password = { type: 'String' };
  }

  if (schema.indexes && Object.keys(schema.indexes).length === 0) {
    delete schema.indexes;
  }

  return schema;
};

class SchemaData {
  __data: any;
  __protectedFields: any;
  constructor(allSchemas = [], protectedFields = {}) {
    this.__data = {};
    this.__protectedFields = protectedFields;
    allSchemas.forEach(schema => {
      if (volatileClasses.includes(schema.className)) {
        return;
      }
      Object.defineProperty(this, schema.className, {
        get: () => {
          if (!this.__data[schema.className]) {
            const data = {};
            data.fields = injectDefaultSchema(schema).fields;
            data.classLevelPermissions = deepcopy(schema.classLevelPermissions);
            data.indexes = schema.indexes;

            const classProtectedFields = this.__protectedFields[schema.className];
            if (classProtectedFields) {
              for (const key in classProtectedFields) {
                const unq = new Set([
                  ...(data.classLevelPermissions.protectedFields[key] || []),
                  ...classProtectedFields[key],
                ]);
                data.classLevelPermissions.protectedFields[key] = Array.from(unq);
              }
            }

            this.__data[schema.className] = data;
          }
          return this.__data[schema.className];
        },
      });
    });

    // Inject the in-memory classes
    volatileClasses.forEach(className => {
      Object.defineProperty(this, className, {
        get: () => {
          if (!this.__data[className]) {
            const schema = injectDefaultSchema({
              className,
              fields: {},
              classLevelPermissions: {},
            });
            const data = {};
            data.fields = schema.fields;
            data.classLevelPermissions = schema.classLevelPermissions;
            data.indexes = schema.indexes;
            this.__data[className] = data;
          }
          return this.__data[className];
        },
      });
    });
  }
}

const injectDefaultSchema = ({ className, fields, classLevelPermissions, indexes }: Schema) => {
  const defaultSchema: Schema = {
    className,
    fields: {
      ...defaultColumns._Default,
      ...(defaultColumns[className] || {}),
      ...fields,
    },
    classLevelPermissions,
  };
  if (indexes && Object.keys(indexes).length !== 0) {
    defaultSchema.indexes = indexes;
  }
  return defaultSchema;
};

const _HooksSchema = { className: '_Hooks', fields: defaultColumns._Hooks };
const _GlobalConfigSchema = {
  className: '_GlobalConfig',
  fields: defaultColumns._GlobalConfig,
};
const _GraphQLConfigSchema = {
  className: '_GraphQLConfig',
  fields: defaultColumns._GraphQLConfig,
};
const _PushStatusSchema = convertSchemaToAdapterSchema(
  injectDefaultSchema({
    className: '_PushStatus',
    fields: {},
    classLevelPermissions: {},
  })
);
const _JobStatusSchema = convertSchemaToAdapterSchema(
  injectDefaultSchema({
    className: '_JobStatus',
    fields: {},
    classLevelPermissions: {},
  })
);
const _JobScheduleSchema = convertSchemaToAdapterSchema(
  injectDefaultSchema({
    className: '_JobSchedule',
    fields: {},
    classLevelPermissions: {},
  })
);
const _AudienceSchema = convertSchemaToAdapterSchema(
  injectDefaultSchema({
    className: '_Audience',
    fields: defaultColumns._Audience,
    classLevelPermissions: {},
  })
);
const _IdempotencySchema = convertSchemaToAdapterSchema(
  injectDefaultSchema({
    className: '_Idempotency',
    fields: defaultColumns._Idempotency,
    classLevelPermissions: {},
  })
);
const VolatileClassesSchemas = [
  _HooksSchema,
  _JobStatusSchema,
  _JobScheduleSchema,
  _PushStatusSchema,
  _GlobalConfigSchema,
  _GraphQLConfigSchema,
  _AudienceSchema,
  _IdempotencySchema,
];

const dbTypeMatchesObjectType = (dbType: SchemaField | string, objectType: SchemaField) => {
  if (dbType.type !== objectType.type) { return false; }
  if (dbType.targetClass !== objectType.targetClass) { return false; }
  if (dbType === objectType.type) { return true; }
  if (dbType.type === objectType.type) { return true; }
  return false;
};

const typeToString = (type: SchemaField | string): string => {
  if (typeof type === 'string') {
    return type;
  }
  if (type.targetClass) {
    return `${type.type}<${type.targetClass}>`;
  }
  return `${type.type}`;
};
const ttl = {
  date: Date.now(),
  duration: undefined,
};

// Stores the entire schema of the app in a weird hybrid format somewhere between
// the mongo format and the Parse format. Soon, this will all be Parse format.
export default class SchemaController {
  _dbAdapter: StorageAdapter;
  schemaData: { [string]: Schema };
  reloadDataPromise: ?Promise<any>;
  protectedFields: any;
  userIdRegEx: RegExp;

  constructor(databaseAdapter: StorageAdapter) {
    this._dbAdapter = databaseAdapter;
    const config = Config.get(Parse.applicationId);
    this.schemaData = new SchemaData(SchemaCache.all(), this.protectedFields);
    this.protectedFields = config.protectedFields;

    const customIds = config.allowCustomObjectId;

    const customIdRegEx = /^.{1,}$/u; // 1+ chars
    const autoIdRegEx = /^[a-zA-Z0-9]{1,}$/;

    this.userIdRegEx = customIds ? customIdRegEx : autoIdRegEx;

    this._dbAdapter.watch(() => {
      this.reloadData({ clearCache: true });
    });
  }

  async reloadDataIfNeeded() {
    if (this._dbAdapter.enableSchemaHooks) {
      return;
    }
    const { date, duration } = ttl || {};
    if (!duration) {
      return;
    }
    const now = Date.now();
    if (now - date > duration) {
      ttl.date = now;
      await this.reloadData({ clearCache: true });
    }
  }

  reloadData(options: LoadSchemaOptions = { clearCache: false }): Promise<any> {
    if (this.reloadDataPromise && !options.clearCache) {
      return this.reloadDataPromise;
    }
    this.reloadDataPromise = this.getAllClasses(options)
      .then(
        allSchemas => {
          this.schemaData = new SchemaData(allSchemas, this.protectedFields);
          delete this.reloadDataPromise;
        },
        err => {
          this.schemaData = new SchemaData();
          delete this.reloadDataPromise;
          throw err;
        }
      )
      .then(() => {});
    return this.reloadDataPromise;
  }

  async getAllClasses(options: LoadSchemaOptions = { clearCache: false }): Promise<Array<Schema>> {
    if (options.clearCache) {
      return this.setAllClasses();
    }
    await this.reloadDataIfNeeded();
    const cached = SchemaCache.all();
    if (cached && cached.length) {
      return Promise.resolve(cached);
    }
    return this.setAllClasses();
  }

  setAllClasses(): Promise<Array<Schema>> {
    return this._dbAdapter
      .getAllClasses()
      .then(allSchemas => allSchemas.map(injectDefaultSchema))
      .then(allSchemas => {
        SchemaCache.put(allSchemas);
        return allSchemas;
      });
  }

  getOneSchema(
    className: string,
    allowVolatileClasses: boolean = false,
    options: LoadSchemaOptions = { clearCache: false }
  ): Promise<Schema> {
    if (options.clearCache) {
      SchemaCache.clear();
    }
    if (allowVolatileClasses && volatileClasses.indexOf(className) > -1) {
      const data = this.schemaData[className];
      return Promise.resolve({
        className,
        fields: data.fields,
        classLevelPermissions: data.classLevelPermissions,
        indexes: data.indexes,
      });
    }
    const cached = SchemaCache.get(className);
    if (cached && !options.clearCache) {
      return Promise.resolve(cached);
    }
    return this.setAllClasses().then(allSchemas => {
      const oneSchema = allSchemas.find(schema => schema.className === className);
      if (!oneSchema) {
        return Promise.reject(undefined);
      }
      return oneSchema;
    });
  }

  // Create a new class that includes the three default fields.
  // ACL is an implicit column that does not get an entry in the
  // _SCHEMAS database. Returns a promise that resolves with the
  // created schema, in mongo format.
  // on success, and rejects with an error on fail. Ensure you
  // have authorization (master key, or client class creation
  // enabled) before calling this function.
  async addClassIfNotExists(
    className: string,
    fields: SchemaFields = {},
    classLevelPermissions: any,
    indexes: any = {}
  ): Promise<void | Schema> {
    var validationError = this.validateNewClass(className, fields, classLevelPermissions);
    if (validationError) {
      if (validationError instanceof Parse.Error) {
        return Promise.reject(validationError);
      } else if (validationError.code && validationError.error) {
        return Promise.reject(new Parse.Error(validationError.code, validationError.error));
      }
      return Promise.reject(validationError);
    }
    try {
      const adapterSchema = await this._dbAdapter.createClass(
        className,
        convertSchemaToAdapterSchema({
          fields,
          classLevelPermissions,
          indexes,
          className,
        })
      );
      // TODO: Remove by updating schema cache directly
      await this.reloadData({ clearCache: true });
      const parseSchema = convertAdapterSchemaToParseSchema(adapterSchema);
      return parseSchema;
    } catch (error) {
      if (error && error.code === Parse.Error.DUPLICATE_VALUE) {
        throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`);
      } else {
        throw error;
      }
    }
  }

  updateClass(
    className: string,
    submittedFields: SchemaFields,
    classLevelPermissions: any,
    indexes: any,
    database: DatabaseController
  ) {
    return this.getOneSchema(className)
      .then(schema => {
        const existingFields = schema.fields;
        Object.keys(submittedFields).forEach(name => {
          const field = submittedFields[name];
          if (
            existingFields[name] &&
            existingFields[name].type !== field.type &&
            field.__op !== 'Delete'
          ) {
            throw new Parse.Error(255, `Field ${name} exists, cannot update.`);
          }
          if (!existingFields[name] && field.__op === 'Delete') {
            throw new Parse.Error(255, `Field ${name} does not exist, cannot delete.`);
          }
        });

        delete existingFields._rperm;
        delete existingFields._wperm;
        const newSchema = buildMergedSchemaObject(existingFields, submittedFields);
        const defaultFields = defaultColumns[className] || defaultColumns._Default;
        const fullNewSchema = Object.assign({}, newSchema, defaultFields);
        const validationError = this.validateSchemaData(
          className,
          newSchema,
          classLevelPermissions,
          Object.keys(existingFields)
        );
        if (validationError) {
          throw new Parse.Error(validationError.code, validationError.error);
        }

        // Finally we have checked to make sure the request is valid and we can start deleting fields.
        // Do all deletions first, then a single save to _SCHEMA collection to handle all additions.
        const deletedFields: string[] = [];
        const insertedFields = [];
        Object.keys(submittedFields).forEach(fieldName => {
          if (submittedFields[fieldName].__op === 'Delete') {
            deletedFields.push(fieldName);
          } else {
            insertedFields.push(fieldName);
          }
        });

        let deletePromise = Promise.resolve();
        if (deletedFields.length > 0) {
          deletePromise = this.deleteFields(deletedFields, className, database);
        }
        let enforceFields = [];
        return (
          deletePromise // Delete Everything
            .then(() => this.reloadData({ clearCache: true })) // Reload our Schema, so we have all the new values
            .then(() => {
              const promises = insertedFields.map(fieldName => {
                const type = submittedFields[fieldName];
                return this.enforceFieldExists(className, fieldName, type);
              });
              return Promise.all(promises);
            })
            .then(results => {
              enforceFields = results.filter(result => !!result);
              return this.setPermissions(className, classLevelPermissions, newSchema);
            })
            .then(() =>
              this._dbAdapter.setIndexesWithSchemaFormat(
                className,
                indexes,
                schema.indexes,
                fullNewSchema
              )
            )
            .then(() => this.reloadData({ clearCache: true }))
            //TODO: Move this logic into the database adapter
            .then(() => {
              this.ensureFields(enforceFields);
              const schema = this.schemaData[className];
              const reloadedSchema: Schema = {
                className: className,
                fields: schema.fields,
                classLevelPermissions: schema.classLevelPermissions,
              };
              if (schema.indexes && Object.keys(schema.indexes).length !== 0) {
                reloadedSchema.indexes = schema.indexes;
              }
              return reloadedSchema;
            })
        );
      })
      .catch(error => {
        if (error === undefined) {
          throw new Parse.Error(
            Parse.Error.INVALID_CLASS_NAME,
            `Class ${className} does not exist.`
          );
        } else {
          throw error;
        }
      });
  }

  // Returns a promise that resolves successfully to the new schema
  // object or fails with a reason.
  enforceClassExists(className: string): Promise<SchemaController> {
    if (this.schemaData[className]) {
      return Promise.resolve(this);
    }
    // We don't have this class. Update the schema
    return (
      // The schema update succeeded. Reload the schema
      this.addClassIfNotExists(className)
        .catch(() => {
          // The schema update failed. This can be okay - it might
          // have failed because there's a race condition and a different
          // client is making the exact same schema update that we want.
          // So just reload the schema.
          return this.reloadData({ clearCache: true });
        })
        .then(() => {
          // Ensure that the schema now validates
          if (this.schemaData[className]) {
            return this;
          } else {
            throw new Parse.Error(Parse.Error.INVALID_JSON, `Failed to add ${className}`);
          }
        })
        .catch(() => {
          // The schema still doesn't validate. Give up
          throw new Parse.Error(Parse.Error.INVALID_JSON, 'schema class name does not revalidate');
        })
    );
  }

  validateNewClass(className: string, fields: SchemaFields = {}, classLevelPermissions: any): any {
    if (this.schemaData[className]) {
      throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, `Class ${className} already exists.`);
    }
    if (!classNameIsValid(className)) {
      return {
        code: Parse.Error.INVALID_CLASS_NAME,
        error: invalidClassNameMessage(className),
      };
    }
    return this.validateSchemaData(className, fields, classLevelPermissions, []);
  }

  validateSchemaData(
    className: string,
    fields: SchemaFields,
    classLevelPermissions: ClassLevelPermissions,
    existingFieldNames: Array<string>
  ) {
    for (const fieldName in fields) {
      if (existingFieldNames.indexOf(fieldName) < 0) {
        if (!fieldNameIsValid(fieldName, className)) {
          return {
            code: Parse.Error.INVALID_KEY_NAME,
            error: 'invalid field name: ' + fieldName,
          };
        }
        if (!fieldNameIsValidForClass(fieldName, className)) {
          return {
            code: 136,
            error: 'field ' + fieldName + ' cannot be added',
          };
        }
        const fieldType = fields[fieldName];
        const error = fieldTypeIsInvalid(fieldType);
        if (error) { return { code: error.code, error: error.message }; }
        if (fieldType.defaultValue !== undefined) {
          let defaultValueType = getType(fieldType.defaultValue);
          if (typeof defaultValueType === 'string') {
            defaultValueType = { type: defaultValueType };
          } else if (typeof defaultValueType === 'object' && fieldType.type === 'Relation') {
            return {
              code: Parse.Error.INCORRECT_TYPE,
              error: `The 'default value' option is not applicable for ${typeToString(fieldType)}`,
            };
          }
          if (!dbTypeMatchesObjectType(fieldType, defaultValueType)) {
            return {
              code: Parse.Error.INCORRECT_TYPE,
              error: `schema mismatch for ${className}.${fieldName} default value; expected ${typeToString(
                fieldType
              )} but got ${typeToString(defaultValueType)}`,
            };
          }
        } else if (fieldType.required) {
          if (typeof fieldType === 'object' && fieldType.type === 'Relation') {
            return {
              code: Parse.Error.INCORRECT_TYPE,
              error: `The 'required' option is not applicable for ${typeToString(fieldType)}`,
            };
          }
        }
      }
    }

    for (const fieldName in defaultColumns[className]) {
      fields[fieldName] = defaultColumns[className][fieldName];
    }

    const geoPoints = Object.keys(fields).filter(
      key => fields[key] && fields[key].type === 'GeoPoint'
    );
    if (geoPoints.length > 1) {
      return {
        code: Parse.Error.INCORRECT_TYPE,
        error:
          'currently, only one GeoPoint field may exist in an object. Adding ' +
          geoPoints[1] +
          ' when ' +
          geoPoints[0] +
          ' already exists.',
      };
    }
    validateCLP(classLevelPermissions, fields, this.userIdRegEx);
  }

  // Sets the Class-level permissions for a given className, which must exist.
  async setPermissions(className: string, perms: any, newSchema: SchemaFields) {
    if (typeof perms === 'undefined') {
      return Promise.resolve();
    }
    validateCLP(perms, newSchema, this.userIdRegEx);
    await this._dbAdapter.setClassLevelPermissions(className, perms);
    const cached = SchemaCache.get(className);
    if (cached) {
      cached.classLevelPermissions = perms;
    }
  }

  // Returns a promise that resolves successfully to the new schema
  // object if the provided className-fieldName-type tuple is valid.
  // The className must already be validated.
  // If 'freeze' is true, refuse to update the schema for this field.
  enforceFieldExists(
    className: string,
    fieldName: string,
    type: string | SchemaField,
    isValidation?: boolean,
    maintenance?: boolean
  ) {
    if (fieldName.indexOf('.') > 0) {
      // "<array>.<index>" for Nested Arrays
      // "<embedded document>.<field>" for Nested Objects
      // JSON Arrays are treated as Nested Objects
      const [x, y] = fieldName.split('.');
      fieldName = x;
      const isArrayIndex = Array.from(y).every(c => c >= '0' && c <= '9');
      if (isArrayIndex && !['sentPerUTCOffset', 'failedPerUTCOffset'].includes(fieldName)) {
        type = 'Array';
      } else {
        type = 'Object';
      }
    }
    let fieldNameToValidate = `${fieldName}`;
    if (maintenance && fieldNameToValidate.charAt(0) === '_') {
      fieldNameToValidate = fieldNameToValidate.substring(1);
    }
    if (!fieldNameIsValid(fieldNameToValidate, className)) {
      throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `Invalid field name: ${fieldName}.`);
    }

    // If someone tries to create a new field with null/undefined as the value, return;
    if (!type) {
      return undefined;
    }

    const expectedType = this.getExpectedType(className, fieldName);
    if (typeof type === 'string') {
      type = ({ type }: SchemaField);
    }

    if (type.defaultValue !== undefined) {
      let defaultValueType = getType(type.defaultValue);
      if (typeof defaultValueType === 'string') {
        defaultValueType = { type: defaultValueType };
      }
      if (!dbTypeMatchesObjectType(type, defaultValueType)) {
        throw new Parse.Error(
          Parse.Error.INCORRECT_TYPE,
          `schema mismatch for ${className}.${fieldName} default value; expected ${typeToString(
            type
          )} but got ${typeToString(defaultValueType)}`
        );
      }
    }

    if (expectedType) {
      if (!dbTypeMatchesObjectType(expectedType, type)) {
        throw new Parse.Error(
          Parse.Error.INCORRECT_TYPE,
          `schema mismatch for ${className}.${fieldName}; expected ${typeToString(
            expectedType
          )} but got ${typeToString(type)}`
        );
      }
      // If type options do not change
      // we can safely return
      if (isValidation || JSON.stringify(expectedType) === JSON.stringify(type)) {
        return undefined;
      }
      // Field options are may be changed
      // ensure to have an update to date schema field
      return this._dbAdapter.updateFieldOptions(className, fieldName, type);
    }

    return this._dbAdapter
      .addFieldIfNotExists(className, fieldName, type)
      .catch(error => {
        if (error.code == Parse.Error.INCORRECT_TYPE) {
          // Make sure that we throw errors when it is appropriate to do so.
          throw error;
        }
        // The update failed. This can be okay - it might have been a race
        // condition where another client updated the schema in the same
        // way that we wanted to. So, just reload the schema
        return Promise.resolve();
      })
      .then(() => {
        return {
          className,
          fieldName,
          type,
        };
      });
  }

  ensureFields(fields: any) {
    for (let i = 0; i < fields.length; i += 1) {
      const { className, fieldName } = fields[i];
      let { type } = fields[i];
      const expectedType = this.getExpectedType(className, fieldName);
      if (typeof type === 'string') {
        type = { type: type };
      }
      if (!expectedType || !dbTypeMatchesObjectType(expectedType, type)) {
        throw new Parse.Error(Parse.Error.INVALID_JSON, `Could not add field ${fieldName}`);
      }
    }
  }

  // maintain compatibility
  deleteField(fieldName: string, className: string, database: DatabaseController) {
    return this.deleteFields([fieldName], className, database);
  }

  // Delete fields, and remove that data from all objects. This is intended
  // to remove unused fields, if other writers are writing objects that include
  // this field, the field may reappear. Returns a Promise that resolves with
  // no object on success, or rejects with { code, error } on failure.
  // Passing the database and prefix is necessary in order to drop relation collections
  // and remove fields from objects. Ideally the database would belong to
  // a database adapter and this function would close over it or access it via member.
  deleteFields(fieldNames: Array<string>, className: string, database: DatabaseController) {
    if (!classNameIsValid(className)) {
      throw new Parse.Error(Parse.Error.INVALID_CLASS_NAME, invalidClassNameMessage(className));
    }

    fieldNames.forEach(fieldName => {
      if (!fieldNameIsValid(fieldName, className)) {
        throw new Parse.Error(Parse.Error.INVALID_KEY_NAME, `invalid field name: ${fieldName}`);
      }
      //Don't allow deleting the default fields.
      if (!fieldNameIsValidForClass(fieldName, className)) {
        throw new Parse.Error(136, `field ${fieldName} cannot be changed`);
      }
    });

    return this.getOneSchema(className, false, { clearCache: true })
      .catch(error => {
        if (error === undefined) {
          throw new Parse.Error(
            Parse.Error.INVALID_CLASS_NAME,
            `Class ${className} does not exist.`
          );
        } else {
          throw error;
        }
      })
      .then(schema => {
        fieldNames.forEach(fieldName => {
          if (!schema.fields[fieldName]) {
            throw new Parse.Error(255, `Field ${fieldName} does not exist, cannot delete.`);
          }
        });

        const schemaFields = { ...schema.fields };
        return database.adapter.deleteFields(className, schema, fieldNames).then(() => {
          return Promise.all(
            fieldNames.map(fieldName => {
              const field = schemaFields[fieldName];
              if (field && field.type === 'Relation') {
                //For relations, drop the _Join table
                return database.adapter.deleteClass(`_Join:${fieldName}:${className}`);
              }
              return Promise.resolve();
            })
          );
        });
      })
      .then(() => {
        SchemaCache.clear();
      });
  }

  // Validates an object provided in REST format.
  // Returns a promise that resolves to the new schema if this object is
  // valid.
  async validateObject(className: string, object: any, query: any, maintenance: boolean) {
    let geocount = 0;
    const schema = await this.enforceClassExists(className);
    const promises = [];

    for (const fieldName in object) {
      if (object[fieldName] && getType(object[fieldName]) === 'GeoPoint') {
        geocount++;
      }
      if (geocount > 1) {
        return Promise.reject(
          new Parse.Error(
            Parse.Error.INCORRECT_TYPE,
            'there can only be one geopoint field in a class'
          )
        );
      }
    }
    for (const fieldName in object) {
      if (object[fieldName] === undefined) {
        continue;
      }
      const expected = getType(object[fieldName]);
      if (!expected) {
        continue;
      }
      if (fieldName === 'ACL') {
        // Every object has ACL implicitly.
        continue;
      }
      promises.push(schema.enforceFieldExists(className, fieldName, expected, true, maintenance));
    }
    const results = await Promise.all(promises);
    const enforceFields = results.filter(result => !!result);

    if (enforceFields.length !== 0) {
      // TODO: Remove by updating schema cache directly
      await this.reloadData({ clearCache: true });
    }
    this.ensureFields(enforceFields);

    const promise = Promise.resolve(schema);
    return thenValidateRequiredColumns(promise, className, object, query);
  }

  // Validates that all the properties are set for the object
  validateRequiredColumns(className: string, object: any, query: any) {
    const columns = requiredColumns.write[className];
    if (!columns || columns.length == 0) {
      return Promise.resolve(this);
    }

    const missingColumns = columns.filter(function (column) {
      if (query && query.objectId) {
        if (object[column] && typeof object[column] === 'object') {
          // Trying to delete a required column
          return object[column].__op == 'Delete';
        }
        // Not trying to do anything there
        return false;
      }
      return !object[column];
    });

    if (missingColumns.length > 0) {
      throw new Parse.Error(Parse.Error.INCORRECT_TYPE, missingColumns[0] + ' is required.');
    }
    return Promise.resolve(this);
  }

  testPermissionsForClassName(className: string, aclGroup: string[], operation: string) {
    return SchemaController.testPermissions(
      this.getClassLevelPermissions(className),
      aclGroup,
      operation
    );
  }

  // Tests that the class level permission let pass the operation for a given aclGroup
  static testPermissions(classPermissions: ?any, aclGroup: string[], operation: string): boolean {
    if (!classPermissions || !classPermissions[operation]) {
      return true;
    }
    const perms = classPermissions[operation];
    if (perms['*']) {
      return true;
    }
    // Check permissions against the aclGroup provided (array of userId/roles)
    if (
      aclGroup.some(acl => {
        return perms[acl] === true;
      })
    ) {
      return true;
    }
    return false;
  }

  // Validates an operation passes class-level-permissions set in the schema
  static validatePermission(
    classPermissions: ?any,
    className: string,
    aclGroup: string[],
    operation: string,
    action?: string
  ) {
    if (SchemaController.testPermissions(classPermissions, aclGroup, operation)) {
      return Promise.resolve();
    }

    if (!classPermissions || !classPermissions[operation]) {
      return true;
    }
    const perms = classPermissions[operation];
    const config = Config.get(Parse.applicationId)
    // If only for authenticated users
    // make sure we have an aclGroup
    if (perms['requiresAuthentication']) {
      // If aclGroup has * (public)
      if (!aclGroup || aclGroup.length == 0) {
        throw createSanitizedError(
          Parse.Error.OBJECT_NOT_FOUND,
          'Permission denied, user needs to be authenticated.',
          config
        );
      } else if (aclGroup.indexOf('*') > -1 && aclGroup.length == 1) {
        throw createSanitizedError(
          Parse.Error.OBJECT_NOT_FOUND,
          'Permission denied, user needs to be authenticated.',
          config
        );
      }
      // requiresAuthentication passed, just move forward
      // probably would be wise at some point to rename to 'authenticatedUser'
      return Promise.resolve();
    }

    // No matching CLP, let's check the Pointer permissions
    // And handle those later
    const permissionField =
      ['get', 'find', 'count'].indexOf(operation) > -1 ? 'readUserFields' : 'writeUserFields';

    // Reject create when write lockdown
    if (permissionField == 'writeUserFields' && operation == 'create') {
      throw createSanitizedError(
        Parse.Error.OPERATION_FORBIDDEN,
        `Permission denied for action ${operation} on class ${className}.`,
        config
      );
    }

    // Process the readUserFields later
    if (
      Array.isArray(classPermissions[permissionField]) &&
      classPermissions[permissionField].length > 0
    ) {
      return Promise.resolve();
    }

    const pointerFields = classPermissions[operation].pointerFields;
    if (Array.isArray(pointerFields) && pointerFields.length > 0) {
      // any op except 'addField as part of create' is ok.
      if (operation !== 'addField' || action === 'update') {
        // We can allow adding field on update flow only.
        return Promise.resolve();
      }
    }

    throw createSanitizedError(
      Parse.Error.OPERATION_FORBIDDEN,
      `Permission denied for action ${operation} on class ${className}.`,
      config
    );
  }

  // Validates an operation passes class-level-permissions set in the schema
  validatePermission(className: string, aclGroup: string[], operation: string, action?: string) {
    return SchemaController.validatePermission(
      this.getClassLevelPermissions(className),
      className,
      aclGroup,
      operation,
      action
    );
  }

  getClassLevelPermissions(className: string): any {
    return this.schemaData[className] && this.schemaData[className].classLevelPermissions;
  }

  // Returns the expected type for a className+key combination
  // or undefined if the schema is not set
  getExpectedType(className: string, fieldName: string): ?(SchemaField | string) {
    if (this.schemaData[className]) {
      const expectedType = this.schemaData[className].fields[fieldName];
      return expectedType === 'map' ? 'Object' : expectedType;
    }
    return undefined;
  }

  // Checks if a given class is in the schema.
  hasClass(className: string) {
    if (this.schemaData[className]) {
      return Promise.resolve(true);
    }
    return this.reloadData().then(() => !!this.schemaData[className]);
  }
}

// Returns a promise for a new Schema.
const load = (dbAdapter: StorageAdapter, options: any): Promise<SchemaController> => {
  const schema = new SchemaController(dbAdapter);
  ttl.duration = dbAdapter.schemaCacheTtl;
  return schema.reloadData(options).then(() => schema);
};

// Builds a new schema (in schema API response format) out of an
// existing mongo schema + a schemas API put request. This response
// does not include the default fields, as it is intended to be passed
// to mongoSchemaFromFieldsAndClassName. No validation is done here, it
// is done in mongoSchemaFromFieldsAndClassName.
function buildMergedSchemaObject(existingFields: SchemaFields, putRequest: any): SchemaFields {
  const newSchema = {};
  // @flow-disable-next
  const sysSchemaField =
    Object.keys(defaultColumns).indexOf(existingFields._id) === -1
      ? []
      : Object.keys(defaultColumns[existingFields._id]);
  for (const oldField in existingFields) {
    if (
      oldField !== '_id' &&
      oldField !== 'ACL' &&
      oldField !== 'updatedAt' &&
      oldField !== 'createdAt' &&
      oldField !== 'objectId'
    ) {
      if (sysSchemaField.length > 0 && sysSchemaField.indexOf(oldField) !== -1) {
        continue;
      }
      const fieldIsDeleted = putRequest[oldField] && putRequest[oldField].__op === 'Delete';
      if (!fieldIsDeleted) {
        newSchema[oldField] = existingFields[oldField];
      }
    }
  }
  for (const newField in putRequest) {
    if (newField !== 'objectId' && putRequest[newField].__op !== 'Delete') {
      if (sysSchemaField.length > 0 && sysSchemaField.indexOf(newField) !== -1) {
        continue;
      }
      newSchema[newField] = putRequest[newField];
    }
  }
  return newSchema;
}

// Given a schema promise, construct another schema promise that
// validates this field once the schema loads.
function thenValidateRequiredColumns(schemaPromise, className, object, query) {
  return schemaPromise.then(schema => {
    return schema.validateRequiredColumns(className, object, query);
  });
}

// Gets the type from a REST API formatted object, where 'type' is
// extended past javascript types to include the rest of the Parse
// type system.
// The output should be a valid schema value.
// TODO: ensure that this is compatible with the format used in Open DB
function getType(obj: any): ?(SchemaField | string) {
  const type = typeof obj;
  switch (type) {
    case 'boolean':
      return 'Boolean';
    case 'string':
      return 'String';
    case 'number':
      return 'Number';
    case 'map':
    case 'object':
      if (!obj) {
        return undefined;
      }
      return getObjectType(obj);
    case 'function':
    case 'symbol':
    case 'undefined':
    default:
      throw 'bad obj: ' + obj;
  }
}

// This gets the type for non-JSON types like pointers and files, but
// also gets the appropriate type for $ operators.
// Returns null if the type is unknown.
function getObjectType(obj): ?(SchemaField | string) {
  if (obj instanceof Array) {
    return 'Array';
  }
  if (obj.__type) {
    switch (obj.__type) {
      case 'Pointer':
        if (obj.className) {
          return {
            type: 'Pointer',
            targetClass: obj.className,
          };
        }
        break;
      case 'Relation':
        if (obj.className) {
          return {
            type: 'Relation',
            targetClass: obj.className,
          };
        }
        break;
      case 'File':
        if (obj.name) {
          return 'File';
        }
        break;
      case 'Date':
        if (obj.iso) {
          return 'Date';
        }
        break;
      case 'GeoPoint':
        if (obj.latitude != null && obj.longitude != null) {
          return 'GeoPoint';
        }
        break;
      case 'Bytes':
        if (obj.base64) {
          return 'Bytes';
        }
        break;
      case 'Polygon':
        if (obj.coordinates) {
          return 'Polygon';
        }
        break;
    }
    throw new Parse.Error(Parse.Error.INCORRECT_TYPE, 'This is not a valid ' + obj.__type);
  }
  if (obj['$ne']) {
    return getObjectType(obj['$ne']);
  }
  if (obj.__op) {
    switch (obj.__op) {
      case 'Increment':
        return 'Number';
      case 'Delete':
        return null;
      case 'Add':
      case 'AddUnique':
      case 'Remove':
        return 'Array';
      case 'AddRelation':
      case 'RemoveRelation':
        return {
          type: 'Relation',
          targetClass: obj.objects[0].className,
        };
      case 'Batch':
        return getObjectType(obj.ops[0]);
      default:
        throw 'unexpected op: ' + obj.__op;
    }
  }
  return 'Object';
}

export {
  load,
  classNameIsValid,
  fieldNameIsValid,
  invalidClassNameMessage,
  buildMergedSchemaObject,
  systemClasses,
  defaultColumns,
  convertSchemaToAdapterSchema,
  VolatileClassesSchemas,
  SchemaController,
  requiredColumns,
};
