Browse Source

chore: adds the "unique" option to the property definition

e22m4u 1 year ago
parent
commit
0af80e0607

+ 1 - 0
README.md

@@ -224,6 +224,7 @@ schema.defineModel({
 - `required: boolean` объявить свойство обязательным
 - `default: any` значение по умолчанию
 - `validate: string | array | object` см. [Валидаторы](#Валидаторы)
+- `unique: boolean` допускать только уникальные значения
 
 **Примеры**
 

+ 1 - 1
docs/assets/navigation.js

@@ -1 +1 @@
-window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAAA6WZXW/bNhSG/4uug2UN2m7LneO4mNE6NlSnRTAMAScd22wpUiOpIuqw/17IsiyKH4c0eqv3Pc+h+Hko/fVfpuFFZ7fZPdFk29aQXWU10YfsNgPeVOp6eP7LQVcsu8q+Ul5mt7//fzVGQiEk0UJuidyD9kJcS5iXAyOaCu4DmVqYMCtJrUGOwQUjSoG6PgnTyFc3ntgPgpQIoZcTODnsqdKyDZIGA8a6Ayb4Xm1FDkqwb752ORaM142pEo0s4B52lNOuRz8RRstuiFw2akfznO3hbnA9GPEdBVaqOSONgq0QzOXZDoz2J1Erwttwr1qGCGvNAUUZOkZa8oI1JWAv6Vhw3rdusGZy31TA9UJK3yD7XAnUdQ3Hhf2JsAZwtGvF+CtRAutm3kfSTY/vvj51PUnErSRc7YSsUKbhSqIiC8j1xInnZfGoKVMh5tR1ATXa2stW+oPQy6pm0E0dKAPzwGPCmMN8wRaC60GJsgQcNzVgrI2kFZHte2hVUq/ifjyTqEFqCqmJEHtCntaY+OFtGzGn5Dg3KJ7BsWL8HHYggRcQ2dj9PpzcVx5pg4C58Sy1UFQLX4eMWhoh3LWuByN+LA5QEZfSP0cjGS3Qc8wyYKzPB5AoyzKgtRkve+NIoVyD3JGiK8wGdYq4efPWQMwFV1o2xWT0DYihY5i1RBoyiBhgxtv1P1+g0CNBt/XxLU6CFf3rH7+9enPjKy7HqWqzPJYY9X7xbvb4Yfu8yZerWf70/H7x9LzJ15tFvn16fpitFnaOaEA0o6dKdZJ4PDGuWVDaPFOLc5gGZ7gHzqhFOYxoDc6rnR5HoxvGhp013E9+V4x9qpbDWMeQQFxzZDxtPcZblsA11a3NGZ5H482624GYYpSkocJmhK3HeOcS0wadhTRCsKstOYm2LL2UZRmLfgCloUR722OJUoWsCKPfocTWtN+VzsZb7bfF6NMy14ZO1TirGz/CHjkDpXL4t6ESSneoQr4of6yhHeQoxSgbIjUl7M5ZqmchkfCZ6oNotPt+th7lCdYmnJUBWwrd2R67tvVfzLprgy9TJOQnsw5V7AOpnLG8IDSxFZOdPOnVkYify5nw4gmR0Tacb2r+5WLriTzkAL78THcjVqSOY1ekTiWfb5D9huOOdcB3Md+8BwbQ9hUwiToX/CjG4Sdnao7TjREiHWPZLqSLYK8ELqwJxEiP2L4Yf3pZD8/ukC/O79csRrYdlzM968ZripGNe67NM6QoJXQsJp+HBampPtYyI2LX8OI4Ca9Hdcp5+9pEMNHtolB7CYOIAODlWET1F93pQTGCHBMC3IOeayGn+/6IMmQcgv4Tm/Civ8Zs9PHz+V276Vh+pOFAUFTNJ4t/RPQKGrppJNjfHUzAqCMYBQwKjQ+e7cFwlO8bRmRgRhoyBtGS8n33G20PL955OXU4qL9/AFRJeBBTHQAA"
+window.navigationData = "data:application/octet-stream;base64,H4sIAAAAAAAAA6WZa2/bNhSG/4s+B8sarN2Wb47jYkbr2FCdFsFQBKx0bHOjSJWkiqjF/vsgy7IoXg5p9Kve9zyHl8Ob/fePTMOLzm6ze6LJtq0hu8pqog/ZbQa8qdT18P2Xg65YdpX9S3mZ3f7x39UYCYWQRAu5JXIP2gtxLWFeDoxoKrgPZGphwqwktQY5BheMKAXq+iRMI1/deGLfC1IihF5O4OSwp0rLNkgaDBjrDpjge7UVOSjBvvna5VgwXjenSjSygHvYUU67Ef1IGC27KXLZqB3Nc7aHh8H1YMS3FFip5ow0CrZCMJdnOzDaX0StCG/Do2oZIqw1BxRl6BhpyQvWlIB10rHgvG/dZM3kvqmA64WUvkn2uRKo6xqOC/sjYQ3gaNeK8VeiBNZV3gfSlcd335i6niTiVhKudkJWKNNwJVGRBeR64sTzsnjUlKkQc+q6gBpt7WUr/UHoZVUz6EoHykAdeEwYc6gXbCG4HpQoS8BxUwPG2khaEdm+g1YljSruxzOJGqSmkJoIsSfkaY3CD2/biDklxyOnXxvgoFS8Jz5zSo6zOd4Lx4rxc9iBBF5A5PDw+3Byf7tJm2jMjWephaJa+AZk1NII4aF1PRjxQ3GAiriU/jsayWiBnpWWAWN9OoBEWZYBvf/xsjeOFMo1yB0pusvfoE4RN6/fGIi54ErLppjMvgExdAyzlkhDBhEDzHi7/vIPFHok6LY+9uIkWNG//vn7q9c3vgvsWKo2y2OJUeei4XoF+iBKm2ZIMcr94u3s8f32eZMvV7P86fnd4ul5k683i3z79PwwWy1sdjQgmtFzn3aSeDwxrnn1tXmmFucwDU7RDJxRi3IY0Rqcrp0+R6Mbxob9OTxOfleMfbrXh7GOIYG45sh82nqMtyyBa6pbmzN8j8abLwQHYopRkoYKqwhbj/HOl2EbdBbSCMGhtuQk2tLZQU6fY9EPoDSU6Gh7LFGqkBVh9DuU2Jr2u9LZeKv9thh9eiG3oVM1zurmj7BHzkCpHL42VELpTlXIF+WPt30HOUoxyoZITQm7c5bqWUgkfKL6IBrt9s/WozzB2oQTN2BLoTvbY9e2/re97oHjyxQJ+cmsw134gVTOXF4QmtiKyU6e1HUk4udyJnQ8ITLahvOb0r9cbD2RhxzAl5/pbsSK1HHsitSp5PNbt99w3LkO+C7mm6/JANp+SCZR54IfxTj85EzNcXp3QmRgLNuFdBEclcCzN4EYGRHbF+NPn/zh6g754vx+zWJk23E507NuvKYY2Xgt2zxDilJCx2LyeViQmurjXWZE7BpeHIvwelSnnDe/mQgmul0Uai9hEBEAvBwvUf1zeXpQjCDHhAD3oOdayOm+P6IMGYeg/95NeNE/8Wz08Yf+u3bTsfxIw4GgqJpPFv+I6BU0dNNIsH+9MAGjjmAUMCg0Pnm2B8NRvm8YkYGKNGQMoiXl++4Pvz28eOty6nBQn/8HVbjGiv0dAAA="

File diff suppressed because it is too large
+ 0 - 0
docs/assets/search.js


File diff suppressed because it is too large
+ 0 - 0
docs/classes/PropertyUniquenessValidator.html


+ 1 - 0
docs/index.html

@@ -120,6 +120,7 @@
 <li><code>required: boolean</code> объявить свойство обязательным</li>
 <li><code>default: any</code> значение по умолчанию</li>
 <li><code>validate: string | array | object</code> см. <a href="#md:Валидаторы">Валидаторы</a></li>
+<li><code>unique: boolean</code> допускать только уникальные значения</li>
 </ul>
 <p><strong>Примеры</strong></p>
 <p>Краткое определение свойств модели.</p>

+ 2 - 0
docs/modules.html

@@ -24,6 +24,7 @@
 <a href="classes/PrimaryKeysDefinitionValidator.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-128"></use></svg><span>Primary<wbr/>Keys<wbr/>Definition<wbr/>Validator</span></a>
 <a href="classes/PropertiesDefinitionValidator.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-128"></use></svg><span>Properties<wbr/>Definition<wbr/>Validator</span></a>
 <a href="classes/PropertyTransformerRegistry.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-128"></use></svg><span>Property<wbr/>Transformer<wbr/>Registry</span></a>
+<a href="classes/PropertyUniquenessValidator.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-128"></use></svg><span>Property<wbr/>Uniqueness<wbr/>Validator</span></a>
 <a href="classes/PropertyValidatorRegistry.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-128"></use></svg><span>Property<wbr/>Validator<wbr/>Registry</span></a>
 <a href="classes/ReferencesManyResolver.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-128"></use></svg><span>References<wbr/>Many<wbr/>Resolver</span></a>
 <a href="classes/RelationsDefinitionValidator.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-128"></use></svg><span>Relations<wbr/>Definition<wbr/>Validator</span></a>
@@ -37,6 +38,7 @@
 <a href="interfaces/OrClause.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-256"></use></svg><span>Or<wbr/>Clause</span></a>
 </div></section><section class="tsd-index-section"><h3 class="tsd-index-heading">Type Aliases</h3><div class="tsd-index-list"><a href="types/AnyObject.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-2097152"></use></svg><span>Any<wbr/>Object</span></a>
 <a href="types/BelongsToDefinition.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-2097152"></use></svg><span>Belongs<wbr/>To<wbr/>Definition</span></a>
+<a href="types/CountMethod.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-2097152"></use></svg><span>Count<wbr/>Method</span></a>
 <a href="types/DEFAULT_PRIMARY_KEY_PROPERTY_NAME.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-2097152"></use></svg><span>DEFAULT_<wbr/>PRIMARY_<wbr/>KEY_<wbr/>PROPERTY_<wbr/>NAME</span></a>
 <a href="types/DatasourceDefinition.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-2097152"></use></svg><span>Datasource<wbr/>Definition</span></a>
 <a href="types/FieldsClause.html" class="tsd-index-link"><svg class="tsd-kind-icon" viewBox="0 0 24 24"><use href="#icon-2097152"></use></svg><span>Fields<wbr/>Clause</span></a>

File diff suppressed because it is too large
+ 0 - 0
docs/types/CountMethod.html


File diff suppressed because it is too large
+ 0 - 0
docs/types/FullPropertyDefinition.html


File diff suppressed because it is too large
+ 0 - 0
docs/types/PropertyDefinition.html


+ 2 - 0
src/adapter/adapter.js

@@ -8,6 +8,7 @@ import {DataValidationDecorator} from './decorator/index.js';
 import {DataSanitizingDecorator} from './decorator/index.js';
 import {FieldsFilteringDecorator} from './decorator/index.js';
 import {DataTransformationDecorator} from './decorator/index.js';
+import {PropertyUniquenessDecorator} from './decorator/index.js';
 
 /**
  * Adapter.
@@ -44,6 +45,7 @@ export class Adapter extends Service {
       this.getService(DefaultValuesDecorator).decorate(this);
       this.getService(DataTransformationDecorator).decorate(this);
       this.getService(DataValidationDecorator).decorate(this);
+      this.getService(PropertyUniquenessDecorator).decorate(this);
       this.getService(FieldsFilteringDecorator).decorate(this);
       this.getService(InclusionDecorator).decorate(this);
     }

+ 8 - 2
src/adapter/adapter.spec.js

@@ -10,6 +10,7 @@ import {DataValidationDecorator} from './decorator/index.js';
 import {DataSanitizingDecorator} from './decorator/index.js';
 import {FieldsFilteringDecorator} from './decorator/index.js';
 import {DataTransformationDecorator} from './decorator/index.js';
+import {PropertyUniquenessDecorator} from './decorator/index.js';
 
 const sandbox = chai.spy.sandbox();
 
@@ -38,8 +39,9 @@ describe('Adapter', function () {
       const dec2 = schema.getService(DefaultValuesDecorator);
       const dec3 = schema.getService(DataTransformationDecorator);
       const dec4 = schema.getService(DataValidationDecorator);
-      const dec5 = schema.getService(FieldsFilteringDecorator);
-      const dec6 = schema.getService(InclusionDecorator);
+      const dec5 = schema.getService(PropertyUniquenessDecorator);
+      const dec6 = schema.getService(FieldsFilteringDecorator);
+      const dec7 = schema.getService(InclusionDecorator);
       const order = [];
       const decorate = function (ctx) {
         expect(ctx).to.be.instanceof(Adapter);
@@ -51,6 +53,7 @@ describe('Adapter', function () {
       sandbox.on(dec4, 'decorate', decorate);
       sandbox.on(dec5, 'decorate', decorate);
       sandbox.on(dec6, 'decorate', decorate);
+      sandbox.on(dec7, 'decorate', decorate);
       new Adapter(schema.container);
       expect(order).to.be.empty;
       expect(dec1.decorate).to.be.not.called;
@@ -59,6 +62,7 @@ describe('Adapter', function () {
       expect(dec4.decorate).to.be.not.called;
       expect(dec5.decorate).to.be.not.called;
       expect(dec6.decorate).to.be.not.called;
+      expect(dec7.decorate).to.be.not.called;
       class ExtendedAdapter extends Adapter {}
       new ExtendedAdapter(schema.container);
       expect(order[0]).to.be.eql(dec1);
@@ -67,12 +71,14 @@ describe('Adapter', function () {
       expect(order[3]).to.be.eql(dec4);
       expect(order[4]).to.be.eql(dec5);
       expect(order[5]).to.be.eql(dec6);
+      expect(order[6]).to.be.eql(dec7);
       expect(dec1.decorate).to.be.called.once;
       expect(dec2.decorate).to.be.called.once;
       expect(dec3.decorate).to.be.called.once;
       expect(dec4.decorate).to.be.called.once;
       expect(dec5.decorate).to.be.called.once;
       expect(dec6.decorate).to.be.called.once;
+      expect(dec7.decorate).to.be.called.once;
     });
   });
 

+ 1 - 0
src/adapter/decorator/index.d.ts

@@ -4,3 +4,4 @@ export * from './data-sanitizing-decorator.js';
 export * from './data-validation-decorator.js';
 export * from './fields-filtering-decorator.js';
 export * from './data-transformation-decorator.js';
+export * from './property-uniqueness-decorator.js';

+ 1 - 0
src/adapter/decorator/index.js

@@ -4,3 +4,4 @@ export * from './data-sanitizing-decorator.js';
 export * from './data-validation-decorator.js';
 export * from './fields-filtering-decorator.js';
 export * from './data-transformation-decorator.js';
+export * from './property-uniqueness-decorator.js';

+ 14 - 0
src/adapter/decorator/property-uniqueness-decorator.d.ts

@@ -0,0 +1,14 @@
+import {Adapter} from '../adapter.js';
+import {Service} from '@e22m4u/js-service';
+
+/**
+ * Property uniqueness decorator.
+ */
+export declare class PropertyUniquenessDecorator extends Service {
+  /**
+   * Decorate.
+   *
+   * @param adapter
+   */
+  decorate(adapter: Adapter): void;
+}

+ 76 - 0
src/adapter/decorator/property-uniqueness-decorator.js

@@ -0,0 +1,76 @@
+import {Adapter} from '../adapter.js';
+import {Service} from '@e22m4u/js-service';
+import {InvalidArgumentError} from '../../errors/index.js';
+import {PropertyUniquenessValidator} from '../../definition/index.js';
+
+/**
+ * Property uniqueness decorator.
+ */
+export class PropertyUniquenessDecorator extends Service {
+  /**
+   * Decorate.
+   *
+   * @param {Adapter} adapter
+   */
+  decorate(adapter) {
+    if (!adapter || !(adapter instanceof Adapter))
+      throw new InvalidArgumentError(
+        'The first argument of PropertyUniquenessDecorator.decorate should be ' +
+          'an Adapter instance, but %v given.',
+        adapter,
+      );
+    const validator = this.getService(PropertyUniquenessValidator);
+
+    const create = adapter.create;
+    adapter.create = async function (modelName, modelData, filter) {
+      const countMethod = adapter.count.bind(adapter, modelName);
+      await validator.validate(countMethod, 'create', modelName, modelData);
+      return create.call(this, modelName, modelData, filter);
+    };
+
+    const replaceById = adapter.replaceById;
+    adapter.replaceById = async function (modelName, id, modelData, filter) {
+      const countMethod = adapter.count.bind(adapter, modelName);
+      await validator.validate(
+        countMethod,
+        'replaceById',
+        modelName,
+        modelData,
+        id,
+      );
+      return replaceById.call(this, modelName, id, modelData, filter);
+    };
+
+    const replaceOrCreate = adapter.replaceOrCreate;
+    adapter.replaceOrCreate = async function (modelName, modelData, filter) {
+      const countMethod = adapter.count.bind(adapter, modelName);
+      await validator.validate(
+        countMethod,
+        'replaceOrCreate',
+        modelName,
+        modelData,
+      );
+      return replaceOrCreate.call(this, modelName, modelData, filter);
+    };
+
+    const patch = adapter.patch;
+    adapter.patch = async function (modelName, modelData, where) {
+      const countMethod = adapter.count.bind(adapter, modelName);
+      await validator.validate(countMethod, 'patch', modelName, modelData);
+      return patch.call(this, modelName, modelData, where);
+    };
+
+    const patchById = adapter.patchById;
+    adapter.patchById = async function (modelName, id, modelData, filter) {
+      const countMethod = adapter.count.bind(adapter, modelName);
+      await validator.validate(
+        countMethod,
+        'patchById',
+        modelName,
+        modelData,
+        id,
+      );
+      return patchById.call(this, modelName, id, modelData, filter);
+    };
+  }
+}

+ 135 - 0
src/adapter/decorator/property-uniqueness-decorator.spec.js

@@ -0,0 +1,135 @@
+import chai from 'chai';
+import {expect} from 'chai';
+import {Adapter} from '../adapter.js';
+import {Schema} from '../../schema.js';
+import {PropertyUniquenessValidator} from '../../definition/index.js';
+
+const S = new Schema();
+S.defineModel({name: 'model'});
+
+class TestAdapter extends Adapter {
+  // eslint-disable-next-line no-unused-vars
+  create(modelName, modelData, filter = undefined) {
+    return Promise.resolve(modelData);
+  }
+
+  // eslint-disable-next-line no-unused-vars
+  replaceById(modelName, id, modelData, filter = undefined) {
+    return Promise.resolve(modelData);
+  }
+
+  // eslint-disable-next-line no-unused-vars
+  replaceOrCreate(modelName, modelData, filter = undefined) {
+    return Promise.resolve(modelData);
+  }
+
+  // eslint-disable-next-line no-unused-vars
+  patch(modelName, modelData, where = undefined) {
+    return Promise.resolve(modelData);
+  }
+
+  // eslint-disable-next-line no-unused-vars
+  patchById(modelName, id, modelData, filter = undefined) {
+    return Promise.resolve(modelData);
+  }
+}
+
+const A = S.getService(TestAdapter);
+const V = S.getService(PropertyUniquenessValidator);
+const sandbox = chai.spy.sandbox();
+
+describe('PropertyUniquenessDecorator', function () {
+  afterEach(function () {
+    sandbox.restore();
+  });
+
+  it('overrides the "create" method and validates a given data', async function () {
+    const data = {kind: 'data'};
+    sandbox.on(
+      V,
+      'validate',
+      (countMethod, methodName, modelName, modelData, id = undefined) => {
+        expect(typeof countMethod).to.be.eq('function');
+        expect(methodName).to.be.eq('create');
+        expect(modelName).to.be.eq('model');
+        expect(modelData).to.be.eql(data);
+        expect(id).to.be.undefined;
+      },
+    );
+    const res = await A.create('model', data);
+    expect(res).to.be.eql(data);
+    expect(V.validate).to.be.called.once;
+  });
+
+  it('overrides the "replaceById" method and validates a given data', async function () {
+    const data = {kind: 'data'};
+    sandbox.on(
+      V,
+      'validate',
+      (countMethod, methodName, modelName, modelData, id = undefined) => {
+        expect(typeof countMethod).to.be.eq('function');
+        expect(methodName).to.be.eq('replaceById');
+        expect(modelName).to.be.eq('model');
+        expect(modelData).to.be.eql(data);
+        expect(id).to.be.eq(1);
+      },
+    );
+    const res = await A.replaceById('model', 1, data);
+    expect(res).to.be.eql(data);
+    expect(V.validate).to.be.called.once;
+  });
+
+  it('overrides the "replaceOrCreate" method and validates a given data', async function () {
+    const data = {kind: 'data'};
+    sandbox.on(
+      V,
+      'validate',
+      (countMethod, methodName, modelName, modelData, id = undefined) => {
+        expect(typeof countMethod).to.be.eq('function');
+        expect(methodName).to.be.eq('replaceOrCreate');
+        expect(modelName).to.be.eq('model');
+        expect(modelData).to.be.eql(data);
+        expect(id).to.be.undefined;
+      },
+    );
+    const res = await A.replaceOrCreate('model', data);
+    expect(res).to.be.eql(data);
+    expect(V.validate).to.be.called.once;
+  });
+
+  it('overrides the "patch" method and validates a given data', async function () {
+    const data = {kind: 'data'};
+    sandbox.on(
+      V,
+      'validate',
+      (countMethod, methodName, modelName, modelData, id = undefined) => {
+        expect(typeof countMethod).to.be.eq('function');
+        expect(methodName).to.be.eq('patch');
+        expect(modelName).to.be.eq('model');
+        expect(modelData).to.be.eql(data);
+        expect(id).to.be.undefined;
+      },
+    );
+    const res = await A.patch('model', data);
+    expect(res).to.be.eql(data);
+    expect(V.validate).to.be.called.once;
+  });
+
+  it('overrides the "patchById" method and validates a given data', async function () {
+    const data = {kind: 'data'};
+    sandbox.on(
+      V,
+      'validate',
+      (countMethod, methodName, modelName, modelData, id = undefined) => {
+        expect(typeof countMethod).to.be.eq('function');
+        expect(methodName).to.be.eq('patchById');
+        expect(modelName).to.be.eq('model');
+        expect(modelData).to.be.eql(data);
+        expect(id).to.be.eq(1);
+      },
+    );
+    const res = await A.patchById('model', 1, data);
+    expect(res).to.be.eql(data);
+    expect(V.validate).to.be.called.once;
+  });
+});

+ 1 - 0
src/definition/model/properties/index.d.ts

@@ -2,5 +2,6 @@ export * from './data-type.js';
 export * from './property-definition.js';
 export * from './property-validator/index.js';
 export * from './property-transformer/index.js';
+export * from './property-uniqueness-validator.js';
 export * from './properties-definition-validator.js';
 export * from './primary-keys-definition-validator.js';

+ 1 - 0
src/definition/model/properties/index.js

@@ -2,5 +2,6 @@ export * from './data-type.js';
 export * from './property-definition.js';
 export * from './property-validator/index.js';
 export * from './property-transformer/index.js';
+export * from './property-uniqueness-validator.js';
 export * from './properties-definition-validator.js';
 export * from './primary-keys-definition-validator.js';

+ 15 - 0
src/definition/model/properties/properties-definition-validator.js

@@ -297,5 +297,20 @@ export class PropertiesDefinitionValidator extends Service {
         );
       }
     }
+    if (propDef.unique && typeof propDef.unique !== 'boolean')
+      throw new InvalidArgumentError(
+        'The provided option "unique" of the property %v in the model %v ' +
+          'should be a Boolean, but %v given.',
+        propName,
+        modelName,
+        propDef.unique,
+      );
+    if (propDef.unique && propDef.primaryKey)
+      throw new InvalidArgumentError(
+        'The property %v of the model %v is a primary key, ' +
+          'so it should not have the option "unique" to be provided.',
+        propName,
+        modelName,
+      );
   }
 }

