Просмотр исходного кода

feat: adds schema name resolver

e22m4u 23 часов назад
Родитель
Сommit
3de13afb89

+ 89 - 8
README.md

@@ -3,8 +3,8 @@
 JavaScript модуль для работы с проекцией данных.
 
 Модуль использует декларативные схемы для определения правил видимости полей
-данных. Поддерживается вложенность, функции-фабрики, области проекции
-и строгий режим.
+данных. Поддерживается вложенность, функции-фабрики, именованных схемы, области
+проекции и строгий режим.
 
 ## Содержание
 
@@ -17,6 +17,7 @@ JavaScript модуль для работы с проекцией данных.
     - [Вложенные схемы](#вложенные-схемы)
     - [Область проекции](#область-проекции)
     - [Фабрика схемы](#фабрика-схемы)
+    - [Именованные схемы](#именованные-схемы)
 - [Тесты](#тесты)
 - [Лицензия](#лицензия)
 
@@ -38,12 +39,13 @@ npm install @e22m4u/js-data-projection
 
 Сигнатура:
 
-- `projectData(schemaOrFactory, data, [options])` - возвращает проекцию;
-  - `schemaOrFactory: object | Function` - схема проекции или фабрика;
-  - `data: object | object[]` - проектируемые данные;
-  - `options?: object` - объект настроек;
-    - `strict?: boolean` - строгий режим;
-    - `scope?: string` - область проекции;
+- `projectData(schemaOrSource, data, [options])`: возвращает проекцию;
+  - `schemaOrFactory: object | Function | string`: схема, фабрика или имя схемы;
+  - `data: object | object[]`: проектируемые данные;
+  - `options?: object`: объект настроек;
+    - `strict?: boolean`: строгий режим;
+    - `scope?: string`: область проекции;
+    - `resolver?: (name: string) => object`: фабрика именных схем;
 
 #### Создание проекции
 
@@ -295,6 +297,85 @@ console.log(result);
 // }
 ```
 
+#### Именованные схемы
+
+Для использования именованных схем требуется определить функцию для разрешения
+имен. Функция передается в опцию `resolver`, принимает имя схемы в виде строки
+и возвращает объект соответствующей схемы.
+
+```js
+import {projectData} from '@e22m4u/js-data-projection';
+
+// функция для разрешения имен
+const resolver = (name) => {
+  if (name === 'user') {
+    return {id: true, name: true, password: false};
+  }
+  throw new Error(`Schema "${name}" is not found!`);
+};
+
+const data = {
+  id: 1,
+  name: 'Fedor',
+  password: 'pass123',
+};
+
+const result = projectData(
+  'user', // <= вместо схемы передается имя
+  data,
+  {resolver}, // <= разрешающая функция
+);
+console.log(result);
+// {
+//   id: 1,
+//   name: 'Fedor'
+// }
+```
+
+Именованные схемы могут быть использованы во вложенной структуре.
+Свойство `schema` принимает имя схемы, которое будет передано
+в разрешающую функцию во время создания проекции.
+
+```js
+import {projectData} from '@e22m4u/js-data-projection';
+
+const userSchema = {
+  name: true,
+  address: {
+    schema: 'address', // <= передается имя вложенной схемы
+  }
+}
+
+// функция для разрешения имен
+const resolver = (name) => {
+  if (name === 'address') {
+    return {city: true, zip: false};
+  }
+  throw new Error(`Schema "${name}" is not found!`);
+};
+
+const data = {
+  name: 'Fedor',
+  address: {
+    city: 'Moscow',
+    zip: 123456,
+  },
+};
+
+const result = projectData(
+  userSchema,
+  data,
+  {resolver}, // <= разрешающая функция
+);
+console.log(result);
+// {
+//   name: 'Fedor',
+//   address: {
+//     city: 'Moscow'
+//   }
+// }
+```
+
 ## Тесты
 
 ```bash

+ 36 - 11
dist/cjs/index.cjs

@@ -66,9 +66,9 @@ function validateProjectionSchema(schema, shallowMode = false) {
       );
     }
     if (options.schema !== void 0) {
-      if (!options.schema || typeof options.schema !== "object" && typeof options.schema !== "function" || Array.isArray(options.schema)) {
+      if (!options.schema || typeof options.schema !== "object" && typeof options.schema !== "function" && typeof options.schema !== "string" || Array.isArray(options.schema)) {
         throw new import_js_format.InvalidArgumentError(
-          "Embedded schema must be an Object or a Function that returns a schema, but %v was given.",
+          "Embedded schema must be an Object, a Function or a non-empty String, but %v was given.",
           options.schema
         );
       }
@@ -112,11 +112,11 @@ function validateProjectionSchema(schema, shallowMode = false) {
 __name(validateProjectionSchema, "validateProjectionSchema");
 
 // src/project-data.js
-function projectData(schemaOrFactory, data, options = void 0) {
-  if (!schemaOrFactory || typeof schemaOrFactory !== "object" && typeof schemaOrFactory !== "function" || Array.isArray(schemaOrFactory)) {
+function projectData(schemaOrSource, data, options = void 0) {
+  if (!schemaOrSource || typeof schemaOrSource !== "object" && typeof schemaOrSource !== "function" && typeof schemaOrSource !== "string" || Array.isArray(schemaOrSource)) {
     throw new import_js_format2.InvalidArgumentError(
-      "Projection schema must be an Object or a Function that returns a schema object, but %v was given.",
-      schemaOrFactory
+      "Projection schema must be an Object, a Function or a non-empty String, but %v was given.",
+      schemaOrSource
     );
   }
   if (options !== void 0) {
@@ -138,15 +138,35 @@ function projectData(schemaOrFactory, data, options = void 0) {
         options.scope
       );
     }
+    if (options.resolver !== void 0 && typeof options.resolver !== "function") {
+      throw new import_js_format2.InvalidArgumentError(
+        'Option "resolver" must be a Function, but %v was given.',
+        options.resolver
+      );
+    }
   }
   const strict = Boolean(options && options.strict);
   const scope = options && options.scope || void 0;
-  let schema = schemaOrFactory;
-  if (typeof schemaOrFactory === "function") {
-    schema = schemaOrFactory();
+  let schema = schemaOrSource;
+  if (typeof schemaOrSource === "function") {
+    schema = schemaOrSource();
     if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
       throw new import_js_format2.InvalidArgumentError(
-        "Projection schema factory must return an Object, but %v was given.",
+        "Schema factory must return an Object, but %v was given.",
+        schema
+      );
+    }
+  } else if (typeof schemaOrSource === "string") {
+    if (!options || !options.resolver) {
+      throw new import_js_format2.InvalidArgumentError(
+        "Unable to resolve the schema %v without a specified resolver.",
+        schemaOrSource
+      );
+    }
+    schema = options.resolver(schemaOrSource);
+    if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
+      throw new import_js_format2.InvalidArgumentError(
+        "Schema resolver must return an Object, but %v was given.",
         schema
       );
     }
@@ -161,7 +181,12 @@ function projectData(schemaOrFactory, data, options = void 0) {
   const result = {};
   const keys = Object.keys(strict ? schema : data);
   for (const key of keys) {
-    if (!(key in data)) continue;
+    if (!(key in data)) {
+      continue;
+    }
+    if (!Object.prototype.hasOwnProperty.call(data, key)) {
+      continue;
+    }
     const propOptionsOrBoolean = schema[key];
     if (_shouldSelect(propOptionsOrBoolean, strict, scope)) {
       const value = data[key];

+ 9 - 3
src/project-data.d.ts

@@ -1,25 +1,31 @@
 import {
   ProjectionSchema,
-  ProjectionSchemaFactory,
+  ProjectionSchemaSource,
 } from './projection-schema.js';
 
+/**
+ * Projection schema resolver.
+ */
+export type ProjectionSchemaResolver = (schemaName: string) => ProjectionSchema;
+
 /**
  * Project data options.
  */
 export type ProjectDataOptions = {
   strict?: boolean;
   scope?: string;
+  resolver?: ProjectionSchemaResolver;
 };
 
 /**
  * Project data.
  *
- * @param schemaOrFactory
+ * @param schemaOrSource
  * @param data
  * @param options
  */
 export declare function projectData<T>(
-  schemaOrFactory: ProjectionSchema | ProjectionSchemaFactory,
+  schemaOrSource: ProjectionSchema | ProjectionSchemaSource,
   data: T,
   options?: ProjectDataOptions,
 ): T;

+ 54 - 15
src/project-data.js

@@ -4,23 +4,24 @@ import {validateProjectionSchema} from './validate-projection-schema.js';
 /**
  * Project data.
  *
- * @param {object|Function} schemaOrFactory
+ * @param {object|Function|string} schemaOrSource
  * @param {object} data
  * @param {object|undefined} options
  * @returns {*}
  */
-export function projectData(schemaOrFactory, data, options = undefined) {
-  // schemaOrFactory
+export function projectData(schemaOrSource, data, options = undefined) {
+  // schemaOrSource
   if (
-    !schemaOrFactory ||
-    (typeof schemaOrFactory !== 'object' &&
-      typeof schemaOrFactory !== 'function') ||
-    Array.isArray(schemaOrFactory)
+    !schemaOrSource ||
+    (typeof schemaOrSource !== 'object' &&
+      typeof schemaOrSource !== 'function' &&
+      typeof schemaOrSource !== 'string') ||
+    Array.isArray(schemaOrSource)
   ) {
     throw new InvalidArgumentError(
-      'Projection schema must be an Object or a Function ' +
-        'that returns a schema object, but %v was given.',
-      schemaOrFactory,
+      'Projection schema must be an Object, a Function ' +
+        'or a non-empty String, but %v was given.',
+      schemaOrSource,
     );
   }
   // options
@@ -48,19 +49,50 @@ export function projectData(schemaOrFactory, data, options = undefined) {
         options.scope,
       );
     }
+    // options.resolver
+    if (
+      options.resolver !== undefined &&
+      typeof options.resolver !== 'function'
+    ) {
+      throw new InvalidArgumentError(
+        'Option "resolver" must be a Function, but %v was given.',
+        options.resolver,
+      );
+    }
   }
   const strict = Boolean(options && options.strict);
   const scope = (options && options.scope) || undefined;
   // если вместо схемы передана фабрика,
   // то извлекается фабричное значение
-  let schema = schemaOrFactory;
-  if (typeof schemaOrFactory === 'function') {
-    schema = schemaOrFactory();
+  let schema = schemaOrSource;
+  if (typeof schemaOrSource === 'function') {
+    schema = schemaOrSource();
     // если не удалось извлечь схему проекции,
     // то выбрасывается ошибка
     if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
       throw new InvalidArgumentError(
-        'Projection schema factory must return an Object, but %v was given.',
+        'Schema factory must return an Object, but %v was given.',
+        schema,
+      );
+    }
+  }
+  // если вместо схемы передана строка,
+  // то строка используется как название схемы
+  else if (typeof schemaOrSource === 'string') {
+    // если функция разрешения схемы не определена,
+    // то выбрасывается ошибка
+    if (!options || !options.resolver) {
+      throw new InvalidArgumentError(
+        'Unable to resolve the schema %v without a specified resolver.',
+        schemaOrSource,
+      );
+    }
+    schema = options.resolver(schemaOrSource);
+    // если не удалось извлечь схему проекции,
+    // то выбрасывается ошибка
+    if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
+      throw new InvalidArgumentError(
+        'Schema resolver must return an Object, but %v was given.',
         schema,
       );
     }
@@ -88,7 +120,14 @@ export function projectData(schemaOrFactory, data, options = undefined) {
   for (const key of keys) {
     // если свойство отсутствует в исходных
     // данных, то свойство игнорируется
-    if (!(key in data)) continue;
+    if (!(key in data)) {
+      continue;
+    }
+    // если свойство принадлежит прототипу,
+    // то свойство игнорируется
+    if (!Object.prototype.hasOwnProperty.call(data, key)) {
+      continue;
+    }
     const propOptionsOrBoolean = schema[key];
     // проверка доступности свойства для данной
     // области проекции (если определена)

+ 91 - 15
src/project-data.spec.js

@@ -3,15 +3,14 @@ import {format} from '@e22m4u/js-format';
 import {projectData} from './project-data.js';
 
 describe('projectData', function () {
-  it('should require the parameter "schemaOrFactory" to be an object or a function', function () {
+  it('should require the parameter "schemaOrSource" to be a valid value', function () {
     const throwable = v => () => projectData(v, {});
     const error = s =>
       format(
-        'Projection schema must be an Object or a Function ' +
-          'that returns a schema object, but %s was given.',
+        'Projection schema must be 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'));
@@ -20,8 +19,9 @@ describe('projectData', function () {
     expect(throwable([])).to.throw(error('Array'));
     expect(throwable(null)).to.throw(error('null'));
     expect(throwable(undefined)).to.throw(error('undefined'));
-    throwable({})();
-    throwable(() => ({}))();
+    projectData({}, {});
+    projectData(() => ({}), {});
+    projectData('mySchema', {}, {resolver: () => ({})});
   });
 
   it('should require the parameter "options" to be an object', function () {
@@ -72,23 +72,20 @@ describe('projectData', function () {
     throwable(undefined)();
   });
 
-  it('should throw an error if the schema factory returns an invalid value', function () {
-    const throwable = v => () => projectData(() => v, {});
+  it('should require the option "resolver" to be a Function', function () {
+    const throwable = v => () => projectData({}, {}, {resolver: v});
     const error = s =>
-      format(
-        'Projection schema factory must return an Object, but %s was given.',
-        s,
-      );
-    expect(throwable('str')).to.throw(error('"str"'));
+      format('Option "resolver" must be a Function, 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'));
-    expect(throwable(undefined)).to.throw(error('undefined'));
-    throwable({})();
+    throwable(() => undefined)();
+    throwable(undefined)();
   });
 
   it('should return a non-object and non-array data as is', function () {
@@ -151,6 +148,22 @@ describe('projectData', function () {
   });
 
   describe('schema factory', function () {
+    it('should throw an error if the schema factory returns an invalid value', function () {
+      const throwable = v => () => projectData(() => v, {});
+      const error = s =>
+        format('Schema factory must return 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(undefined)).to.throw(error('undefined'));
+      throwable({})();
+    });
+
     it('should resolve a schema object from the given factory', function () {
       let invoked = 0;
       const factory = () => {
@@ -177,6 +190,60 @@ describe('projectData', function () {
     });
   });
 
+  describe('named schema', function () {
+    it('should throw an error if the schema resolver returns an invalid value', function () {
+      const throwable = v => () =>
+        projectData('mySchema', {}, {resolver: () => v});
+      const error = s =>
+        format('Schema resolver must return 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(undefined)).to.throw(error('undefined'));
+      throwable({})();
+    });
+
+    it('should throw an error if no schema resolver is provided when a schema name is given', function () {
+      const throwable = () => projectData('mySchema', {});
+      expect(throwable).to.throw(
+        'Unable to resolve the schema "mySchema" without a specified resolver.',
+      );
+    });
+
+    it('should pass the schema name to the schema resolver and project the given data', function () {
+      let invoked = 0;
+      const resolver = name => {
+        expect(name).to.be.eq('mySchema');
+        invoked++;
+        return {foo: true, bar: false};
+      };
+      const res = projectData('mySchema', {foo: 10, bar: 20}, {resolver});
+      expect(res).to.be.eql({foo: 10});
+      expect(invoked).to.be.eq(1);
+    });
+
+    it('should use the schema resolver in the nested schema', function () {
+      let invoked = 0;
+      const resolver = name => {
+        expect(name).to.be.eq('mySchema');
+        invoked++;
+        return {baz: true, qux: false};
+      };
+      const res = projectData(
+        {foo: true, bar: {schema: 'mySchema'}},
+        {foo: 10, bar: {baz: 20, qux: 30}},
+        {resolver},
+      );
+      expect(res).to.be.eql({foo: 10, bar: {baz: 20}});
+      expect(invoked).to.be.eq(1);
+    });
+  });
+
   describe('strict mode', function () {
     it('should preserve fields not defined in the schema when the strict option is false', function () {
       const res = projectData({}, {foo: 10});
@@ -214,6 +281,15 @@ describe('projectData', function () {
       );
       expect(res).to.be.eql({bar: {baz: 20}});
     });
+
+    it('should ignore prototype properties', function () {
+      const res = projectData(
+        {bar: true, toString: true},
+        {bar: 10},
+        {strict: true},
+      );
+      expect(res).to.be.eql({bar: 10});
+    });
   });
 
   describe('projection scope', function () {

+ 6 - 1
src/projection-schema.d.ts

@@ -10,13 +10,18 @@ export type ProjectionSchema = {
  */
 export type ProjectionSchemaFactory = () => ProjectionSchema;
 
+/**
+ * Projection schema source.
+ */
+export type ProjectionSchemaSource = string | ProjectionSchemaFactory;
+
 /**
  * Projection schema property options.
  */
 export type ProjectionSchemaPropertyOptions = {
   select?: boolean;
   scopes?: ProjectionSchemaScopes;
-  schema?: ProjectionSchema | ProjectionSchemaFactory;
+  schema?: ProjectionSchema | ProjectionSchemaSource;
 }
 
 /**

+ 4 - 3
src/validate-projection-schema.js

@@ -53,12 +53,13 @@ export function validateProjectionSchema(schema, shallowMode = false) {
       if (
         !options.schema ||
         (typeof options.schema !== 'object' &&
-          typeof options.schema !== 'function') ||
+          typeof options.schema !== 'function' &&
+          typeof options.schema !== 'string') ||
         Array.isArray(options.schema)
       ) {
         throw new InvalidArgumentError(
-          'Embedded schema must be an Object or a Function ' +
-            'that returns a schema, but %v was given.',
+          'Embedded schema must be an Object, a Function ' +
+            'or a non-empty String, but %v was given.',
           options.schema,
         );
       }

+ 4 - 4
src/validate-projection-schema.spec.js

@@ -73,15 +73,14 @@ describe('validateProjectionSchema', function () {
     throwable(undefined)();
   });
 
-  it('should require the property option "schema" to be an object or a function', function () {
+  it('should require the property option "schema" to be a valid value', function () {
     const throwable = v => () => validateProjectionSchema({foo: {schema: v}});
     const error = s =>
       format(
-        'Embedded schema must be an Object or a Function ' +
-          'that returns a schema, but %s was given.',
+        'Embedded schema must be 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'));
@@ -89,6 +88,7 @@ describe('validateProjectionSchema', function () {
     expect(throwable(false)).to.throw(error('false'));
     expect(throwable([])).to.throw(error('Array'));
     expect(throwable(null)).to.throw(error('null'));
+    throwable('mySchema')();
     throwable({})();
     throwable(() => ({}))();
     throwable(undefined)();