e22m4u 1 неделя назад
Родитель
Сommit
21b5cbc987

+ 28 - 108
README.md

@@ -9,7 +9,6 @@ JavaScript модуль для создания проекции данных н
 - [Использование](#использование)
   - [Применение схемы](#применение-схемы)
   - [Неуказанные поля](#неуказанные-поля)
-  - [Область проекции](#область-проекции)
   - [Фабричные функции](#фабричные-функции)
   - [Именованные схемы](#именованные-схемы)
 - [Тесты](#тесты)
@@ -43,10 +42,6 @@ const {projectData} = require('@e22m4u/js-data-projector');
 {
   foo: true, // поле доступно
   bar: false // поле скрыто
-  // объект настроек
-  baz: {},              // поле доступно
-  qux: {select: true},  // поле доступно
-  buz: {select: false}, // поле скрыто
 }
 ```
 
@@ -55,26 +50,9 @@ const {projectData} = require('@e22m4u/js-data-projector');
 ```js
 {
   name: true, // поле "name" доступно
-  address: {  // настройки поля "address"
-    select: true, // поле "address" доступно (не обязательно)
-    schema: {     // вложенная схема
-      city: true,     // поле "address.city" доступно
-      zip: false      // поле "address.zip" скрыто
-    }
-  }
-}
-```
-
-Определение правил для областей проекции.
-
-```js
-{
-  password: { // настройки поля "password"
-    select: false, // поле "password" недоступно по умолчанию
-    scopes: {      // настройки для областей проекции
-      input: true,    // поле доступно для области "input"
-      output: false   // но скрыто для области "output"
-    }
+  address: {  // поле "address" доступно
+    city: true,  // поле "address.city" доступно
+    zip: false   // поле "address.zip" скрыто
   }
 }
 ```
@@ -83,11 +61,9 @@ const {projectData} = require('@e22m4u/js-data-projector');
 
 ```js
 {
-  name: true, // поле "name" доступно
-  address: {  // настройки поля "address"
-    select: true,     // поле "address" доступно (не обязательно)
-    schema: 'address' // имя вложенной схемы
-  }
+  name: true,        // поле "name" доступно
+  contact: 'address' // поле "contact" доступно
+  // поле "contact" использует вложенную схему "address"
 }
 ```
 
@@ -151,12 +127,10 @@ import {projectData} from '@e22m4u/js-data-projector';
 const schema = {
   id: false,
   name: true,
+  // вложенная схема
   city: {
-    select: true, // правило видимости поля "city" (не обязательно)
-    schema: {     // вложенная схема
-      id: false,
-      name: true,
-    },
+    id: false,
+    name: true,
   },
 };
 
@@ -207,84 +181,34 @@ console.log(result);
 // }
 ```
 
-### Область проекции
-
-Создание проекции определенной области.
+Переопределение параметра `keepUnknown` схемой проекции.
 
 ```js
 import {projectData} from '@e22m4u/js-data-projector';
 
 const schema = {
+  $keepUnknown: true, // <= допускать неуказанные поля
   name: true,
-  password: {
-    scopes: {
-      input: true,   // правило для области "input"
-      output: false, // правило для области "output"
-    },
-  },
+  password: false,
+  // in-line параметр "$keepUnknown" определяет режим только
+  // для текущего уровня вложенности, и если данная схема
+  // объекта имеет вложенные схемы, то на них переопределение
+  // не влияет
 };
 
 const data = {
   name: 'Fedor',       // допускается, явное правило
-  password: 'pass123', // исключается для области "output"
+  password: 'pass123', // исключается, явное правило
+  extra: 10,           // допускается, in-line параметр "$keepUnknown"
 };
 
-const inputData = projectData(data, schema, {
-  scope: "input", // <= область видимости
+const result = projectData(data, schema, {
+  keepUnknown: false, // <= допуск неуказанных полей запрещен
 });
-console.log(inputData);
+console.log(result);
 // {
 //   name: 'Fedor',
-//   password: 'pass123'
-// }
-
-const outputData = projectData(data, schema, {
-  scope: "output", // <= область видимости
-});
-console.log(outputData);
-// {
-//   name: 'Fedor'
-// }
-```
-
-Использование сокращенных методов для работы с областями.
-
-```js
-import {DataProjector} from '@e22m4u/js-data-projector';
-
-const projector = new DataProjector();
-
-projector.defineSchema({
-  name: 'user',
-  schema: {
-    username: true,
-    password: {
-      scopes: {
-        input: true,   // поле "password" доступно для области "input"
-        output: false, // поле "password" скрыто для области "output"
-      },
-    },
-  },
-});
-
-const data = {
-  username: 'john_doe',
-  password: 'secret123',
-}
-
-// аналог projector.project(data, 'user', {scope: "input"})
-const input = projector.projectInput(data, 'user');
-console.log(input);
-// {
-//   username: 'john_doe',
-//   password: 'secret123'
-// }
-
-// аналог projector.project(data, 'user', {scope: "output"})
-const output = projector.projectOutput(data, 'user');
-console.log(output);
-// {
-//   username: 'john_doe'
+//   extra: 10
 // }
 ```
 
@@ -331,9 +255,7 @@ const getAddressSchema = () => {
 
 const userSchema = {
   name: true,
-  address: {
-    schema: getAddressSchema, // <= использование фабрики
-  },
+  address: getAddressSchema, // <= использование фабрики
 };
 
 const data = {
@@ -359,12 +281,13 @@ console.log(result);
 ```js
 import {projectData} from '@e22m4u/js-data-projector';
 
-// объект будет передан в параметры фабрики
+// объект "logger" будет передан
+// в параметры фабрики,
 const logger = {
   log: (message) => console.log(message),
 };
 
-// фабрика использует logger
+// фабрика использует "logger"
 const getSchema = (logger) => {
   logger.log('Factory was invoked!');
   return {name: true, secret: false};
@@ -376,7 +299,7 @@ const data = {
 };
 
 const result = projectData(data, getSchema, {
-  factoryArgs: [logger], // <= передача logger в фабрику
+  factoryArgs: [logger], // <= передача "logger" в фабрику
 });
 // Factory was invoked!
 console.log(result);
@@ -469,10 +392,7 @@ projector.defineSchema({
   name: 'user',
   schema: {
     name: true,
-    address: {
-      select: true,      // видимость поля "address" (не обязательно)
-      schema: 'address', // <= имя вложенной схемы
-    },
+    address: 'address', // <= имя вложенной схемы
   },
 });
 

+ 66 - 126
dist/cjs/index.cjs

@@ -35,9 +35,9 @@ var import_js_format2 = require("@e22m4u/js-format");
 // src/validate-projection-schema.js
 var import_js_format = require("@e22m4u/js-format");
 function validateProjectionSchema(schema, shallowMode = false, validatedSchemas = /* @__PURE__ */ new Set()) {
-  if (!schema || typeof schema !== "object" && typeof schema !== "function" && typeof schema !== "string" || Array.isArray(schema)) {
+  if (!schema && schema !== false || typeof schema !== "boolean" && typeof schema !== "object" && typeof schema !== "function" && typeof schema !== "string" || Array.isArray(schema)) {
     throw new import_js_format.InvalidArgumentError(
-      "Projection schema must be an Object, a Function or a non-empty String, but %v was given.",
+      "Projection schema must be a Boolean, an Object, a Function or a non-empty String, but %v was given.",
       schema
     );
   }
@@ -61,70 +61,26 @@ function validateProjectionSchema(schema, shallowMode = false, validatedSchemas
   }
   validatedSchemas.add(schema);
   Object.keys(schema).forEach((propName) => {
-    const propOptions = schema[propName];
-    if (propOptions === void 0) {
+    const propSchema = schema[propName];
+    if (propSchema === void 0) {
       return;
     }
-    if (propOptions === null || typeof propOptions !== "object" && typeof propOptions !== "boolean" || Array.isArray(propOptions)) {
+    if (!propSchema && propSchema !== false || typeof propSchema !== "boolean" && typeof propSchema !== "object" && typeof propSchema !== "function" && typeof propSchema !== "string" || Array.isArray(propSchema)) {
       throw new import_js_format.InvalidArgumentError(
-        "Property options must be an Object or a Boolean, but %v was given.",
-        propOptions
+        "Projection schema of the property %v must be a Boolean, an Object, a Function or a non-empty String, but %v was given.",
+        propName,
+        propSchema
       );
     }
-    if (typeof propOptions === "boolean") {
-      return;
-    }
-    if (propOptions.select !== void 0 && typeof propOptions.select !== "boolean") {
-      throw new import_js_format.InvalidArgumentError(
-        'Property option "select" must be a Boolean, but %v was given.',
-        propOptions.select
-      );
-    }
-    if (propOptions.scopes !== void 0) {
-      if (!propOptions.scopes || typeof propOptions.scopes !== "object" || Array.isArray(propOptions.scopes)) {
-        throw new import_js_format.InvalidArgumentError(
-          'Property option "scopes" must be an Object, but %v was given.',
-          propOptions.scopes
-        );
-      }
-      Object.values(propOptions.scopes).forEach((scopeOptions) => {
-        if (scopeOptions === void 0) {
-          return;
-        }
-        if (scopeOptions === null || typeof scopeOptions !== "object" && typeof scopeOptions !== "boolean" || Array.isArray(scopeOptions)) {
-          throw new import_js_format.InvalidArgumentError(
-            "Scope options must be an Object or a Boolean, but %v was given.",
-            scopeOptions
-          );
-        }
-        if (scopeOptions.select !== void 0 && typeof scopeOptions.select !== "boolean") {
-          throw new import_js_format.InvalidArgumentError(
-            'Scope option "select" must be a Boolean, but %v was given.',
-            scopeOptions.select
-          );
-        }
-      });
-    }
-    if (propOptions.schema !== void 0) {
-      if (!propOptions.schema || typeof propOptions.schema !== "object" && typeof propOptions.schema !== "function" && typeof propOptions.schema !== "string" || Array.isArray(propOptions.schema)) {
-        throw new import_js_format.InvalidArgumentError(
-          'Property option "schema" must be an Object, a Function or a non-empty String, but %v was given.',
-          propOptions.schema
-        );
-      }
-      if (!shallowMode && typeof propOptions.schema === "object") {
-        validateProjectionSchema(
-          propOptions.schema,
-          shallowMode,
-          validatedSchemas
-        );
-      }
+    if (!shallowMode && typeof propSchema === "object") {
+      validateProjectionSchema(propSchema, shallowMode, validatedSchemas);
     }
   });
 }
 __name(validateProjectionSchema, "validateProjectionSchema");
 
 // src/project-data.js
+var DATA_SCHEMA_INLINE_OPTIONS = ["$keepUnknown"];
 function projectData(data, schema, options) {
   if (options !== void 0) {
     if (!options || typeof options !== "object" || Array.isArray(options)) {
@@ -139,12 +95,6 @@ function projectData(data, schema, options) {
         options.keepUnknown
       );
     }
-    if (options.scope !== void 0 && (options.scope === "" || typeof options.scope !== "string")) {
-      throw new import_js_format2.InvalidArgumentError(
-        'Projection option "scope" must be a non-empty String, but %v was given.',
-        options.scope
-      );
-    }
     if (options.nameResolver !== void 0 && typeof options.nameResolver !== "function") {
       throw new import_js_format2.InvalidArgumentError(
         'Projection option "nameResolver" must be a Function, but %v was given.',
@@ -161,9 +111,9 @@ function projectData(data, schema, options) {
   if (typeof schema === "function") {
     const factoryArgs = options && options.factoryArgs || [];
     schema = schema(...factoryArgs);
-    if (!schema || typeof schema !== "object" && typeof schema !== "string" || Array.isArray(schema)) {
+    if (!schema && schema !== false || typeof schema !== "boolean" && typeof schema !== "object" && typeof schema !== "string" || Array.isArray(schema)) {
       throw new import_js_format2.InvalidArgumentError(
-        "Schema factory must return an Object or a non-empty String, but %v was given.",
+        "Schema factory must return a Boolean, an Object or a non-empty String, but %v was given.",
         schema
       );
     }
@@ -171,14 +121,14 @@ function projectData(data, schema, options) {
   if (typeof schema === "string") {
     if (!options || !options.nameResolver) {
       throw new import_js_format2.InvalidArgumentError(
-        'Projection option "nameResolver" is required to resolve %v name.',
+        'Failed to resolve the schema name %v because the option "nameResolver" is missing.',
         schema
       );
     }
     schema = options.nameResolver(schema);
-    if (!schema || typeof schema !== "object" && typeof schema !== "function" && typeof schema !== "string" || Array.isArray(schema)) {
+    if (!schema && schema !== false || typeof schema !== "boolean" && typeof schema !== "object" && typeof schema !== "function" && typeof schema !== "string" || Array.isArray(schema)) {
       throw new import_js_format2.InvalidArgumentError(
-        "Name resolver must return an Object, a Function or a non-empty String, but %v was given.",
+        "Name resolver must return a Boolean, an Object, a Function or a non-empty String, but %v was given.",
         schema
       );
     }
@@ -186,56 +136,56 @@ function projectData(data, schema, options) {
       return projectData(data, schema, options);
     }
   }
+  if (typeof schema === "boolean") {
+    return schema ? data : void 0;
+  }
   validateProjectionSchema(schema, true);
-  if (data == null || typeof data !== "object") {
+  if (data == null) {
     return data;
   }
+  if (typeof data !== "object") {
+    throw new import_js_format2.InvalidArgumentError(
+      "Data source of the object projection must be an Object or an Array, but %v was given.",
+      data
+    );
+  }
   if (Array.isArray(data)) {
     return data.map((item) => projectData(item, schema, options));
   }
   const result = {};
-  const keepUnknown = Boolean(options && options.keepUnknown);
-  const scope = options && options.scope || void 0;
+  if (schema.$keepUnknown !== void 0 && typeof schema.$keepUnknown !== "boolean") {
+    throw new import_js_format2.InvalidArgumentError(
+      'Schema property "$keepUnknown" must be a Boolean, but %v was given.',
+      schema.$keepUnknown
+    );
+  }
+  const keepUnknown = Boolean(
+    typeof schema.$keepUnknown === "boolean" ? schema.$keepUnknown : options && options.keepUnknown
+  );
   const propNames = Object.keys(keepUnknown ? data : schema);
   propNames.forEach((propName) => {
-    if (!(propName in data)) {
+    if (!(propName in data) || DATA_SCHEMA_INLINE_OPTIONS.includes(propName)) {
       return;
     }
-    const propOptions = schema[propName];
-    if (_shouldSelect(propOptions, keepUnknown, scope)) {
-      const value = data[propName];
-      if (propOptions && typeof propOptions === "object" && propOptions.schema) {
-        result[propName] = projectData(value, propOptions.schema, options);
-      } else {
-        result[propName] = value;
+    const propSchema = schema[propName];
+    const propValue = data[propName];
+    if (typeof propSchema === "boolean") {
+      if (propSchema) {
+        result[propName] = propValue;
       }
+      return;
+    }
+    if (propSchema != null) {
+      result[propName] = projectData(propValue, propSchema, options);
+      return;
+    }
+    if (keepUnknown) {
+      result[propName] = propValue;
     }
   });
   return result;
 }
 __name(projectData, "projectData");
-function _shouldSelect(propOptions, keepUnknown, scope) {
-  if (typeof propOptions === "boolean") {
-    return propOptions;
-  }
-  if (propOptions && typeof propOptions === "object" && !Array.isArray(propOptions)) {
-    if (scope && typeof scope === "string" && propOptions.scopes && typeof propOptions.scopes === "object" && propOptions.scopes[scope] !== void 0) {
-      const scopeOptions = propOptions.scopes[scope];
-      if (typeof scopeOptions === "boolean") {
-        return scopeOptions;
-      }
-      if (scopeOptions && typeof scopeOptions === "object" && !Array.isArray(scopeOptions) && typeof scopeOptions.select === "boolean") {
-        return scopeOptions.select;
-      }
-    }
-    if (typeof propOptions.select === "boolean") {
-      return propOptions.select;
-    }
-    return true;
-  }
-  return Boolean(keepUnknown);
-}
-__name(_shouldSelect, "_shouldSelect");
 
 // src/data-projector.js
 var import_js_service2 = require("@e22m4u/js-service");
@@ -283,12 +233,6 @@ var _ProjectionSchemaRegistry = class _ProjectionSchemaRegistry extends import_j
    */
   defineSchema(schemaDef) {
     validateProjectionSchemaDefinition(schemaDef);
-    if (this._definitions.has(schemaDef.name)) {
-      throw new import_js_format4.InvalidArgumentError(
-        "Projection schema %v is already registered.",
-        schemaDef.name
-      );
-    }
     this._definitions.set(schemaDef.name, schemaDef);
     return this;
   }
@@ -350,41 +294,37 @@ var _DataProjector = class _DataProjector extends import_js_service2.Service {
     return this;
   }
   /**
-   * Project.
+   * Has schema.
    *
-   * @param {object|object[]|*} data
-   * @param {object|Function|string} schema
-   * @param {object} [options]
-   * @returns {*}
+   * @param {string} schemaName
+   * @returns {boolean}
    */
-  project(data, schema, options) {
-    const registry = this.getService(ProjectionSchemaRegistry);
-    const defaultNameResolver = /* @__PURE__ */ __name((name) => registry.getSchema(name), "defaultNameResolver");
-    const nameResolver = options && options.nameResolver || defaultNameResolver;
-    const factoryArgs = options && options.factoryArgs || [this.container];
-    return projectData(data, schema, { ...options, nameResolver, factoryArgs });
+  hasSchema(schemaName) {
+    return this.getService(ProjectionSchemaRegistry).hasSchema(schemaName);
   }
   /**
-   * Project input.
+   * Get schema.
    *
-   * @param {object|object[]|*} data
-   * @param {object|Function|string} schema
-   * @param {object} [options]
-   * @returns {*}
+   * @param {string} schemaName
+   * @returns {object}
    */
-  projectInput(data, schema, options) {
-    return this.project(data, schema, { ...options, scope: "input" });
+  getSchema(schemaName) {
+    return this.getService(ProjectionSchemaRegistry).getSchema(schemaName);
   }
   /**
-   * Project output.
+   * Project.
    *
    * @param {object|object[]|*} data
    * @param {object|Function|string} schema
    * @param {object} [options]
    * @returns {*}
    */
-  projectOutput(data, schema, options) {
-    return this.project(data, schema, { ...options, scope: "output" });
+  project(data, schema, options) {
+    const registry = this.getService(ProjectionSchemaRegistry);
+    const defaultNameResolver = /* @__PURE__ */ __name((name) => registry.getSchema(name), "defaultNameResolver");
+    const nameResolver = options && options.nameResolver || defaultNameResolver;
+    const factoryArgs = options && options.factoryArgs || [this.container];
+    return projectData(data, schema, { ...options, nameResolver, factoryArgs });
   }
 };
 __name(_DataProjector, "DataProjector");

+ 9 - 21
src/data-projector.d.ts

@@ -15,41 +15,29 @@ export class DataProjector extends Service {
   defineSchema(schemaDef: ProjectionSchemaDefinition): this;
 
   /**
-   * Project.
+   * Has schema.
    *
-   * @param data
-   * @param schema
-   * @param options
+   * @param schemaName
    */
-  project<T>(
-    data: T,
-    schema: ProjectionSchema,
-    options?: ProjectDataOptions,
-  ): T;
+  hasSchema(schemaName: string): boolean;
 
   /**
-   * Project input.
+   * Get schema.
    *
-   * @param data
-   * @param schema
-   * @param options
+   * @param schemaName
    */
-  projectInput<T>(
-    data: T,
-    schema: ProjectionSchema,
-    options?: Omit<ProjectDataOptions, 'scope'>,
-  ): T;
+  getSchema(schemaName: string): ProjectionSchema;
 
   /**
-   * Project output.
+   * Project.
    *
    * @param data
    * @param schema
    * @param options
    */
-  projectOutput<T>(
+  project<T>(
     data: T,
     schema: ProjectionSchema,
-    options?: Omit<ProjectDataOptions, 'scope'>,
+    options?: ProjectDataOptions,
   ): T;
 }

+ 18 - 22
src/data-projector.js

@@ -18,43 +18,39 @@ export class DataProjector extends Service {
   }
 
   /**
-   * Project.
+   * Has schema.
    *
-   * @param {object|object[]|*} data
-   * @param {object|Function|string} schema
-   * @param {object} [options]
-   * @returns {*}
+   * @param {string} schemaName
+   * @returns {boolean}
    */
-  project(data, schema, options) {
-    const registry = this.getService(ProjectionSchemaRegistry);
-    const defaultNameResolver = name => registry.getSchema(name);
-    const nameResolver =
-      (options && options.nameResolver) || defaultNameResolver;
-    const factoryArgs = (options && options.factoryArgs) || [this.container];
-    return projectData(data, schema, {...options, nameResolver, factoryArgs});
+  hasSchema(schemaName) {
+    return this.getService(ProjectionSchemaRegistry).hasSchema(schemaName);
   }
 
   /**
-   * Project input.
+   * Get schema.
    *
-   * @param {object|object[]|*} data
-   * @param {object|Function|string} schema
-   * @param {object} [options]
-   * @returns {*}
+   * @param {string} schemaName
+   * @returns {object}
    */
-  projectInput(data, schema, options) {
-    return this.project(data, schema, {...options, scope: 'input'});
+  getSchema(schemaName) {
+    return this.getService(ProjectionSchemaRegistry).getSchema(schemaName);
   }
 
   /**
-   * Project output.
+   * Project.
    *
    * @param {object|object[]|*} data
    * @param {object|Function|string} schema
    * @param {object} [options]
    * @returns {*}
    */
-  projectOutput(data, schema, options) {
-    return this.project(data, schema, {...options, scope: 'output'});
+  project(data, schema, options) {
+    const registry = this.getService(ProjectionSchemaRegistry);
+    const defaultNameResolver = name => registry.getSchema(name);
+    const nameResolver =
+      (options && options.nameResolver) || defaultNameResolver;
+    const factoryArgs = (options && options.factoryArgs) || [this.container];
+    return projectData(data, schema, {...options, nameResolver, factoryArgs});
   }
 }

+ 36 - 71
src/data-projector.spec.js

@@ -4,7 +4,7 @@ import {ProjectionSchemaRegistry} from './projection-schema-registry.js';
 
 describe('DataProjector', function () {
   describe('defineSchema', function () {
-    it('should validate the given definition', function () {
+    it('should validate a given definition', function () {
       const S = new DataProjector();
       const throwable = () => S.defineSchema({});
       expect(throwable).to.throw(
@@ -13,7 +13,7 @@ describe('DataProjector', function () {
       );
     });
 
-    it('should register the given definition', function () {
+    it('should register a given definition', function () {
       const def = {name: 'mySchema', schema: {}};
       const S = new DataProjector();
       S.defineSchema(def);
@@ -22,14 +22,14 @@ describe('DataProjector', function () {
       expect(res).to.be.eql(def);
     });
 
-    it('should throw an error if the schema name is already registered', function () {
+    it('should allow override a registered schema', function () {
       const S = new DataProjector();
-      const def = {name: 'mySchema', schema: {}};
-      S.defineSchema(def);
-      const throwable = () => S.defineSchema(def);
-      expect(throwable).to.throw(
-        'Projection schema "mySchema" is already registered.',
-      );
+      const def1 = {name: 'mySchema', schema: {foo: true}};
+      const def2 = {name: 'mySchema', schema: {foo: false}};
+      S.defineSchema(def1);
+      expect(S.getSchema(def1.name)).to.be.eql(def1.schema);
+      S.defineSchema(def2);
+      expect(S.getSchema(def2.name)).to.be.eql(def2.schema);
     });
 
     it('should return the current instance', function () {
@@ -39,14 +39,39 @@ describe('DataProjector', function () {
     });
   });
 
+  describe('hasSchema', function () {
+    it('should return true when a given name is registered', function () {
+      const S = new DataProjector();
+      expect(S.hasSchema('mySchema')).to.be.false;
+      S.defineSchema({name: 'mySchema', schema: {}});
+      expect(S.hasSchema('mySchema')).to.be.true;
+    });
+  });
+
+  describe('getSchema', function () {
+    it('should throw an error if a given name is not registered', function () {
+      const S = new DataProjector();
+      const throwable = () => S.getSchema('mySchema');
+      expect(throwable).to.throw('Projection schema "mySchema" is not found.');
+    });
+
+    it('should return a registered schema by a given name', function () {
+      const def = {name: 'mySchema', schema: {foo: true, bar: false}};
+      const S = new DataProjector();
+      S.defineSchema(def);
+      const res = S.getSchema(def.name);
+      expect(res).to.be.eql(def.schema);
+    });
+  });
+
   describe('project', function () {
-    it('should project the data object by the given schema', function () {
+    it('should project a given object by a data schema', function () {
       const S = new DataProjector();
       const res = S.project({foo: 10, bar: 20}, {foo: true, bar: false});
       expect(res).to.be.eql({foo: 10});
     });
 
-    it('should project the data object by the schema name', function () {
+    it('should project a given object by a schema name', function () {
       const S = new DataProjector();
       S.defineSchema({name: 'mySchema', schema: {foo: true, bar: false}});
       const res = S.project({foo: 10, bar: 20, baz: 30}, 'mySchema');
@@ -103,64 +128,4 @@ describe('DataProjector', function () {
       expect(invoked).to.be.eq(1);
     });
   });
-
-  describe('projectInput', function () {
-    it('should invoke the "project" method with the "input" scope and return its result', function () {
-      const S = new DataProjector();
-      const schema = {foo: true, bar: false};
-      const data = {foo: 10, bar: 20};
-      const options = {extra: true};
-      const result = {foo: 10};
-      let invoked = 0;
-      S.project = (...args) => {
-        expect(args).to.be.eql([data, schema, {extra: true, scope: 'input'}]);
-        invoked++;
-        return result;
-      };
-      const res = S.projectInput(data, schema, options);
-      expect(res).to.be.eq(result);
-      expect(invoked).to.be.eq(1);
-    });
-
-    it('should project the given object with the "input" scope', function () {
-      const S = new DataProjector();
-      const schema = {
-        foo: {select: false, scopes: {input: true}},
-        bar: {select: true, scopes: {input: false}},
-      };
-      const data = {foo: 10, bar: 20};
-      const res = S.projectInput(data, schema);
-      expect(res).to.be.eql({foo: 10});
-    });
-  });
-
-  describe('projectOutput', function () {
-    it('should invoke the "project" method with the "output" scope and return its result', function () {
-      const S = new DataProjector();
-      const schema = {foo: true, bar: false};
-      const data = {foo: 10, bar: 20};
-      const options = {extra: true};
-      const result = {foo: 10};
-      let invoked = 0;
-      S.project = (...args) => {
-        expect(args).to.be.eql([data, schema, {extra: true, scope: 'output'}]);
-        invoked++;
-        return result;
-      };
-      const res = S.projectOutput(data, schema, options);
-      expect(res).to.be.eq(result);
-      expect(invoked).to.be.eq(1);
-    });
-
-    it('should project the given object with the "output" scope', function () {
-      const S = new DataProjector();
-      const schema = {
-        foo: {select: false, scopes: {output: true}},
-        bar: {select: true, scopes: {output: false}},
-      };
-      const data = {foo: 10, bar: 20};
-      const res = S.projectOutput(data, schema);
-      expect(res).to.be.eql({foo: 10});
-    });
-  });
 });

+ 0 - 1
src/project-data.d.ts

@@ -12,7 +12,6 @@ export type ProjectionSchemaNameResolver = (
  */
 export type ProjectDataOptions = {
   keepUnknown?: boolean;
-  scope?: string;
   nameResolver?: ProjectionSchemaNameResolver;
   factoryArgs?: unknown[];
 };

+ 91 - 119
src/project-data.js

@@ -1,6 +1,11 @@
 import {InvalidArgumentError} from '@e22m4u/js-format';
 import {validateProjectionSchema} from './validate-projection-schema.js';
 
+/**
+ * Data schema service properties.
+ */
+const DATA_SCHEMA_INLINE_OPTIONS = ['$keepUnknown'];
+
 /**
  * Project data.
  *
@@ -28,17 +33,6 @@ export function projectData(data, schema, options) {
         options.keepUnknown,
       );
     }
-    // options.scope
-    if (
-      options.scope !== undefined &&
-      (options.scope === '' || typeof options.scope !== 'string')
-    ) {
-      throw new InvalidArgumentError(
-        'Projection option "scope" must be a non-empty String, ' +
-          'but %v was given.',
-        options.scope,
-      );
-    }
     // options.nameResolver
     if (
       options.nameResolver !== undefined &&
@@ -67,13 +61,18 @@ export function projectData(data, schema, options) {
   if (typeof schema === 'function') {
     const factoryArgs = (options && options.factoryArgs) || [];
     schema = schema(...factoryArgs);
+    // если фабричное значение не является логическим
+    // значением, объектом или не пустой строкой,
+    // то выбрасывается ошибка
     if (
-      !schema ||
-      (typeof schema !== 'object' && typeof schema !== 'string') ||
+      (!schema && schema !== false) ||
+      (typeof schema !== 'boolean' &&
+        typeof schema !== 'object' &&
+        typeof schema !== 'string') ||
       Array.isArray(schema)
     ) {
       throw new InvalidArgumentError(
-        'Schema factory must return an Object ' +
+        'Schema factory must return a Boolean, an Object ' +
           'or a non-empty String, but %v was given.',
         schema,
       );
@@ -86,23 +85,24 @@ export function projectData(data, schema, options) {
     // то выбрасывается ошибка
     if (!options || !options.nameResolver) {
       throw new InvalidArgumentError(
-        'Projection option "nameResolver" is required to resolve %v name.',
+        'Failed to resolve the schema name %v because ' +
+          'the option "nameResolver" is missing.',
         schema,
       );
     }
     schema = options.nameResolver(schema);
     // если результат разрешающей функции не является
-    // объектом, функцией или строкой, то выбрасывается
-    // ошибка
+    // корректным значением, то выбрасывается ошибка
     if (
-      !schema ||
-      (typeof schema !== 'object' &&
+      (!schema && schema !== false) ||
+      (typeof schema !== 'boolean' &&
+        typeof schema !== 'object' &&
         typeof schema !== 'function' &&
         typeof schema !== 'string') ||
       Array.isArray(schema)
     ) {
       throw new InvalidArgumentError(
-        'Name resolver must return an Object, a Function ' +
+        'Name resolver must return a Boolean, an Object, a Function ' +
           'or a non-empty String, but %v was given.',
         schema,
       );
@@ -113,126 +113,98 @@ export function projectData(data, schema, options) {
       return projectData(data, schema, options);
     }
   }
-  // после нормализации схемы в объект,
+  // если схема является логическим значением,
+  // то значение используется как правило видимости
+  if (typeof schema === 'boolean') {
+    return schema ? data : undefined;
+  }
+  // после приведения схемы к объекту,
   // выполняется поверхностная проверка
   validateProjectionSchema(schema, true);
-  // если данные не являются объектом или массивом,
+  // на данном этапе схемой является объект с правилами
+  // свойств, и если значением является undefined или null,
   // то значение возвращается без изменений
-  if (data == null || typeof data !== 'object') {
+  if (data == null) {
     return data;
   }
-  // если данные являются массивом,
-  // то схема применяется к каждому элементу
+  // на данном этапе схемой является объект с правилами
+  // свойств, и если данные не являются объектом
+  // или массивом, то выбрасывается ошибка
+  if (typeof data !== 'object') {
+    throw new InvalidArgumentError(
+      'Data source of the object projection must be an Object or an Array, ' +
+        'but %v was given.',
+      data,
+    );
+  }
+  // если данные являются массивом, то схема
+  // применяется к каждому элементу
   if (Array.isArray(data)) {
+    // здесь можно было бы обработать случай, когда схемой
+    // является логическое false, исключив при этом элементы
+    // массива, но данный случай уже был обработан выше
     return data.map(item => projectData(item, schema, options));
   }
   // если данные являются объектом,
   // то проекция создается согласно схеме
   const result = {};
-  const keepUnknown = Boolean(options && options.keepUnknown);
-  const scope = (options && options.scope) || undefined;
+  // если схема проекции имеет свойство "$keepUnknown",
+  // то свойство должно содержать логическое значение
+  if (
+    schema.$keepUnknown !== undefined &&
+    typeof schema.$keepUnknown !== 'boolean'
+  ) {
+    throw new InvalidArgumentError(
+      'Schema property "$keepUnknown" must be a Boolean, but %v was given.',
+      schema.$keepUnknown,
+    );
+  }
+  // схема может содержать свойство "$keepUnknown",
+  // которое переопределяет поведение одноименного
+  // параметра, но только для данного объекта,
+  // не влияя на проекцию вложенных данных
+  const keepUnknown = Boolean(
+    typeof schema.$keepUnknown === 'boolean'
+      ? schema.$keepUnknown
+      : options && options.keepUnknown,
+  );
   // в обычном режиме итерация выполняется по ключам схемы,
   // но при активном параметре "keepUnknown" итерация выполняется
   // по ключам исходного объекта (исключая ключи прототипа)
   const propNames = Object.keys(keepUnknown ? data : schema);
   propNames.forEach(propName => {
     // если свойство отсутствует в исходных
-    // данных, то свойство игнорируется
-    if (!(propName in data)) {
+    // данных или свойство является сервисным,
+    // то свойство игнорируется
+    if (!(propName in data) || DATA_SCHEMA_INLINE_OPTIONS.includes(propName)) {
       return;
     }
-    const propOptions = schema[propName];
-    // проверка доступности свойства для данной
-    // области проекции (если определена)
-    if (_shouldSelect(propOptions, keepUnknown, scope)) {
-      const value = data[propName];
-      // если определена вложенная схема,
-      // то проекция применяется рекурсивно
-      if (
-        propOptions &&
-        typeof propOptions === 'object' &&
-        propOptions.schema
-      ) {
-        result[propName] = projectData(value, propOptions.schema, options);
-      }
-      // иначе значение присваивается
-      // свойству без изменений
-      else {
-        result[propName] = value;
+    const propSchema = schema[propName];
+    const propValue = data[propName];
+    // если схема является логическим значением,
+    // то значение определяет правило видимости
+    if (typeof propSchema === 'boolean') {
+      // если правило содержит значение true,
+      // то свойство добавляется к результату
+      if (propSchema) {
+        result[propName] = propValue;
       }
+      // если правило содержит false,
+      // то свойство игнорируется
+      return;
     }
-  });
-  return result;
-}
-
-/**
- * Should select (internal).
- *
- * Определяет, следует ли включать свойство в результат.
- *
- * Приоритет:
- *   1. Правило для области.
- *   2. Общее правило.
- *   3. Режим "keepUnknown".
- *
- * @param {object|boolean} propOptions
- * @param {boolean|undefined} keepUnknown
- * @param {string|undefined} scope
- * @returns {boolean}
- */
-function _shouldSelect(propOptions, keepUnknown, scope) {
-  // если настройки свойства являются логическим значением,
-  // то значение используется как индикатор видимости
-  if (typeof propOptions === 'boolean') {
-    return propOptions;
-  }
-  // если настройки свойства являются объектом,
-  // то проверяется правило области и общее правило
-  if (
-    propOptions &&
-    typeof propOptions === 'object' &&
-    !Array.isArray(propOptions)
-  ) {
-    // если определена область проекции,
-    // то выполняется проверка правила области
-    if (
-      scope &&
-      typeof scope === 'string' &&
-      propOptions.scopes &&
-      typeof propOptions.scopes === 'object' &&
-      propOptions.scopes[scope] !== undefined
-    ) {
-      const scopeOptions = propOptions.scopes[scope];
-      // если настройки активной области проекции
-      // являются логическим значением, то значение
-      // возвращается в качестве результата
-      if (typeof scopeOptions === 'boolean') {
-        return scopeOptions;
-      }
-      // если настройки активной области проекции
-      // являются объектом и содержат параметр select,
-      // то значение параметра возвращается как результат
-      if (
-        scopeOptions &&
-        typeof scopeOptions === 'object' &&
-        !Array.isArray(scopeOptions) &&
-        typeof scopeOptions.select === 'boolean'
-      ) {
-        return scopeOptions.select;
-      }
+    // если определена вложенная схема,
+    // то проекция применяется рекурсивно
+    if (propSchema != null) {
+      result[propName] = projectData(propValue, propSchema, options);
+      return;
     }
-    // если правило видимости для активной области
-    // проекции не определено, то проверяется наличие
-    // общего правила
-    if (typeof propOptions.select === 'boolean') {
-      return propOptions.select;
+    // если схема свойства отсутствует и активен
+    // режим "keepUnknown", то свойство добавляется
+    // к результату
+    if (keepUnknown) {
+      result[propName] = propValue;
     }
-    // если настройки свойства являются объектом,
-    // но правила видимости не определены,
-    // то свойство считается видимым
-    return true;
-  }
-  // если правила видимости не определены
-  // то результат будет зависеть от режима
-  return Boolean(keepUnknown);
+  });
+  return result;
 }

+ 231 - 334
src/project-data.spec.js

@@ -3,8 +3,8 @@ import {format} from '@e22m4u/js-format';
 import {projectData} from './project-data.js';
 
 describe('projectData', function () {
-  it('should require the "options" argument to be an object', function () {
-    const throwable = v => () => projectData(10, {}, v);
+  it('should require the "options" parameter to be an Object', function () {
+    const throwable = v => () => projectData(10, true, v);
     const error = s =>
       format('Projection options must be an Object, but %s was given.', s);
     expect(throwable('str')).to.throw(error('"str"'));
@@ -19,8 +19,8 @@ describe('projectData', function () {
     throwable(undefined)();
   });
 
-  it('should require the "keepUnknown" option to be a boolean', function () {
-    const throwable = v => () => projectData(10, {}, {keepUnknown: v});
+  it('should require the "keepUnknown" option to be a Boolean', function () {
+    const throwable = v => () => projectData(10, true, {keepUnknown: v});
     const error = s =>
       format(
         'Projection option "keepUnknown" must be a Boolean, but %s was given.',
@@ -38,28 +38,8 @@ describe('projectData', function () {
     throwable(undefined)();
   });
 
-  it('should require the "scope" option to be a non-empty string', function () {
-    const throwable = v => () => projectData(10, {}, {scope: v});
-    const error = s =>
-      format(
-        'Projection option "scope" must be a non-empty String, ' +
-          'but %s was given.',
-        s,
-      );
-    expect(throwable('')).to.throw(error('""'));
-    expect(throwable(10)).to.throw(error('10'));
-    expect(throwable(0)).to.throw(error('0'));
-    expect(throwable(true)).to.throw(error('true'));
-    expect(throwable(false)).to.throw(error('false'));
-    expect(throwable([])).to.throw(error('Array'));
-    expect(throwable({})).to.throw(error('Object'));
-    expect(throwable(null)).to.throw(error('null'));
-    throwable('str')();
-    throwable(undefined)();
-  });
-
-  it('should require the "nameResolver" option to be a function', function () {
-    const throwable = v => () => projectData(10, {}, {nameResolver: v});
+  it('should require the "nameResolver" option to be a Function', function () {
+    const throwable = v => () => projectData(10, true, {nameResolver: v});
     const error = s =>
       format(
         'Projection option "nameResolver" must be ' +
@@ -80,7 +60,7 @@ describe('projectData', function () {
   });
 
   it('should require the "factoryArgs" option to be an Array', function () {
-    const throwable = v => () => projectData(10, {}, {factoryArgs: v});
+    const throwable = v => () => projectData(10, true, {factoryArgs: v});
     const error = s =>
       format(
         'Projection option "factoryArgs" must be an Array, but %s was given.',
@@ -98,449 +78,366 @@ describe('projectData', function () {
     throwable([])();
   });
 
-  it('should resolve a factory value as the schema object', function () {
+  it('should resolve a boolean value from the schema factory', function () {
     let invoked = 0;
     const factory = () => {
       invoked++;
-      return {foo: true, bar: false};
+      return true;
     };
     const res = projectData({foo: 10, bar: 20}, factory);
-    expect(res).to.be.eql({foo: 10});
+    expect(res).to.be.eql({foo: 10, bar: 20});
     expect(invoked).to.be.eq(1);
   });
 
-  it('should pass the "factoryArgs" option to the schema factory', function () {
+  it('should resolve a schema object from the schema factory', function () {
     let invoked = 0;
-    const factoryArgs = [1, 2, 3];
-    const factory = (...args) => {
+    const factory = () => {
       invoked++;
-      expect(args).to.be.eql(factoryArgs);
       return {foo: true, bar: false};
     };
-    const res = projectData({foo: 10, bar: 20}, factory, {factoryArgs});
+    const res = projectData({foo: 10, bar: 20}, factory);
     expect(res).to.be.eql({foo: 10});
     expect(invoked).to.be.eq(1);
   });
 
-  it('should pass the "factoryArgs" option to the schema factory in the nested schema', function () {
+  it('should resolve a named schema from the schema factory', function () {
+    let factoryInvoked = 0;
+    const factory = () => {
+      factoryInvoked++;
+      return 'mySchema';
+    };
+    let resolverInvoked = 0;
+    const nameResolver = name => {
+      resolverInvoked++;
+      expect(name).to.be.eq('mySchema');
+      return {foo: true, bar: false};
+    };
+    const res = projectData({foo: 10, bar: 20}, factory, {nameResolver});
+    expect(res).to.be.eql({foo: 10});
+    expect(factoryInvoked).to.be.eq(1);
+    expect(resolverInvoked).to.be.eq(1);
+  });
+
+  it('should pass the "factoryArgs" option to the schema factory', function () {
     let invoked = 0;
     const factoryArgs = [1, 2, 3];
     const factory = (...args) => {
       invoked++;
       expect(args).to.be.eql(factoryArgs);
-      return {bar: true, baz: false};
+      return {foo: true, bar: false};
     };
-    const res = projectData(
-      {foo: {bar: 10, baz: 20}},
-      {foo: {schema: factory}},
-      {factoryArgs},
-    );
-    expect(res).to.be.eql({foo: {bar: 10}});
+    const res = projectData({foo: 10, bar: 20}, factory, {factoryArgs});
+    expect(res).to.be.eql({foo: 10});
     expect(invoked).to.be.eq(1);
   });
 
-  it('should require a factory value to be an object or a non-empty string', function () {
-    const throwable = v => () => projectData({}, () => v);
+  it('should require a factory value to be a valid schema', function () {
+    const nameResolver = () => ({});
+    const throwable = v => () => projectData({}, () => v, {nameResolver});
     const error = s =>
       format(
-        'Schema factory must return an Object ' +
+        'Schema factory must return a Boolean, an Object ' +
           'or a non-empty String, but %s was given.',
         s,
       );
     expect(throwable('')).to.throw(error('""'));
     expect(throwable(10)).to.throw(error('10'));
     expect(throwable(0)).to.throw(error('0'));
-    expect(throwable(true)).to.throw(error('true'));
-    expect(throwable(false)).to.throw(error('false'));
     expect(throwable([])).to.throw(error('Array'));
     expect(throwable(undefined)).to.throw(error('undefined'));
     expect(throwable(null)).to.throw(error('null'));
-    expect(throwable(() => undefined)).to.throw(error('Function'));
-    projectData({}, () => ({}));
-    projectData({}, () => 'str', {nameResolver: () => ({})});
-  });
-
-  it('should resolve a schema name by the name resolver', function () {
-    let invoked = 0;
-    const nameResolver = name => {
-      invoked++;
-      expect(name).to.be.eql('mySchema');
-      return {foo: true, bar: false};
-    };
-    const res = projectData({foo: 10, bar: 20}, 'mySchema', {nameResolver});
-    expect(res).to.be.eql({foo: 10});
-    expect(invoked).to.be.eq(1);
+    expect(throwable(() => 'mySchema')).to.throw(error('Function'));
+    throwable('str')();
+    throwable(true)();
+    throwable(false)();
+    throwable({})();
   });
 
-  it('should require the "nameResolver" option when a schema name is provided', function () {
+  it('should throw an error if no name resolver is provided for the schema name', function () {
     const throwable = () => projectData({}, 'mySchema');
     expect(throwable).to.throw(
-      'Projection option "nameResolver" is required ' +
-        'to resolve "mySchema" name.',
+      'Failed to resolve the schema name "mySchema" because ' +
+        'the option "nameResolver" is missing.',
     );
   });
 
-  it('should require the name resolver to return an object', function () {
-    const throwable = v => () =>
-      projectData({}, 'mySchema', {nameResolver: () => v});
-    const error = s =>
-      format(
-        'Name resolver must return an Object, a Function ' +
-          'or a non-empty String, but %s was given.',
-        s,
-      );
-    expect(throwable('')).to.throw(error('""'));
-    expect(throwable(10)).to.throw(error('10'));
-    expect(throwable(0)).to.throw(error('0'));
-    expect(throwable(true)).to.throw(error('true'));
-    expect(throwable(false)).to.throw(error('false'));
-    expect(throwable([])).to.throw(error('Array'));
-    expect(throwable(undefined)).to.throw(error('undefined'));
-    expect(throwable(null)).to.throw(error('null'));
-    throwable({})();
-  });
-
-  it('should resolve a schema name from the factory function', function () {
+  it('should resolve a named schema with a boolean value', function () {
     let invoked = 0;
     const nameResolver = name => {
+      expect(name).to.be.eq('mySchema');
       invoked++;
-      expect(name).to.be.eql('mySchema');
-      return {foo: true, bar: false};
+      return true;
     };
-    const res = projectData({foo: 10, bar: 20}, () => 'mySchema', {
-      nameResolver,
-    });
-    expect(res).to.be.eql({foo: 10});
+    const res = projectData({foo: 10, bar: 20}, 'mySchema', {nameResolver});
+    expect(res).to.be.eql({foo: 10, bar: 20});
     expect(invoked).to.be.eq(1);
   });
 
-  it('should resolve a schema name in the nested object', function () {
+  it('should resolve a named schema with a schema object', function () {
     let invoked = 0;
     const nameResolver = name => {
+      expect(name).to.be.eq('mySchema');
       invoked++;
-      if (name === 'schema1') {
-        return {foo: true, bar: {schema: 'schema2'}};
-      } else if (name === 'schema2') {
-        return {baz: true, qux: false};
-      }
+      return {foo: true, bar: false};
     };
-    const data = {foo: 10, bar: {baz: 20, qux: 30}};
-    const res = projectData(data, 'schema1', {nameResolver});
-    expect(res).to.be.eql({foo: 10, bar: {baz: 20}});
-    expect(invoked).to.be.eq(2);
+    const res = projectData({foo: 10, bar: 20}, 'mySchema', {nameResolver});
+    expect(res).to.be.eql({foo: 10});
+    expect(invoked).to.be.eq(1);
   });
 
-  it('should resolve the schema object through the name chain', function () {
+  it('should resolve a named schema with a factory function', function () {
     let invoked = 0;
     const nameResolver = name => {
+      expect(name).to.be.eq('mySchema');
       invoked++;
-      if (name === 'schema1') {
-        return 'schema2';
-      } else if (name === 'schema2') {
-        return {foo: true, bar: false};
-      }
-      throw new Error('Invalid argument.');
+      return () => ({foo: true, bar: false});
     };
-    const res = projectData({foo: 10, bar: 20}, 'schema1', {nameResolver});
+    const res = projectData({foo: 10, bar: 20}, 'mySchema', {nameResolver});
     expect(res).to.be.eql({foo: 10});
-    expect(invoked).to.be.eq(2);
+    expect(invoked).to.be.eq(1);
   });
 
-  it('should validate a resolved value from the name chain', function () {
+  it('should require a resolved schema to be a valid value', function () {
     const nameResolver = v => name => {
-      if (name === 'schema1') {
-        return 'schema2';
-      } else if (name === 'schema2') {
+      if (name === 'mySchema') {
         return v;
-      } else if (name === 'schema3') {
+      } else if (name === 'nestedSchema') {
         return {};
       }
-      throw new Error('Invalid argument.');
+      throw new Error('Unknown schema');
     };
     const throwable = v => () =>
-      projectData({foo: 10, bar: 20}, 'schema1', {
-        nameResolver: nameResolver(v),
-      });
+      projectData({}, 'mySchema', {nameResolver: nameResolver(v)});
     const error = s =>
       format(
-        'Name resolver must return an Object, a Function ' +
+        'Name resolver must return a Boolean, an Object, a Function ' +
           'or a non-empty String, but %s was given.',
         s,
       );
     expect(throwable('')).to.throw(error('""'));
     expect(throwable(10)).to.throw(error('10'));
     expect(throwable(0)).to.throw(error('0'));
-    expect(throwable(true)).to.throw(error('true'));
-    expect(throwable(false)).to.throw(error('false'));
     expect(throwable([])).to.throw(error('Array'));
     expect(throwable(undefined)).to.throw(error('undefined'));
     expect(throwable(null)).to.throw(error('null'));
-    throwable('schema3')();
+    throwable('nestedSchema')();
+    throwable(true);
+    throwable(false);
+    throwable({});
+    throwable(() => 'nestedSchema');
+    throwable(() => true);
+    throwable(() => false);
+    throwable(() => ({}));
   });
 
-  it('should resolve schema names through the factory function', function () {
+  it('should resolve a factory function from a registered schema', function () {
     let invoked = 0;
     const nameResolver = name => {
       invoked++;
-      if (name === 'schema1') {
-        return () => 'schema2';
-      } else if (name === 'schema2') {
+      if (name === 'schemaA') {
+        return () => 'schemaB';
+      } else if (name === 'schemaB') {
         return {foo: true, bar: false};
       }
-      throw new Error('Invalid argument.');
+      throw new Error('Unknown schema');
     };
-    const res = projectData({foo: 10, bar: 20}, 'schema1', {nameResolver});
+    const res = projectData({foo: 10, bar: 20}, 'schemaA', {nameResolver});
     expect(res).to.be.eql({foo: 10});
     expect(invoked).to.be.eq(2);
   });
 
-  it('should validate a return value of the factory resolved through the name chain', function () {
-    const nameResolver = v => name => {
-      if (name === 'schema1') {
-        return 'schema2';
-      } else if (name === 'schema2') {
-        return () => v;
-      } else if (name === 'schema3') {
-        return {};
+  it('should resolve a schema name from another registered schema', function () {
+    let invoked = 0;
+    const nameResolver = name => {
+      invoked++;
+      if (name === 'schemaA') {
+        return 'schemaB';
+      } else if (name === 'schemaB') {
+        return {foo: true, bar: false};
       }
-      throw new Error('Invalid argument.');
+      throw new Error('Unknown schema');
     };
-    const throwable = v => () =>
-      projectData({foo: 10, bar: 20}, 'schema1', {
-        nameResolver: nameResolver(v),
-      });
-    const error = s =>
-      format(
-        'Schema factory must return an Object ' +
-          'or a non-empty String, but %s was given.',
-        s,
-      );
-    expect(throwable('')).to.throw(error('""'));
-    expect(throwable(10)).to.throw(error('10'));
-    expect(throwable(0)).to.throw(error('0'));
-    expect(throwable(true)).to.throw(error('true'));
-    expect(throwable(false)).to.throw(error('false'));
-    expect(throwable([])).to.throw(error('Array'));
-    expect(throwable(undefined)).to.throw(error('undefined'));
-    expect(throwable(null)).to.throw(error('null'));
-    throwable('schema3')();
-  });
-
-  it('should validate the given schema in the shallow mode', function () {
-    const schema1 = {foo: '?'};
-    const schema2 = {foo: true, bar: {schema: {baz: '?'}}};
-    expect(() => projectData({foo: 10}, schema1)).to.throw(
-      'Property options must be an Object or a Boolean, but "?" was given.',
-    );
-    const res = projectData({foo: 10}, schema2);
-    expect(res).to.be.eql({foo: 10});
-    expect(() => projectData({bar: {baz: 20}}, schema2)).to.throw(
-      'Property options must be an Object or a Boolean, but "?" was given.',
-    );
-  });
-
-  it('should return primitive value as is', function () {
-    expect(projectData('str', {})).to.be.eq('str');
-    expect(projectData('', {})).to.be.eq('');
-    expect(projectData(10, {})).to.be.eq(10);
-    expect(projectData(0, {})).to.be.eq(0);
-    expect(projectData(true, {})).to.be.eq(true);
-    expect(projectData(false, {})).to.be.eq(false);
-    expect(projectData(undefined, {})).to.be.eq(undefined);
-    expect(projectData(null, {})).to.be.eq(null);
-  });
-
-  it('should include a property defined with an empty object', function () {
-    const schema = {foo: {}, bar: {}};
-    const data = {foo: 10, baz: 30};
-    const res = projectData(data, schema);
+    const res = projectData({foo: 10, bar: 20}, 'schemaA', {nameResolver});
     expect(res).to.be.eql({foo: 10});
+    expect(invoked).to.be.eq(2);
   });
 
-  it('should exclude a property when the logical rule is false', function () {
-    const schema = {foo: false, bar: false};
-    const data = {foo: 10, bar: 20};
-    const res = projectData(data, schema);
-    expect(res).to.be.eql({});
-  });
-
-  it('should include a property when the logical rule is true', function () {
-    const schema = {foo: true, bar: true};
-    const data = {foo: 10, bar: 20};
-    const res = projectData(data, schema);
-    expect(res).to.be.eql({foo: 10, bar: 20});
-  });
-
-  it('should exclude a property when the "select" option is false', function () {
-    const schema = {foo: true, bar: {select: false}};
-    const data = {foo: 10, bar: 20};
-    const res = projectData(data, schema);
-    expect(res).to.be.eql({foo: 10});
+  it('should use a boolean schema as a visibility rule for a non-array value', function () {
+    const values = ['str', '', 10, 0, true, false, {p: 1}, {}, [1], []];
+    values.forEach(value => {
+      const res1 = projectData(value, true);
+      const res2 = projectData(value, false);
+      expect(res1).to.be.eql(value);
+      expect(res2).to.be.undefined;
+    });
   });
 
-  it('should include a property when the "select" option is true', function () {
-    const schema = {foo: true, bar: {select: true}};
-    const data = {foo: 10, bar: 20};
-    const res = projectData(data, schema);
-    expect(res).to.be.eql({foo: 10, bar: 20});
+  it('should validate schema parameters of a schema object', function () {
+    const throwable = () => projectData({}, {prop: 10});
+    expect(throwable).to.throw(
+      'Projection schema of the property "prop" must be a Boolean, ' +
+        'an Object, a Function or a non-empty String, but 10 was given.',
+    );
   });
 
-  it('should include a property with a nested schema', function () {
-    const schema = {user: {schema: {id: true, name: false}}};
-    const data = {user: {id: 1, name: 'John Doe'}};
-    const res = projectData(data, schema);
-    expect(res).to.be.eql({user: {id: 1}});
+  it('should validate schema parameters of a factory value', function () {
+    const throwable = () => projectData({}, () => ({prop: 10}));
+    expect(throwable).to.throw(
+      'Projection schema of the property "prop" must be a Boolean, ' +
+        'an Object, a Function or a non-empty String, but 10 was given.',
+    );
   });
 
-  it('should exclude properties not defined in a given schema', function () {
-    const res = projectData({foo: 10, bar: 20}, {});
-    expect(res).to.be.eql({});
+  it('should validate schema parameters from a named schema', function () {
+    let invoked = 0;
+    const nameResolver = name => {
+      expect(name).to.be.eql('mySchema');
+      invoked++;
+      return {prop: 10};
+    };
+    const throwable = () => projectData({}, 'mySchema', {nameResolver});
+    expect(throwable).to.throw(
+      'Projection schema of the property "prop" must be a Boolean, ' +
+        'an Object, a Function or a non-empty String, but 10 was given.',
+    );
+    expect(invoked).to.be.eq(1);
   });
 
-  it('should project an array items by a given schema', function () {
-    const list = [
-      {foo: 10, bar: 20, baz: 30},
-      {bar: 20, qux: 30},
-    ];
-    const expectedList = [{foo: 10}, {}];
-    const res = projectData(list, {foo: true, bar: false});
-    expect(res).to.be.eql(expectedList);
+  it('should return undefined and null as is when an object schema is given', function () {
+    expect(projectData(undefined, {})).to.be.undefined;
+    expect(projectData(null, {})).to.be.null;
   });
 
-  it('should include a property defined with an empty object in the keep unknown mode', function () {
-    const schema = {foo: {}, bar: {}};
-    const data = {foo: 10, bar: 20};
-    const res = projectData(data, schema, {keepUnknown: true});
-    expect(res).to.be.eql({foo: 10, bar: 20});
+  it('should throw an error for a non-object value when an object schema is given', function () {
+    const throwable = v => () => projectData(v, {});
+    const error = s =>
+      format(
+        'Data source of the object projection must be an Object or an Array, ' +
+          'but %s was given.',
+        s,
+      );
+    expect(throwable('str')).to.throw(error('"str"'));
+    expect(throwable('')).to.throw(error('""'));
+    expect(throwable(10)).to.throw(error('10'));
+    expect(throwable(0)).to.throw(error('0'));
+    expect(throwable(true)).to.throw(error('true'));
+    expect(throwable(false)).to.throw(error('false'));
   });
 
-  it('should exclude a property when the logical rule is false in the keep unknown mode', function () {
-    const schema = {foo: false, bar: false};
-    const data = {foo: 10, bar: 20};
-    const res = projectData(data, schema, {keepUnknown: true});
-    expect(res).to.be.eql({});
+  it('should apply an object schema for each array item', function () {
+    const schema = {foo: true, bar: false};
+    const res = projectData(
+      [
+        {foo: 10, bar: 20},
+        {foo: 30, bar: 40},
+      ],
+      schema,
+    );
+    expect(res).to.be.eql([{foo: 10}, {foo: 30}]);
   });
 
-  it('should include a property when the logical rule is true in the keep unknown mode', function () {
-    const schema = {foo: true, bar: true};
-    const data = {foo: 10, bar: 20};
-    const res = projectData(data, schema, {keepUnknown: true});
-    expect(res).to.be.eql({foo: 10, bar: 20});
+  it('should require the property "$keepUnknown" to be a Boolean', function () {
+    const throwable = v => () => projectData({}, {$keepUnknown: v});
+    const error1 = s =>
+      format(
+        'Schema property "$keepUnknown" must be a Boolean, but %s was given.',
+        s,
+      );
+    const error2 = s =>
+      format(
+        'Projection schema of the property "$keepUnknown" must be ' +
+          'a Boolean, an Object, a Function or a non-empty String, ' +
+          'but %s was given.',
+        s,
+      );
+    expect(throwable('str')).to.throw(error1('"str"'));
+    expect(throwable('')).to.throw(error2('""'));
+    expect(throwable(10)).to.throw(error2('10'));
+    expect(throwable(0)).to.throw(error2('0'));
+    expect(throwable([])).to.throw(error2('Array'));
+    expect(throwable({})).to.throw(error1('Object'));
+    throwable(true)();
+    throwable(false)();
+    throwable(undefined)();
   });
 
-  it('should exclude a property when the "select" option is false in the keep unknown mode', function () {
-    const schema = {foo: true, bar: {select: false}};
-    const data = {foo: 10, bar: 20};
-    const res = projectData(data, schema, {keepUnknown: true});
+  it('should prioritize the option "$keepUnknown" over the function option', function () {
+    const res = projectData(
+      {foo: 10},
+      {$keepUnknown: true},
+      {keepUnknown: false},
+    );
     expect(res).to.be.eql({foo: 10});
   });
 
-  it('should include a property when the "select" option is true in the keep unknown mode', function () {
-    const schema = {foo: true, bar: {select: true}};
-    const data = {foo: 10, bar: 20};
-    const res = projectData(data, schema, {keepUnknown: true});
-    expect(res).to.be.eql({foo: 10, bar: 20});
-  });
-
-  it('should include a property with a nested schema in the keep unknown mode', function () {
-    const schema = {user: {schema: {id: true, name: false}}};
-    const data = {user: {id: 1, name: 'John Doe'}};
-    const res = projectData(data, schema, {keepUnknown: true});
-    expect(res).to.be.eql({user: {id: 1}});
-  });
-
-  it('should include unknown properties in the keep unknown mode', function () {
-    const data = {foo: 10, bar: 20};
-    const res = projectData(data, {}, {keepUnknown: true});
-    expect(res).to.be.eql({foo: 10, bar: 20});
-  });
-
-  it('should project an array items in the keep unknown mode', function () {
-    const list = [{foo: 10, bar: 20, baz: 30}, {qux: 30}];
-    const expectedList = [{foo: 10, baz: 30}, {qux: 30}];
-    const res = projectData(list, {foo: true, bar: false}, {keepUnknown: true});
-    expect(res).to.be.eql(expectedList);
-  });
-
-  it('should ignore prototype properties', function () {
-    const data = Object.create({baz: 30});
-    data.foo = 10;
-    data.bar = 20;
-    expect(data).to.be.eql({foo: 10, bar: 20, baz: 30});
-    const res = projectData(data, {foo: true, bar: false});
+  it('should ignore properties not defined in a given schema by default', function () {
+    const res = projectData({foo: 10, bar: 20}, {foo: true});
     expect(res).to.be.eql({foo: 10});
   });
 
-  it('should ignore scope options when no active scope is provided', function () {
-    const schema = {
-      foo: {select: true, scopes: {input: false}},
-      bar: {select: false, scopes: {output: true}},
-    };
-    const res = projectData({foo: 10, bar: 20}, schema);
-    expect(res).to.be.eql({foo: 10});
+  it('should keep unknown properties when the keep unknown mode is enabled', function () {
+    const res = projectData(
+      {foo: 10, bar: 20},
+      {foo: true},
+      {keepUnknown: true},
+    );
+    expect(res).to.be.eql({foo: 10, bar: 20});
   });
 
-  it('should project the active scope by a boolean rule', function () {
-    const schema = {
-      foo: {scopes: {input: true}},
-      bar: {scopes: {input: false}},
-    };
-    const res = projectData({foo: 10, bar: 20}, schema, {scope: 'input'});
+  it('should not add properties that do not exist in a given data', function () {
+    const res = projectData({foo: 10}, {foo: true, bar: true});
     expect(res).to.be.eql({foo: 10});
   });
 
-  it('should project the active scope by the select option', function () {
-    const schema = {
-      foo: {scopes: {input: {select: true}}},
-      bar: {scopes: {input: {select: false}}},
-    };
-    const res = projectData({foo: 10, bar: 20}, schema, {scope: 'input'});
+  it('should not add inline options to a projection result', function () {
+    const res = projectData(
+      {foo: 10, bar: 20},
+      {$keepUnknown: true, foo: true, bar: false},
+    );
     expect(res).to.be.eql({foo: 10});
   });
 
-  it('should prioritize the scope rule over common rules', function () {
-    const schema = {
-      foo: {select: false, scopes: {input: true}},
-      bar: {select: true, scopes: {input: false}},
-    };
-    const res = projectData({foo: 10, bar: 20}, schema, {scope: 'input'});
+  it('should use a boolean rule to project an object property', function () {
+    const res = projectData({foo: 10, bar: 20}, {foo: true, bar: false});
     expect(res).to.be.eql({foo: 10});
   });
 
-  it('should ignore scope options not matched with the active scope', function () {
-    const schema = {
-      foo: {scopes: {input: true, output: false}},
-      bar: {scopes: {input: false, output: true}},
-    };
-    const res = projectData({foo: 10, bar: 20}, schema, {scope: 'input'});
-    expect(res).to.be.eql({foo: 10});
+  it('should project a nested object for a nested schema', function () {
+    const res = projectData(
+      {foo: {bar: 10, baz: 20}},
+      {foo: {bar: true, baz: false}},
+    );
+    expect(res).to.be.eql({foo: {bar: 10}});
   });
 
-  it('should include a property in the keep unknown mode if no rule for the active scope is specified', function () {
-    const schema = {
-      foo: {scopes: {input: true}},
-      bar: {scopes: {input: false}},
-      baz: {scopes: {output: true}},
-    };
-    const data = {foo: 10, bar: 20, baz: 30, qux: 40};
-    const res = projectData(data, schema, {scope: 'input', keepUnknown: true});
-    expect(res).to.be.eql({foo: 10, baz: 30, qux: 40});
+  it('should resolve a factory function in a nested schema', function () {
+    const res = projectData(
+      {foo: {bar: 10, baz: 20}},
+      {foo: () => ({bar: true, baz: false})},
+    );
+    expect(res).to.be.eql({foo: {bar: 10}});
   });
 
-  it('should prioritize scope options over common options in the keep unknown mode', function () {
-    const schema = {
-      foo: {select: false, scopes: {input: true}},
-      bar: {select: false, scopes: {input: {select: true}}},
+  it('should resolve a schema name in a nested schema', function () {
+    let invoked = 0;
+    const nameResolver = name => {
+      invoked++;
+      expect(name).to.be.eq('mySchema');
+      return {bar: true, baz: false};
     };
-    const data = {foo: 10, bar: 20, baz: 30};
-    const res = projectData(data, schema, {scope: 'input', keepUnknown: true});
-    expect(res).to.be.eql({foo: 10, bar: 20, baz: 30});
+    const res = projectData(
+      {foo: {bar: 10, baz: 20}},
+      {foo: 'mySchema'},
+      {nameResolver},
+    );
+    expect(res).to.be.eql({foo: {bar: 10}});
+    expect(invoked).to.be.eq(1);
   });
 
-  it('should project a nested object by a given schema', function () {
-    const schema = {foo: true, bar: {schema: {baz: true, qux: false}}};
-    const data = {foo: 10, bar: {baz: 20, qux: 30, buz: 40}};
-    const res = projectData(data, schema, {scope: 'input'});
-    expect(res).to.be.eql({foo: 10, bar: {baz: 20}});
+  it('should project a nested object in the keep unknown mode', function () {
+    const data = {foo: {bar: 10, baz: 20, qux: 30}};
+    const schema = {foo: {bar: true, baz: false}};
+    const res = projectData(data, schema, {keepUnknown: true});
+    expect(res).to.be.eql({foo: {bar: 10, qux: 30}});
   });
 });

+ 0 - 6
src/projection-schema-registry.js

@@ -19,12 +19,6 @@ export class ProjectionSchemaRegistry extends Service {
    */
   defineSchema(schemaDef) {
     validateProjectionSchemaDefinition(schemaDef);
-    if (this._definitions.has(schemaDef.name)) {
-      throw new InvalidArgumentError(
-        'Projection schema %v is already registered.',
-        schemaDef.name,
-      );
-    }
     this._definitions.set(schemaDef.name, schemaDef);
     return this;
   }

+ 14 - 14
src/projection-schema-registry.spec.js

@@ -3,7 +3,7 @@ import {ProjectionSchemaRegistry} from './projection-schema-registry.js';
 
 describe('ProjectionSchemaRegistry', function () {
   describe('defineSchema', function () {
-    it('should validate the given definition', function () {
+    it('should validate a given definition', function () {
       const S = new ProjectionSchemaRegistry();
       const throwable = () => S.defineSchema({});
       expect(throwable).to.throw(
@@ -12,7 +12,7 @@ describe('ProjectionSchemaRegistry', function () {
       );
     });
 
-    it('should register the given definition', function () {
+    it('should register a given definition', function () {
       const def = {name: 'mySchema', schema: {}};
       const S = new ProjectionSchemaRegistry();
       S.defineSchema(def);
@@ -20,19 +20,19 @@ describe('ProjectionSchemaRegistry', function () {
       expect(res).to.be.eql(def);
     });
 
-    it('should throw an error if the schema name is already registered', function () {
+    it('should allow override a registered schema', function () {
       const S = new ProjectionSchemaRegistry();
-      const def = {name: 'mySchema', schema: {}};
-      S.defineSchema(def);
-      const throwable = () => S.defineSchema(def);
-      expect(throwable).to.throw(
-        'Projection schema "mySchema" is already registered.',
-      );
+      const def1 = {name: 'mySchema', schema: {foo: true}};
+      const def2 = {name: 'mySchema', schema: {foo: false}};
+      S.defineSchema(def1);
+      expect(S.getDefinition(def1.name)).to.be.eql(def1);
+      S.defineSchema(def2);
+      expect(S.getDefinition(def2.name)).to.be.eql(def2);
     });
   });
 
   describe('hasSchema', function () {
-    it('should return true when the given name is registered', function () {
+    it('should return true when a given name is registered', function () {
       const S = new ProjectionSchemaRegistry();
       expect(S.hasSchema('mySchema')).to.be.false;
       S.defineSchema({name: 'mySchema', schema: {}});
@@ -41,13 +41,13 @@ describe('ProjectionSchemaRegistry', function () {
   });
 
   describe('getSchema', function () {
-    it('should throw an error if the given name is not registered', function () {
+    it('should throw an error if a given name is not registered', function () {
       const S = new ProjectionSchemaRegistry();
       const throwable = () => S.getSchema('mySchema');
       expect(throwable).to.throw('Projection schema "mySchema" is not found.');
     });
 
-    it('should return a registered schema by the given name', function () {
+    it('should return a registered schema by a given name', function () {
       const def = {name: 'mySchema', schema: {foo: true, bar: false}};
       const S = new ProjectionSchemaRegistry();
       S.defineSchema(def);
@@ -57,13 +57,13 @@ describe('ProjectionSchemaRegistry', function () {
   });
 
   describe('getDefinition', function () {
-    it('should throw an error if the given name is not registered', function () {
+    it('should throw an error if a given name is not registered', function () {
       const S = new ProjectionSchemaRegistry();
       const throwable = () => S.getDefinition('mySchema');
       expect(throwable).to.throw('Schema definition "mySchema" is not found.');
     });
 
-    it('should return a registered definition by the given name', function () {
+    it('should return a registered definition by a given name', function () {
       const def = {name: 'mySchema', schema: {}};
       const S = new ProjectionSchemaRegistry();
       S.defineSchema(def);

+ 8 - 25
src/projection-schema.d.ts

@@ -11,7 +11,7 @@ export type ProjectionSchema =
  */
 export type ProjectionSchemaFactory = (
   ...args: any[]
-) => ProjectionSchemaProperties | ProjectionSchemaName;
+) => boolean | ProjectionSchemaProperties | ProjectionSchemaName;
 
 /**
  * Projection schema name.
@@ -22,28 +22,11 @@ export type ProjectionSchemaName = string;
  * Projection schema properties.
  */
 export type ProjectionSchemaProperties = {
-  [property: string]: boolean | ProjectionSchemaPropertyOptions | undefined;
-};
-
-/**
- * Projection schema property options.
- */
-export type ProjectionSchemaPropertyOptions = {
-  select?: boolean;
-  scopes?: ProjectionSchemaScopes;
-  schema?: ProjectionSchema;
-};
-
-/**
- * Projection schema scopes.
- */
-export type ProjectionSchemaScopes = {
-  [scope: string]: boolean | ProjectionSchemaScopeOptions | undefined;
-};
-
-/**
- * Projection schema scope options.
- */
-export type ProjectionSchemaScopeOptions = {
-  select?: boolean;
+  $keepUnknown?: boolean;
+  [property: string]:
+    | boolean
+    | ProjectionSchemaProperties
+    | ProjectionSchemaFactory
+    | string
+    | undefined;
 };

+ 2 - 2
src/validate-projection-schema-definition.spec.js

@@ -3,7 +3,7 @@ import {format} from '@e22m4u/js-format';
 import {validateProjectionSchemaDefinition} from './validate-projection-schema-definition.js';
 
 describe('validateProjectionSchemaDefinition', function () {
-  it('should require the "schemaDef" argument to be an object', function () {
+  it('should require the "schemaDef" parameter to be an Object', function () {
     const throwable = v => () => validateProjectionSchemaDefinition(v);
     const error = s =>
       format('Schema definition must be an Object, but %s was given.', s);
@@ -20,7 +20,7 @@ describe('validateProjectionSchemaDefinition', function () {
     throwable({name: 'mySchema', schema: {}})();
   });
 
-  it('should require the "name" option to be a non-empty string', function () {
+  it('should require the "name" option to be a non-empty String', function () {
     const throwable = v => () =>
       validateProjectionSchemaDefinition({name: v, schema: {}});
     const error = s =>

+ 27 - 88
src/validate-projection-schema.js

@@ -12,18 +12,19 @@ export function validateProjectionSchema(
   shallowMode = false,
   validatedSchemas = new Set(),
 ) {
-  // если схема не является объектом, функцией
-  // и не пустой строкой, то выбрасывается ошибка
+  // если схема не является корректным
+  // значением, то выбрасывается ошибка
   if (
-    !schema ||
-    (typeof schema !== 'object' &&
+    (!schema && schema !== false) ||
+    (typeof schema !== 'boolean' &&
+      typeof schema !== 'object' &&
       typeof schema !== 'function' &&
       typeof schema !== 'string') ||
     Array.isArray(schema)
   ) {
     throw new InvalidArgumentError(
-      'Projection schema must be an Object, a Function ' +
-        'or a non-empty String, but %v was given.',
+      'Projection schema must be a Boolean, an Object, ' +
+        'a Function or a non-empty String, but %v was given.',
       schema,
     );
   }
@@ -59,95 +60,33 @@ export function validateProjectionSchema(
   validatedSchemas.add(schema);
   // schema[k]
   Object.keys(schema).forEach(propName => {
-    const propOptions = schema[propName];
-    if (propOptions === undefined) {
+    // если схема свойства не определена,
+    // то свойство пропускается
+    const propSchema = schema[propName];
+    if (propSchema === undefined) {
       return;
     }
+    // если схема свойства не является корректным
+    // значением, то выбрасывается ошибка
     if (
-      propOptions === null ||
-      (typeof propOptions !== 'object' && typeof propOptions !== 'boolean') ||
-      Array.isArray(propOptions)
+      (!propSchema && propSchema !== false) ||
+      (typeof propSchema !== 'boolean' &&
+        typeof propSchema !== 'object' &&
+        typeof propSchema !== 'function' &&
+        typeof propSchema !== 'string') ||
+      Array.isArray(propSchema)
     ) {
       throw new InvalidArgumentError(
-        'Property options must be an Object or a Boolean, but %v was given.',
-        propOptions,
+        'Projection schema of the property %v must be a Boolean, ' +
+          'an Object, a Function or a non-empty String, but %v was given.',
+        propName,
+        propSchema,
       );
     }
-    if (typeof propOptions === 'boolean') {
-      return;
-    }
-    // schema[k].select
-    if (
-      propOptions.select !== undefined &&
-      typeof propOptions.select !== 'boolean'
-    ) {
-      throw new InvalidArgumentError(
-        'Property option "select" must be a Boolean, but %v was given.',
-        propOptions.select,
-      );
-    }
-    // schema[k].scopes
-    if (propOptions.scopes !== undefined) {
-      if (
-        !propOptions.scopes ||
-        typeof propOptions.scopes !== 'object' ||
-        Array.isArray(propOptions.scopes)
-      ) {
-        throw new InvalidArgumentError(
-          'Property option "scopes" must be an Object, but %v was given.',
-          propOptions.scopes,
-        );
-      }
-      Object.values(propOptions.scopes).forEach(scopeOptions => {
-        if (scopeOptions === undefined) {
-          return;
-        }
-        // schema[k].scopes[k]
-        if (
-          scopeOptions === null ||
-          (typeof scopeOptions !== 'object' &&
-            typeof scopeOptions !== 'boolean') ||
-          Array.isArray(scopeOptions)
-        ) {
-          throw new InvalidArgumentError(
-            'Scope options must be an Object or a Boolean, but %v was given.',
-            scopeOptions,
-          );
-        }
-        // schema[k].scopes[k].select
-        if (
-          scopeOptions.select !== undefined &&
-          typeof scopeOptions.select !== 'boolean'
-        ) {
-          throw new InvalidArgumentError(
-            'Scope option "select" must be a Boolean, but %v was given.',
-            scopeOptions.select,
-          );
-        }
-      });
-    }
-    // schema[k].schema
-    if (propOptions.schema !== undefined) {
-      if (
-        !propOptions.schema ||
-        (typeof propOptions.schema !== 'object' &&
-          typeof propOptions.schema !== 'function' &&
-          typeof propOptions.schema !== 'string') ||
-        Array.isArray(propOptions.schema)
-      ) {
-        throw new InvalidArgumentError(
-          'Property option "schema" must be an Object, a Function' +
-            ' or a non-empty String, but %v was given.',
-          propOptions.schema,
-        );
-      }
-      if (!shallowMode && typeof propOptions.schema === 'object') {
-        validateProjectionSchema(
-          propOptions.schema,
-          shallowMode,
-          validatedSchemas,
-        );
-      }
+    // если схема свойства является объектом,
+    // то выполняется рекурсивная проверка
+    if (!shallowMode && typeof propSchema === 'object') {
+      validateProjectionSchema(propSchema, shallowMode, validatedSchemas);
     }
   });
 }

+ 63 - 92
src/validate-projection-schema.spec.js

@@ -7,19 +7,19 @@ describe('validateProjectionSchema', function () {
     const throwable = v => () => validateProjectionSchema(v);
     const error = s =>
       format(
-        'Projection schema must be an Object, a Function ' +
+        'Projection schema must be a Boolean, an Object, a Function ' +
           'or a non-empty String, but %s was given.',
         s,
       );
     expect(throwable('')).to.throw(error('""'));
     expect(throwable(10)).to.throw(error('10'));
     expect(throwable(0)).to.throw(error('0'));
-    expect(throwable(true)).to.throw(error('true'));
-    expect(throwable(false)).to.throw(error('false'));
     expect(throwable([])).to.throw(error('Array'));
     expect(throwable(undefined)).to.throw(error('undefined'));
     expect(throwable(null)).to.throw(error('null'));
     throwable('str')();
+    throwable(true)();
+    throwable(false)();
     throwable({})();
     throwable(() => ({}))();
   });
@@ -63,165 +63,136 @@ describe('validateProjectionSchema', function () {
     throwable(undefined)();
   });
 
-  it('should skip validation if the given schema is already validated', function () {
+  it('should skip validation if a given schema is already validated', function () {
     const validatedSchemas = new Set();
     const schema = {foo: 10};
     validatedSchemas.add(schema);
     validateProjectionSchema(schema, false, validatedSchemas);
   });
 
-  it('should require property options to be an object or a boolean', function () {
+  it('should ignore a non-object schema', function () {
+    validateProjectionSchema('str');
+    validateProjectionSchema(true);
+    validateProjectionSchema(false);
+    validateProjectionSchema(() => undefined);
+  });
+
+  it('should add visited objects to the schemas set', function () {
+    const schema = {foo: {bar: {baz: true}}};
+    const validatedSchemas = new Set();
+    validateProjectionSchema(schema, undefined, validatedSchemas);
+    const visitedSchemas = Array.from(validatedSchemas.values());
+    expect(visitedSchemas[0]).to.be.eql(schema);
+    expect(visitedSchemas[1]).to.be.eql(schema.foo);
+    expect(visitedSchemas[2]).to.be.eql(schema.foo.bar);
+    expect(visitedSchemas).to.have.length(3);
+  });
+
+  it('should ignore an undefined value as a property schema', function () {
+    validateProjectionSchema({foo: undefined});
+  });
+
+  it('should require a property schema to be a valid value', function () {
     const throwable = v => () => validateProjectionSchema({foo: v});
     const error = s =>
       format(
-        'Property options must be an Object or a Boolean, but %s was given.',
+        'Projection schema of the property "foo" must be a Boolean, ' +
+          'an Object, a Function or a non-empty String, but %s was given.',
         s,
       );
-    expect(throwable('str')).to.throw(error('"str"'));
     expect(throwable('')).to.throw(error('""'));
     expect(throwable(10)).to.throw(error('10'));
     expect(throwable(0)).to.throw(error('0'));
     expect(throwable([])).to.throw(error('Array'));
     expect(throwable(null)).to.throw(error('null'));
-    expect(throwable(() => ({}))).to.throw(error('Function'));
-    throwable({})();
+    throwable('str')();
     throwable(true)();
     throwable(false)();
+    throwable({})();
+    throwable(() => undefined)();
     throwable(undefined)();
   });
 
-  it('should require the "select" option to be a boolean', function () {
-    const throwable = v => () => validateProjectionSchema({foo: {select: v}});
+  it('should validate a property schema recursively', function () {
+    const throwable = v => () => validateProjectionSchema({foo: {bar: v}});
     const error = s =>
       format(
-        'Property option "select" must be a Boolean, but %s was given.',
+        'Projection schema of the property "bar" must be a Boolean, ' +
+          'an Object, a Function or a non-empty String, but %s was given.',
         s,
       );
-    expect(throwable('str')).to.throw(error('"str"'));
     expect(throwable('')).to.throw(error('""'));
     expect(throwable(10)).to.throw(error('10'));
     expect(throwable(0)).to.throw(error('0'));
-    expect(throwable({})).to.throw(error('Object'));
     expect(throwable([])).to.throw(error('Array'));
     expect(throwable(null)).to.throw(error('null'));
-    expect(throwable(() => ({}))).to.throw(error('Function'));
+    throwable('str')();
     throwable(true)();
     throwable(false)();
-    throwable(undefined)();
-  });
-
-  it('should require the "scopes" option to be an object', function () {
-    const throwable = v => () => validateProjectionSchema({foo: {scopes: v}});
-    const error = s =>
-      format(
-        'Property option "scopes" must be an Object, but %s was given.',
-        s,
-      );
-    expect(throwable('str')).to.throw(error('"str"'));
-    expect(throwable('')).to.throw(error('""'));
-    expect(throwable(10)).to.throw(error('10'));
-    expect(throwable(0)).to.throw(error('0'));
-    expect(throwable(true)).to.throw(error('true'));
-    expect(throwable(false)).to.throw(error('false'));
-    expect(throwable([])).to.throw(error('Array'));
-    expect(throwable(null)).to.throw(error('null'));
-    expect(throwable(() => ({}))).to.throw(error('Function'));
     throwable({})();
+    throwable(() => undefined)();
     throwable(undefined)();
   });
 
-  it('should require the scope options to be an object or a boolean', function () {
-    const throwable = v => () =>
-      validateProjectionSchema({foo: {scopes: {input: v}}});
+  it('should validate a given schema in the shallow mode', function () {
+    const throwable = v => () => validateProjectionSchema(v, true);
     const error = s =>
       format(
-        'Scope options must be an Object or a Boolean, but %s was given.',
+        'Projection schema must be a Boolean, an Object, ' +
+          'a Function or a non-empty String, but %s was given.',
         s,
       );
-    expect(throwable('str')).to.throw(error('"str"'));
     expect(throwable('')).to.throw(error('""'));
     expect(throwable(10)).to.throw(error('10'));
     expect(throwable(0)).to.throw(error('0'));
     expect(throwable([])).to.throw(error('Array'));
+    expect(throwable(undefined)).to.throw(error('undefined'));
     expect(throwable(null)).to.throw(error('null'));
-    expect(throwable(() => ({}))).to.throw(error('Function'));
-    throwable({})();
-    throwable(true)();
-    throwable(false)();
-    throwable(undefined)();
-  });
-
-  it('should require the "select" option of scope to be a boolean', function () {
-    const throwable = v => () =>
-      validateProjectionSchema({foo: {scopes: {input: {select: v}}}});
-    const error = s =>
-      format('Scope option "select" must be a Boolean, but %s was given.', s);
-    expect(throwable('str')).to.throw(error('"str"'));
-    expect(throwable('')).to.throw(error('""'));
-    expect(throwable(10)).to.throw(error('10'));
-    expect(throwable(0)).to.throw(error('0'));
-    expect(throwable({})).to.throw(error('Object'));
-    expect(throwable([])).to.throw(error('Array'));
-    expect(throwable(null)).to.throw(error('null'));
-    expect(throwable(() => ({}))).to.throw(error('Function'));
+    throwable('str')();
     throwable(true)();
     throwable(false)();
-    throwable(undefined)();
+    throwable({})();
+    throwable(() => undefined)();
   });
 
-  it('should require the "schema" option to be a valid value', function () {
-    const throwable = v => () => validateProjectionSchema({foo: {schema: v}});
+  it('should validate a property schema in the shallow mode', function () {
+    const throwable = v => () => validateProjectionSchema({foo: v}, true);
     const error = s =>
       format(
-        'Property option "schema" must be an Object, a Function ' +
-          'or a non-empty String, but %s was given.',
+        'Projection schema of the property "foo" must be a Boolean, ' +
+          'an Object, a Function or a non-empty String, but %s was given.',
         s,
       );
     expect(throwable('')).to.throw(error('""'));
     expect(throwable(10)).to.throw(error('10'));
     expect(throwable(0)).to.throw(error('0'));
-    expect(throwable(true)).to.throw(error('true'));
-    expect(throwable(false)).to.throw(error('false'));
     expect(throwable([])).to.throw(error('Array'));
     expect(throwable(null)).to.throw(error('null'));
     throwable('str')();
+    throwable(true)();
+    throwable(false)();
     throwable({})();
-    throwable(() => ({}))();
+    throwable(() => undefined)();
     throwable(undefined)();
   });
 
-  it('should validate the embedded schema', function () {
-    const throwable = () =>
-      validateProjectionSchema({foo: {schema: {bar: 10}}});
-    expect(throwable).to.throw(
-      'Property options must be an Object or a Boolean, but 10 was given.',
-    );
-  });
-
-  it('should validate the given schema in the shallow mode', function () {
-    const throwable = () => validateProjectionSchema({foo: 10}, true);
-    expect(throwable).to.throw(
-      'Property options must be an Object or a Boolean, but 10 was given.',
-    );
-  });
-
-  it('should skip embedded schema validation in the shallow mode', function () {
-    validateProjectionSchema({foo: {schema: {bar: 10}}}, true);
+  it('should skip validation of a nested property in the shallow mode', function () {
+    validateProjectionSchema({foo: {bar: 10}}, true);
   });
 
   it('should allow circular schema validation', function () {
-    const schemaA = {foo: {select: true}};
-    const schemaB = {bar: {select: true}};
-    schemaA.foo.schema = schemaB;
-    schemaB.bar.schema = schemaA;
+    const schemaA = {};
+    const schemaB = {};
+    schemaA.foo = schemaB;
+    schemaB.bar = schemaA;
     validateProjectionSchema(schemaA);
   });
 
   it('should allow circular schema validation in the shallow mode', function () {
-    const schemaA = {foo: {select: true}};
-    const schemaB = {bar: {select: true}};
-    schemaA.foo.schema = schemaB;
-    schemaB.bar.schema = schemaA;
+    const schemaA = {};
+    const schemaB = {};
+    schemaA.foo = schemaB;
+    schemaB.bar = schemaA;
     validateProjectionSchema(schemaA, true);
   });
 });