+ 46 - 5
src/definition/model/properties/properties-definition-validator.spec.js

@@ -250,6 +250,7 @@ describe('PropertiesDefinitionValidator', function () {
             'should be a Boolean, but %s given.',
           v,
         );
+      expect(validate('str')).to.throw(error('"str"'));
       expect(validate(10)).to.throw(error('10'));
       expect(validate([])).to.throw(error('Array'));
       expect(validate({})).to.throw(error('Object'));
@@ -277,7 +278,7 @@ describe('PropertiesDefinitionValidator', function () {
       expect(validate([])).to.throw(error);
       expect(validate({})).to.throw(error);
       expect(validate(null)).to.throw(error);
-      validate(undefined);
+      validate(undefined)();
     });
 
     it('expects the primary key should not have the option "required" to be true', function () {
@@ -294,8 +295,8 @@ describe('PropertiesDefinitionValidator', function () {
           'so it should not have the option "required" to be provided.',
       );
       expect(validate(true)).to.throw(error);
-      validate(false);
-      validate(undefined);
+      validate(false)();
+      validate(undefined)();
     });
 
     it('expects the primary key should not have the option "default" to be provided', function () {
@@ -318,7 +319,7 @@ describe('PropertiesDefinitionValidator', function () {
       expect(validate([])).to.throw(error);
       expect(validate({})).to.throw(error);
       expect(validate(null)).to.throw(error);
-      validate(undefined);
+      validate(undefined)();
     });
 
     it('expects a non-array property should not have the option "itemType" to be provided', function () {
@@ -337,7 +338,7 @@ describe('PropertiesDefinitionValidator', function () {
       expect(validate(DataType.NUMBER)).to.throw(error);
       expect(validate(DataType.BOOLEAN)).to.throw(error);
       expect(validate(DataType.OBJECT)).to.throw(error);
-      validate(DataType.ARRAY);
+      validate(DataType.ARRAY)();
     });
 
     it('the option "model" requires the "object" property type', function () {
@@ -488,5 +489,45 @@ describe('PropertiesDefinitionValidator', function () {
       validate(['myTransformer'])();
       validate({myTransformer: true})();
     });
+
+    it('expects provided the option "unique" to be a boolean', function () {
+      const validate = v => {
+        const foo = {
+          type: DataType.STRING,
+          unique: v,
+        };
+        return () => S.validate('model', {foo});
+      };
+      const error = v =>
+        format(
+          'The provided option "unique" of the property "foo" in the model "model" ' +
+            'should be a Boolean, but %s given.',
+          v,
+        );
+      expect(validate('str')).to.throw(error('"str"'));
+      expect(validate(10)).to.throw(error('10'));
+      expect(validate([])).to.throw(error('Array'));
+      expect(validate({})).to.throw(error('Object'));
+      validate(true)();
+      validate(false)();
+    });
+
+    it('expects the primary key should not have the option "unique" to be true', function () {
+      const validate = v => () => {
+        const foo = {
+          type: DataType.ANY,
+          primaryKey: true,
+          unique: v,
+        };
+        S.validate('model', {foo});
+      };
+      const error = format(
+        'The property "foo" of the model "model" is a primary key, ' +
+          'so it should not have the option "unique" to be provided.',
+      );
+      expect(validate(true)).to.throw(error);
+      validate(false)();
+      validate(undefined)();
+    });
   });
 });

+ 1 - 0
src/definition/model/properties/property-definition.d.ts

@@ -16,6 +16,7 @@ export declare type FullPropertyDefinition = {
   default?: unknown;
   validate?: PropertyValidateOptions;
   transform?: PropertyTransformOptions;
+  unique?: boolean;
 };
 
 /**

+ 31 - 0
src/definition/model/properties/property-uniqueness-validator.d.ts

@@ -0,0 +1,31 @@
+import {ModelId} from '../../../types.js';
+import {Service} from '@e22m4u/js-service';
+import {ModelData} from '../../../types.js';
+import {WhereClause} from '../../../filter/index.js';
+
+/**
+ * Count method.
+ */
+type CountMethod = (where: WhereClause) => Promise<number>;
+
+/**
+ * Property uniqueness validator.
+ */
+export declare class PropertyUniquenessValidator extends Service {
+  /**
+   * Validate.
+   *
+   * @param countMethod
+   * @param methodName
+   * @param modelName
+   * @param modelData
+   * @param modelId
+   */
+  validate(
+    countMethod: CountMethod,
+    methodName: string,
+    modelName: string,
+    modelData: ModelData,
+    modelId?: ModelId,
+  ): Promise<void>;
+}

+ 131 - 0
src/definition/model/properties/property-uniqueness-validator.js

@@ -0,0 +1,131 @@
+import {Service} from '@e22m4u/js-service';
+import {isPureObject} from '../../../utils/index.js';
+import {InvalidArgumentError} from '../../../errors/index.js';
+import {ModelDefinitionUtils} from '../model-definition-utils.js';
+
+/**
+ * Property uniqueness validator.
+ */
+export class PropertyUniquenessValidator extends Service {
+  /**
+   * Validate.
+   *
+   * @param {Function} countMethod
+   * @param {string} methodName
+   * @param {string} modelName
+   * @param {object} modelData
+   * @param {*} modelId
+   * @returns {Promise<undefined>}
+   */
+  async validate(
+    countMethod,
+    methodName,
+    modelName,
+    modelData,
+    modelId = undefined,
+  ) {
+    if (typeof countMethod !== 'function')
+      throw new InvalidArgumentError(
+        'The parameter "countMethod" of the PropertyUniquenessValidator ' +
+          'must be a Function, but %v given.',
+        countMethod,
+      );
+    if (!methodName || typeof methodName !== 'string')
+      throw new InvalidArgumentError(
+        'The parameter "methodName" of the PropertyUniquenessValidator ' +
+          'must be a non-empty String, but %v given.',
+        methodName,
+      );
+    if (!modelName || typeof modelName !== 'string')
+      throw new InvalidArgumentError(
+        'The parameter "modelName" of the PropertyUniquenessValidator ' +
+          'must be a non-empty String, but %v given.',
+        modelName,
+      );
+    if (!isPureObject(modelData))
+      throw new InvalidArgumentError(
+        'The data of the model %v should be an Object, but %v given.',
+        modelName,
+        modelData,
+      );
+    const propDefs =
+      this.getService(
+        ModelDefinitionUtils,
+      ).getPropertiesDefinitionInBaseModelHierarchy(modelName);
+    const isPartial = methodName === 'patch' || methodName === 'patchById';
+    const propNames = Object.keys(isPartial ? modelData : propDefs);
+    const idProp =
+      this.getService(ModelDefinitionUtils).getPrimaryKeyAsPropertyName(
+        modelName,
+      );
+    const createError = (propName, propValue) =>
+      new InvalidArgumentError(
+        'An existing document of the model %v already has ' +
+          'the property %v with the value %v and should be unique.',
+        modelName,
+        propName,
+        propValue,
+      );
+    let willBeReplaced = undefined;
+    for (const propName of propNames) {
+      const propDef = propDefs[propName];
+      if (!propDef || typeof propDef === 'string' || !propDef.unique) continue;
+      const propValue = modelData[propName];
+      // create
+      if (methodName === 'create') {
+        const count = await countMethod({[propName]: propValue});
+        if (count > 0) throw createError(propName, propValue);
+      }
+      // replaceById
+      else if (methodName === 'replaceById') {
+        const count = await countMethod({
+          [idProp]: {neq: modelId},
+          [propName]: propValue,
+        });
+        if (count > 0) throw createError(propName, propValue);
+      }
+      // replaceOrCreate
+      else if (methodName === 'replaceOrCreate') {
+        const idFromData = modelData[idProp];
+        if (willBeReplaced == null && idFromData != null) {
+          const count = await countMethod({[idProp]: idFromData});
+          willBeReplaced = count > 0;
+        }
+        // replaceById by replaceOrCreate
+        if (willBeReplaced) {
+          const count = await countMethod({
+            [idProp]: {neq: idFromData},
+            [propName]: propValue,
+          });
+          if (count > 0) throw createError(propName, propValue);
+        }
+        // create by replaceOrCreate
+        else {
+          const count = await countMethod({[propName]: propValue});
+          if (count > 0) throw createError(propName, propValue);
+        }
+      }
+      // patch
+      else if (methodName === 'patch') {
+        const count = await countMethod({[propName]: propValue});
+        if (count > 0) throw createError(propName, propValue);
+      }
+      // patchById
+      else if (methodName === 'patchById') {
+        const count = await countMethod({
+          [idProp]: {neq: modelId},
+          [propName]: propValue,
+        });
+        if (count > 0) throw createError(propName, propValue);
+      }
+      // unsupported method
+      else {
+        throw new InvalidArgumentError(
+          'The PropertyUniquenessValidator does not ' +
+            'support the adapter method %v.',
+          methodName,
+        );
+      }
+    }
+  }
+}

+ 943 - 0
src/definition/model/properties/property-uniqueness-validator.spec.js

@@ -0,0 +1,943 @@
+import {expect} from 'chai';
+import {DataType} from './data-type.js';
+import {format} from '@e22m4u/js-format';
+import {Schema} from '../../../schema.js';
+import {PropertyUniquenessValidator} from './property-uniqueness-validator.js';
+import {DEFAULT_PRIMARY_KEY_PROPERTY_NAME as DEF_PK} from '../model-definition-utils.js';
+
+describe('PropertyUniquenessValidator', function () {
+  describe('validate', function () {
+    it('requires the parameter "countMethod" to be a Function', async function () {
+      const schema = new Schema();
+      schema.defineModel({
+        name: 'model',
+        properties: {
+          foo: {
+            type: DataType.ANY,
+            unique: true,
+          },
+        },
+      });
+      const S = schema.getService(PropertyUniquenessValidator);
+      const throwable = v => S.validate(v, 'create', 'model', {});
+      const error = v =>
+        format(
+          'The parameter "countMethod" of the PropertyUniquenessValidator ' +
+            'must be a Function, but %s given.',
+          v,
+        );
+      await expect(throwable('str')).to.be.rejectedWith(error('"str"'));
+      await expect(throwable('')).to.be.rejectedWith(error('""'));
+      await expect(throwable(10)).to.be.rejectedWith(error('10'));
+      await expect(throwable(0)).to.be.rejectedWith(error('0'));
+      await expect(throwable(true)).to.be.rejectedWith(error('true'));
+      await expect(throwable(false)).to.be.rejectedWith(error('false'));
+      await expect(throwable(undefined)).to.be.rejectedWith(error('undefined'));
+      await expect(throwable(null)).to.be.rejectedWith(error('null'));
+      await expect(throwable(new Date())).to.be.rejectedWith(error('Date'));
+      await expect(throwable([1, 2, 3])).to.be.rejectedWith(error('Array'));
+      await expect(throwable([])).to.be.rejectedWith(error('Array'));
+      await expect(throwable({foo: 'bar'})).to.be.rejectedWith(error('Object'));
+      await expect(throwable({})).to.be.rejectedWith(error('Object'));
+      await throwable(() => 0);
+    });
+
+    it('requires the parameter "methodName" to be a non-empty String', async function () {
+      const schema = new Schema();
+      schema.defineModel({
+        name: 'model',
+        properties: {
+          foo: {
+            type: DataType.ANY,
+            unique: true,
+          },
+        },
+      });
+      const S = schema.getService(PropertyUniquenessValidator);
+      const throwable = v => S.validate(() => 0, v, 'model', {});
+      const error = v =>
+        format(
+          'The parameter "methodName" of the PropertyUniquenessValidator ' +
+            'must be a non-empty String, but %s given.',
+          v,
+        );
+      await expect(throwable('')).to.be.rejectedWith(error('""'));
+      await expect(throwable(10)).to.be.rejectedWith(error('10'));
+      await expect(throwable(0)).to.be.rejectedWith(error('0'));
+      await expect(throwable(true)).to.be.rejectedWith(error('true'));
+      await expect(throwable(false)).to.be.rejectedWith(error('false'));
+      await expect(throwable(undefined)).to.be.rejectedWith(error('undefined'));
+      await expect(throwable(null)).to.be.rejectedWith(error('null'));
+      await expect(throwable(new Date())).to.be.rejectedWith(error('Date'));
+      await expect(throwable([1, 2, 3])).to.be.rejectedWith(error('Array'));
+      await expect(throwable([])).to.be.rejectedWith(error('Array'));
+      await expect(throwable({foo: 'bar'})).to.be.rejectedWith(error('Object'));
+      await expect(throwable({})).to.be.rejectedWith(error('Object'));
+      await expect(throwable(() => 0)).to.be.rejectedWith(error('Function'));
+      await throwable('create');
+    });
+
+    it('requires the parameter "modelName" to be a non-empty String', async function () {
+      const schema = new Schema();
+      schema.defineModel({
+        name: 'model',
+        properties: {
+          foo: {
+            type: DataType.ANY,
+            unique: true,
+          },
+        },
+      });
+      const S = schema.getService(PropertyUniquenessValidator);
+      const throwable = v => S.validate(() => 0, 'create', v, {});
+      const error = v =>
+        format(
+          'The parameter "modelName" of the PropertyUniquenessValidator ' +
+            'must be a non-empty String, but %s given.',
+          v,
+        );
+      await expect(throwable('')).to.be.rejectedWith(error('""'));
+      await expect(throwable(10)).to.be.rejectedWith(error('10'));
+      await expect(throwable(0)).to.be.rejectedWith(error('0'));
+      await expect(throwable(true)).to.be.rejectedWith(error('true'));
+      await expect(throwable(false)).to.be.rejectedWith(error('false'));
+      await expect(throwable(undefined)).to.be.rejectedWith(error('undefined'));
+      await expect(throwable(null)).to.be.rejectedWith(error('null'));
+      await expect(throwable(new Date())).to.be.rejectedWith(error('Date'));
+      await expect(throwable([1, 2, 3])).to.be.rejectedWith(error('Array'));
+      await expect(throwable([])).to.be.rejectedWith(error('Array'));
+      await expect(throwable({foo: 'bar'})).to.be.rejectedWith(error('Object'));
+      await expect(throwable({})).to.be.rejectedWith(error('Object'));
+      await expect(throwable(() => 0)).to.be.rejectedWith(error('Function'));
+      await throwable('model');
+    });
+
+    it('requires the parameter "modelData" to be a pure Object', async function () {
+      const schema = new Schema();
+      schema.defineModel({
+        name: 'model',
+        properties: {
+          foo: {
+            type: DataType.ANY,
+            unique: true,
+          },
+        },
+      });
+      const S = schema.getService(PropertyUniquenessValidator);
+      const throwable = v => S.validate(() => 0, 'create', 'model', v);
+      const error = v =>
+        format(
+          'The data of the model "model" should be an Object, but %s given.',
+          v,
+        );
+      await expect(throwable('str')).to.be.rejectedWith(error('"str"'));
+      await expect(throwable('')).to.be.rejectedWith(error('""'));
+      await expect(throwable(10)).to.be.rejectedWith(error('10'));
+      await expect(throwable(0)).to.be.rejectedWith(error('0'));
+      await expect(throwable(true)).to.be.rejectedWith(error('true'));
+      await expect(throwable(false)).to.be.rejectedWith(error('false'));
+      await expect(throwable(undefined)).to.be.rejectedWith(error('undefined'));
+      await expect(throwable(null)).to.be.rejectedWith(error('null'));
+      await expect(throwable([1, 2, 3])).to.be.rejectedWith(error('Array'));
+      await expect(throwable([])).to.be.rejectedWith(error('Array'));
+      await expect(throwable(new Date())).to.be.rejectedWith(error('Date'));
+      await expect(throwable(() => 0)).to.be.rejectedWith(error('Function'));
+      await throwable({foo: 'bar'});
+      await throwable({});
+    });
+
+    it('skips checking if the option "unique" is false or not defined', async function () {
+      const schema = new Schema();
+      schema.defineModel({
+        name: 'model',
+        properties: {
+          foo: DataType.ANY,
+          bar: {
+            type: DataType.ANY,
+          },
+          baz: {
+            type: DataType.ANY,
+            unique: false,
+          },
+        },
+      });
+      const S = schema.getService(PropertyUniquenessValidator);
+      const promise = S.validate(() => 1, 'create', 'model', {});
+      await expect(promise).not.to.be.rejected;
+    });
+
+    it('throws an error for unsupported method', async function () {
+      const schema = new Schema();
+      schema.defineModel({
+        name: 'model',
+        properties: {
+          foo: {
+            type: DataType.ANY,
+            unique: true,
+          },
+        },
+      });
+      const S = schema.getService(PropertyUniquenessValidator);
+      const promise = S.validate(() => 1, 'unsupported', 'model', {});
+      await expect(promise).to.be.rejectedWith(
+        'The PropertyUniquenessValidator does not ' +
+          'support the adapter method "unsupported".',
+      );
+    });
+
+    describe('create', function () {
+      it('throws an error if the "countMethod" returns a positive number', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        const promise = S.validate(() => 1, 'create', 'model', {foo: 'bar'});
+        await expect(promise).to.be.rejectedWith(
+          'An existing document of the model "model" already has ' +
+            'the property "foo" with the value "bar" and should be unique.',
+        );
+      });
+
+      it('passes validation if the "countMethod" returns zero', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        await S.validate(() => 0, 'create', 'model', {foo: 'bar'});
+      });
+
+      it('invokes the "countMethod" for each unique property of the model', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+            bar: {
+              type: DataType.ANY,
+              unique: false,
+            },
+            baz: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        let invoked = 0;
+        const modelData = {foo: 'val1', bar: 'val2'};
+        const countMethod = where => {
+          if (invoked === 0) {
+            expect(where).to.be.eql({foo: 'val1'});
+          } else if (invoked === 1) {
+            expect(where).to.be.eql({baz: undefined});
+          }
+          invoked++;
+          return 0;
+        };
+        await S.validate(countMethod, 'create', 'model', modelData);
+        expect(invoked).to.be.eq(2);
+      });
+    });
+
+    describe('replaceById', function () {
+      it('throws an error if the "countMethod" returns a positive number', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        const promise = S.validate(
+          () => 1,
+          'replaceById',
+          'model',
+          {foo: 'bar'},
+          1,
+        );
+        await expect(promise).to.be.rejectedWith(
+          'An existing document of the model "model" already has ' +
+            'the property "foo" with the value "bar" and should be unique.',
+        );
+      });
+
+      it('passes validation if the "countMethod" returns zero', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        await S.validate(() => 0, 'replaceById', 'model', {foo: 'bar'}, 1);
+      });
+
+      it('invokes the "countMethod" for each unique property of the model', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+            bar: {
+              type: DataType.ANY,
+              unique: false,
+            },
+            baz: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        let invoked = 0;
+        const idValue = 1;
+        const modelData = {foo: 'val1', bar: 'val2'};
+        const countMethod = where => {
+          if (invoked === 0) {
+            expect(where).to.be.eql({
+              [DEF_PK]: {neq: idValue},
+              foo: 'val1',
+            });
+          } else if (invoked === 1) {
+            expect(where).to.be.eql({
+              [DEF_PK]: {neq: idValue},
+              baz: undefined,
+            });
+          }
+          invoked++;
+          return 0;
+        };
+        await S.validate(
+          countMethod,
+          'replaceById',
+          'model',
+          modelData,
+          idValue,
+        );
+        expect(invoked).to.be.eq(2);
+      });
+
+      it('can use a custom primary key', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            myId: {
+              type: DataType.NUMBER,
+              primaryKey: true,
+            },
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        let invoked = 0;
+        const idValue = 1;
+        const modelData = {foo: 'bar'};
+        const countMethod = where => {
+          if (invoked === 0) {
+            expect(where).to.be.eql({
+              myId: {neq: idValue},
+              foo: 'bar',
+            });
+          }
+          invoked++;
+          return 0;
+        };
+        await S.validate(
+          countMethod,
+          'replaceById',
+          'model',
+          modelData,
+          idValue,
+        );
+        expect(invoked).to.be.eq(1);
+      });
+    });
+
+    describe('replaceOrCreate', function () {
+      it('throws an error if the "countMethod" returns a positive number', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        const promise = S.validate(() => 1, 'replaceOrCreate', 'model', {
+          foo: 'bar',
+        });
+        await expect(promise).to.be.rejectedWith(
+          'An existing document of the model "model" already has ' +
+            'the property "foo" with the value "bar" and should be unique.',
+        );
+      });
+
+      it('passes validation if the "countMethod" returns zero', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        await S.validate(() => 0, 'replaceOrCreate', 'model', {foo: 'bar'});
+      });
+
+      it('invokes the "countMethod" for each unique property of the model', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+            bar: {
+              type: DataType.ANY,
+              unique: false,
+            },
+            baz: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        let invoked = 0;
+        const modelData = {foo: 'val1', bar: 'val2'};
+        const countMethod = where => {
+          if (invoked === 0) {
+            expect(where).to.be.eql({foo: 'val1'});
+          } else if (invoked === 1) {
+            expect(where).to.be.eql({baz: undefined});
+          }
+          invoked++;
+          return 0;
+        };
+        await S.validate(countMethod, 'replaceOrCreate', 'model', modelData);
+        expect(invoked).to.be.eq(2);
+      });
+
+      describe('in case that the given model has a document identifier', function () {
+        describe('a document of the given identifier does not exist', function () {
+          it('uses the default primary key to check existence of the given identifier', async function () {
+            const schema = new Schema();
+            schema.defineModel({
+              name: 'model',
+              properties: {
+                foo: {
+                  type: DataType.ANY,
+                  unique: true,
+                },
+              },
+            });
+            const S = schema.getService(PropertyUniquenessValidator);
+            let invoked = 0;
+            const idValue = 1;
+            const modelData = {[DEF_PK]: idValue, foo: 'bar'};
+            const countMethod = where => {
+              invoked++;
+              if (invoked === 1) {
+                expect(where).to.be.eql({[DEF_PK]: idValue});
+                return 0;
+              } else if (invoked === 2) {
+                expect(where).to.be.eql({foo: 'bar'});
+                return 0;
+              }
+            };
+            await S.validate(
+              countMethod,
+              'replaceOrCreate',
+              'model',
+              modelData,
+            );
+            expect(invoked).to.be.eq(2);
+          });
+
+          it('uses a custom primary key to check existence of the given identifier', async function () {
+            const schema = new Schema();
+            schema.defineModel({
+              name: 'model',
+              properties: {
+                myId: {
+                  type: DataType.NUMBER,
+                  primaryKey: true,
+                },
+                foo: {
+                  type: DataType.ANY,
+                  unique: true,
+                },
+              },
+            });
+            const S = schema.getService(PropertyUniquenessValidator);
+            let invoked = 0;
+            const idValue = 1;
+            const modelData = {myId: idValue, foo: 'bar'};
+            const countMethod = where => {
+              invoked++;
+              if (invoked === 1) {
+                expect(where).to.be.eql({myId: idValue});
+                return 0;
+              } else if (invoked === 2) {
+                expect(where).to.be.eql({foo: 'bar'});
+                return 0;
+              }
+            };
+            await S.validate(
+              countMethod,
+              'replaceOrCreate',
+              'model',
+              modelData,
+              idValue,
+            );
+            expect(invoked).to.be.eq(2);
+          });
+
+          it('checks the given identifier only once', async function () {
+            const schema = new Schema();
+            schema.defineModel({
+              name: 'model',
+              properties: {
+                foo: {
+                  type: DataType.ANY,
+                  unique: true,
+                },
+                bar: {
+                  type: DataType.ANY,
+                  unique: true,
+                },
+              },
+            });
+            const S = schema.getService(PropertyUniquenessValidator);
+            let invoked = 0;
+            const idValue = 1;
+            const modelData = {[DEF_PK]: idValue, foo: 'val1', bar: 'val2'};
+            const countMethod = where => {
+              invoked++;
+              if (invoked === 1) {
+                expect(where).to.be.eql({[DEF_PK]: idValue});
+                return 0;
+              } else if (invoked === 2) {
+                expect(where).to.be.eql({foo: 'val1'});
+                return 0;
+              } else if (invoked === 3) {
+                expect(where).to.be.eql({bar: 'val2'});
+                return 0;
+              }
+            };
+            await S.validate(
+              countMethod,
+              'replaceOrCreate',
+              'model',
+              modelData,
+            );
+            expect(invoked).to.be.eq(3);
+          });
+        });
+
+        describe('a document of the given identifier already exist', function () {
+          it('uses the default primary key to check existence of the given identifier', async function () {
+            const schema = new Schema();
+            schema.defineModel({
+              name: 'model',
+              properties: {
+                foo: {
+                  type: DataType.ANY,
+                  unique: true,
+                },
+              },
+            });
+            const S = schema.getService(PropertyUniquenessValidator);
+            let invoked = 0;
+            const idValue = 1;
+            const modelData = {
+              [DEF_PK]: idValue,
+              foo: 'bar',
+            };
+            const countMethod = where => {
+              invoked++;
+              if (invoked === 1) {
+                expect(where).to.be.eql({
+                  [DEF_PK]: idValue,
+                });
+                return 1;
+              } else if (invoked === 2) {
+                expect(where).to.be.eql({
+                  [DEF_PK]: {neq: idValue},
+                  foo: 'bar',
+                });
+                return 0;
+              }
+            };
+            await S.validate(
+              countMethod,
+              'replaceOrCreate',
+              'model',
+              modelData,
+            );
+            expect(invoked).to.be.eq(2);
+          });
+
+          it('uses a custom primary key to check existence of the given identifier', async function () {
+            const schema = new Schema();
+            schema.defineModel({
+              name: 'model',
+              properties: {
+                myId: {
+                  type: DataType.NUMBER,
+                  primaryKey: true,
+                },
+                foo: {
+                  type: DataType.ANY,
+                  unique: true,
+                },
+              },
+            });
+            const S = schema.getService(PropertyUniquenessValidator);
+            let invoked = 0;
+            const idValue = 1;
+            const modelData = {myId: idValue, foo: 'bar'};
+            const countMethod = where => {
+              invoked++;
+              if (invoked === 1) {
+                expect(where).to.be.eql({
+                  myId: idValue,
+                });
+                return 1;
+              } else if (invoked === 2) {
+                expect(where).to.be.eql({
+                  myId: {neq: idValue},
+                  foo: 'bar',
+                });
+                return 0;
+              }
+            };
+            await S.validate(
+              countMethod,
+              'replaceOrCreate',
+              'model',
+              modelData,
+              idValue,
+            );
+            expect(invoked).to.be.eq(2);
+          });
+
+          it('checks the given identifier only once', async function () {
+            const schema = new Schema();
+            schema.defineModel({
+              name: 'model',
+              properties: {
+                foo: {
+                  type: DataType.ANY,
+                  unique: true,
+                },
+                bar: {
+                  type: DataType.ANY,
+                  unique: true,
+                },
+              },
+            });
+            const S = schema.getService(PropertyUniquenessValidator);
+            let invoked = 0;
+            const idValue = 1;
+            const modelData = {
+              [DEF_PK]: idValue,
+              foo: 'val1',
+              bar: 'val2',
+            };
+            const countMethod = where => {
+              invoked++;
+              if (invoked === 1) {
+                expect(where).to.be.eql({[DEF_PK]: idValue});
+                return 1;
+              } else if (invoked === 2) {
+                expect(where).to.be.eql({
+                  [DEF_PK]: {neq: idValue},
+                  foo: 'val1',
+                });
+                return 0;
+              } else if (invoked === 3) {
+                expect(where).to.be.eql({
+                  [DEF_PK]: {neq: idValue},
+                  bar: 'val2',
+                });
+                return 0;
+              }
+            };
+            await S.validate(
+              countMethod,
+              'replaceOrCreate',
+              'model',
+              modelData,
+            );
+            expect(invoked).to.be.eq(3);
+          });
+        });
+      });
+    });
+
+    describe('patch', function () {
+      it('throws an error if the "countMethod" returns a positive number', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        const promise = S.validate(() => 1, 'patch', 'model', {foo: 'bar'});
+        await expect(promise).to.be.rejectedWith(
+          'An existing document of the model "model" already has ' +
+            'the property "foo" with the value "bar" and should be unique.',
+        );
+      });
+
+      it('passes validation if the "countMethod" returns zero', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        await S.validate(() => 0, 'patch', 'model', {foo: 'bar'});
+      });
+
+      it('invokes the "countMethod" only for given properties which should be unique', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+            bar: {
+              type: DataType.ANY,
+              unique: false,
+            },
+            baz: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        let invoked = 0;
+        const modelData = {foo: 'val1', bar: 'val2'};
+        const countMethod = where => {
+          if (invoked === 0) expect(where).to.be.eql({foo: 'val1'});
+          invoked++;
+          return 0;
+        };
+        await S.validate(countMethod, 'patch', 'model', modelData);
+        expect(invoked).to.be.eq(1);
+      });
+
+      it('skips not provided fields to check uniqueness', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        const promise1 = S.validate(() => 1, 'patch', 'model', {foo: 'bar'});
+        const promise2 = S.validate(() => 1, 'patch', 'model', {baz: 'qux'});
+        await expect(promise1).to.be.rejectedWith(
+          'An existing document of the model "model" already has ' +
+            'the property "foo" with the value "bar" and should be unique.',
+        );
+        await expect(promise2).not.to.be.rejected;
+      });
+    });
+
+    describe('patchById', function () {
+      it('throws an error if the "countMethod" returns a positive number', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        const promise = S.validate(
+          () => 1,
+          'patchById',
+          'model',
+          {foo: 'bar'},
+          1,
+        );
+        await expect(promise).to.be.rejectedWith(
+          'An existing document of the model "model" already has ' +
+            'the property "foo" with the value "bar" and should be unique.',
+        );
+      });
+
+      it('passes validation if the "countMethod" returns zero', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        await S.validate(() => 0, 'patchById', 'model', {foo: 'bar'}, 1);
+      });
+
+      it('invokes the "countMethod" only for given properties which should be unique', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+            bar: {
+              type: DataType.ANY,
+              unique: false,
+            },
+            baz: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        let invoked = 0;
+        const idValue = 1;
+        const modelData = {foo: 'val1', bar: 'val2'};
+        const countMethod = where => {
+          if (invoked === 0) {
+            expect(where).to.be.eql({
+              [DEF_PK]: {neq: idValue},
+              foo: 'val1',
+            });
+          }
+          invoked++;
+          return 0;
+        };
+        await S.validate(countMethod, 'patchById', 'model', modelData, idValue);
+        expect(invoked).to.be.eq(1);
+      });
+
+      it('skips not provided fields to check uniqueness', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        const promise1 = S.validate(() => 1, 'patchById', 'model', {
+          foo: 'bar',
+        });
+        const promise2 = S.validate(() => 1, 'patchById', 'model', {
+          baz: 'qux',
+        });
+        await expect(promise1).to.be.rejectedWith(
+          'An existing document of the model "model" already has ' +
+            'the property "foo" with the value "bar" and should be unique.',
+        );
+        await expect(promise2).not.to.be.rejected;
+      });
+
+      it('uses a custom primary key to check existence of the given identifier', async function () {
+        const schema = new Schema();
+        schema.defineModel({
+          name: 'model',
+          properties: {
+            myId: {
+              type: DataType.NUMBER,
+              primaryKey: true,
+            },
+            foo: {
+              type: DataType.ANY,
+              unique: true,
+            },
+          },
+        });
+        const S = schema.getService(PropertyUniquenessValidator);
+        let invoked = 0;
+        const idValue = 1;
+        const modelData = {foo: 'bar'};
+        const countMethod = where => {
+          if (invoked === 0) {
+            expect(where).to.be.eql({
+              myId: {neq: idValue},
+              foo: 'bar',
+            });
+          }
+          invoked++;
+          return 0;
+        };
+        await S.validate(countMethod, 'patchById', 'model', modelData, idValue);
+        expect(invoked).to.be.eq(1);
+      });
+    });
+  });
+});

Some files were not shown because too many files changed in this diff