@jsiqle/core
Advanced tools
Comparing version 1.4.2 to 2.0.0-beta.1
@@ -1,1 +0,1 @@ | ||
!function(root,factory){"object"==typeof exports&&"object"==typeof module?module.exports=factory():"function"==typeof define&&define.amd?define([],factory):"object"==typeof exports?exports["@jsiqle/core"]=factory():root["@jsiqle/core"]=factory()}(global,function(){return(()=>{"use strict";var __webpack_require__={n:module=>{var getter=module&&module.__esModule?()=>module.default:()=>module;return __webpack_require__.d(getter,{a:getter}),getter},d:(exports,definition)=>{for(var key in definition)__webpack_require__.o(definition,key)&&!__webpack_require__.o(exports,key)&&Object.defineProperty(exports,key,{enumerable:!0,get:definition[key]})},o:(obj,prop)=>Object.prototype.hasOwnProperty.call(obj,prop),r:exports=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(exports,"__esModule",{value:!0})}},__webpack_exports__={};__webpack_require__.r(__webpack_exports__),__webpack_require__.d(__webpack_exports__,{default:()=>src});var isUndefined=require("events"),external_events_default=__webpack_require__.n(isUndefined),symbols=["fields","key","keyType","properties","cachedProperties","methods","scopes","relationships","relationshipField","validators","recordModel","recordValue","wrappedRecordValue","recordHandler","recordTag","setRecordKey","defaultValue","addScope","addRelationshipAsField","addRelationshipAsProperty","getField","getProperty","removeScope","instances","isRecord","groupTag","get","handleExperimentalAPIMessage"].reduce((acc,curr)=>(acc["$"+curr]=Symbol.for(curr),acc),{});class NameError extends Error{constructor(message){super(message),this.name="NameError"}}class ValidationError extends Error{constructor(message){super(message),this.name="ValidationError"}}class DuplicationError extends Error{constructor(message){super(message),this.name="DuplicationError"}}class DefaultValueError extends Error{constructor(message){super(message),this.name="DefaultValueError"}}class ExperimentalAPIUsageError extends Error{constructor(message){super(message),this.name="ExperimentalAPIUsageError"}}class Validator{static unique(field){return(value,data)=>data.every(item=>item[field]!==value[field])}static length(field,[min,max]){return value=>value[field].length>=min&&value[field].length<=max}static minLength(field,min){return value=>value[field].length>=min}static maxLength(field,max){return value=>value[field].length<=max}static range(field,[min,max]){return value=>value[field]>=min&&value[field]<=max}static min(field,min){return value=>value[field]>=min}static max(field,max){return value=>value[field]<=max}static integer(field){return value=>Number.isInteger(value[field])}static regex(field,regex){return value=>regex.test(value[field])}static uniqueValues(field){return value=>new Set(value[field]).size===value[field].length}static sortedAscending(field){return value=>value[field].every((item,index)=>0===index||item>=value[field][index-1])}static sortedDescending(field){return value=>value[field].every((item,index)=>0===index||item<=value[field][index-1])}static custom(field,fn){return(value,data)=>fn(value[field],data.map(item=>item[field]))}}const restrictedNames={Model:["toString","toObject","toJSON"],Field:["toString","toObject","toJSON"],Property:["toString","toObject","toJSON"],Method:["toString","toObject","toJSON"],Relationship:["toString","toObject","toJSON"]},validateName=(objectType,name)=>{var[isValid,message]=((name,restrictedNames=[])=>"string"!=typeof name?[!1,"must be a string"]:name?/^\d/.test(name)?[!1,"cannot start with a number"]:restrictedNames.includes(name)?[!1,"is reserved"]:[/^\w+$/.test(name),"must contain only alphanumeric characters, numbers or underscores"]:[!1,"is required"])(name,restrictedNames[objectType]);if(!isValid)throw new NameError(objectType+` name ${message}.`);return name},capitalize=([first,...rest])=>first.toUpperCase()+rest.join(""),reverseCapitalize=([first,...rest])=>first.toLowerCase()+rest.join(""),deepClone=obj=>{if(null===obj)return null;if(obj instanceof Date)return new Date(obj);let clone=Object.assign({},obj);return Object.entries(clone).forEach(([key,value])=>clone[key]="object"==typeof obj[key]?deepClone(value):value),Array.isArray(obj)?(clone.length=obj.length,Array.from(clone)):clone},validateObjectWithUniqueName=({objectType,parentType,parentName},obj,collection)=>{if(!(obj=>obj&&"object"==typeof obj)(obj))throw new TypeError(objectType+` ${obj} is not an object.`);if(((collection,item)=>collection.includes(item))(collection,obj.name))throw new DuplicationError(`${parentType} ${parentName} already has a ${objectType.toLowerCase()} named ${obj.name}.`);return!0};var isBoolean=val=>"boolean"==typeof val,isNumber=val=>"number"==typeof val&&val==val,isString=val=>"string"==typeof val,isDate=val=>val instanceof Date,and=(...types)=>val=>types.every(type=>type(val));const or=(...types)=>val=>types.some(type=>type(val));var isPositive=val=>0<=val;const isArrayOf=type=>val=>Array.isArray(val)&&val.every(type);var standardTypes=shape=>{const props=Object.keys(shape);return val=>{return null!=val&&"object"==typeof val&&(0===props.length||Object.keys(val).length===props.length&&props.every(prop=>shape[prop](val[prop])))}},isObjectOf=type=>val=>null!=val&&"object"==typeof val&&Object.keys(val).every(prop=>type(val[prop])),isNull=val=>null===val,isUndefined=val=>void 0===val;const isNil=or(isNull,isUndefined);const types={bool:isBoolean,number:isNumber,positiveNumber:and(isNumber,isPositive),string:isString,date:isDate,stringOrNumber:or(isString,isNumber),numberOrString:or(isString,isNumber),enum:(...values)=>val=>values.includes(val),boolArray:isArrayOf(isBoolean),numberArray:isArrayOf(isNumber),stringArray:isArrayOf(isString),dateArray:isArrayOf(isDate),oneOf:or,arrayOf:isArrayOf,oneOrArrayOf:type=>val=>or(isArrayOf(type),type)(val),object:standardTypes,objectOf:isObjectOf,optional:type=>val=>or(isNil,type)(val),null:isNull,undefined:isUndefined,nil:isNil};standardTypes={boolean:{type:isBoolean,defaultValue:!1},number:{type:isNumber,defaultValue:0},positiveNumber:{type:and(isNumber,isPositive),defaultValue:0},string:{type:isString,defaultValue:""},date:{type:isDate,defaultValue:new Date},stringOrNumber:{type:or(isString,isNumber),defaultValue:""},numberOrString:{type:or(isString,isNumber),defaultValue:0},booleanArray:{type:isArrayOf(isBoolean),defaultValue:[]},numberArray:{type:isArrayOf(isNumber),defaultValue:[]},stringArray:{type:isArrayOf(isString),defaultValue:[]},dateArray:{type:isArrayOf(isDate),defaultValue:[]},object:{type:standardTypes({}),defaultValue:{}},booleanObject:{type:isObjectOf(isBoolean),defaultValue:{a:!0}},numberObject:{type:isObjectOf(isNumber),defaultValue:{}},stringObject:{type:isObjectOf(isString),defaultValue:{}},dateObject:{type:isObjectOf(isDate),defaultValue:{}},objectArray:{type:isArrayOf(standardTypes({})),defaultValue:[]}};const key=and(isString,val=>0!==val.trim().length),{$defaultValue,$validators}=symbols;class Field{#name;#defaultValue;#required;#type;#validators;constructor({name,type,required=!1,defaultValue=null,validators={}}){this.#name=validateName("Field",name),this.#required=Field.#validateRequired(required),this.#type=Field.#validateType(type,required),this.#defaultValue=Field.#validateDefaultValue(defaultValue,this.#type,this.#required),this.#validators=new Map,Object.entries(validators).forEach(([validatorName,validator])=>{this.addValidator(validatorName,validator)})}addValidator(validatorName,validator){this.#validators.set(...Field.#parseFieldValidator(this.#name,validatorName,validator))}get name(){return this.#name}get required(){return this.#required}typeCheck(value){return this.#type(value)}get[$defaultValue](){return this.#defaultValue}get[$validators](){return this.#validators}static#validateType(type,required){if("function"!=typeof type)throw new TypeError("Field type must be a function.");return required?type:types.optional(type)}static#validateRequired(required){if("boolean"!=typeof required)throw new TypeError("Field required must be a boolean.");return required}static#validateDefaultValue(defaultValue,type,required){if(required&&types.nil(defaultValue))throw new ValidationError("Default value cannot be null or undefined.");if(!type(defaultValue))throw new ValidationError("Default value must be valid.");return defaultValue}static#parseFieldValidator(fieldName,validatorName,validator){if(void 0!==Validator[validatorName])return[""+fieldName+capitalize(validatorName),Validator[validatorName](fieldName,validator)];if("function"!=typeof validator)throw new TypeError(`Validator ${validatorName} is not defined.`);return[""+fieldName+capitalize(validatorName),Validator.custom(fieldName,validator)]}}Object.entries(standardTypes).forEach(([typeName,standardType])=>{const{type,defaultValue:typeDefaultValue}=standardType;Field[typeName]=options=>"string"==typeof options?new Field({name:options,type:type}):new Field({...options,type:type}),Field[typeName+"Required"]=options=>{if("string"==typeof options)return new Field({name:options,type:type,required:!0,defaultValue:typeDefaultValue});var defaultValue=options.defaultValue||typeDefaultValue;return new Field({...options,type:type,required:!0,defaultValue:defaultValue})}}),Field.enum=({name,values})=>new Field({name:name,type:types.enum(...values)}),Field.enumRequired=({name,values,defaultValue=values[0]})=>new Field({name:name,type:types.enum(...values),required:!0,defaultValue:defaultValue}),Field.auto=autoField=>{autoField="string"==typeof autoField?autoField:autoField.name;const generator=function*(){let i=0;for(;;)yield i++}();let currentValue=0;autoField=new Field({name:autoField,type:value=>value===currentValue,required:!0,defaultValue:currentValue});return Object.defineProperty(autoField,$defaultValue,{get(){var value=generator.next().value;return currentValue=value}}),autoField};const{$recordValue,$wrappedRecordValue,$recordHandler,$recordModel,$recordTag,$cachedProperties,$key}=symbols;class Record{#recordValue;#recordHandler;#proxiedRecord;#cachedProperties;constructor(value,handler){return this.#recordValue=value,this.#recordHandler=handler,this.#cachedProperties=new Map,this.#proxiedRecord=new Proxy(this,this.#recordHandler),this.#proxiedRecord}get[$cachedProperties](){return this.#cachedProperties}get[$recordHandler](){return this.#recordHandler}get[$recordValue](){return this.#recordValue}get[$wrappedRecordValue](){return this.#proxiedRecord}get[$recordModel](){return this.#recordHandler.model}get[$recordTag](){var model=this[$recordModel],key=model[$key].name;return model.name+"#"+this[$recordValue][key]}get[Symbol.toStringTag](){return this[$recordTag]}}const record=Record,partial_$recordTag=symbols["$recordTag"];class PartialRecord{#tag;constructor(value,tag){Object.keys(value).forEach(key=>{this[key]=value[key]}),this.#tag=tag}get[partial_$recordTag](){return this.#tag}get[Symbol.toStringTag](){return this[partial_$recordTag]}toObject(){return{...this}}toJSON(){return this.toObject()}}const fragment_$recordTag=symbols["$recordTag"];class RecordFragment extends Array{#tag;constructor(values,tag){super(),values.forEach(value=>{this.push(value)}),this.#tag=tag}get[fragment_$recordTag](){return this.#tag}get[Symbol.toStringTag](){return this[fragment_$recordTag]}toObject(){return[...this]}toJSON(){return this.toObject()}}const{$recordModel:set_$recordModel,$recordTag:set_$recordTag,$scopes,$addScope,$removeScope,$isRecord,$key:set_$key,$setRecordKey}=symbols;class RecordSet extends Map{#frozen;#scopes;#keyName;constructor({iterable=[],copyScopesFrom=null}={}){super();for(var[key,value]of iterable)this.set(key,value);this.#scopes=new Map,copyScopesFrom&&this.#copyScopes(copyScopesFrom),this.#frozen=!1}freeze(){return this.#frozen=!0,this}set(key,value){if(this.#frozen)throw new TypeError("Cannot modify a frozen RecordSet.");return super.set(key,value),this}delete(key){if(this.#frozen)throw new TypeError("Cannot modify a frozen RecordSet.");return super.delete(key)}clear(){if(this.#frozen)throw new TypeError("Cannot modify a frozen RecordSet.");super.clear()}map(callbackFn){return[...this.entries()].reduce((newMap,[key,value])=>(newMap[key]=callbackFn(value,key,this),newMap),{})}flatMap(callbackFn){return[...this.entries()].map(([key,value])=>callbackFn(value,key,this))}reduce(callbackFn,initialValue){return[...this.entries()].reduce((acc,[key,value])=>callbackFn(acc,value,key,this),initialValue)}filter(callbackFn){return[...this.entries()].reduce((newMap,[key,value])=>(callbackFn(value,key,this)&&newMap.set(key,value),newMap),new RecordSet({copyScopesFrom:this})).freeze()}flatFilter(callbackFn){return[...this.entries()].reduce((arr,[key,value])=>(callbackFn(value,key,this)&&arr.push(value),arr),[])}find(callbackFn){for(var[key,value]of this.entries())if(callbackFn(value,key,this))return value}findKey(callbackFn){for(var[key,value]of this.entries())if(callbackFn(value,key,this))return key}only(...keys){return new RecordSet({iterable:keys.reduce((itr,key)=>(this.has(key)&&itr.push([key,this.get(key)]),itr),[]),copyScopesFrom:this}).freeze()}except(...keys){return new RecordSet({iterable:[...this.entries()].filter(([key])=>!keys.includes(key)),copyScopesFrom:this}).freeze()}sort(comparatorFn){var sorted=[...this.entries()].sort(([key1,value1],[key2,value2])=>comparatorFn(value1,value2,key1,key2));return new RecordSet({iterable:sorted,copyScopesFrom:this}).freeze()}every(callbackFn){return 0===this.size||[...this.entries()].every(([key,value])=>callbackFn(value,key,this))}some(callbackFn){return 0!==this.size&&[...this.entries()].some(([key,value])=>callbackFn(value,key,this))}select(...keys){return new RecordSet({iterable:[...this.entries()].map(([key,value])=>{const obj={};return keys.forEach(key=>obj[key]=value[key]),[key,new PartialRecord(obj,value[set_$recordTag])]}),copyScopesFrom:this}).freeze()}flatSelect(...keys){return[...this.values()].map(value=>keys.reduce((obj,key)=>({...obj,[key]:value[key]}),{}))}pluck(...keys){return new RecordSet({iterable:[...this.entries()].map(([key,value])=>{var values=keys.map(key=>value[key]);return[key,new RecordFragment(values,value[set_$recordTag])]}),copyScopesFrom:this}).freeze()}flatPluck(...keys){if(1!==keys.length)return[...this.values()].map(value=>keys.map(key=>value[key]));{const key=keys[0];return this.#keyName===key?[...this.keys()]:[...this.values()].map(value=>value[key])}}groupBy(key){const res=new RecordSet({copyScopesFrom:this,iterable:[]});for(var[recordKey,value]of this.entries()){let keyValue=value[key];void 0!==keyValue&&null!==keyValue&&keyValue[$isRecord]&&(keyValue=value[key][set_$key]),res.has(keyValue)||res.set(keyValue,new RecordGroup({copyScopesFrom:this,iterable:[],groupName:keyValue})),res.get(keyValue).set(recordKey,value)}for(const value of res.values())value.freeze();return res.freeze()}duplicate(){return new RecordSet({iterable:[...this.entries()],copyScopesFrom:this}).freeze()}merge(...recordSets){const res=new Map([...this.entries()]);for(const recordSet of recordSets)for(var[key,value]of recordSet.entries()){if(res.has(key))throw new DuplicationError(`Key ${key} already exists in the record set.`);res.set(key,value)}return new RecordSet({iterable:[...res.entries()],copyScopesFrom:this}).freeze()}append(...records){const res=new RecordSet({iterable:[...this.entries()],copyScopesFrom:this});for(const record of records)res.set(record[set_$key],record);return res.freeze()}where(callbackFn){return this.filter(callbackFn)}whereNot(callbackFn){return this.filter((value,key,map)=>!callbackFn(value,key,map))}*flatBatchKeysIterator(batchSize){let batch=[];for(const key of this.keys())batch.push(key),batch.length===batchSize&&(yield batch,batch=[]);batch.length&&(yield batch)}*flatBatchIterator(batchSize){let batch=[];for(var[,value]of this)batch.push(value),batch.length===batchSize&&(yield batch,batch=[]);batch.length&&(yield batch)}*batchIterator(batchSize){let batch=[];for(var[key,value]of this)batch.push([key,value]),batch.length===batchSize&&(yield new RecordSet({copyScopesFrom:this,iterable:batch}).freeze(),batch=[]);batch.length&&(yield new RecordSet({copyScopesFrom:this,iterable:batch}).freeze())}limit(n){let records=[];for(var[key,value]of this)if(records.push([key,value]),records.length===n)break;return new RecordSet({iterable:records,copyScopesFrom:this}).freeze()}offset(n){let counter=0,records=[];for(var[key,value]of this)counter<n?counter++:records.push([key,value]);return new RecordSet({iterable:records,copyScopesFrom:this}).freeze()}slice(start,end){return new RecordSet({iterable:[...this.entries()].slice(start,end),copyScopesFrom:this}).freeze()}get first(){for(var[,value]of this)return value}get last(){if(0!==this.size)return[...this.entries()].pop()[1]}get count(){return this.size}get length(){return this.size}toArray(){return[...this.values()]}toFlatArray(){return[...this.values()].map(value=>value instanceof RecordGroup?value.toFlatArray():value.toObject())}toObject(){return[...this.entries()].reduce((obj,[key,value])=>(obj[key]=value,obj),{})}toFlatObject(){return[...this.entries()].reduce((obj,[key,value])=>(obj[key]=value instanceof RecordGroup?value.toFlatArray():value.toObject(),obj),{})}toJSON(){return this.toObject()}get[Symbol.toStringTag](){var records=[...this.values()];try{const firstModel=records[0][set_$recordModel].name;if(((arr,fn)=>{const eql=fn(arr[0]);return arr.every(val=>fn(val)===eql)})(records,value=>value[set_$recordModel].name===firstModel))return firstModel}catch(e){return""}return""}static get[Symbol.species](){return Map}[$addScope](name,scope,sortFn){if(RecordSet.#validateProperty("Scope",name,scope,this.#scopes),sortFn&&RecordSet.#validateFunction("Scope comparator",name,sortFn),this[name]||Object.getOwnPropertyNames(RecordSet.prototype).includes(name))throw new NameError(`Scope name ${name} is already in use.`);this.#scopes.set(name,[scope,sortFn]),Object.defineProperty(this,name,{configurable:!0,get:()=>this.#scopedWhere(name)})}[$removeScope](name){this.#scopes.delete(RecordSet.#validateContains("Scope",name,this.#scopes)),delete this[name]}[$setRecordKey](keyName){this.#keyName=keyName}get[$scopes](){return this.#scopes}#copyScopes(otherRecordSet){otherRecordSet[$scopes].forEach((scope,name)=>{this.#scopes.set(name,scope),Object.defineProperty(this,name,{configurable:!0,get:()=>this.#scopedWhere(name)})})}#scopedWhere(scopeName){const[matcherFn,comparatorFn]=this.#scopes.get(scopeName);let matches=[];for(var[key,value]of this.entries())matcherFn(value,key,this)&&matches.push([key,value]);return comparatorFn&&matches.sort(([key1,value1],[key2,value2])=>comparatorFn(value1,value2,key1,key2)),new RecordSet({iterable:matches,copyScopesFrom:this}).freeze()}static#validateProperty(callbackType,callbackName,callback,callbacks){if("function"!=typeof callback)throw new TypeError(callbackType+` ${callbackName} is not a function.`);if(callbacks.has(callbackName))throw new DuplicationError(callbackType+` ${callbackName} already exists.`);return callback}static#validateFunction(callbackType,callbackName,callback){if("function"!=typeof callback)throw new TypeError(callbackType+` ${callbackName} is not a function.`);return callback}static#validateContains(objectType,objectName,objects){if(!objects.has(objectName))throw new ReferenceError(objectType+` ${objectName} does not exist.`);return objectName}}const set=RecordSet,$groupTag=symbols["$groupTag"];class RecordGroup extends set{#groupName;constructor({iterable=[],copyScopesFrom=null,groupName=""}={}){super({iterable:iterable,copyScopesFrom:copyScopesFrom}),this.#groupName=groupName}get[$groupTag](){return this.#groupName}get[Symbol.toStringTag](){return this[$groupTag]}}const{$fields,$defaultValue:handler_$defaultValue,$key:handler_$key,$keyType,$properties,$cachedProperties:handler_$cachedProperties,$methods,$relationships,$validators:handler_$validators,$recordValue:handler_$recordValue,$wrappedRecordValue:handler_$wrappedRecordValue,$recordModel:handler_$recordModel,$recordTag:handler_$recordTag,$isRecord:handler_$isRecord,$get}=symbols;class RecordHandler{#model;constructor(model){this.#model=model}get model(){return this.#model}createRecord(recordData){if(!recordData)throw new TypeError("Record data cannot be empty.");if("object"!=typeof recordData)throw new TypeError("Record data must be an object.");const modelName=this.#getModelName(),newRecordKey=RecordHandler.#validateNewRecordKey(modelName,this.#getKey(),recordData[this.#getKey().name],this.#model.records),clonedRecord=deepClone(recordData),extraProperties=Object.keys(clonedRecord).filter(property=>!this.#hasField(property)&&!this.#isModelKey(property));0<extraProperties.length&&console.warn(`${modelName} record has extra fields: ${extraProperties.join(", ")}.`);const newRecord=new record({[this.#getKey().name]:newRecordKey,...extraProperties.reduce((obj,property)=>({...obj,[property]:clonedRecord[property]}),{})},this);return this.#getFieldNames().forEach(field=>{this.set(newRecord,field,clonedRecord[field],newRecord,!0)}),this.#getValidators().forEach((validator,validatorName)=>{if(!validator(newRecord,this.#model.records))throw new RangeError(`${modelName} record with key ${newRecordKey} failed validation for ${validatorName}.`)}),[newRecordKey,newRecord]}get(record,property){return this.#hasRelationshipField(property)?this.#getRelationship(record,property):this.#isModelKey(property)||this.#hasField(property)?this.#getFieldValue(record,property):this.#hasProperty(property)?this.#getProperty(record,property):this.#hasMethod(property)?this.#getMethod(record,property):this.#isCallToSerialize(property)?RecordHandler.#recordToObject(record,this.#model,this):this.#isCallToString(property)?()=>this.#getKeyValue(record):this.#isKnownSymbol(property)?this.#getKnownSymbol(record,property):void 0}set(record,property,value,receiver,skipValidation){const recordValue=record[handler_$recordValue],recordKey=this.#getKeyValue(record),otherRecords=this.#model.records.except(recordKey);if(this.#hasProperty(property))throw new TypeError(`${this.#getModelName()} record ${recordKey} cannot set property ${property}.`);if(this.#hasMethod(property))throw new TypeError(`${this.#getModelName()} record ${recordKey} cannot set method ${property}.`);if(this.#hasField(property)){const field=this.#getField(property);RecordHandler.#setRecordField(this.#model.name,record,field,value),field[handler_$validators].forEach((validator,validatorName)=>{if(![null,void 0].includes(recordValue[property])&&!validator(recordValue,otherRecords))throw new RangeError(`${this.#getModelName()} record with key ${recordKey} failed validation for ${validatorName}.`)})}else console.warn(this.#model.name+` record has extra field: ${property}.`),recordValue[property]=value;return skipValidation||this.#getValidators().forEach((validator,validatorName)=>{if(!validator(recordValue,otherRecords))throw new RangeError(`${this.#getModelName()} record with key ${recordKey} failed validation for ${validatorName}.`)}),!0}static#setRecordField(modelName,record,field,recordValue){recordValue=field.required&&types.nil(recordValue)?field[handler_$defaultValue]:recordValue;if(!field.typeCheck(recordValue))throw new TypeError(`${modelName} record has invalid value for field ${field.name}.`);record[handler_$wrappedRecordValue]&&record[handler_$cachedProperties].clear(),record[handler_$recordValue][field.name]=recordValue}static#recordToObject(record,key,handler){const recordValue=record[handler_$recordValue],fields=key[$fields],properties=key[$properties];key=key[handler_$key].name;const object={[key]:recordValue[key]};fields.forEach(field=>{void 0!==recordValue[field.name]&&(object[field.name]=recordValue[field.name])});return({include=[]}={})=>{var result=object;const included=include.map(name=>{const[field,...props]=name.split(".");return[field,props.join(".")]});return included.forEach(([includedField,props])=>{if(object[includedField])if(Array.isArray(object[includedField])){const records=handler.get(record,includedField);object[includedField]=records.map(record=>record.toObject({include:[props]}))}else object[includedField]=handler.get(record,includedField).toObject({include:[props]});else properties.has(includedField)&&(object[includedField]=handler.get(record,includedField))}),result}}static#validateNewRecordKey=(modelName,modelKey,recordKey,records)=>{let newRecordKey=recordKey;if("string"===modelKey[$keyType]&&!modelKey.typeCheck(newRecordKey))throw new TypeError(`${modelName} record has invalid value for key ${modelKey.name}.`);if("auto"===modelKey[$keyType]&&(newRecordKey=modelKey[handler_$defaultValue]),records.has(newRecordKey))throw new DuplicationError(`${modelName} record with key ${newRecordKey} already exists.`);return newRecordKey};#getModelName(){return this.#model.name}#getFieldNames(){return[...this.#model[$fields].keys()]}#getValidators(){return this.#model[handler_$validators]}#isModelKey(property){return this.#model[handler_$key].name===property}#getKey(){return this.#model[handler_$key]}#getKeyValue(record){return record[handler_$recordValue][this.#model[handler_$key].name]}#hasField(property){return this.#model[$fields].has(property)}#getField(property){return this.#model[$fields].get(property)}#getFieldValue(record,property){return record[handler_$recordValue][property]}#hasProperty(property){return this.#model[$properties].has(property)}#getProperty(record,property){if(this.#model[handler_$cachedProperties].has(property)){if(record[handler_$cachedProperties]&&record[handler_$cachedProperties].has(property))return record[handler_$cachedProperties].get(property);var value=this.#model[$properties].get(property)(record[handler_$wrappedRecordValue]);return record[handler_$cachedProperties].set(property,value),value}return this.#model[$properties].get(property)(record[handler_$wrappedRecordValue])}#hasMethod(method){return this.#model[$methods].has(method)}#getMethod(record,method){const methodFn=this.#model[$methods].get(method);return(...args)=>methodFn(record[handler_$wrappedRecordValue],...args)}#hasRelationshipField(property){return!!this.#hasField(property)&&this.#model[$relationships].has(property+"."+property)}#getRelationship(record,property){return this.#model[$relationships].get(property+"."+property)[$get](this.#getModelName(),property,record[handler_$recordValue])}#isCallToSerialize(property){return"toObject"===property||"toJSON"===property}#isCallToString(property){return"toString"===property}#isKnownSymbol(property){return[handler_$recordModel,handler_$recordTag,handler_$recordValue,handler_$isRecord,handler_$key].includes(property)}#getKnownSymbol(record,property){return property===handler_$isRecord||(property===handler_$key&&this.#getKey(),record[property])}}const handler=RecordHandler,{$fields:model_$fields,$defaultValue:model_$defaultValue,$key:model_$key,$keyType:model_$keyType,$properties:model_$properties,$cachedProperties:model_$cachedProperties,$methods:model_$methods,$scopes:model_$scopes,$relationships:model_$relationships,$validators:model_$validators,$recordHandler:model_$recordHandler,$addScope:model_$addScope,$addRelationshipAsField,$addRelationshipAsProperty,$getField,$getProperty,$removeScope:model_$removeScope,$instances,$handleExperimentalAPIMessage,$setRecordKey:model_$setRecordKey}=symbols,allStandardTypes=[...Object.keys(standardTypes),...Object.keys(standardTypes).map(type=>type+"Required"),"enum","enumRequired","auto"];class Model extends external_events_default(){#records;#recordHandler;#fields;#key;#properties;#methods;#relationships;#validators;#updatingField=!1;#cachedProperties;static#instances=new Map;constructor({name,fields=[],key="id",properties={},methods={},scopes={},validators={},cacheProperties=[]}={}){if(super(),this.name=validateName("Model",name),Model.#instances.has(name))throw new DuplicationError(`A model named ${name} already exists.`);this.#records=new set,this.#recordHandler=new handler(this),this.#key=Model.#parseKey(this.name,key),this.#records[model_$setRecordKey](key),this.#fields=new Map,this.#properties=new Map,this.#methods=new Map,this.#relationships=new Map,this.#validators=new Map,this.#cachedProperties=new Set,fields.forEach(field=>this.addField(field)),Object.entries(properties).forEach(([propertyName,property])=>{this.addProperty(propertyName,property,cacheProperties.includes(propertyName))}),Object.entries(methods).forEach(([methodName,method])=>{this.addMethod(methodName,method)}),Object.entries(scopes).forEach(([scopeName,scope])=>{this.addScope(scopeName,...Model.#parseScope(scope))}),Object.entries(validators).forEach(([validatorName,validator])=>{this.addValidator(validatorName,validator)}),Model.#instances.set(this.name,this)}addField(fieldOptions,retrofill){this.#updatingField||this.emit("beforeAddField",{field:fieldOptions,model:this});var field=Model.#parseField(this.name,fieldOptions,[...this.#fields.keys(),this.#key.name,...this.#properties.keys(),...this.#methods.keys()]);return this.#fields.set(fieldOptions.name,field),this.#updatingField||this.emit("fieldAdded",{field:field,model:this}),this.emit("beforeRetrofillField",{field:field,retrofill:retrofill,model:this}),Model.#applyFieldRetrofill(field,this.#records,retrofill),this.emit("fieldRetrofilled",{field:field,retrofill:retrofill,model:this}),this.#updatingField||this.emit("change",{type:"fieldAdded",field:field,model:this}),field}removeField(name){if(!Model.#validateContains(this.name,"Field",name,this.#fields))return!1;var field=this.#fields.get(name);return this.#updatingField||this.emit("beforeRemoveField",{field:field,model:this}),this.#fields.delete(name),this.#updatingField||(this.emit("fieldRemoved",{field:{name:name},model:this}),this.emit("change",{type:"fieldRemoved",field:field,model:this})),!0}updateField(name,field,newField){if(field.name!==name)throw new NameError(`Field name ${field.name} does not match ${name}.`);if(!Model.#validateContains(this.name,"Field",name,this.#fields))throw new ReferenceError(`Field ${name} does not exist.`);var prevField=this.#fields.get(name);this.#updatingField=!0,this.emit("beforeUpdateField",{prevField:prevField,field:field,model:this}),this.removeField(name);newField=this.addField(field,newField);this.emit("fieldUpdated",{field:newField,model:this}),this.#updatingField=!1,this.emit("change",{type:"fieldUpdated",field:newField,model:this})}addProperty(name,property,cache=!1){this.emit("beforeAddProperty",{property:{name:name,body:property},model:this});var propertyName=validateName("Property",name);this.#properties.set(propertyName,Model.#validateFunction("Property",name,property,[...this.#fields.keys(),this.#key.name,...this.#properties.keys(),...this.#methods.keys()])),cache&&this.#cachedProperties.add(propertyName),this.emit("propertyAdded",{property:{name:propertyName,body:property},model:this}),this.emit("change",{type:"propertyAdded",property:{name:propertyName,body:property},model:this})}removeProperty(name){if(!Model.#validateContains(this.name,"Property",name,this.#properties))return!1;var property=this.#properties.get(name);return this.emit("beforeRemoveProperty",{property:{name:name,body:property},model:this}),this.#properties.delete(name),this.#cachedProperties.has(name)&&this.#cachedProperties.delete(name),this.emit("propertyRemoved",{property:{name:name},model:this}),this.emit("change",{type:"propertyRemoved",property:{name:name,body:property},model:this}),!0}addMethod(name,method){this.emit("beforeAddMethod",{method:{name:name,body:method},model:this});var methodName=validateName("Method",name);this.#methods.set(methodName,Model.#validateFunction("Method",name,method,[...this.#fields.keys(),this.#key.name,...this.#properties.keys(),...this.#methods.keys()])),this.emit("methodAdded",{method:{name:methodName,body:method},model:this}),this.emit("change",{type:"methodAdded",method:{name:methodName,body:method},model:this})}removeMethod(name){if(!Model.#validateContains(this.name,"Method",name,this.#methods))return!1;var method=this.#methods.get(name);return this.emit("beforeRemoveMethod",{method:{name:name,body:method},model:this}),this.#methods.delete(name),this.emit("methodRemoved",{method:{name:name},model:this}),this.emit("change",{type:"methodRemoved",method:{name:name,body:method},model:this}),!0}addScope(scopeName,scope,sortFn){this.emit("beforeAddScope",{scope:{name:scopeName,body:scope},model:this});scopeName=validateName("Scope",scopeName);this.#records[model_$addScope](scopeName,scope,sortFn),this.emit("scopeAdded",{scope:{name:scopeName,body:scope},model:this}),this.emit("change",{type:"scopeAdded",scope:{name:scopeName,body:scope},model:this})}removeScope(name){if(!Model.#validateContains(this.name,"Scope",name,this.#records[model_$scopes]))return!1;var scope=this.#records[model_$scopes].get(name);return this.emit("beforeRemoveScope",{scope:{name:name,body:scope},model:this}),this.#records[model_$removeScope](name),this.emit("scopeRemoved",{scope:{name:name},model:this}),this.emit("change",{type:"scopeRemoved",scope:{name:name,body:scope},model:this}),!0}addValidator(name,validator){this.emit("beforeAddValidator",{validator:{name:name,body:validator},model:this}),this.#validators.set(name,Model.#validateFunction("Validator",name,validator,[...this.#validators.keys()])),this.emit("validatorAdded",{validator:{name:name,body:validator},model:this}),this.emit("change",{type:"validatorAdded",validator:{name:name,body:validator},model:this})}removeValidator(name){if(!Model.#validateContains(this.name,"Validator",name,this.#validators))return!1;var validator=this.#validators.get(name);return this.emit("beforeRemoveValidator",{validator:{name:name,body:validator},model:this}),this.#validators.delete(name),this.emit("validatorRemoved",{validator:{name:name},model:this}),this.emit("change",{type:"validatorRemoved",validator:{name:name,body:validator},model:this}),!0}createRecord(newRecord){this.emit("beforeCreateRecord",{record:newRecord,model:this});var[newRecordKey,newRecord]=this.#recordHandler.createRecord(newRecord);return this.#records.set(newRecordKey,newRecord),this.emit("recordCreated",{newRecord:newRecord,model:this}),newRecord}removeRecord(recordKey){if(!this.#records.has(recordKey))return console.warn(`Record ${recordKey} does not exist.`),!1;var record=this.#records.get(recordKey);return this.emit("beforeRemoveRecord",{record:record,model:this}),this.#records.delete(recordKey),this.emit("recordRemoved",{record:{[this.#key.name]:recordKey},model:this}),!0}updateRecord(recordKey,record){if("object"!=typeof record)throw new TypeError("Record data must be an object.");if(!this.#records.has(recordKey))throw new ReferenceError(`Record ${recordKey} does not exist.`);const oldRecord=this.#records.get(recordKey);return this.emit("beforeUpdateRecord",{record:oldRecord,newRecord:{[this.#key.name]:recordKey,...record},model:this}),Object.entries(record).forEach(([fieldName,fieldValue])=>{oldRecord[fieldName]=fieldValue}),this.emit("recordUpdated",{record:oldRecord,model:this}),oldRecord}get records(){return this.#records}static get[$instances](){return Model.#instances}get[model_$recordHandler](){return this.#recordHandler}get[model_$fields](){return this.#fields}get[model_$key](){return this.#key}get[model_$properties](){return this.#properties}get[model_$cachedProperties](){return this.#cachedProperties}get[model_$methods](){return this.#methods}get[model_$relationships](){return this.#relationships}get[model_$validators](){return this.#validators}[$addRelationshipAsField](relationship){var{name,type,fieldName,field}=relationship[$getField](),relationshipName=name+"."+fieldName;if(this.emit("beforeAddRelationship",{relationship:{name:name,type:type},model:this}),[...this.#fields.keys(),this.#key.name,...this.#properties.keys(),...this.#methods.keys()].includes(fieldName))throw new NameError(`Relationship field ${fieldName} is already in use.`);if(this.#relationships.has(relationshipName))throw new NameError(`Relationship ${relationshipName} is already in use.`);this.#fields.set(fieldName,field),this.#relationships.set(relationshipName,relationship),this.emit("relationshipAdded",{relationship:{name:name,type:type},model:this}),this.emit("change",{type:"relationshipAdded",relationship:{relationship:{name:name,type:type},model:this},model:this})}[$addRelationshipAsProperty](relationship){var{name,type,propertyName,property}=relationship[$getProperty](),relationshipName=name+"."+propertyName;if(this.emit("beforeAddRelationship",{relationship:{name:name,type:type},model:this}),[...this.#fields.keys(),this.#key.name,...this.#properties.keys(),...this.#methods.keys()].includes(propertyName))throw new NameError(`Relationship property ${propertyName} is already in use.`);if(this.#relationships.has(relationshipName))throw new NameError(`Relationship ${name} is already in use.`);this.#properties.set(propertyName,property),this.#relationships.set(relationshipName,relationship),this.emit("relationshipAdded",{relationship:{name:name,type:type},model:this}),this.emit("change",{type:"relationshipAdded",relationship:{relationship:{name:name,type:type},model:this},model:this})}static#createKey(options){let name="id",type="string";"string"==typeof options?name=options:"object"==typeof options&&(name=options.name||name,type=options.type||type);let keyField;return"string"===type?(keyField=new Field({name:name,type:key,required:!0,defaultValue:"__emptyKey__"}),Object.defineProperty(keyField,model_$defaultValue,{get(){throw new DefaultValueError(`Key field ${name} does not have a default value.`)}})):"auto"===type&&(keyField=Field.auto(name)),Object.defineProperty(keyField,model_$keyType,{get(){return type}}),keyField}static#parseKey(modelName,key){if("string"!=typeof key&&"object"!=typeof key)throw new TypeError(modelName+` key ${key} is not a string or object.`);if("object"==typeof key&&!key.name)throw new TypeError(modelName+` key ${key} is missing a name.`);if("object"==typeof key&&!["auto","string"].includes(key.type))throw new TypeError(modelName+` key ${key} type must be either "string" or "auto".`);return Model.#createKey(key)}static#parseField(modelName,field,restrictedNames){return validateObjectWithUniqueName({objectType:"Field",parentType:"Model",parentName:modelName},field,restrictedNames),allStandardTypes.includes(field.type)?Field[field.type](field):("function"==typeof field.type&&Schema[$handleExperimentalAPIMessage](`The provided type for ${field.name} is not part of the standard types. Function types are experimental and may go away in a later release.`),new Field(field))}static#parseScope(sorter){if("function"==typeof sorter)return[sorter];if("object"!=typeof sorter)throw new TypeError("The provided scope is not a function or valid object.");var{matcher,sorter}=sorter;if("function"!=typeof matcher)throw new TypeError("The provided matcher for the scope is not a function.");if(sorter&&"function"!=typeof sorter)throw new TypeError("The provided sorter for the scope is not a function.");return[matcher,sorter]}static#validateFunction(callbackType,callbackName,callback,restrictedNames){if("function"!=typeof callback)throw new TypeError(callbackType+` ${callbackName} is not a function.`);if(restrictedNames.includes(callbackName))throw new DuplicationError(callbackType+` ${callbackName} already exists.`);return callback}static#validateContains(modelName,objectType,objectName,objects){return!!objects.has(objectName)||(console.warn(`Model ${modelName} does not contain a ${objectType.toLowerCase()} named ${objectName}.`),!1)}static#applyFieldRetrofill(field,records,retrofill){if(field.required||void 0!==retrofill){const retrofillFunction=void 0!==retrofill?"function"==typeof retrofill?retrofill:()=>retrofill:record=>record[field.name]||field[model_$defaultValue];records.forEach(record=>{record[field.name]=retrofillFunction(record)})}}}const{$key:relationship_$key,$recordValue:relationship_$recordValue,$fields:relationship_$fields,$getField:relationship_$getField,$getProperty:relationship_$getProperty,$get:relationship_$get,$defaultValue:relationship_$defaultValue,$instances:relationship_$instances,$handleExperimentalAPIMessage:relationship_$handleExperimentalAPIMessage}=symbols,relationshipEnum={oneToOne:"oneToOne",oneToMany:"oneToMany",manyToOne:"manyToOne",manyToMany:"manyToMany"};class Relationship{#type;#from;#to;#name;#reverseName;#relationshipField;#relationshipProperty;constructor({from:fromName,to:toModel,type:toName}={}){Schema[relationship_$handleExperimentalAPIMessage]("Relationships are experimental in the current version. There is neither validation of existence in foreign tables nor guarantee that associations work. Please use with caution."),this.#type=Relationship.#validateType(toName);var[fromModel,fromName,toModel,toName]=Relationship.#parseModelsAndNames(fromName,toModel,toName);if(this.#from=fromModel,this.#to=toModel,this.#name=fromName,this.#reverseName=toName,this.#to===this.#from&&Relationship.#isSymmetric(this.#type)&&this.#name===this.#reverseName)throw new RangeError("Relationship cannot be symmetric if the from and to models are the same and no name is provided for either one.");this.#relationshipField=Relationship.#createField(this.#name,this.#type,this.#to[relationship_$key]),this.#relationshipProperty=record=>this.#getAssociatedRecordsReverse(record)}[relationship_$getField](){return{name:this.#name,type:this.#type,fieldName:this.#name,field:this.#relationshipField}}[relationship_$getProperty](){return{name:this.#name,type:this.#type,propertyName:this.#reverseName,property:this.#relationshipProperty}}[relationship_$get](modelName,property,record){return modelName===this.#from.name&&property===this.#name?this.#getAssociatedRecords(record):modelName===this.#to.name&&property===this.#reverseName?(console.warn("Relationship getter called by the receiver model. This might indicate an issue with the library and should be reported."),this.#getAssociatedRecordsReverse(record)):void 0}#getAssociatedRecords(associationValues){if(Relationship.#isToOne(this.#type)){var associationValue=associationValues[this.#name];return this.#to.records.get(associationValue)}associationValues=associationValues[this.#name]||[];return this.#to.records.only(...associationValues)}#getAssociatedRecordsReverse(matcher){const associationValue=matcher[this.#to[relationship_$key].name];matcher=Relationship.#isToOne(this.#type)?associatedRecord=>associatedRecord[relationship_$recordValue][this.#name]===associationValue:associatedRecord=>{var associatedRecordValue=associatedRecord[relationship_$recordValue][this.#name];return![void 0,null].includes(associatedRecordValue)&&associatedRecord[relationship_$recordValue][this.#name].includes(associationValue)};return Relationship.#isFromOne(this.#type)?this.#from.records.find(matcher):this.#from.records.where(matcher)}static#isToOne(type){return[relationshipEnum.oneToOne,relationshipEnum.manyToOne].includes(type)}static#isToMany(type){return[relationshipEnum.oneToMany,relationshipEnum.manyToMany].includes(type)}static#isFromOne(type){return[relationshipEnum.oneToMany,relationshipEnum.oneToOne].includes(type)}static#isFromMany(type){return[relationshipEnum.manyToOne,relationshipEnum.manyToMany].includes(type)}static#isSymmetric(type){return[relationshipEnum.oneToOne,relationshipEnum.manyToMany].includes(type)}static#createField(name,type,foreignField){var isSingleSource=Relationship.#isFromOne(type),relationshipField=Relationship.#isToMany(type),type=relationshipField?types.arrayOf(value=>foreignField.typeCheck(value)):value=>foreignField.typeCheck(value);const validators={};isSingleSource&&!relationshipField&&(validators.unique=!0),relationshipField&&(validators.uniqueValues=!0);relationshipField=new Field({name:name,type:type,required:!1,defaultValue:relationshipField?[]:null,validators:validators});return Object.defineProperty(relationshipField,relationship_$defaultValue,{get(){throw new DefaultValueError("Relationship field does not have a default value.")}}),relationshipField}static#validateType(relationshipType){if(!Object.values(relationshipEnum).includes(relationshipType))throw new TypeError(`Invalid relationship type: ${relationshipType}.`);return relationshipType}static#validateModel(modelName){modelName="string"==typeof modelName?modelName:modelName.model;if(!Model[relationship_$instances].has(modelName))throw new ReferenceError(`Model ${modelName} does not exist.`);return Model[relationship_$instances].get(modelName)}static#createName(type,to){return Relationship.#isToOne(type)?reverseCapitalize(to):Relationship.#isToMany(type)?reverseCapitalize(to)+"Set":void 0}static#createReverseName=(type,from)=>Relationship.#isFromOne(type)?reverseCapitalize(from):Relationship.#isFromMany(type)?reverseCapitalize(from)+"Set":void 0;static#validateModelParams(name){const model=Relationship.#validateModel(name);name="string"==typeof name?null:validateName("Field",name.name);if(null!==name&&model[relationship_$fields].has(name))throw new DuplicationError(`Field ${name} already exists in ${model.name}.`);return[model,name]}static#parseModelsAndNames(from,to,type){let fromModel,fromName,toModel,toName;return[fromModel,fromName]=Relationship.#validateModelParams(from),[toModel,toName]=Relationship.#validateModelParams(to),null===fromName&&(fromName=Relationship.#createName(type,toModel.name)),null===toName&&(toName=Relationship.#createReverseName(type,fromModel.name)),[fromModel,fromName,toModel,toName]}}class Serializer{#name;#attributes;#methods;constructor({name,attributes=[],methods={}}){this.#name=validateName("Serializer",name),this.#attributes=new Map,this.#methods=new Map,attributes.forEach(attributeName=>{var[attributeValue,attributeName]="string"==typeof attributeName?[attributeName,attributeName]:attributeName;this.#attributes.set(Serializer.#validateAttribute(attributeName,[...this.#attributes.keys()]),attributeValue)}),Object.entries(methods).forEach(([methodName,methodBody])=>{this.addMethod(methodName,methodBody)})}addMethod(methodName,method){method=Serializer.#validateFunction(methodName,method,[...this.#methods.keys()]);this.#methods.set(methodName,method)}serialize(object,options){const serialized={};return this.#attributes.forEach((value,attributeName)=>{value=this.#methods.has(value)?this.#methods.get(value)(object,options):object[value];void 0!==value&&(serialized[attributeName]=value)}),serialized}serializeArray(objects,options){return objects.map(object=>this.serialize(object,options))}serializeRecordSet(objects,options,keyMapFn){const serialized={};return objects.forEach((value,mappedKey)=>{mappedKey=keyMapFn(mappedKey,value);void 0!==mappedKey&&(serialized[mappedKey]=this.serialize(value,options))}),serialized}get name(){return this.#name}static#validateAttribute(attributeName,restrictedNames){if("string"!=typeof attributeName)throw new TypeError(`Attribute ${attributeName} is not a string.`);if(restrictedNames.includes(attributeName))throw new DuplicationError(`Attribute ${attributeName} already exists.`);return attributeName}static#validateFunction(callbackName,callback,restrictedNames){if("function"!=typeof callback)throw new TypeError(`Method ${callbackName} is not a function.`);if(restrictedNames.includes(callbackName))throw new DuplicationError(`Method ${callbackName} already exists.`);return callback}}const{$addRelationshipAsField:schema_$addRelationshipAsField,$addRelationshipAsProperty:schema_$addRelationshipAsProperty,$handleExperimentalAPIMessage:schema_$handleExperimentalAPIMessage,$key:schema_$key,$keyType:schema_$keyType,$instances:schema_$instances}=symbols;class Schema extends external_events_default(){#name;#models;#serializers;static defaultConfig={experimentalAPIMessages:"warn"};static config={...Schema.defaultConfig};static#schemas=new Map;constructor({name,models=[],relationships=[],serializers=[],config={}}={}){super(),this.#name=validateName("Schema",name),this.#models=new Map,this.#serializers=new Map,Schema.#parseConfig(config),Schema.#schemas.set(this.#name,this),models.forEach(model=>this.createModel(model)),relationships.forEach(relationship=>this.createRelationship(relationship)),serializers.forEach(serializer=>this.createSerializer(serializer));const schemaData={models:Object.fromEntries([...this.#models.entries()]),serializers:Object.fromEntries([...this.#serializers.entries()])};models.forEach(model=>{const modelRecord=this.getModel(model.name),cachedProperties=model.cacheProperties||[];model.lazyProperties&&Object.entries(model.lazyProperties).forEach(([propertyName,propertyInitializer])=>{modelRecord.addProperty(propertyName,propertyInitializer(schemaData),cachedProperties.includes(propertyName))}),model.lazyMethods&&Object.entries(model.lazyMethods).forEach(([methodName,methodInitializer])=>{modelRecord.addMethod(methodName,methodInitializer(schemaData))})}),serializers.forEach(serializer=>{const serializerRecord=this.getSerializer(serializer.name);serializer.lazyMethods&&Object.entries(serializer.lazyMethods).forEach(([methodName,methodInitializer])=>{serializerRecord.addMethod(methodName,methodInitializer(schemaData))})})}createModel(modelData){this.emit("beforeCreateModel",{model:modelData,schema:this});const model=Schema.#parseModel(this.#name,modelData,this.#models);return this.#models.set(model.name,model),model.on("change",({type,...eventData})=>{this.emit("change",{type:"model"+capitalize(type),...eventData,schema:this})}),this.emit("modelCreated",{model:model,schema:this}),this.emit("change",{type:"modelCreated",model:model,schema:this}),model}getModel(name){return this.#models.get(name)}removeModel(name){var model=this.getModel(name);if(this.emit("beforeRemoveModel",{model:model,schema:this}),!this.#models.has(name))throw new ReferenceError(`Model ${name} does not exist in schema ${this.#name}.`);this.#models.delete(name),Model[schema_$instances].delete(name),this.emit("modelRemoved",{model:{name:name},schema:this}),this.emit("change",{type:"modelRemoved",model:model,schema:this})}createRelationship(relationship){this.emit("beforeCreateRelationship",{relationship:relationship,schema:this});relationship=Schema.#applyRelationship(this.#name,relationship,this.#models);return this.emit("relationshipCreated",{relationship:relationship,schema:this}),this.emit("change",{type:"relationshipCreated",relationship:relationship,schema:this}),relationship}createSerializer(serializer){this.emit("beforeCreateSerializer",{serializer:serializer,schema:this});serializer=Schema.#parseSerializer(this.#name,serializer,this.#serializers);return this.#serializers.set(serializer.name,serializer),this.emit("serializerCreated",{serializer:serializer,schema:this}),this.emit("change",{type:"serializerCreated",serializer:serializer,schema:this}),serializer}getSerializer(name){return this.#serializers.get(name)}get name(){return this.#name}get models(){return this.#models}static create(schemaData){return new Schema(schemaData)}static get(name){return Schema.#schemas.get(name)}get(pathName){this.emit("beforeGet",{pathName:pathName,schema:this});const[modelName,recordKey,...rest]=pathName.split("."),model=this.getModel(modelName);if(!model)throw new ReferenceError(`Model ${modelName} does not exist in schema ${this.#name}.`);if(void 0===recordKey)return model;var result=model[schema_$key][schema_$keyType],result=model.records.get("string"===result?recordKey:Number.parseInt(recordKey));if(!rest.length)return result;if(!result)throw new ReferenceError(`Record ${recordKey} does not exist in model ${modelName}.`);result=rest.reduce((acc,key)=>acc[key],result);return this.emit("got",{pathName:pathName,result:result,schema:this}),result}static[schema_$handleExperimentalAPIMessage](message){var experimentalAPIMessages=Schema.config["experimentalAPIMessages"];if("warn"===experimentalAPIMessages)console.warn(message);else if("error"===experimentalAPIMessages)throw new ExperimentalAPIUsageError(message)}static#parseModel(schemaName,modelData,models){return validateObjectWithUniqueName({objectType:"Model",parentType:"Schema",parentName:schemaName},modelData,[...models.keys()]),new Model(modelData)}static#applyRelationship(relationship,toModelName,models){const{from,to,type}=toModelName;[from,to].forEach(model=>{if(!["string","object"].includes(typeof model))throw new TypeError(`Invalid relationship model: ${model}.`)});var fromModelName="string"==typeof from?from:from.model,toModelName="string"==typeof to?to:to.model;const fromModel=models.get(fromModelName),toModel=models.get(toModelName);if(!fromModel)throw new ReferenceError(`Model ${fromModelName} not found in schema ${relationship} when attempting to create a relationship.`);if(!toModel)throw new ReferenceError(`Model ${toModelName} not found in schema ${relationship} when attempting to create a relationship.`);relationship=new Relationship({from:from,to:to,type:type});return fromModel[schema_$addRelationshipAsField](relationship),toModel[schema_$addRelationshipAsProperty](relationship),relationship}static#parseSerializer(schemaName,serializerData,serializers){return validateObjectWithUniqueName({objectType:"Serializer",parentType:"Schema",parentName:schemaName},serializerData,[...serializers.keys()]),new Serializer(serializerData)}static#parseConfig(config={}){config&&["experimentalAPIMessages"].forEach(key=>{void 0!==config[key]&&["warn","error","off"].includes(config[key])&&(Schema.config[key]=config[key])})}}const src=Schema;return __webpack_exports__})()}); | ||
!function(root,factory){"object"==typeof exports&&"object"==typeof module?module.exports=factory():"function"==typeof define&&define.amd?define([],factory):"object"==typeof exports?exports["@jsiqle/core"]=factory():root["@jsiqle/core"]=factory()}(global,(()=>(()=>{"use strict";var __webpack_require__={d:(exports,definition)=>{for(var key in definition)__webpack_require__.o(definition,key)&&!__webpack_require__.o(exports,key)&&Object.defineProperty(exports,key,{enumerable:!0,get:definition[key]})},o:(obj,prop)=>Object.prototype.hasOwnProperty.call(obj,prop),r:exports=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(exports,"__esModule",{value:!0})}},__webpack_exports__={};__webpack_require__.r(__webpack_exports__),__webpack_require__.d(__webpack_exports__,{default:()=>src});const isBoolean=val=>"boolean"==typeof val,isNumber=val=>"number"==typeof val&&val==val,isString=val=>"string"==typeof val,isDate=val=>val instanceof Date,and=(...types)=>val=>types.every((type=>type(val))),isArrayOf=type=>val=>Array.isArray(val)&&val.every(type),isNull=val=>null===val,standardTypes={boolean:{type:isBoolean},number:{type:isNumber},string:{type:isString},date:{type:isDate},booleanArray:{type:isArrayOf(isBoolean)},numberArray:{type:isArrayOf(isNumber)},stringArray:{type:isArrayOf(isString)},dateArray:{type:isArrayOf(isDate)},object:{type:val=>"object"==typeof val}},recordId=and(isString,(val=>0!==val.trim().length)),recordIdArray=and(isArrayOf(recordId),(arr=>new Set(arr).size===arr.length)),symbols=((...str)=>str.reduce(((acc,curr)=>(acc[`$${curr}`]=Symbol.for(curr),acc)),{}))("fields","properties","cachedProperties","methods","scopes","relationships","relationshipField","recordModel","recordValue","wrappedRecordValue","recordHandler","recordTag","emptyRecordTemplate","addScope","addRelationshipAsField","addRelationshipAsProperty","getField","getProperty","isDateField","instances","isRecord","groupTag","set","delete","get","handleExperimentalAPIMessage","clearSchemaForTesting","clearCachedProperties","clearRecordSetForTesting","schemaObject"),{$isDateField}=symbols;class Field{#name;#type;#isDateField=!1;constructor({name,type}){this.#name=name,this.#type=(type=>val=>((...types)=>val=>types.some((type=>type(val))))(isNull,type)(val))(type)}get name(){return this.#name}typeCheck(value){return this.#type(value)}get[$isDateField](){return this.#isDateField}set[$isDateField](value){this.#isDateField=value}}Object.entries(standardTypes).forEach((([typeName,standardType])=>{const{type}=standardType;Field[typeName]="date"===typeName?name=>{const field=new Field({name,type});return field[$isDateField]=!0,field}:name=>new Field({name,type})}));const{$recordValue,$wrappedRecordValue,$recordHandler,$recordModel,$recordTag,$cachedProperties}=symbols;class Record{#recordValue;#recordHandler;#proxiedRecord;#cachedProperties;constructor(value,handler){return this.#recordValue=value,this.#recordHandler=handler,this.#cachedProperties=new Map,this.#proxiedRecord=new Proxy(this,this.#recordHandler),this.#proxiedRecord}get[$cachedProperties](){return this.#cachedProperties}get[$recordHandler](){return this.#recordHandler}get[$recordValue](){return this.#recordValue}get[$wrappedRecordValue](){return this.#proxiedRecord}get[$recordModel](){return this.#recordHandler.model}get[$recordTag](){return`${this[$recordModel].name}#${this[$recordValue].id}`}get[Symbol.toStringTag](){return this[$recordTag]}}const record=Record;class NameError extends Error{constructor(message){super(message),this.name="NameError"}}class DuplicationError extends Error{constructor(message){super(message),this.name="DuplicationError"}}class ExperimentalAPIUsageError extends Error{constructor(message){super(message),this.name="ExperimentalAPIUsageError"}}const restrictedNames=["toString","toObject","toJSON","id"],validateName=name=>{const[isValid,message]=(name=>"string"!=typeof name?[!1,"must be a string"]:name?/^\d/.test(name)?[!1,"cannot start with a number"]:restrictedNames.includes(name)?[!1,"is reserved"]:[/^\w+$/.test(name),"must contain only alphanumeric characters, numbers or underscores"]:[!1,"cannot be empty"])(name);if(!isValid)throw new NameError(`Name "${name}" is invalid - ${message}.`);return name},reverseCapitalize=([first,...rest])=>first.toLowerCase()+rest.join(""),deepClone=obj=>{if("object"!=typeof obj)return obj;if(null===obj)return null;if(obj instanceof Date)return new Date(obj);let clone=Object.assign({},obj);return Object.entries(clone).forEach((([key,value])=>clone[key]="object"==typeof obj[key]?deepClone(value):value)),Array.isArray(obj)?(clone.length=obj.length,Array.from(clone)):clone},validateObjectWithUniqueName=({objectType,parentType,parentName},obj,collection)=>{if(!(obj=>obj&&"object"==typeof obj)(obj))throw new TypeError(`${objectType} ${obj} is not an object.`);if(collection.includes(obj.name)){throw new DuplicationError(`${parentName?`${parentType} ${parentName}`:parentType} already has a ${objectType.toLowerCase()} named ${obj.name}.`)}return!0},{$fields,$properties,$cachedProperties:handler_$cachedProperties,$methods,$relationships,$recordValue:handler_$recordValue,$wrappedRecordValue:handler_$wrappedRecordValue,$emptyRecordTemplate,$recordModel:handler_$recordModel,$recordTag:handler_$recordTag,$isRecord,$isDateField:handler_$isDateField,$get,$schemaObject}=symbols;class RecordHandler{#model;constructor(model){this.#model=model}get model(){return this.#model}createRecord(recordData){if(!recordData)throw new TypeError("Record data cannot be empty.");if("object"!=typeof recordData)throw new TypeError("Record data must be an object.");const modelName=this.#getModelName(),newRecordId=RecordHandler.#validateNewRecordId(modelName,recordData.id,this.#model.records),newRecord=new record({id:newRecordId,...this.#getEmptyRecordTemplate()},this);return this.#getFieldNames().forEach((field=>{void 0!==recordData[field]&&this.set(newRecord,field,deepClone(recordData[field]),newRecord)})),[newRecordId,newRecord]}get(record,property){return this.#hasRelationshipField(property)?this.#getRelationship(record,property):this.#isRecordId(property)||this.#hasField(property)?this.#getFieldValue(record,property):this.#hasProperty(property)?this.#getProperty(record,property):this.#hasMethod(property)?this.#getMethod(record,property):this.#isCallToSerialize(property)?RecordHandler.#recordToObject(record,this.#model,this):this.#isCallToString(property)?()=>this.getRecordId(record):this.#isKnownSymbol(property)?this.#getKnownSymbol(record,property):void 0}set(record,property,value){const recordId=this.getRecordId(record);if(this.#hasProperty(property))throw new TypeError(`${this.#getModelName()} record ${recordId} cannot set property ${property}.`);if(this.#hasMethod(property))throw new TypeError(`${this.#getModelName()} record ${recordId} cannot set method ${property}.`);if(this.#hasField(property)){const field=this.#getField(property);RecordHandler.#setRecordField(this.#model.name,record,field,value,this.#hasRelationshipField(property))}return!0}static#setRecordField(modelName,record,field,value,isRelationship){const recordValue=isRelationship||void 0!==value?field[handler_$isDateField]?new Date(value):value:null;if(!isRelationship&&!field.typeCheck(recordValue))throw new TypeError(`${modelName} record has invalid value for field ${field.name}.`);record[handler_$wrappedRecordValue]&&record[handler_$cachedProperties].clear(),record[handler_$recordValue][field.name]=recordValue}static#recordToObject(record,model){const recordValue=record[handler_$recordValue],fields=model[$fields],object={id:recordValue.id};return fields.forEach((field=>{void 0!==recordValue[field.name]&&(object[field.name]=recordValue[field.name])})),()=>object}static#validateNewRecordId=(modelName,id,records)=>{let newRecordId=id;if(!recordId(newRecordId))throw new TypeError(`${modelName} record has invalid id.`);if(records.has(newRecordId))throw new DuplicationError(`${modelName} record with id ${newRecordId} already exists.`);return newRecordId};#getModelName(){return this.#model.name}#getFieldNames(){return[...this.#model[$fields].keys()]}#getEmptyRecordTemplate(){return this.#model[$emptyRecordTemplate]}#isRecordId(property){return"id"===property}getRecordId(record){return record[handler_$recordValue].id}#hasField(property){return this.#model[$fields].has(property)}#getField(property){return this.#model[$fields].get(property)}#getFieldValue(record,property){return record[handler_$recordValue][property]}#hasProperty(property){return this.#model[$properties].has(property)}#getProperty(record,property){if(this.#model[handler_$cachedProperties].has(property)){if(record[handler_$cachedProperties]&&record[handler_$cachedProperties].has(property))return record[handler_$cachedProperties].get(property);const value=this.#model[$properties].get(property)(record[handler_$wrappedRecordValue],schema[$schemaObject]);return record[handler_$cachedProperties].set(property,value),value}return this.#model[$properties].get(property)(record[handler_$wrappedRecordValue],schema[$schemaObject])}#hasMethod(method){return this.#model[$methods].has(method)}#getMethod(record,method){const methodFn=this.#model[$methods].get(method);return(...args)=>methodFn(record[handler_$wrappedRecordValue],...args,schema[$schemaObject])}#hasRelationshipField(property){return!!this.#hasField(property)&&this.#model[$relationships].has(`${property}.${property}`)}#getRelationship(record,property){return this.#model[$relationships].get(`${property}.${property}`)[$get](this.#getModelName(),property,record[handler_$recordValue])}#isCallToSerialize(property){return"toObject"===property||"toJSON"===property}#isCallToString(property){return"toString"===property}#isKnownSymbol(property){return[handler_$recordModel,handler_$recordTag,handler_$recordValue,$isRecord].includes(property)}#getKnownSymbol(record,property){return property===$isRecord||record[property]}}const handler=RecordHandler,{$scopes,$addScope,$isRecord:set_$isRecord,$set,$delete,$clearRecordSetForTesting}=symbols;class RecordSet extends Map{#model;constructor({iterable=[],model=null}={}){if(super(),!model)throw new TypeError("Model cannot be empty.");this.#model=model;for(const[id,value]of iterable)this[$set](id,value);this.#copyScopesFromModel()}set(){throw new TypeError("You cannot directly modify a RecordSet. Please use `Model.prototype.createRecord()` instead.")}delete(){throw new TypeError("You cannot directly modify a RecordSet. Please use `Model.prototype.deleteRecord()` instead.")}clear(){throw new TypeError("You cannot directly modify a RecordSet Please use `Model.prototype.deleteRecord()` instead.")}map(callbackFn,{flat=!1}={}){return flat?[...this.entries()].map((([id,value])=>callbackFn(value,id,this))):[...this.entries()].reduce(((newMap,[id,value])=>(newMap[id]=callbackFn(value,id,this),newMap)),{})}reduce(callbackFn,initialValue){return[...this.entries()].reduce(((acc,[id,value])=>callbackFn(acc,value,id,this)),initialValue)}filter(callbackFn,{flat=!1}={}){return flat?[...this.entries()].reduce(((arr,[id,value])=>(callbackFn(value,id,this)&&arr.push(value),arr)),[]):[...this.entries()].reduce(((newRecordSet,[id,record])=>(callbackFn(record,id,this)&&newRecordSet[$set](id,record),newRecordSet)),new RecordSet({model:this.#model}))}find(callbackFn){for(const[id,record]of this)if(callbackFn(record,id,this))return record}findId(callbackFn){for(const[id,value]of this)if(callbackFn(value,id,this))return id}only(...ids){return ids.reduce(((newRecordSet,id)=>(this.has(id)&&newRecordSet[$set](id,this.get(id)),newRecordSet)),new RecordSet({model:this.#model}))}except(...ids){const newRecordSet=new RecordSet({model:this.#model});for(const[id,record]of this)ids.includes(id)||newRecordSet[$set](id,record);return newRecordSet}sort(comparatorFn){const newRecordSet=new RecordSet({model:this.#model}),sorted=[...this.entries()].sort((([id1,value1],[id2,value2])=>comparatorFn(value1,value2,id1,id2)));for(const[id,record]of sorted)newRecordSet[$set](id,record);return newRecordSet}every(callbackFn){return 0===this.size||[...this.entries()].every((([id,value])=>callbackFn(value,id,this)))}some(callbackFn){return 0!==this.size&&[...this.entries()].some((([id,value])=>callbackFn(value,id,this)))}select(...keys){return[...this.values()].map((value=>keys.reduce(((obj,key)=>({...obj,[key]:value[key]})),{})))}pluck(...keys){if(1===keys.length){const key=keys[0];return"id"===key?[...this.ids()]:[...this.values()].map((value=>value[key]))}return[...this.values()].map((value=>keys.map((key=>value[key]))))}groupBy(key){const res={};for(const[id,record]of this){let keyValue=record[key];null!=keyValue&&keyValue[set_$isRecord]&&(keyValue=record[key].id),res[keyValue]||(res[keyValue]=new RecordSet({model:this.#model})),res[keyValue][$set](id,record)}return res}where(callbackFn){return this.filter(callbackFn)}whereNot(callbackFn){return this.filter(((value,id,map)=>!callbackFn(value,id,map)))}*batchIterator(batchSize,{flat=!1}={}){if(flat){let batch=[];for(const[id,record]of this)batch.push(flat?record:[id,record]),batch.length===batchSize&&(yield batch,batch=[]);batch.length&&(yield batch)}else{let newRecordSet=new RecordSet({model:this.#model});for(const[id,record]of this)newRecordSet[$set](id,record),newRecordSet.size===batchSize&&(yield newRecordSet,newRecordSet=new RecordSet({model:this.#model}));newRecordSet.size&&(yield newRecordSet)}}limit(n){const newRecordSet=new RecordSet({model:this.#model});for(const[id,record]of this)if(newRecordSet[$set](id,record),newRecordSet.size===n)break;return newRecordSet}offset(n){const newRecordSet=new RecordSet({model:this.#model});let counter=0;for(const[id,record]of this)counter<n?counter++:newRecordSet[$set](id,record);return newRecordSet}slice(start,end){return[...this.entries()].slice(start,end).reduce(((newRecordSet,[id,record])=>(newRecordSet[$set](id,record),newRecordSet)),new RecordSet({model:this.#model}))}get first(){for(const[,record]of this)return record}get last(){if(0!==this.size)return[...this.entries()].pop()[1]}get count(){return this.size}get length(){return this.size}get ids(){return this.keys}toArray({flat=!1}={}){const values=[...this.values()];return flat?values.map((value=>value.toObject())):values}toObject({flat=!1}={}){return flat?[...this.entries()].reduce(((obj,[id,value])=>(obj[id]=value.toObject(),obj)),{}):[...this.entries()].reduce(((obj,[id,value])=>(obj[id]=value,obj)),{})}toJSON(){return this.toObject()}get[Symbol.toStringTag](){return this.#model.name}static get[Symbol.species](){return Map}[$set](id,value){return super.set(id,value),this}[$delete](id){return super.delete(id),this}[$addScope](name){Object.defineProperty(this,name,{configurable:!1,get:()=>this.#scopedWhere(name)})}[$clearRecordSetForTesting](){super.clear()}#copyScopesFromModel(){this.#model[$scopes].forEach(((scope,name)=>{this[name]||Object.defineProperty(this,name,{configurable:!1,get:()=>this.#scopedWhere(name)})}))}#scopedWhere(scopeName){const[matcherFn,comparatorFn]=this.#model[$scopes].get(scopeName),newRecordSet=new RecordSet({model:this.#model});if(comparatorFn){let matches=[];for(const[id,record]of this)matcherFn(record,id,this)&&matches.push([id,record]);comparatorFn&&matches.sort((([id1,value1],[id2,value2])=>comparatorFn(value1,value2,id1,id2)));for(const[id,record]of matches)newRecordSet[$set](id,record)}else for(const[id,record]of this)matcherFn(record,id,this)&&newRecordSet[$set](id,record);return newRecordSet}}const set=RecordSet,{$fields:model_$fields,$properties:model_$properties,$cachedProperties:model_$cachedProperties,$clearCachedProperties,$methods:model_$methods,$relationships:model_$relationships,$scopes:model_$scopes,$recordHandler:model_$recordHandler,$emptyRecordTemplate:model_$emptyRecordTemplate,$addScope:model_$addScope,$addRelationshipAsField,$addRelationshipAsProperty,$getField,$getProperty,$instances,$set:model_$set,$delete:model_$delete}=symbols,allStandardTypes=Object.keys(standardTypes);class Model{#records;#recordHandler;#fields;#properties;#methods;#relationships;#cachedProperties;#scopes;#emptyRecordTemplate;static#instances=new Map;constructor({name,fields={},properties={},methods={},scopes={}}={}){if(this.name=name,Model.#instances.has(name))throw new DuplicationError(`A model named ${name} already exists.`);this.#scopes=new Map,this.#records=new set({model:this}),this.#recordHandler=new handler(this),this.#fields=new Map,this.#properties=new Map,this.#methods=new Map,this.#relationships=new Map,this.#cachedProperties=new Set,Object.entries(fields).forEach((([fieldName,fieldType])=>{this.#addField(fieldType,fieldName)})),this.#emptyRecordTemplate=this.#generateEmptyRecordTemplate(),Object.entries(properties).forEach((([propertyName,property])=>{"object"==typeof property?this.#addProperty({name:propertyName,...property}):this.#addProperty({name:propertyName,body:property})})),Object.entries(methods).forEach((([methodName,method])=>{this.#addMethod(methodName,method)})),Object.entries(scopes).forEach((([scopeName,scope])=>{this.#addScope(scopeName,...Model.#parseScope(scope))})),Model.#instances.set(this.name,this)}createRecord(record){const[newRecordId,newRecord]=this.#recordHandler.createRecord(record);return this.#records[model_$set](newRecordId,newRecord),newRecord}removeRecord(recordId){return this.#records.has(recordId)?(this.#records[model_$delete](recordId),!0):(console.warn(`Record ${recordId} does not exist.`),!1)}updateRecord(recordId,record){if("object"!=typeof record)throw new TypeError("Record data must be an object.");if(!this.#records.has(recordId))throw new ReferenceError(`Record ${recordId} does not exist.`);const oldRecord=this.#records.get(recordId);return Object.entries(record).forEach((([fieldName,fieldValue])=>{oldRecord[fieldName]=fieldValue})),oldRecord}get records(){return this.#records}static get[$instances](){return Model.#instances}get[model_$recordHandler](){return this.#recordHandler}get[model_$fields](){return this.#fields}get[model_$properties](){return this.#properties}get[model_$cachedProperties](){return this.#cachedProperties}get[model_$methods](){return this.#methods}get[model_$relationships](){return this.#relationships}get[model_$scopes](){return this.#scopes}get[model_$emptyRecordTemplate](){return this.#emptyRecordTemplate}[$addRelationshipAsField](relationship){const{name,fieldName,field}=relationship[$getField](),relationshipName=`${name}.${fieldName}`;if(["id",...this.#fields.keys(),...this.#properties.keys(),...this.#methods.keys()].includes(fieldName))throw new NameError(`Relationship field ${fieldName} is already in use.`);if(this.#relationships.has(relationshipName))throw new NameError(`Relationship ${relationshipName} is already in use.`);this.#fields.set(fieldName,field),this.#relationships.set(relationshipName,relationship),this.#emptyRecordTemplate[fieldName]=void 0}[$addRelationshipAsProperty](relationship){const{name,propertyName,property}=relationship[$getProperty](),relationshipName=`${name}.${propertyName}`;if(["id",...this.#fields.keys(),...this.#properties.keys(),...this.#methods.keys()].includes(propertyName))throw new NameError(`Relationship property ${propertyName} is already in use.`);if(this.#relationships.has(relationshipName))throw new NameError(`Relationship ${name} is already in use.`);this.#properties.set(propertyName,property),this.#relationships.set(relationshipName,relationship)}[$clearCachedProperties](){this.#cachedProperties.clear()}#addField(type,name){const isStandardType=allStandardTypes.includes(type);if("string"!=typeof type||!isStandardType)throw new TypeError(`Field ${name} is not a standard type.`);this.#fields.set(name,Field[type](name))}#addProperty({name,body,cache=!1,inverse=null}){if("function"!=typeof body)throw new TypeError(`Property ${name} is not a function.`);this.#properties.set(name,body);const hasInverse="string"==typeof inverse&&inverse.length>0;if(hasInverse){if(this.#properties.has(inverse))throw new NameError(`Property ${inverse} is already in use.`);const inverseBody=(...args)=>!body(...args);this.#properties.set(inverse,inverseBody)}cache&&(this.#cachedProperties.add(name),hasInverse&&this.#cachedProperties.add(inverse))}#addMethod(name,method){if("function"!=typeof method)throw new TypeError(`Method ${name} is not a function.`);this.#methods.set(name,method)}#addScope(name,scope,sortFn){if("function"!=typeof scope)throw new TypeError(`Scope ${name} is not a function.`);if(sortFn&&"function"!=typeof sortFn)throw new TypeError(`Scope ${name} comparator function is not a function.`);const scopeName=validateName(name);if(this.#records[scopeName]||Object.getOwnPropertyNames(set.prototype).includes(scopeName))throw new NameError(`Scope name ${scopeName} is already in use.`);this.#scopes.set(name,[scope,sortFn]),this.#records[model_$addScope](scopeName)}#generateEmptyRecordTemplate(){const emptyRecordTemplate={};return this.#fields.forEach((field=>{emptyRecordTemplate[field.name]=null})),emptyRecordTemplate}static#parseScope(scope){if("function"==typeof scope)return[scope];if("object"==typeof scope){const{matcher,sorter}=scope;if("function"!=typeof matcher)throw new TypeError("The provided matcher for the scope is not a function.");if(sorter&&"function"!=typeof sorter)throw new TypeError("The provided sorter for the scope is not a function.");return[matcher,sorter]}throw new TypeError("The provided scope is not a function or valid object.")}}const{$recordValue:relationship_$recordValue,$fields:relationship_$fields,$getField:relationship_$getField,$getProperty:relationship_$getProperty,$get:relationship_$get,$instances:relationship_$instances,$handleExperimentalAPIMessage}=symbols,relationshipEnum={oneToOne:"oneToOne",oneToMany:"oneToMany",manyToOne:"manyToOne",manyToMany:"manyToMany"};class Relationship{#type;#from;#to;#name;#reverseName;#relationshipField;#relationshipProperty;constructor({from,to,type}={}){Schema[$handleExperimentalAPIMessage]("Relationships are experimental in the current version. There is neither validation of existence in foreign tables nor guarantee that associations work. Please use with caution."),this.#type=Relationship.#validateType(type);const[fromModel,fromName,toModel,toName]=Relationship.#parseModelsAndNames(from,to,type);if(this.#from=fromModel,this.#to=toModel,this.#name=fromName,this.#reverseName=toName,this.#to===this.#from&&Relationship.#isSymmetric(this.#type)&&this.#name===this.#reverseName)throw new RangeError("Relationship cannot be symmetric if the from and to models are the same and no name is provided for either one.");this.#relationshipField=Relationship.#createField(this.#name,this.#type),this.#relationshipProperty=record=>this.#getAssociatedRecordsReverse(record)}[relationship_$getField](){return{name:this.#name,type:this.#type,fieldName:this.#name,field:this.#relationshipField}}[relationship_$getProperty](){return{name:this.#name,type:this.#type,propertyName:this.#reverseName,property:this.#relationshipProperty}}[relationship_$get](modelName,property,record){return modelName===this.#from.name&&property===this.#name?this.#getAssociatedRecords(record):modelName===this.#to.name&&property===this.#reverseName?(console.warn("Relationship getter called by the receiver model. This might indicate an issue with the library and should be reported."),this.#getAssociatedRecordsReverse(record)):void 0}#getAssociatedRecords(record){if(Relationship.#isToOne(this.#type)){const associationValue=record[this.#name];return this.#to.records.get(associationValue)}const associationValues=record[this.#name]||[];return this.#to.records.only(...associationValues)}#getAssociatedRecordsReverse(record){const associationValue=record.id,matcher=Relationship.#isToOne(this.#type)?associatedRecord=>associatedRecord[relationship_$recordValue][this.#name]===associationValue:associatedRecord=>{const associatedRecordValue=associatedRecord[relationship_$recordValue][this.#name];return![void 0,null].includes(associatedRecordValue)&&associatedRecord[relationship_$recordValue][this.#name].includes(associationValue)};return Relationship.#isFromOne(this.#type)?this.#from.records.find(matcher):this.#from.records.where(matcher)}static#isToOne(type){return[relationshipEnum.oneToOne,relationshipEnum.manyToOne].includes(type)}static#isToMany(type){return[relationshipEnum.oneToMany,relationshipEnum.manyToMany].includes(type)}static#isFromOne(type){return[relationshipEnum.oneToMany,relationshipEnum.oneToOne].includes(type)}static#isFromMany(type){return[relationshipEnum.manyToOne,relationshipEnum.manyToMany].includes(type)}static#isSymmetric(type){return[relationshipEnum.oneToOne,relationshipEnum.manyToMany].includes(type)}static#createField(name,relationshipType){const isMultiple=Relationship.#isToMany(relationshipType);return new Field({name,type:isMultiple?recordIdArray:recordId})}static#validateType(relationshipType){if(!Object.values(relationshipEnum).includes(relationshipType))throw new TypeError(`Invalid relationship type: ${relationshipType}.`);return relationshipType}static#validateModel(modelData){const modelName="string"==typeof modelData?modelData:modelData.model;if(!Model[relationship_$instances].has(modelName))throw new ReferenceError(`Model ${modelName} does not exist.`);return Model[relationship_$instances].get(modelName)}static#createName(type,to){return Relationship.#isToOne(type)?reverseCapitalize(to):Relationship.#isToMany(type)?`${reverseCapitalize(to)}Set`:void 0}static#createReverseName=(type,from)=>Relationship.#isFromOne(type)?reverseCapitalize(from):Relationship.#isFromMany(type)?`${reverseCapitalize(from)}Set`:void 0;static#validateModelParams(modelData){const model=Relationship.#validateModel(modelData),name="string"==typeof modelData?null:validateName(modelData.name);if(null!==name&&model[relationship_$fields].has(name))throw new DuplicationError(`Field ${name} already exists in ${model.name}.`);return[model,name]}static#parseModelsAndNames(from,to,type){let fromModel,fromName,toModel,toName;return[fromModel,fromName]=Relationship.#validateModelParams(from),[toModel,toName]=Relationship.#validateModelParams(to),null===fromName&&(fromName=Relationship.#createName(type,toModel.name)),null===toName&&(toName=Relationship.#createReverseName(type,fromModel.name)),[fromModel,fromName,toModel,toName]}}class Serializer{#name;#attributes;#methods;constructor({name,attributes=[],methods={}}){this.#name=name,this.#attributes=new Map,this.#methods=new Map,attributes.forEach((attribute=>{const[attributeValue,attributeName]="string"==typeof attribute?[attribute,attribute]:attribute;this.#attributes.set(Serializer.#validateAttribute(attributeName,[...this.#attributes.keys()]),attributeValue)})),Object.entries(methods).forEach((([methodName,methodBody])=>{this.#addMethod(methodName,methodBody)}))}serialize(object,options){const serialized={};return this.#attributes.forEach(((attributeValue,attributeName)=>{const value=this.#methods.has(attributeValue)?this.#methods.get(attributeValue)(object,options):object[attributeValue];void 0!==value&&(serialized[attributeName]=value)})),serialized}serializeArray(objects,options){return objects.map((object=>this.serialize(object,options)))}serializeRecordSet(objects,options,keyMapFn){const serialized={};return objects.forEach(((value,key)=>{const mappedKey=keyMapFn(key,value);void 0!==mappedKey&&(serialized[mappedKey]=this.serialize(value,options))})),serialized}get name(){return this.#name}#addMethod(methodName,methodBody){const method=Serializer.#validateFunction(methodName,methodBody,[...this.#methods.keys()]);this.#methods.set(methodName,method)}static#validateAttribute(attributeName,restrictedNames){if("string"!=typeof attributeName)throw new TypeError(`Attribute ${attributeName} is not a string.`);if(restrictedNames.includes(attributeName))throw new DuplicationError(`Attribute ${attributeName} already exists.`);return attributeName}static#validateFunction(callbackName,callback,restrictedNames){if("function"!=typeof callback)throw new TypeError(`Method ${callbackName} is not a function.`);if(restrictedNames.includes(callbackName))throw new DuplicationError(`Method ${callbackName} already exists.`);return callback}}const{$addRelationshipAsField:schema_$addRelationshipAsField,$addRelationshipAsProperty:schema_$addRelationshipAsProperty,$handleExperimentalAPIMessage:schema_$handleExperimentalAPIMessage,$clearCachedProperties:schema_$clearCachedProperties,$clearSchemaForTesting,$schemaObject:schema_$schemaObject}=symbols;class Schema{static#models=new Map;static#serializers=new Map;static#schemaObject={};static#instantiated=!1;static defaultConfig={experimentalAPIMessages:"warn"};static config={...Schema.defaultConfig};static create({models=[],relationships=[],serializers=[],config={}}={}){if(Schema.#instantiated)throw new Error("Only one schema can be created.");return Schema.#parseConfig(config),models.forEach((modelData=>{const{fields={},properties={},methods={}}=modelData,names=[...Object.keys(fields),...Object.keys(properties),...Object.keys(methods)];if(new Set(names).size!==names.length)throw new Error(`Model ${modelData.name} has duplicate field, property or method names.`);names.forEach((name=>validateName(name))),Schema.#createModel(modelData)})),relationships.forEach((relationship=>Schema.#createRelationship(relationship))),serializers.forEach((serializer=>Schema.#createSerializer(serializer))),Schema.#schemaObject={models:Object.fromEntries([...Schema.#models.entries()]),serializers:Object.fromEntries([...Schema.#serializers.entries()])},Schema.#instantiated=!0,Schema}static clearPropertyCache(){return Schema[schema_$handleExperimentalAPIMessage]("Clearing the property cache of all models should only be done if something is known to have caused the cache to contain stale data. Please use with caution."),Schema.#models.forEach((model=>model[schema_$clearCachedProperties]())),Schema}static getModel(name){return Schema.#models.get(name)}static getSerializer(name){return Schema.#serializers.get(name)}static get models(){return Schema.#models}static get[schema_$schemaObject](){return Schema.#schemaObject}static get(pathName){const[modelName,recordId,...rest]=pathName.split("."),model=Schema.getModel(modelName);if(!model)throw new ReferenceError(`Model ${modelName} does not exist in the schema.`);if(void 0===recordId)return model;const record=model.records.get(recordId);if(!rest.length)return record;if(!record)throw new ReferenceError(`Record ${recordId} does not exist in model ${modelName}.`);return rest.reduce(((acc,key)=>acc[key]),record)}static[schema_$handleExperimentalAPIMessage](message){const{experimentalAPIMessages}=Schema.config;if("warn"===experimentalAPIMessages)console.warn(message);else if("error"===experimentalAPIMessages)throw new ExperimentalAPIUsageError(message)}static[$clearSchemaForTesting](){Schema.#models.clear(),Schema.#serializers.clear(),Schema.#schemaObject={},Schema.#instantiated=!1}static#createModel(modelData){const modelName=validateName(modelData.name);validateObjectWithUniqueName({objectType:"Model",parentType:"Schema"},modelData,[...Schema.#models.keys()]);const model=new Model(modelData);Schema.#models.set(modelName,model)}static#createSerializer(serializerData){const serializerName=validateName(serializerData.name);validateObjectWithUniqueName({objectType:"Serializer",parentType:"Schema"},serializerData,[...Schema.#serializers.keys()]);const serializer=new Serializer(serializerData);Schema.#serializers.set(serializerName,serializer)}static#createRelationship(relationshipData){const{from,to,type}=relationshipData;[from,to].forEach((model=>{if(!["string","object"].includes(typeof model))throw new TypeError(`Invalid relationship model: ${model}.`)}));const fromModelName="string"==typeof from?from:from.model,toModelName="string"==typeof to?to:to.model,fromModel=Schema.#models.get(fromModelName),toModel=Schema.#models.get(toModelName);if(!fromModel)throw new ReferenceError(`Model ${fromModelName} not found in schema when attempting to create a relationship.`);if(!toModel)throw new ReferenceError(`Model ${toModelName} not found in schema when attempting to create a relationship.`);const relationship=new Relationship({from,to,type});fromModel[schema_$addRelationshipAsField](relationship),toModel[schema_$addRelationshipAsProperty](relationship)}static#parseConfig(config={}){config&&["experimentalAPIMessages"].forEach((key=>{void 0!==config[key]&&["warn","error","off"].includes(config[key])&&(Schema.config[key]=config[key])}))}}const schema=Schema,src=Schema;return __webpack_exports__})())); |
{ | ||
"name": "@jsiqle/core", | ||
"version": "1.4.2", | ||
"version": "2.0.0-beta.1", | ||
"description": "JavaScript In-memory Query Language with Events.", | ||
@@ -24,4 +24,4 @@ "main": "./dist/main.js", | ||
"devDependencies": { | ||
"@babel/core": "^7.15.8", | ||
"@babel/preset-env": "^7.15.8", | ||
"@babel/core": "^7.20.2", | ||
"@babel/preset-env": "^7.20.2", | ||
"babel-jest": "^27.3.1", | ||
@@ -34,6 +34,5 @@ "babel-loader": "^8.2.3", | ||
"prettier": "^2.4.1", | ||
"uglifyjs-webpack-plugin": "^2.2.0", | ||
"webpack": "^5.59.1", | ||
"webpack": "^5.86.0", | ||
"webpack-cli": "^4.9.1" | ||
} | ||
} |
560
README.md
# @jsiqle/core | ||
JavaScript In-memory Query Language with Events. | ||
JavaScript In-memory Query Language. | ||
@@ -21,26 +21,11 @@ ## Installation | ||
const Ledger = jsiqle.create({ | ||
name: 'Ledger', | ||
models: [ | ||
{ | ||
name: 'Person', | ||
key: { name: 'id', type: 'auto' }, | ||
fields: [ | ||
{ | ||
name: 'username', | ||
type: 'string', | ||
validators: { | ||
unique: true, | ||
minLength: 5, | ||
regex: /\w/g | ||
} | ||
}, | ||
{ | ||
name: 'role', | ||
type: 'enum', | ||
values: ['user', 'admin'], | ||
defaultValue: 'user' | ||
}, | ||
{ name: 'firstName', type: 'string' } | ||
{ name: 'lastName', type: 'string' }, | ||
], | ||
fields: { | ||
username: 'string', | ||
role: 'string', | ||
firstName: 'string', | ||
lastName: 'string' | ||
}, | ||
properties: { | ||
@@ -52,23 +37,22 @@ fullName: rec => `${rec.firstName} ${rec.lastName}` | ||
name: 'Transaction', | ||
key: { name: 'id', type: 'auto' }, | ||
fields: [ | ||
{ name: 'time', type: 'date' } | ||
{ name: 'amount', type: 'number' } | ||
] | ||
fields: { | ||
time: 'date', | ||
amount: 'number' | ||
} | ||
} | ||
], | ||
relationships: [ | ||
{ | ||
from: { model: 'Transaction', name: 'payer' }, | ||
to: { model: 'Person', name: 'outgoingTransactions' }, | ||
type: 'manyToOne' | ||
}, | ||
{ | ||
from: { model: 'Transaction', name: 'payee' }, | ||
to: { model: 'Person', name: 'incomingTransactions' }, | ||
type: 'manyToOne' | ||
} | ||
] | ||
}); | ||
Ledger.createRelationship({ | ||
from: { model: 'Transaction', name: 'payer' }, | ||
to: { model: 'Person', name: 'outgoingTransactions' }, | ||
type: 'manyToOne' | ||
}); | ||
Ledger.createRelationship({ | ||
from: { model: 'Transaction', name: 'payee' }, | ||
to: { model: 'Person', name: 'incomingTransactions' }, | ||
type: 'manyToOne' | ||
}); | ||
const Person = Ledger.getModel('Person'); | ||
@@ -99,3 +83,3 @@ const Transaction = Ledger.getModel('Transaction'); | ||
import jsiqle from '@jsiqle/core'; | ||
const MySchema = jsiqle.create({ name: 'MySchema' }); | ||
const MySchema = jsiqle.create({}); | ||
``` | ||
@@ -105,4 +89,5 @@ | ||
- `name`: The name of the schema. By convention, schema names and variables should be title-cased (i.e. `MySchema` instead of `mySchema`). | ||
- `models`: (Optional) An array of models that are part of the schema. More information about model definitions can be found in the next section. | ||
- `relationships`: (Optional) An array of relationships between models. More information about relationship definitions can be found in one of the following sections. | ||
- `serializers`: (Optional) An array of serializers for the schema. More information about serializer definitions can be found in one of the following sections. | ||
- `config`: (Optional) A configuration object that supports the following attributes: | ||
@@ -113,3 +98,3 @@ - `experimentalAPIMessages`: One of `'warn'`, `'error'` or `'off'`. Depending on this flag, experimental API messages can either be logged as warnings, throw an error or be turned off entirely. | ||
Models can be defined either as part of the schema definition or individually using `Schema.prototype.createModel()`: | ||
Models can be defined as part of the schema definition. | ||
@@ -119,18 +104,14 @@ ```js | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [{ name: 'MyModel' }] | ||
}); | ||
const AnotherModel = MySchema.createModel({ name: 'AnotherModel' }); | ||
const MyModel = MySchema.getModel('MyModel'); | ||
``` | ||
Both of these model definition options require an object argument with the following attributes: | ||
Model definition options require an object argument with the following attributes: | ||
- `name`: The name of the model. By convention, model names and variables should be title-cased (i.e. `MyModel` instead of `myModel`). Model names must be globally unique. | ||
- `fields`: (Optional) An array of fields that make up the model. More information about field definitions can be found in the next section. | ||
- `key`: (Optional) Parameter to create a key field, not part of the `fields` themselves. Can either be a string with the name of the key or an object with a `name` (string) and a `type` (either `'string'` or `'auto'`) representing a string or auto-incrementing integer key. By default, a model's key is a string field named `'id'`. | ||
- `fields`: (Optional) An object containing key-value pairs for fields that make up the model. More information about field definitions can be found in the next section. | ||
- `properties`: (Optional) An object containing key-value pairs for getter properties to be defined on the model. All properties expect a single argument representing a record of the given model. More information about property definitions can be found in one of the following sections. | ||
- `cacheProperties`: (Optional) An array containing property names that should be cached. Properties are cached as long as a record doesn't have any field changes. More information about property caching can be found in one of the following sections. | ||
- `scopes`: (Optional) An object containing key-value pairs for getter properties to be defined on the record set of the model. All scopes expect a single argument representing the record set or a subset of records from the current model. Alternatively, an object with a `matcher` and `sorter` key can be supplied for ordered scopes. More information about scope definitions can be found in one of the following sections. | ||
- `validators`: (Optional) An object containing key-value pairs for validation properties that return a boolean value depending on the validation's result. All validators expect two arguments, the current record and the record set of the current model. More information about validators and field validators can be found in one of the following sections. | ||
@@ -143,11 +124,5 @@ You can retrieve an already defined model by calling `Schema.prototype.getModel()` with the model name: | ||
Finally, models can be removed from a schema by calling `Schema.prototype.removeModel()` with the model name: | ||
```js | ||
MySchema.removeModel('MyModel'); | ||
``` | ||
#### Field definitions | ||
Fields can be defined as part of a model definition or added individually to a model by calling `Model.prototype.addField()`: | ||
Fields can be defined as part of a model definition. | ||
@@ -157,62 +132,23 @@ ```js | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [ | ||
{ | ||
name: 'MyModel', | ||
fields: [ | ||
{ name: 'firstName', type: 'string' }, | ||
{ name: 'age', type: 'numberRequired', defaultValue: 18 }, | ||
{ | ||
name: 'username', | ||
type: 'stringRequired', | ||
defaultValue: '', | ||
validators: { | ||
unique: true, | ||
minLength: 5 | ||
} | ||
} | ||
] | ||
fields: { | ||
firstName: 'string', | ||
age: 'number', | ||
username: 'string', | ||
role: 'string' | ||
} | ||
} | ||
] | ||
}); | ||
const MyModel = MySchema.getModel('MyModel'); | ||
MyModel.addField( | ||
{ | ||
name: 'role', | ||
type: 'enumRequired', | ||
values: ['user', 'admin'], | ||
defaultValue: 'user' | ||
}, | ||
record => record.username === 'admin' ? 'admin' : 'user' | ||
); | ||
``` | ||
Both of these field definition options require an object argument with the following attributes: | ||
Field definition options require an object argument with the following attributes: | ||
- `name`: The name of the field. By convention, field names should be camel-cased (i.e. `myField`). Field names must be unique for each model. | ||
- `type`: The type of the field. Read below for more information on types and validation. | ||
- `required`: (Optional) Boolean value that determines if a field can be empty (`null` or `undefined`). If specified, `defaultValue` also needs to be specified. | ||
- `defaultValue`: (Optional) A value that will be used as the default for records with an empty value in this field if the field is required. The `defaultValue` must be a valid value for the given type. | ||
- `validators`: (Optional) An object that defines what validations the field needs | ||
to pass. More information can be found below. | ||
Fields added via `Model.prototype.addField()` can specify a retrofill function as a second argument. This function takes one argument, the existing record, and is used to determine the value of the new field added to the record. | ||
In the case of defining the field in the model definition, the field `name` should be defined as the key that the type string corresponds to. | ||
Fields can be updated by calling `Model.prototype.updateField()` with the field name and a new field definition. The new field definition's name must match the existing one: | ||
```js | ||
MyModel.updateField('firstName', { | ||
name: 'firstName', | ||
type: 'stringRequired' | ||
}); | ||
``` | ||
Finally, fields can be removed by calling `Model.prototype.removeField()` with the field name: | ||
```js | ||
MyModel.removeField('firstName'); | ||
``` | ||
##### Field types | ||
@@ -223,38 +159,9 @@ | ||
``` | ||
boolean string number date positiveNumber | ||
stringOrNumber numberOrString | ||
boolean string number date object | ||
booleanArray numberArray stringArray dateArray | ||
object booleanObject numberObject | ||
stringObject dateObject objectArray | ||
enum | ||
``` | ||
Each of these types can be specified as is or suffixed with `Required` (e.g. `stringRequired`) to specify the `required` flag of the field as well. Required field types have a preset default value corresponding to an "empty" value of the type. | ||
For `enum` types, the `values` key must also be specified as an array of distinct values. | ||
Apart from standard types, there's also an experimental API that allows types to be specified as a function that takes one argument and returns a boolean determining the validity of the argument. In most cases, a validator function would suffice and you're strongly recommended to use one, instead. | ||
##### Field validation | ||
Fields can have additional validations specified, by specifying a `validators` object which includes key-value pairs for each validator. Standard validators for common use-cases are as follows: | ||
- `unique`: Takes one argument (`true`) and validates that each record has a unique value for this field. Works with any value type. | ||
- `minLength`: Takes a numeric argument, `min`, and validates that no value can have a length smaller than the `min` specified. Works with strings, arrays and other enumerable types. | ||
- `maxLength`: Takes a numeric argument, `max`, and validates that no value can have a length greater than the `max` specified. Works with strings, arrays and other enumerable types. | ||
- `length`: Takes a 2-element array, `[min, max]`, and acts as a combination of `minLength` and `maxLength`. Works with strings, arrays and other enumerable types. | ||
- `min`: Takes a numeric argument, `min`, and validates that no value can be smaller than the `min` specified. Works with numeric values and dates. | ||
- `max`: Takes a numeric argument, `max`, and validates that no value can be greater than the `max` specified. Works with numeric values and dates. | ||
- `range`: Takes a 2-element array, `[min, max]`, and acts as a combination of `min` and `max`. Works with numeric values and dates. | ||
- `integer`: Takes one argument (`true`) and validates that a given numeric value is an integer. Works with numeric types. | ||
- `regex`: Takes a regular expression argument, `regex`, and checks each value against it. Works with strings. | ||
- `uniqueValues`: Takes one argument (`true`) and validates that an array has no duplicates. Works with array types. | ||
- `sortedAscending`: Takes one argument (`true`) and validates that an array is sorted in ascending order. Works with array types. | ||
- `sortedDescending`: Takes one argument (`true`) and validates that an array is sorted in descending order. Works with array types. | ||
Apart from standard validators, custom ones can be specified using a new name as the key and a function as the value. The function takes two arguments, the field value of the current record and an array of field values in other records in the model. | ||
#### Property definitions | ||
Properties can be defined as part of a model definition or added individually to a model by calling `Model.prototype.addProperty()`: | ||
Properties can be defined as part of a model definition. | ||
@@ -264,12 +171,15 @@ ```js | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [ | ||
{ | ||
name: 'MyModel', | ||
fields: [ | ||
{ name: 'firstName', type: 'string' }, | ||
{ name: 'lastName', type: 'string' }, | ||
], | ||
fields: { | ||
firstName: 'string', | ||
lastName: 'string' | ||
}, | ||
properties: { | ||
fullName: record => `${record.firstName} ${record.lastName}` | ||
fullName: record => `${record.firstName} ${record.lastName}`, | ||
formalName: { | ||
body: record => `${record.lastName} ${record.firstName}`, | ||
cache: true | ||
} | ||
} | ||
@@ -279,18 +189,11 @@ } | ||
}); | ||
const MyModel = MySchema.getModel('MyModel'); | ||
MyModel.addProperty( | ||
'formalName', | ||
record => `${record.lastName} ${record.firstName}` | ||
); | ||
``` | ||
Properties defined as part of the model definition are specified as key-value pairs, whereas properties defined in `Model.prototype.addProperty()` are passed as two separate arguments, the name and the property body. | ||
Properties are specified as key-value pairs. The value can either be a function or an object containing a `body` and optional `cache`. | ||
Properties expect one argument, the current record, and may return any type of value. | ||
Property functions can expect up to two arguments. If they do not expect any arguments or only expect a single argument, they are called with the current record as their sole argument. If they expect two arguments, they are considered "lazy" properties and their second argument is automatically bound to the current schema object representation (`{ models, serializers }`). These can only be defined as part of the model definition, are added to the model post initialization and are useful if you need access to other models or serializers. | ||
`Model.prototype.addProperty()` can receive an additional boolean argument indicating if the property should be cached. Property caches are persisted as long as there are no field changes for a given record and cannot be specified for relationships. This means that properties that depend on other properties, methods or external values are not good candidates for caching. If a cached property is stale, the only way to force a recalculation is via updating any field on the record manually. | ||
Properties can receive an additional boolean key, `cache`, indicating if the property should be cached. Property caches are persisted as long as there are no field changes for a given record and cannot be specified for relationships. This means that properties that depend on other properties, methods or external values are not good candidates for caching. If a cached property is stale, yo can force a recalculation is via updating any field on the record manually. Additionally, all cached properties across all models can be cleared via `Schema.prototype.clearPropertyCache()`. | ||
Additionally, "lazy" properties can be defined as part of the model definition by passing an additional `lazyProperties` key structured as an object. Lazy properties are added to the model post schema initialization and are useful if you need access to other models or serializers. The value of each property must be a function that returns a function. The outer function will receive an object representing the schema (`{ models, serializers }`), allowing data from it to be passed to the property body. | ||
Boolean properties can receive an additional string key, `inverse`, defining the name of the inverse boolean property. Inverse boolean properties are automatically updated when the property is updated and vice versa and can be called like regular properties on any records of the model. Caching is also applied to inverse boolean properties, if specified via the `cache` boolean key. | ||
@@ -300,12 +203,19 @@ ```js | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [ | ||
{ | ||
name: 'MyModel', | ||
fields: [ | ||
{ name: 'firstName', type: 'string' }, | ||
{ name: 'lastName', type: 'string' }, | ||
], | ||
fields: { | ||
firstName: 'string', | ||
lastName: 'string' | ||
}, | ||
properties: { | ||
fullName: record => `${record.firstName} ${record.lastName}` | ||
fullName: record => `${record.firstName} ${record.lastName}`, | ||
formalName: { | ||
body: record => `${record.lastName} ${record.firstName}`, | ||
cache: true | ||
}, | ||
hasLongLastName: { | ||
body: record => record.lastName.length > 10, | ||
inverse: 'hasShortLastName' | ||
}, | ||
} | ||
@@ -315,4 +225,4 @@ }, | ||
name: 'AnotherModel', | ||
lazyProperties: { | ||
myModelName: ({ models: { myModel }}) => () => myModel.name | ||
properties: { | ||
myModelName: (record, { models: { myModel }}) => myModel.name | ||
} | ||
@@ -324,10 +234,5 @@ } | ||
You can remove a property from a model using `Model.prototype.removeProperty()`: | ||
```js | ||
MyModel.removeProperty('formalName'); | ||
``` | ||
#### Method definitions | ||
Methods can be defined as part of a model definition or added individually to a model by calling `Model.prototype.addMethod()`: | ||
Methods can be defined as part of a model definition. | ||
@@ -337,12 +242,13 @@ ```js | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [ | ||
{ | ||
name: 'MyModel', | ||
fields: [ | ||
{ name: 'firstName', type: 'string' }, | ||
{ name: 'lastName', type: 'string' }, | ||
], | ||
fields: { | ||
firstName: 'string', | ||
lastName: 'string' | ||
}, | ||
methods: { | ||
prefixedName: (record, prefix) => `${prefix} ${record.lastName}` | ||
prefixedName: (record, prefix) => `${prefix} ${record.lastName}`, | ||
suffixedName: (record, suffix) => `${record.firstName} ${suffix}`, | ||
isModelNameCorrect: (record, { models: { myModel }}, valey) => value === myModel.name | ||
} | ||
@@ -352,52 +258,11 @@ } | ||
}); | ||
const MyModel = MySchema.getModel('MyModel'); | ||
MyModel.addMethod( | ||
'suffixedName', | ||
(record, suffix) => `${record.firstName} ${suffix}` | ||
); | ||
``` | ||
Methods defined as part of the model definition are specified as key-value pairs, whereas methods defined in `Model.prototype.addMethod()` are passed as two separate arguments, the name and the method body. | ||
Methods definition are specified as key-value pairs. | ||
Methods expect any number of arguments, the current record and any arguments passed to them when called, and may return any type of value. | ||
Methods expect any number of arguments, the current record and any arguments passed to them when called, and may return any type of value. They are called with the current record as their first argument, any other arguments passed to them at call time and the current schema object representation (`{ models, serializers }`) as their last argument. This means that methods can access other models and serializers. | ||
Additionally, "lazy" methods can be defined as part of the model definition by passing an additional `lazyMethods` key structured as an object. Lazy methods are added to the model post schema initialization and are useful if you need access to other models or serializers. The value of each method must be a function that returns a function. The outer function will receive an object representing the schema (`{ models, serializers }`), allowing data from it to be passed to the method body. | ||
```js | ||
import jsiqle from '@jsiqle/core'; | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [ | ||
{ | ||
name: 'MyModel', | ||
fields: [ | ||
{ name: 'firstName', type: 'string' }, | ||
{ name: 'lastName', type: 'string' }, | ||
], | ||
methods: { | ||
prefixedName: (record, prefix) => `${prefix} ${record.lastName}` | ||
} | ||
}, | ||
{ | ||
name: 'AnotherModel', | ||
lazyMethods: { | ||
isModelNameCorrect: | ||
({ models: { myModel }}) => (value) => value === myModel.name | ||
} | ||
} | ||
] | ||
}); | ||
``` | ||
You can remove a method from a model using `Model.prototype.removeMethod()`: | ||
```js | ||
MyModel.removeMethod('prefixedName'); | ||
``` | ||
#### Scope definitions | ||
Scopes can be defined as part of a model definition or added individually to a model by calling `Model.prototype.addScope()`: | ||
Scopes can be defined as part of a model definition. | ||
@@ -407,12 +272,16 @@ ```js | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [ | ||
{ | ||
name: 'MyModel', | ||
fields: [ | ||
{ name: 'firstName', type: 'string' }, | ||
{ name: 'lastName', type: 'string' }, | ||
], | ||
fields: { | ||
firstName: 'string', | ||
lastName: 'string' | ||
}, | ||
scopes: { | ||
smiths: record => record.lastName === 'Smith' | ||
does: record => record.lastName === 'Doe' | ||
orderedSmiths: { | ||
matcher: record => record.lastName === 'Smith', | ||
sorter: (a, b) => a.firstName.localeCompare(b.firstName) | ||
} | ||
} | ||
@@ -422,60 +291,8 @@ } | ||
}); | ||
const MyModel = MySchema.getModel('MyModel'); | ||
MyModel.addScope('does', record => record.lastName === 'Doe'); | ||
``` | ||
Scopes defined as part of the model definition as specified as key-value pairs, whereas scopes defined in `Model.prototype.addScope()` are passed as two separate arguments, the name and the scope body. | ||
Scopes definition are specified as key-value pairs. | ||
Scopes expect one argument, the current record, and must return a boolean indicating if the scope should include the record or not. Alternatively, scopes can be specified as objects when defined as part of the model definition with a `matcher` function and a `sorter` function. This will create an ordered scope that will always apply the `sorter` to matched records before returning them. Ordered scopes can also be created by supplying a third argument to `Model.prototype.addScope()` which will act as the `sorter` function. | ||
Scopes expect one argument, the current record, and must return a boolean indicating if the scope should include the record or not. Alternatively, scopes can be specified as objects when defined as part of the model definition with a `matcher` function and a `sorter` function. This will create an ordered scope that will always apply the `sorter` to matched records before returning them. | ||
You can remove a scope from a model using `Model.prototype.removeScope()`: | ||
```js | ||
MyModel.removeProperty('does'); | ||
``` | ||
#### Validator definitions | ||
Validators can be defined as part of a model definition or added individually to a model by calling `Model.prototype.addValidator()`: | ||
```js | ||
import jsiqle from '@jsiqle/core'; | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [ | ||
{ | ||
name: 'MyModel', | ||
fields: [ | ||
{ name: 'startDate', type: 'date' }, | ||
{ name: 'endDate', type: 'date' }, | ||
], | ||
validators: { | ||
datesValid: record => record.startDate <= record.endDate | ||
} | ||
} | ||
] | ||
}); | ||
const MyModel = MySchema.getModel('MyModel'); | ||
MyModel.addValidator( | ||
'datesDifferent', | ||
record => record.startDate !== record.endDate | ||
); | ||
``` | ||
Validators defined as part of the model definition as specified as key-value pairs, whereas validators defined in `Model.prototype.addValidator()` are passed as two separate arguments, the name and the validator body. | ||
Validators expect two arguments, the current record and an array of other records in the model. They return a boolean indicating if the current record is valid. | ||
You can remove a validator from a model using `Model.prototype.removeValidator()`: | ||
```js | ||
MyModel.removeValidator('datesDifferent'); | ||
``` | ||
Validators should be used to perform multi-field validations. For single-field validations, validators specified on the field are preferred due to their increased performance. | ||
#### Relationship definitions | ||
@@ -485,3 +302,3 @@ | ||
Relationships can be defined either as part of the schema definition or individually using `Schema.prototype.createRelationship()`: | ||
Relationships can be defined as part of the schema definition. | ||
@@ -491,18 +308,14 @@ ```js | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [ | ||
{ | ||
name: 'Person', | ||
fields: [ | ||
{ | ||
name: 'username', | ||
type: 'string', | ||
}, | ||
], | ||
fields: { | ||
username: 'string', | ||
} | ||
}, | ||
{ | ||
name: 'Transaction', | ||
fields: [ | ||
{ name: 'amount', type: 'number' } | ||
] | ||
fields: { | ||
amount: 'number', | ||
}, | ||
} | ||
@@ -515,11 +328,10 @@ ], | ||
type: 'manyToOne' | ||
}, | ||
{ | ||
from: { model: 'Transaction', name: 'payee' }, | ||
to: { model: 'Person', name: 'incomingTransactions' }, | ||
type: 'manyToOne' | ||
} | ||
] | ||
}); | ||
Ledger.createRelationship({ | ||
from: { model: 'Transaction', name: 'payee' }, | ||
to: { model: 'Person', name: 'incomingTransactions' }, | ||
type: 'manyToOne' | ||
}); | ||
``` | ||
@@ -539,4 +351,43 @@ | ||
#### Serializer definitions | ||
Serializers can be defined as part of the schema definition. | ||
```js | ||
import jsiqle from '@jsiqle/core'; | ||
const MySchema = jsiqle.create({ | ||
models: [ | ||
{ | ||
name: 'MyModel', | ||
fields: { | ||
firstName: 'string', | ||
lastName: 'string' | ||
} | ||
} | ||
], | ||
serializers: [ | ||
{ | ||
name: 'MySerializer', | ||
attributes: [ | ||
'firstName, | ||
['lastName', 'surname'], | ||
['fullName', 'name'] | ||
'truncatedName' | ||
], | ||
methods: { | ||
fullName: (record) => `${record.firstName} ${record.lastName}`, | ||
truncatedName: (record) => record.fullName.slice(0, 20) | ||
} | ||
} | ||
] | ||
}); | ||
``` | ||
Serializer definition options require an object argument with the following attributes: | ||
- `name`: The name of the serializer. Must be unique. | ||
- `attributes`: An array of strings or arrays of strings. Each string represents the name of a model field or property or a serializer method. Each array represents the name of a model field or property or a serializer method and the name that should be used in the serializer. For example, `['lastName', 'surname']` would create a field named `surname` in the serializer that would be populated with the value of the `lastName` field on the model. | ||
- `methods`: An object with key-value pairs. Each key represents the name of a serializer method and each value a function that will be called with the current record as its first argument and any arguments passed to the serializer at call time as an object as its second argument. The method can return any value. | ||
### Record manipulation | ||
@@ -553,10 +404,9 @@ | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [ | ||
{ | ||
name: 'MyModel', | ||
fields: [ | ||
{ name: 'firstName', type: 'string' }, | ||
{ name: 'lastName', type: 'string' }, | ||
], | ||
fields: { | ||
firstName: 'string', | ||
lastName: 'string' | ||
}, | ||
properties: { | ||
@@ -574,3 +424,3 @@ fullName: record => `${record.firstName} ${record.lastName}` | ||
Each record definition consists of an object with the appropriate key-value pairs. Required fields without a value will be automatically set to the respective field's `defaultValue`. Key-value pairs that do not match a field definition will be stored in the record. This can be useful for fields that might be added in later operations (e.g. adding relationships to a populated model). | ||
Each record definition consists of an object with the appropriate key-value pairs. Fields without a value will be automatically set to `null`. All records must contain an `id` key with a string value that is unique within the model. Key-value pairs that do not match a field definition will be stored in the record. This can be useful for fields that might be added in later operations (e.g. adding relationships to a populated model). | ||
@@ -607,10 +457,9 @@ #### Updating records | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [ | ||
{ | ||
name: 'MyModel', | ||
fields: [ | ||
{ name: 'firstName', type: 'string' }, | ||
{ name: 'lastName', type: 'string' }, | ||
], | ||
fields: { | ||
firstName: 'string', | ||
lastName: 'string' | ||
}, | ||
properties: { | ||
@@ -634,16 +483,14 @@ fullName: record => `${record.firstName} ${record.lastName}` | ||
- `RecordSet.prototype.forEach()`: Executes a provided function once for every element in the calling record set. This method takes a callback function as an argument that expects three arguments (`record`, `key`, `recordSet`), similar to `Array.prototype.forEach()`. The method does not return a result. | ||
- `RecordSet.prototype.map()`: Creates an object populated with the results of calling a provided mapping function on every element in the calling record set. This method takes a mapping callback function as an argument that expects three arguments (`record`, `key`, `recordSet`), similar to `Array.prototype.map()`. The result is an object with each key mapped to the result of the mapping function. | ||
- `RecordSet.prototype.flatMap()`: Same as `RecordSet.prototype.map()` except that the resulting value is an array instead of an object. | ||
- `RecordSet.prototype.reduce()`: Executes a user-supplied reducer callback function on each element of the record set, passing in the return value from the calculation on the preceding element. This method takes a reducer callback function as an argument that expects four arguments (`accumulator`, `record`, `key`, `recordSet`) and an initial value, similar to `Array.prototype.reduce()`. The final result of running the reducer across all elements of the record set is a single value. | ||
- `RecordSet.prototype.filter()`: Creates a new record set with all elements that pass the test implemented by the provided filtering function. This method takes a filtering callback function as an argument that expects three arguments (`record`, `key`, `recordSet`), similar to `Array.prototype.filter()`. The result is a record set containing only the records that pass the test. | ||
- `RecordSet.prototype.flatFilter()`: Same as `RecordSet.prototype.filter()` except that the resulting value is an array instead of a record set. | ||
- `RecordSet.prototype.find()`: Retrieves the first record matching the condition implemented by the provided testing function. This method takes a testing callback function as an argument that expects three arguments (`record`, `key`, `recordSet`), similar to `Array.prototype.find()`. The result is a record or `undefined` if none match the condition. | ||
- `RecordSet.prototype.findKey()`: Same as `RecordSet.prototype.find()` except that the resulting value is the record's key instead of the record itself. | ||
- `RecordSet.prototype.only()`: Returns a new record set containing only objects that match the key/keys provided. Records are returned in order of appearance in the provided keys. Expects any number of keys as arguments. | ||
- `RecordSet.prototype.except()`: Returns a new record set containing only objects that don't match the key/keys provided. Expects any number of keys as arguments. | ||
- `RecordSet.prototype.every()`: Returns a boolean indicating if all the records in the record set pass the test implemented by the provided testing function. This method takes a testing callback function as an argument that expects three arguments (`record`, `key`, `recordSet`), similar to `Array.prototype.every()`. | ||
- `RecordSet.prototype.some()`: Returns a boolean indicating if any of the records in the record set pass the test implemented by the provided testing function. This method takes a testing callback function as an argument that expects three arguments (`record`, `key`, `recordSet`), similar to `Array.prototype.some()`. | ||
- `RecordSet.prototype.where()`: Creates a new record set with all elements that pass the test implemented by the provided filtering function. This method takes a filtering callback function as an argument that expects three arguments (`record`, `key`, `recordSet`), similar to `Array.prototype.filter()`. The result is a record set containing only the records that pass the test. | ||
- `RecordSet.prototype.whereNot()`: Creates a new record set with all elements that fail the test implemented by the provided filtering function. This method takes a filtering callback function as an argument that expects three arguments (`record`, `key`, `recordSet`), similar to `Array.prototype.filter()`. The result is a record set containing only the records that fail the test. | ||
- `RecordSet.prototype.forEach()`: Executes a provided function once for every element in the calling record set. This method takes a callback function as an argument that expects three arguments (`record`, `id`, `recordSet`), similar to `Array.prototype.forEach()`. The method does not return a result. | ||
- `RecordSet.prototype.map()`: Creates an array or object populated with the results of calling a provided mapping function on every element in the calling record set. This method takes a mapping callback function as an argument that expects three arguments (`record`, `id`, `recordSet`), similar to `Array.prototype.map()`. The result is an array object with each id mapped to the result of the mapping function. Pass the `{ flat: true }` option to return an array instead of an object. | ||
- `RecordSet.prototype.reduce()`: Executes a user-supplied reducer callback function on each element of the record set, passing in the return value from the calculation on the preceding element. This method takes a reducer callback function as an argument that expects four arguments (`accumulator`, `record`, `id`, `recordSet`) and an initial value, similar to `Array.prototype.reduce()`. The final result of running the reducer across all elements of the record set is a single value. | ||
- `RecordSet.prototype.filter()`: Creates a new record set or array with all elements that pass the test implemented by the provided filtering function. This method takes a filtering callback function as an argument that expects three arguments (`record`, `id`, `recordSet`), similar to `Array.prototype.filter()`. The result is a record set or array containing only the records that pass the test. Pass the `{ flat: true }` option to return an array instead of a record set. | ||
- `RecordSet.prototype.find()`: Retrieves the first record matching the condition implemented by the provided testing function. This method takes a testing callback function as an argument that expects three arguments (`record`, `id`, `recordSet`), similar to `Array.prototype.find()`. The result is a record or `undefined` if none match the condition. | ||
- `RecordSet.prototype.findId()`: Same as `RecordSet.prototype.find()` except that the resulting value is the record's id instead of the record itself. | ||
- `RecordSet.prototype.only()`: Returns a new record set containing only objects that match the id/ids provided. Records are returned in order of appearance in the provided ids. Expects any number of ids as arguments. | ||
- `RecordSet.prototype.except()`: Returns a new record set containing only objects that don't match the id/ids provided. Expects any number of ids as arguments. | ||
- `RecordSet.prototype.every()`: Returns a boolean indicating if all the records in the record set pass the test implemented by the provided testing function. This method takes a testing callback function as an argument that expects three arguments (`record`, `id`, `recordSet`), similar to `Array.prototype.every()`. | ||
- `RecordSet.prototype.some()`: Returns a boolean indicating if any of the records in the record set pass the test implemented by the provided testing function. This method takes a testing callback function as an argument that expects three arguments (`record`, `id`, `recordSet`), similar to `Array.prototype.some()`. | ||
- `RecordSet.prototype.where()`: Creates a new record set with all elements that pass the test implemented by the provided filtering function. This method takes a filtering callback function as an argument that expects three arguments (`record`, `id`, `recordSet`), similar to `Array.prototype.filter()`. The result is a record set containing only the records that pass the test. | ||
- `RecordSet.prototype.whereNot()`: Creates a new record set with all elements that fail the test implemented by the provided filtering function. This method takes a filtering callback function as an argument that expects three arguments (`record`, `id`, `recordSet`), similar to `Array.prototype.filter()`. The result is a record set containing only the records that fail the test. | ||
@@ -654,6 +501,4 @@ #### Attribute selection | ||
- `RecordSet.prototype.select()`: Expects any number of field names in a record. Returns a record set with partial records containing only those fields. | ||
- `RecordSet.prototype.flatSelect()`: Same as `RecordSet.prototype.select()` except that the resulting value is an array of objects instead of a record set of partial records. | ||
- `RecordSet.prototype.pluck()`: Expects any number of field names in a record. Returns a record set with record fragments containing only those fields. Record fragments behave similar to arrays. | ||
- `RecordSet.prototype.flatPluck()`: Same as `RecordSet.prototype.pluck()` except that the resulting value is an array of arrays instead of a record set of record fragments. If only one key is provided, an array of individual attributes will be returned instead. | ||
- `RecordSet.prototype.select()`: Expects any number of field names in a record. Returns an array of objects with only the selected fields. | ||
- `RecordSet.prototype.pluck()`: Expects any number of field names in a record. Returns an array of arrays with only the selected field values. If only one key is provided, an array of individual attributes will be returned instead. | ||
@@ -664,8 +509,8 @@ #### Sorting and grouping | ||
- `RecordSet.prototype.groupBy()`: Expects a field name and groups the records based on its value. Returns a record set containing record groups, which in turn behave like nested record sets themselves. | ||
- `RecordSet.prototype.sort()`: Sorts the elements of the record set and returns a new sorted record set. Expects a comparator callback function as an argument that takes three arguments (`firstValue`, `secondValue`, `firstKey`, `secondKey`) and returns an appropriate value for sorting similar to `Array.prototype.sort()`. | ||
- `RecordSet.prototype.groupBy()`: Expects a field name and groups the records based on its value. Returns an object with value-based keys containing record sets. | ||
- `RecordSet.prototype.sort()`: Sorts the elements of the record set and returns a new sorted record set. Expects a comparator callback function as an argument that takes three arguments (`firstValue`, `secondValue`, `firstId`, `secondId`) and returns an appropriate value for sorting similar to `Array.prototype.sort()`. | ||
#### Iterating over records | ||
Record sets are iterable, meaning you can use `for` loops to iterate over them, similar to a regular ES6 `Map`. Additionally, `RecordSet.prototype.batchOperator()` is available expecting a `batchSize` numeric argument and allowing for the records in a record set to be iterated in batches. | ||
Record sets are iterable, meaning you can use `for` loops to iterate over them, similar to a regular ES6 `Map`. Additionally, `RecordSet.prototype.batchIterator()` is available expecting a `batchSize` numeric argument and allowing for the records in a record set to be iterated in batches. An additional `{ flat: true }` argument can be passed to return an array of records instead of a record set for each batch. | ||
@@ -686,10 +531,9 @@ #### Accessing specific records | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [ | ||
{ | ||
name: 'MyModel', | ||
fields: [ | ||
{ name: 'firstName', type: 'string' }, | ||
{ name: 'lastName', type: 'string' }, | ||
], | ||
fields: { | ||
firstName: 'string', | ||
lastName: 'string' | ||
}, | ||
scopes: { | ||
@@ -720,10 +564,9 @@ smiths: record => record.lastName === 'Smith' | ||
const MySchema = jsiqle.create({ | ||
name: 'MySchema', | ||
models: [ | ||
{ | ||
name: 'MyModel', | ||
fields: [ | ||
{ name: 'firstName', type: 'string' }, | ||
{ name: 'lastName', type: 'string' }, | ||
], | ||
fields: { | ||
firstName: 'string', | ||
lastName: 'string' | ||
}, | ||
} | ||
@@ -749,51 +592,14 @@ ] | ||
- `RecordSet.prototype.toArray()`: Returns an array of records contained in the record set. | ||
- `RecordSet.prototype.toFlatArray()`: Returns an array of objects representing the records contained in the record set. | ||
- `RecordSet.prototype.toObject()`: Returns an object of records representing the key-value pairs of the records in the record set. | ||
- `RecordSet.prototype.toFlatObject()`: Returns an object of objects representing the key-value pairs of the records in the record set. | ||
### Listening for events | ||
Both of these methods can be called with an optional `{ flat: true }` options argument to convert records into objects. | ||
Both the schema and its models are event emitters, allowing event listeners to be added to them as necessary. | ||
#### Using serializers | ||
```js | ||
MySchema.addEventListener('change', data => console.log(data)); | ||
MyModel.addEventListener('change', data => console.log(data)); | ||
``` | ||
#### Schema events | ||
Serializers can be used to serialize records and record sets into custom formats. They are defined on the schema level and can be used by calling one of the methods available as part of the individual serializer: | ||
The schema object emits the following events: | ||
- `Serializer.prototype.serialize()`: Serializes a record in the format defined by the serializer. Expects a record as the first argument and an optional options object as the second argument. | ||
- `Serializer.prototype.serializeArray()`: Serializes an array of records in the format defined by the serializer. Expects an array of records as the first argument and an optional options object as the second argument. | ||
- `Serializer.prototype.serializeRecordSet()`: Serializes a record set in the format defined by the serializer. Expects a record set as the first argument and an optional options object as the second argument. A third argument can be passed to specify a function mapping each record to a key in the serialized object. | ||
``` | ||
beforeCreateModel modelCreated | ||
beforeRemoveModel modelRemoved | ||
beforeCreateRelationship relationshipCreated | ||
beforeGet got | ||
change | ||
``` | ||
All emitted events contain an object argument with the related data in appropriate keys, as well as a `schema` key with the schema itself. Events prefixed with `before` contain the raw data passed to the related method call, whereas events emitted after a method finishes contain the result of the method. `change` events are emitted for all non-`before` events except `got` and have the same arguments, as well as a `type` argument that specifies the event type. `change` events are also emitted as wrappers of model `change` events with the model event `type` prefixed (e.g. `modelPropertyAdded` instead of `propertyAdded`). | ||
#### Model events | ||
Model objects emit the following events: | ||
``` | ||
beforeAddField fieldAdded | ||
beforeRetrofillField fieldRetrofilled | ||
beforeUpdateField fieldUpdated | ||
beforeAddProperty propertyAdded | ||
beforeRemoveProperty propertyRemoved | ||
beforeAddScope scopeAdded | ||
beforeRemoveScope scopeRemoved | ||
beforeAddValidator validatorAdded | ||
beforeRemoveValidator validatorRemoved | ||
beforeCreateRecord recordCreated | ||
beforeRemoveRecord recordRemoved | ||
beforeUpdateRecord recordUpdated | ||
beforeAddRelationship relationshipAdded | ||
change | ||
``` | ||
All emitted events contain an object argument with the related data in appropriate keys, as well as a `model` key with the model itself. Events prefixed with `before` contain the raw data passed to the related method call, whereas events emitted after a method finishes contain the result of the method. `change` events are emitted for all non-`before` events except `recordCreated`, `recordRemoved` and `recordUpdated` and have the same arguments, as well as a `type` argument that specifies the event type. | ||
### Naming conventions | ||
@@ -812,3 +618,3 @@ | ||
- A schema is a set of definitions that contain models, fields, relationships etc. The data contained within a schema is called a dataset. | ||
- A model is a set of field, property, validator and scope definitions. The data contained within a model is called a record set and each individual item within it is called a record. | ||
- A model is a set of field, property and scope definitions. The data contained within a model is called a record set and each individual item within it is called a record. | ||
- A record is a set of values corresponding to different keys. Each of these values is called an attribute. | ||
@@ -815,0 +621,0 @@ |
@@ -16,28 +16,24 @@ const jsiqle = require('../dist/main').default; | ||
name: 'snippet', | ||
key: 'name', | ||
fields: [ | ||
{ | ||
name: 'description', | ||
type: 'stringRequired', | ||
// Refactor to flatten validators in the definition? | ||
validators: { | ||
unique: true, | ||
minLength: 5, | ||
containsTheWordDescription: value => value.includes('description'), | ||
}, | ||
fields: { | ||
description: 'string', | ||
code: 'string', | ||
language: 'string', | ||
tags: 'stringArray', | ||
special: 'string', | ||
}, | ||
properties: { | ||
isCool: record => { | ||
return record.tags.includes('cool'); | ||
}, | ||
{ | ||
name: 'code', | ||
type: 'stringRequired', | ||
}, | ||
scopes: { | ||
cool: record => { | ||
return record.isCool; | ||
}, | ||
{ | ||
name: 'language', | ||
type: 'string', | ||
}, | ||
{ | ||
name: 'tags', | ||
type: 'stringArray', | ||
}, | ||
], | ||
}, | ||
}, | ||
{ | ||
name: 'category', | ||
fields: { description: 'string' }, | ||
}, | ||
], | ||
@@ -50,2 +46,7 @@ relationships: [ | ||
}, | ||
{ | ||
from: 'snippet', | ||
to: 'category', | ||
type: 'manyToMany', | ||
}, | ||
], | ||
@@ -66,3 +67,3 @@ serializers: [ | ||
children: snippet => { | ||
return snippet.children.flatPluck('code'); | ||
return snippet.children.pluck('code'); | ||
}, | ||
@@ -77,29 +78,8 @@ }, | ||
schema.on('beforeCreateModel', ({ model }) => { | ||
console.log(`Creating new model named ${model.name}...`); | ||
}); | ||
schema.on('modelCreated', ({ model }) => { | ||
console.log(`Model ${model.name} created!`); | ||
}); | ||
schema.on('change', data => { | ||
console.log(data.type); | ||
}); | ||
const snippet = schema.getModel('snippet'); | ||
const category = schema.createModel({ | ||
name: 'category', | ||
key: 'name', | ||
fields: [ | ||
{ | ||
name: 'description', | ||
type: 'stringRequired', | ||
}, | ||
], | ||
}); | ||
const category = schema.getModel('category'); | ||
const snippetA = snippet.createRecord({ | ||
name: 'snippetA', | ||
id: 'snippetA', | ||
description: 'description of snippetA', | ||
@@ -112,3 +92,3 @@ code: 'console.log("Hello World!");', | ||
const snippetB = snippet.createRecord({ | ||
name: 'snippetB', | ||
id: 'snippetB', | ||
description: 'description of snippetB', | ||
@@ -121,3 +101,3 @@ code: 'console.log("Hello World!");', | ||
const categoryA = category.createRecord({ | ||
name: 'categoryA', | ||
id: 'categoryA', | ||
description: 'description of categoryA', | ||
@@ -127,34 +107,8 @@ }); | ||
const categoryB = category.createRecord({ | ||
name: 'categoryB', | ||
id: 'categoryB', | ||
description: 'description of categoryB', | ||
}); | ||
snippet.addField( | ||
{ | ||
name: 'special', | ||
type: 'stringRequired', | ||
}, | ||
record => { | ||
if (record.name === 'snippetA') { | ||
return 'special value for snippetA'; | ||
} else return record.name; | ||
} | ||
); | ||
snippet.addProperty('isCool', record => { | ||
return record.tags.includes('cool'); | ||
}); | ||
snippet.addScope('cool', record => { | ||
return record.isCool; | ||
}); | ||
schema.createRelationship({ | ||
from: 'snippet', | ||
to: 'category', | ||
type: 'manyToMany', | ||
}); | ||
const snippetC = snippet.createRecord({ | ||
name: 'snippetC', | ||
id: 'snippetC', | ||
description: 'description of snippetC', | ||
@@ -168,5 +122,5 @@ code: 'console.log("Hello World!");', | ||
Array.from({ length: 1000 }).forEach(() => { | ||
Array.from({ length: 1000 }).forEach((_, i) => { | ||
snippet.createRecord({ | ||
name: `snippet${i}`, | ||
id: `snippet${i}`, | ||
description: `description of snippet${i}`, | ||
@@ -181,4 +135,2 @@ code: `console.log("Hello World!");`, | ||
// categoryA.snippetSet = ['snippetC']; | ||
replServer.context.schema = schema; | ||
@@ -204,7 +156,1 @@ | ||
}; | ||
// try { | ||
// snippetA.snippetSet; | ||
// } catch (e) { | ||
// console.trace(e); | ||
// } |
@@ -22,9 +22,2 @@ export class NameError extends Error { | ||
export class DefaultValueError extends Error { | ||
constructor(message) { | ||
super(message); | ||
this.name = 'DefaultValueError'; | ||
} | ||
} | ||
export class ExperimentalAPIUsageError extends Error { | ||
@@ -31,0 +24,0 @@ constructor(message) { |
155
src/field.js
@@ -0,43 +1,16 @@ | ||
import { isOptional, standardTypes } from 'src/types'; | ||
import symbols from 'src/symbols'; | ||
import { ValidationError } from 'src/errors'; | ||
import { Validator } from 'src/validator'; | ||
import { validateName, capitalize } from 'src/utils'; | ||
import types, { standardTypes } from 'src/types'; | ||
const { $defaultValue, $validators } = symbols; | ||
const { $isDateField } = symbols; | ||
class Field { | ||
#name; | ||
#defaultValue; | ||
#required; | ||
#type; | ||
#validators; | ||
#isDateField = false; | ||
constructor({ | ||
name, | ||
type, | ||
required = false, | ||
defaultValue = null, | ||
validators = {}, | ||
}) { | ||
this.#name = validateName('Field', name); | ||
this.#required = Field.#validateRequired(required); | ||
this.#type = Field.#validateType(type, required); | ||
this.#defaultValue = Field.#validateDefaultValue( | ||
defaultValue, | ||
this.#type, | ||
this.#required | ||
); | ||
this.#validators = new Map(); | ||
Object.entries(validators).forEach(([validatorName, validator]) => { | ||
this.addValidator(validatorName, validator); | ||
}); | ||
constructor({ name, type }) { | ||
this.#name = name; | ||
this.#type = isOptional(type); | ||
} | ||
addValidator(validatorName, validator) { | ||
this.#validators.set( | ||
...Field.#parseFieldValidator(this.#name, validatorName, validator) | ||
); | ||
} | ||
get name() { | ||
@@ -47,6 +20,2 @@ return this.#name; | ||
get required() { | ||
return this.#required; | ||
} | ||
typeCheck(value) { | ||
@@ -56,49 +25,9 @@ return this.#type(value); | ||
// Protected (package internal-use only) | ||
get [$defaultValue]() { | ||
return this.#defaultValue; | ||
get [$isDateField]() { | ||
return this.#isDateField; | ||
} | ||
get [$validators]() { | ||
return this.#validators; | ||
set [$isDateField](value) { | ||
this.#isDateField = value; | ||
} | ||
// Private | ||
static #validateType(type, required) { | ||
if (typeof type !== 'function') { | ||
throw new TypeError('Field type must be a function.'); | ||
} | ||
return required ? type : types.optional(type); | ||
} | ||
static #validateRequired(required) { | ||
if (typeof required !== 'boolean') { | ||
throw new TypeError('Field required must be a boolean.'); | ||
} | ||
return required; | ||
} | ||
static #validateDefaultValue(defaultValue, type, required) { | ||
if (required && types.nil(defaultValue)) | ||
throw new ValidationError('Default value cannot be null or undefined.'); | ||
if (!type(defaultValue)) | ||
throw new ValidationError('Default value must be valid.'); | ||
return defaultValue; | ||
} | ||
static #parseFieldValidator(fieldName, validatorName, validator) { | ||
if (Validator[validatorName] !== undefined) | ||
return [ | ||
`${fieldName}${capitalize(validatorName)}`, | ||
Validator[validatorName](fieldName, validator), | ||
]; | ||
if (typeof validator !== 'function') | ||
throw new TypeError(`Validator ${validatorName} is not defined.`); | ||
return [ | ||
`${fieldName}${capitalize(validatorName)}`, | ||
Validator.custom(fieldName, validator), | ||
]; | ||
} | ||
} | ||
@@ -108,60 +37,12 @@ | ||
Object.entries(standardTypes).forEach(([typeName, standardType]) => { | ||
const { type, defaultValue: typeDefaultValue } = standardType; | ||
Field[typeName] = options => { | ||
if (typeof options === 'string') return new Field({ name: options, type }); | ||
return new Field({ ...options, type }); | ||
}; | ||
Field[`${typeName}Required`] = options => { | ||
if (typeof options === 'string') | ||
return new Field({ | ||
name: options, | ||
type, | ||
required: true, | ||
defaultValue: typeDefaultValue, | ||
}); | ||
const defaultValue = options.defaultValue || typeDefaultValue; | ||
return new Field({ ...options, type, required: true, defaultValue }); | ||
}; | ||
const { type } = standardType; | ||
if (typeName === 'date') { | ||
Field[typeName] = name => { | ||
const field = new Field({ name, type }); | ||
field[$isDateField] = true; | ||
return field; | ||
}; | ||
} else Field[typeName] = name => new Field({ name, type }); | ||
}); | ||
// Enum is special, handle it separately | ||
Field.enum = ({ name, values }) => | ||
new Field({ name, type: types.enum(...values) }); | ||
Field.enumRequired = ({ name, values, defaultValue = values[0] }) => | ||
new Field({ | ||
name, | ||
type: types.enum(...values), | ||
required: true, | ||
defaultValue, | ||
}); | ||
// Auto-field is special, handle it separately | ||
Field.auto = options => { | ||
const name = typeof options === 'string' ? options : options.name; | ||
// Generator function to generate a new value each time | ||
function* autoGenerator() { | ||
let i = 0; | ||
while (true) yield i++; | ||
} | ||
const generator = autoGenerator(); | ||
let currentValue = 0; | ||
// Create the field | ||
const autoField = new Field({ | ||
name, | ||
type: value => value === currentValue, | ||
required: true, | ||
defaultValue: currentValue, | ||
}); | ||
// Override the default value to be the next value in the sequence | ||
Object.defineProperty(autoField, $defaultValue, { | ||
get() { | ||
const value = generator.next().value; | ||
currentValue = value; | ||
return value; | ||
}, | ||
}); | ||
return autoField; | ||
}; | ||
export { Field }; |
582
src/model.js
@@ -1,22 +0,18 @@ | ||
import EventEmitter from 'events'; | ||
import { Schema } from 'src/schema'; | ||
import { Field } from 'src/field'; | ||
import { RecordSet, RecordHandler } from 'src/record'; | ||
import { NameError, DuplicationError, DefaultValueError } from 'src/errors'; | ||
import { NameError, DuplicationError } from 'src/errors'; | ||
import symbols from 'src/symbols'; | ||
import { standardTypes, key } from 'src/types'; | ||
import { validateObjectWithUniqueName, validateName } from 'src/utils'; | ||
import { standardTypes } from 'src/types'; | ||
import { validateName } from 'src/utils'; | ||
const { | ||
$fields, | ||
$defaultValue, | ||
$key, | ||
$keyType, | ||
$properties, | ||
$cachedProperties, | ||
$clearCachedProperties, | ||
$methods, | ||
$relationships, | ||
$scopes, | ||
$relationships, | ||
$validators, | ||
$recordHandler, | ||
$emptyRecordTemplate, | ||
$addScope, | ||
@@ -27,27 +23,19 @@ $addRelationshipAsField, | ||
$getProperty, | ||
$removeScope, | ||
$instances, | ||
$handleExperimentalAPIMessage, | ||
$setRecordKey, | ||
$set, | ||
$delete, | ||
} = symbols; | ||
const allStandardTypes = [ | ||
...Object.keys(standardTypes), | ||
...Object.keys(standardTypes).map(type => `${type}Required`), | ||
'enum', | ||
'enumRequired', | ||
'auto', | ||
]; | ||
const allStandardTypes = Object.keys(standardTypes); | ||
export class Model extends EventEmitter { | ||
export class Model { | ||
#records; | ||
#recordHandler; | ||
#fields; | ||
#key; | ||
#properties; | ||
#methods; | ||
#relationships; | ||
#validators; | ||
#updatingField = false; | ||
#cachedProperties; | ||
#scopes; | ||
#emptyRecordTemplate; | ||
@@ -58,15 +46,8 @@ static #instances = new Map(); | ||
name, | ||
fields = [], | ||
key = 'id', | ||
fields = {}, | ||
properties = {}, | ||
methods = {}, | ||
scopes = {}, | ||
validators = {}, | ||
cacheProperties = [], | ||
// TODO: V2 Enhancements | ||
// Adding a hooks parameter would be an interesting idea. There's a blind | ||
// spot currently where we can't listen for events on model creation. | ||
} = {}) { | ||
super(); | ||
this.name = validateName('Model', name); | ||
this.name = name; | ||
@@ -76,11 +57,10 @@ if (Model.#instances.has(name)) | ||
// Instantiate this before the record storage, so it can be | ||
// queried if needed. | ||
this.#scopes = new Map(); | ||
// Create the record storage and handler | ||
// This needs to be initialized before fields to allow for retrofilling | ||
this.#records = new RecordSet(); | ||
this.#records = new RecordSet({ model: this }); | ||
this.#recordHandler = new RecordHandler(this); | ||
// Check and create the key field, no need to check for duplicate fields | ||
this.#key = Model.#parseKey(this.name, key); | ||
this.#records[$setRecordKey](key); | ||
// Initialize private fields | ||
@@ -91,15 +71,20 @@ this.#fields = new Map(); | ||
this.#relationships = new Map(); | ||
this.#validators = new Map(); | ||
this.#cachedProperties = new Set(); | ||
// Add fields, checking for duplicates and invalids | ||
fields.forEach(field => this.addField(field)); | ||
Object.entries(fields).forEach(([fieldName, fieldType]) => { | ||
this.#addField(fieldType, fieldName); | ||
}); | ||
this.#emptyRecordTemplate = this.#generateEmptyRecordTemplate(); | ||
// Add properties, checking for duplicates and invalids | ||
Object.entries(properties).forEach(([propertyName, property]) => { | ||
this.addProperty( | ||
propertyName, | ||
property, | ||
cacheProperties.includes(propertyName) | ||
); | ||
if (typeof property === 'object') | ||
this.#addProperty({ name: propertyName, ...property }); | ||
else | ||
this.#addProperty({ | ||
name: propertyName, | ||
body: property, | ||
}); | ||
}); | ||
@@ -109,3 +94,3 @@ | ||
Object.entries(methods).forEach(([methodName, method]) => { | ||
this.addMethod(methodName, method); | ||
this.#addMethod(methodName, method); | ||
}); | ||
@@ -115,10 +100,5 @@ | ||
Object.entries(scopes).forEach(([scopeName, scope]) => { | ||
this.addScope(scopeName, ...Model.#parseScope(scope)); | ||
this.#addScope(scopeName, ...Model.#parseScope(scope)); | ||
}); | ||
// Add validators, checking for duplicates and invalids | ||
Object.entries(validators).forEach(([validatorName, validator]) => { | ||
this.addValidator(validatorName, validator); | ||
}); | ||
// Add the model to the instances map | ||
@@ -128,285 +108,26 @@ Model.#instances.set(this.name, this); | ||
addField(fieldOptions, retrofill) { | ||
if (!this.#updatingField) | ||
this.emit('beforeAddField', { field: fieldOptions, model: this }); | ||
const field = Model.#parseField(this.name, fieldOptions, [ | ||
...this.#fields.keys(), | ||
this.#key.name, | ||
...this.#properties.keys(), | ||
...this.#methods.keys(), | ||
]); | ||
this.#fields.set(fieldOptions.name, field); | ||
if (!this.#updatingField) this.emit('fieldAdded', { field, model: this }); | ||
// Retrofill records with new fields | ||
// TODO: V2 enhancements | ||
// This before might be erroneous if the retrofill is non-existent. We could | ||
// check for that and skip emitting the event if it's not there. | ||
this.emit('beforeRetrofillField', { field, retrofill, model: this }); | ||
Model.#applyFieldRetrofill(field, this.#records, retrofill); | ||
this.emit('fieldRetrofilled', { field, retrofill, model: this }); | ||
if (!this.#updatingField) | ||
this.emit('change', { type: 'fieldAdded', field, model: this }); | ||
return field; | ||
} | ||
removeField(name) { | ||
if (!Model.#validateContains(this.name, 'Field', name, this.#fields)) | ||
return false; | ||
const field = this.#fields.get(name); | ||
if (!this.#updatingField) | ||
this.emit('beforeRemoveField', { field, model: this }); | ||
this.#fields.delete(name); | ||
if (!this.#updatingField) { | ||
this.emit('fieldRemoved', { field: { name }, model: this }); | ||
this.emit('change', { type: 'fieldRemoved', field, model: this }); | ||
} | ||
return true; | ||
} | ||
updateField(name, field, retrofill) { | ||
if (field.name !== name) | ||
throw new NameError(`Field name ${field.name} does not match ${name}.`); | ||
if (!Model.#validateContains(this.name, 'Field', name, this.#fields)) | ||
throw new ReferenceError(`Field ${name} does not exist.`); | ||
const prevField = this.#fields.get(name); | ||
// Ensure that only update events are emitted, not add/remove ones. | ||
this.#updatingField = true; | ||
this.emit('beforeUpdateField', { prevField, field, model: this }); | ||
this.removeField(name); | ||
const newField = this.addField(field, retrofill); | ||
this.emit('fieldUpdated', { field: newField, model: this }); | ||
this.#updatingField = false; | ||
this.emit('change', { type: 'fieldUpdated', field: newField, model: this }); | ||
} | ||
addProperty(name, property, cache = false) { | ||
this.emit('beforeAddProperty', { | ||
property: { name, body: property }, | ||
model: this, | ||
}); | ||
const propertyName = validateName('Property', name); | ||
this.#properties.set( | ||
propertyName, | ||
Model.#validateFunction('Property', name, property, [ | ||
...this.#fields.keys(), | ||
this.#key.name, | ||
...this.#properties.keys(), | ||
...this.#methods.keys(), | ||
]) | ||
); | ||
if (cache) this.#cachedProperties.add(propertyName); | ||
this.emit('propertyAdded', { | ||
property: { name: propertyName, body: property }, | ||
model: this, | ||
}); | ||
this.emit('change', { | ||
type: 'propertyAdded', | ||
property: { name: propertyName, body: property }, | ||
model: this, | ||
}); | ||
} | ||
removeProperty(name) { | ||
if (!Model.#validateContains(this.name, 'Property', name, this.#properties)) | ||
return false; | ||
const property = this.#properties.get(name); | ||
this.emit('beforeRemoveProperty', { | ||
property: { name, body: property }, | ||
model: this, | ||
}); | ||
this.#properties.delete(name); | ||
if (this.#cachedProperties.has(name)) this.#cachedProperties.delete(name); | ||
this.emit('propertyRemoved', { | ||
property: { name }, | ||
model: this, | ||
}); | ||
this.emit('change', { | ||
type: 'propertyRemoved', | ||
property: { name, body: property }, | ||
model: this, | ||
}); | ||
return true; | ||
} | ||
addMethod(name, method) { | ||
this.emit('beforeAddMethod', { | ||
method: { name, body: method }, | ||
model: this, | ||
}); | ||
const methodName = validateName('Method', name); | ||
this.#methods.set( | ||
methodName, | ||
Model.#validateFunction('Method', name, method, [ | ||
...this.#fields.keys(), | ||
this.#key.name, | ||
...this.#properties.keys(), | ||
...this.#methods.keys(), | ||
]) | ||
); | ||
this.emit('methodAdded', { | ||
method: { name: methodName, body: method }, | ||
model: this, | ||
}); | ||
this.emit('change', { | ||
type: 'methodAdded', | ||
method: { name: methodName, body: method }, | ||
model: this, | ||
}); | ||
} | ||
removeMethod(name) { | ||
if (!Model.#validateContains(this.name, 'Method', name, this.#methods)) | ||
return false; | ||
const method = this.#methods.get(name); | ||
this.emit('beforeRemoveMethod', { | ||
method: { name, body: method }, | ||
model: this, | ||
}); | ||
this.#methods.delete(name); | ||
this.emit('methodRemoved', { | ||
method: { name }, | ||
model: this, | ||
}); | ||
this.emit('change', { | ||
type: 'methodRemoved', | ||
method: { name, body: method }, | ||
model: this, | ||
}); | ||
return true; | ||
} | ||
addScope(name, scope, sortFn) { | ||
this.emit('beforeAddScope', { | ||
scope: { name, body: scope }, | ||
model: this, | ||
}); | ||
const scopeName = validateName('Scope', name); | ||
this.#records[$addScope](scopeName, scope, sortFn); | ||
this.emit('scopeAdded', { | ||
scope: { name: scopeName, body: scope }, | ||
model: this, | ||
}); | ||
this.emit('change', { | ||
type: 'scopeAdded', | ||
scope: { name: scopeName, body: scope }, | ||
model: this, | ||
}); | ||
} | ||
removeScope(name) { | ||
if ( | ||
!Model.#validateContains(this.name, 'Scope', name, this.#records[$scopes]) | ||
) | ||
return false; | ||
const scope = this.#records[$scopes].get(name); | ||
this.emit('beforeRemoveScope', { | ||
scope: { name, body: scope }, | ||
model: this, | ||
}); | ||
this.#records[$removeScope](name); | ||
this.emit('scopeRemoved', { | ||
scope: { name }, | ||
model: this, | ||
}); | ||
this.emit('change', { | ||
type: 'scopeRemoved', | ||
scope: { name, body: scope }, | ||
model: this, | ||
}); | ||
return true; | ||
} | ||
addValidator(name, validator) { | ||
this.emit('beforeAddValidator', { | ||
validator: { name, body: validator }, | ||
model: this, | ||
}); | ||
// Validators are not name-validated by design. | ||
this.#validators.set( | ||
name, | ||
Model.#validateFunction('Validator', name, validator, [ | ||
...this.#validators.keys(), | ||
]) | ||
); | ||
this.emit('validatorAdded', { | ||
validator: { name, body: validator }, | ||
model: this, | ||
}); | ||
this.emit('change', { | ||
type: 'validatorAdded', | ||
validator: { name, body: validator }, | ||
model: this, | ||
}); | ||
} | ||
removeValidator(name) { | ||
if ( | ||
!Model.#validateContains(this.name, 'Validator', name, this.#validators) | ||
) | ||
return false; | ||
const validator = this.#validators.get(name); | ||
this.emit('beforeRemoveValidator', { | ||
validator: { name, body: validator }, | ||
model: this, | ||
}); | ||
this.#validators.delete(name); | ||
this.emit('validatorRemoved', { | ||
validator: { name }, | ||
model: this, | ||
}); | ||
this.emit('change', { | ||
type: 'validatorRemoved', | ||
validator: { name, body: validator }, | ||
model: this, | ||
}); | ||
return true; | ||
} | ||
// TODO: V2 Enhancements | ||
// If loading records from a storage, the key is already populated. This will | ||
// cause problems when validating auto-incrementing key values and could | ||
// result in random keys for the same object between runs. | ||
// | ||
// Record operations do not emit 'change' events by design | ||
createRecord(record) { | ||
this.emit('beforeCreateRecord', { record, model: this }); | ||
const [newRecordKey, newRecord] = this.#recordHandler.createRecord(record); | ||
this.#records.set(newRecordKey, newRecord); | ||
this.emit('recordCreated', { newRecord, model: this }); | ||
const [newRecordId, newRecord] = this.#recordHandler.createRecord(record); | ||
this.#records[$set](newRecordId, newRecord); | ||
return newRecord; | ||
} | ||
removeRecord(recordKey) { | ||
if (!this.#records.has(recordKey)) { | ||
console.warn(`Record ${recordKey} does not exist.`); | ||
removeRecord(recordId) { | ||
if (!this.#records.has(recordId)) { | ||
console.warn(`Record ${recordId} does not exist.`); | ||
return false; | ||
} | ||
const record = this.#records.get(recordKey); | ||
this.emit('beforeRemoveRecord', { record, model: this }); | ||
this.#records.delete(recordKey); | ||
this.emit('recordRemoved', { | ||
record: { [this.#key.name]: recordKey }, | ||
model: this, | ||
}); | ||
this.#records[$delete](recordId); | ||
return true; | ||
} | ||
updateRecord(recordKey, record) { | ||
updateRecord(recordId, record) { | ||
if (typeof record !== 'object') | ||
throw new TypeError('Record data must be an object.'); | ||
if (!this.#records.has(recordKey)) | ||
throw new ReferenceError(`Record ${recordKey} does not exist.`); | ||
const oldRecord = this.#records.get(recordKey); | ||
this.emit('beforeUpdateRecord', { | ||
record: oldRecord, | ||
newRecord: { [this.#key.name]: recordKey, ...record }, | ||
model: this, | ||
}); | ||
if (!this.#records.has(recordId)) | ||
throw new ReferenceError(`Record ${recordId} does not exist.`); | ||
const oldRecord = this.#records.get(recordId); | ||
Object.entries(record).forEach(([fieldName, fieldValue]) => { | ||
oldRecord[fieldName] = fieldValue; | ||
}); | ||
this.emit('recordUpdated', { | ||
record: oldRecord, | ||
model: this, | ||
}); | ||
return oldRecord; | ||
@@ -433,6 +154,2 @@ } | ||
get [$key]() { | ||
return this.#key; | ||
} | ||
get [$properties]() { | ||
@@ -442,6 +159,2 @@ return this.#properties; | ||
// TODO: V2 Enhancements | ||
// Add a method to the model, so that it's possible to reset caches for all | ||
// records. This removes some uncertainty and allows for recalculation without | ||
// hacks. Also update the docs to reflect this. | ||
get [$cachedProperties]() { | ||
@@ -459,17 +172,17 @@ return this.#cachedProperties; | ||
get [$validators]() { | ||
return this.#validators; | ||
get [$scopes]() { | ||
return this.#scopes; | ||
} | ||
get [$emptyRecordTemplate]() { | ||
return this.#emptyRecordTemplate; | ||
} | ||
[$addRelationshipAsField](relationship) { | ||
const { name, type, fieldName, field } = relationship[$getField](); | ||
const { name, fieldName, field } = relationship[$getField](); | ||
const relationshipName = `${name}.${fieldName}`; | ||
this.emit('beforeAddRelationship', { | ||
relationship: { name, type }, | ||
model: this, | ||
}); | ||
if ( | ||
[ | ||
'id', | ||
...this.#fields.keys(), | ||
this.#key.name, | ||
...this.#properties.keys(), | ||
@@ -487,28 +200,12 @@ ...this.#methods.keys(), | ||
this.#relationships.set(relationshipName, relationship); | ||
this.emit('relationshipAdded', { | ||
relationship: { name, type }, | ||
model: this, | ||
}); | ||
this.emit('change', { | ||
type: 'relationshipAdded', | ||
relationship: { | ||
relationship: { name, type }, | ||
model: this, | ||
}, | ||
model: this, | ||
}); | ||
this.#emptyRecordTemplate[fieldName] = undefined; | ||
} | ||
[$addRelationshipAsProperty](relationship) { | ||
const { name, type, propertyName, property } = relationship[$getProperty](); | ||
const { name, propertyName, property } = relationship[$getProperty](); | ||
const relationshipName = `${name}.${propertyName}`; | ||
this.emit('beforeAddRelationship', { | ||
relationship: { name, type }, | ||
model: this, | ||
}); | ||
if ( | ||
[ | ||
'id', | ||
...this.#fields.keys(), | ||
this.#key.name, | ||
...this.#properties.keys(), | ||
@@ -526,15 +223,6 @@ ...this.#methods.keys(), | ||
this.#relationships.set(relationshipName, relationship); | ||
} | ||
this.emit('relationshipAdded', { | ||
relationship: { name, type }, | ||
model: this, | ||
}); | ||
this.emit('change', { | ||
type: 'relationshipAdded', | ||
relationship: { | ||
relationship: { name, type }, | ||
model: this, | ||
}, | ||
model: this, | ||
}); | ||
[$clearCachedProperties]() { | ||
this.#cachedProperties.clear(); | ||
} | ||
@@ -544,78 +232,61 @@ | ||
static #createKey(options) { | ||
let name = 'id'; | ||
let type = 'string'; | ||
if (typeof options === 'string') name = options; | ||
else if (typeof options === 'object') { | ||
// Don't worry about these two being uncovered, they are a safeguard | ||
// that should never be reached under normal circumstances. | ||
name = options.name || name; | ||
type = options.type || type; | ||
} | ||
#addField(type, name) { | ||
const isStandardType = allStandardTypes.includes(type); | ||
let keyField; | ||
if (typeof type !== 'string' || !isStandardType) | ||
throw new TypeError(`Field ${name} is not a standard type.`); | ||
this.#fields.set(name, Field[type](name)); | ||
} | ||
if (type === 'string') { | ||
keyField = new Field({ | ||
name, | ||
type: key, | ||
required: true, | ||
defaultValue: '__emptyKey__', | ||
}); | ||
// Override the default value to throw an error | ||
Object.defineProperty(keyField, $defaultValue, { | ||
/* istanbul ignore next */ | ||
get() { | ||
throw new DefaultValueError( | ||
`Key field ${name} does not have a default value.` | ||
); | ||
}, | ||
}); | ||
} else if (type === 'auto') keyField = Field.auto(name); | ||
// Additional property to get the type from the model | ||
Object.defineProperty(keyField, $keyType, { | ||
get() { | ||
return type; | ||
}, | ||
}); | ||
#addProperty({ name, body, cache = false, inverse = null }) { | ||
if (typeof body !== 'function') | ||
throw new TypeError(`Property ${name} is not a function.`); | ||
this.#properties.set(name, body); | ||
return keyField; | ||
const hasInverse = typeof inverse === 'string' && inverse.length > 0; | ||
if (hasInverse) { | ||
if (this.#properties.has(inverse)) | ||
throw new NameError(`Property ${inverse} is already in use.`); | ||
const inverseBody = (...args) => !body(...args); | ||
this.#properties.set(inverse, inverseBody); | ||
} | ||
if (cache) { | ||
this.#cachedProperties.add(name); | ||
if (hasInverse) this.#cachedProperties.add(inverse); | ||
} | ||
} | ||
static #parseKey(modelName, key) { | ||
if (typeof key !== 'string' && typeof key !== 'object') | ||
throw new TypeError(`${modelName} key ${key} is not a string or object.`); | ||
#addMethod(name, method) { | ||
if (typeof method !== 'function') | ||
throw new TypeError(`Method ${name} is not a function.`); | ||
this.#methods.set(name, method); | ||
} | ||
if (typeof key === 'object' && !key.name) | ||
throw new TypeError(`${modelName} key ${key} is missing a name.`); | ||
if (typeof key === 'object' && !['auto', 'string'].includes(key.type)) | ||
#addScope(name, scope, sortFn) { | ||
if (typeof scope !== 'function') | ||
throw new TypeError(`Scope ${name} is not a function.`); | ||
if (sortFn && typeof sortFn !== 'function') | ||
throw new TypeError( | ||
`${modelName} key ${key} type must be either "string" or "auto".` | ||
`Scope ${name} comparator function is not a function.` | ||
); | ||
const _key = Model.#createKey(key); | ||
return _key; | ||
const scopeName = validateName(name); | ||
if ( | ||
this.#records[scopeName] || | ||
Object.getOwnPropertyNames(RecordSet.prototype).includes(scopeName) | ||
) | ||
throw new NameError(`Scope name ${scopeName} is already in use.`); | ||
this.#scopes.set(name, [scope, sortFn]); | ||
this.#records[$addScope](scopeName); | ||
} | ||
static #parseField(modelName, field, restrictedNames) { | ||
validateObjectWithUniqueName( | ||
{ | ||
objectType: 'Field', | ||
parentType: 'Model', | ||
parentName: modelName, | ||
}, | ||
field, | ||
restrictedNames | ||
); | ||
const isStandardType = allStandardTypes.includes(field.type); | ||
if (isStandardType) return Field[field.type](field); | ||
else if (typeof field.type === 'function') { | ||
Schema[$handleExperimentalAPIMessage]( | ||
`The provided type for ${field.name} is not part of the standard types. Function types are experimental and may go away in a later release.` | ||
); | ||
} | ||
return new Field(field); | ||
#generateEmptyRecordTemplate() { | ||
const emptyRecordTemplate = {}; | ||
this.#fields.forEach(field => { | ||
emptyRecordTemplate[field.name] = null; | ||
}); | ||
return emptyRecordTemplate; | ||
} | ||
@@ -641,45 +312,2 @@ | ||
} | ||
static #validateFunction( | ||
callbackType, | ||
callbackName, | ||
callback, | ||
restrictedNames | ||
) { | ||
if (typeof callback !== 'function') | ||
throw new TypeError(`${callbackType} ${callbackName} is not a function.`); | ||
if (restrictedNames.includes(callbackName)) | ||
throw new DuplicationError( | ||
`${callbackType} ${callbackName} already exists.` | ||
); | ||
return callback; | ||
} | ||
static #validateContains(modelName, objectType, objectName, objects) { | ||
if (!objects.has(objectName)) { | ||
console.warn( | ||
`Model ${modelName} does not contain a ${objectType.toLowerCase()} named ${objectName}.` | ||
); | ||
return false; | ||
} | ||
return true; | ||
} | ||
static #applyFieldRetrofill(field, records, retrofill) { | ||
if (!field.required && retrofill === undefined) return; | ||
const retrofillFunction = | ||
retrofill !== undefined | ||
? typeof retrofill === 'function' | ||
? retrofill | ||
: () => retrofill | ||
: record => | ||
record[field.name] ? record[field.name] : field[$defaultValue]; | ||
records.forEach(record => { | ||
record[field.name] = retrofillFunction(record); | ||
}); | ||
} | ||
} |
import Record from './record'; | ||
import Schema from '../schema'; | ||
import { DuplicationError } from 'src/errors'; | ||
import types from 'src/types'; | ||
import { isUndefined, recordId } from 'src/types'; | ||
import symbols from 'src/symbols'; | ||
@@ -9,5 +10,2 @@ import { deepClone } from 'src/utils'; | ||
$fields, | ||
$defaultValue, | ||
$key, | ||
$keyType, | ||
$properties, | ||
@@ -17,9 +15,11 @@ $cachedProperties, | ||
$relationships, | ||
$validators, | ||
$recordValue, | ||
$wrappedRecordValue, | ||
$emptyRecordTemplate, | ||
$recordModel, | ||
$recordTag, | ||
$isRecord, | ||
$isDateField, | ||
$get, | ||
$schemaObject, | ||
} = symbols; | ||
@@ -43,43 +43,25 @@ | ||
const modelName = this.#getModelName(); | ||
// Validate record key | ||
const newRecordKey = RecordHandler.#validateNewRecordKey( | ||
// Validate record id | ||
const newRecordId = RecordHandler.#validateNewRecordId( | ||
modelName, | ||
this.#getKey(), | ||
recordData[this.#getKey().name], | ||
recordData.id, | ||
this.#model.records | ||
); | ||
// Clone record data, check for extra properties | ||
const clonedRecord = deepClone(recordData); | ||
const extraProperties = Object.keys(clonedRecord).filter( | ||
property => !this.#hasField(property) && !this.#isModelKey(property) | ||
); | ||
if (extraProperties.length > 0) { | ||
console.warn( | ||
`${modelName} record has extra fields: ${extraProperties.join(', ')}.` | ||
); | ||
} | ||
// Create record with key and extra properties only | ||
// Create a new record from the template with the new id | ||
const newRecord = new Record( | ||
{ | ||
[this.#getKey().name]: newRecordKey, | ||
...extraProperties.reduce( | ||
(obj, property) => ({ ...obj, [property]: clonedRecord[property] }), | ||
{} | ||
), | ||
id: newRecordId, | ||
...this.#getEmptyRecordTemplate(), | ||
}, | ||
this | ||
); | ||
// Set fields and skip validation | ||
this.#getFieldNames().forEach(field => { | ||
this.set(newRecord, field, clonedRecord[field], newRecord, true); | ||
if (recordData[field] === undefined) return; | ||
this.set(newRecord, field, deepClone(recordData[field]), newRecord); | ||
}); | ||
// Validate record just once | ||
this.#getValidators().forEach((validator, validatorName) => { | ||
if (!validator(newRecord, this.#model.records)) | ||
throw new RangeError( | ||
`${modelName} record with key ${newRecordKey} failed validation for ${validatorName}.` | ||
); | ||
}); | ||
return [newRecordKey, newRecord]; | ||
return [newRecordId, newRecord]; | ||
} | ||
@@ -93,4 +75,4 @@ | ||
return this.#getRelationship(record, property); | ||
// Key or field, return as-is | ||
if (this.#isModelKey(property) || this.#hasField(property)) | ||
// Id or field, return as-is | ||
if (this.#isRecordId(property) || this.#hasField(property)) | ||
return this.#getFieldValue(record, property); | ||
@@ -105,3 +87,3 @@ // Property, get and call, this also matches relationship reverses (properties) | ||
// Call toString method, return key value | ||
if (this.#isCallToString(property)) return () => this.#getKeyValue(record); | ||
if (this.#isCallToString(property)) return () => this.getRecordId(record); | ||
// Known symbol, handle as required | ||
@@ -114,7 +96,5 @@ if (this.#isKnownSymbol(property)) | ||
set(record, property, value, receiver, skipValidation) { | ||
set(record, property, value) { | ||
// Receiver is the same as record but never used (API compatibility) | ||
const recordValue = record[$recordValue]; | ||
const recordKey = this.#getKeyValue(record); | ||
const otherRecords = this.#model.records.except(recordKey); | ||
const recordId = this.getRecordId(record); | ||
// Throw an error when trying to set a property, also catches | ||
@@ -124,3 +104,3 @@ // relationship reverses, safeguarding against issues there. | ||
throw new TypeError( | ||
`${this.#getModelName()} record ${recordKey} cannot set property ${property}.` | ||
`${this.#getModelName()} record ${recordId} cannot set property ${property}.` | ||
); | ||
@@ -130,3 +110,3 @@ // Throw an error when trying to set a method. | ||
throw new TypeError( | ||
`${this.#getModelName()} record ${recordKey} cannot set method ${property}.` | ||
`${this.#getModelName()} record ${recordId} cannot set method ${property}.` | ||
); | ||
@@ -137,28 +117,10 @@ // Validate and set field, warn if field is not defined | ||
const field = this.#getField(property); | ||
RecordHandler.#setRecordField(this.#model.name, record, field, value); | ||
// Never skip individual field validation | ||
field[$validators].forEach((validator, validatorName) => { | ||
if ( | ||
![null, undefined].includes(recordValue[property]) && | ||
!validator(recordValue, otherRecords) | ||
) | ||
throw new RangeError( | ||
`${this.#getModelName()} record with key ${recordKey} failed validation for ${validatorName}.` | ||
); | ||
}); | ||
} else { | ||
console.warn(`${this.#model.name} record has extra field: ${property}.`); | ||
recordValue[property] = value; | ||
RecordHandler.#setRecordField( | ||
this.#model.name, | ||
record, | ||
field, | ||
value, | ||
this.#hasRelationshipField(property) | ||
); | ||
} | ||
// Perform model validations | ||
// The last argument, `skipValidation`, is used to skip validation | ||
// and should only ever be set to `true` by the by the handler itself. | ||
if (!skipValidation) { | ||
this.#getValidators().forEach((validator, validatorName) => { | ||
if (!validator(recordValue, otherRecords)) | ||
throw new RangeError( | ||
`${this.#getModelName()} record with key ${recordKey} failed validation for ${validatorName}.` | ||
); | ||
}); | ||
} | ||
return true; | ||
@@ -169,7 +131,11 @@ } | ||
static #setRecordField(modelName, record, field, value) { | ||
static #setRecordField(modelName, record, field, value, isRelationship) { | ||
// Set the default value if the field is null or undefined | ||
const recordValue = | ||
field.required && types.nil(value) ? field[$defaultValue] : value; | ||
if (!field.typeCheck(recordValue)) | ||
!isRelationship && isUndefined(value) | ||
? null | ||
: field[$isDateField] | ||
? new Date(value) | ||
: value; | ||
if (!isRelationship && !field.typeCheck(recordValue)) | ||
// Throw an error if the field value is invalid | ||
@@ -185,9 +151,7 @@ throw new TypeError( | ||
static #recordToObject(record, model, handler) { | ||
static #recordToObject(record, model) { | ||
const recordValue = record[$recordValue]; | ||
const fields = model[$fields]; | ||
const properties = model[$properties]; | ||
const key = model[$key].name; | ||
const object = { | ||
[key]: recordValue[key], | ||
id: recordValue.id, | ||
}; | ||
@@ -200,52 +164,16 @@ | ||
// TODO: V2 enhancements | ||
// If we end up keeping this API, we might be interested in adding | ||
// nesting that works correctly with relationships. Currently, you can | ||
// only specify the relationship name, and it will serialize the | ||
// full object. Examples like ['category', 'siblings.category'] should | ||
// work eventually. | ||
// We also need to account for nested arrays and objects etc. | ||
const toObject = ({ include = [] } = {}) => { | ||
let result = object; | ||
const included = include.map(name => { | ||
const [field, ...props] = name.split('.'); | ||
return [field, props.join('.')]; | ||
}); | ||
included.forEach(([includedField, props]) => { | ||
if (object[includedField]) { | ||
if (Array.isArray(object[includedField])) { | ||
const records = handler.get(record, includedField); | ||
object[includedField] = records.map(record => | ||
record.toObject({ include: [props] }) | ||
); | ||
} else { | ||
object[includedField] = handler | ||
.get(record, includedField) | ||
.toObject({ include: [props] }); | ||
} | ||
} else if (properties.has(includedField)) { | ||
object[includedField] = handler.get(record, includedField); | ||
} | ||
}); | ||
return result; | ||
}; | ||
return toObject; | ||
return () => object; | ||
} | ||
static #validateNewRecordKey = (modelName, modelKey, recordKey, records) => { | ||
let newRecordKey = recordKey; | ||
static #validateNewRecordId = (modelName, id, records) => { | ||
let newRecordId = id; | ||
if (modelKey[$keyType] === 'string' && !modelKey.typeCheck(newRecordKey)) | ||
throw new TypeError( | ||
`${modelName} record has invalid value for key ${modelKey.name}.` | ||
); | ||
if (modelKey[$keyType] === 'auto') newRecordKey = modelKey[$defaultValue]; | ||
if (!recordId(newRecordId)) | ||
throw new TypeError(`${modelName} record has invalid id.`); | ||
if (records.has(newRecordKey)) | ||
if (records.has(newRecordId)) | ||
throw new DuplicationError( | ||
`${modelName} record with key ${newRecordKey} already exists.` | ||
`${modelName} record with id ${newRecordId} already exists.` | ||
); | ||
return newRecordKey; | ||
return newRecordId; | ||
}; | ||
@@ -263,18 +191,14 @@ | ||
#getValidators() { | ||
return this.#model[$validators]; | ||
#getEmptyRecordTemplate() { | ||
return this.#model[$emptyRecordTemplate]; | ||
} | ||
#isModelKey(property) { | ||
return this.#model[$key].name === property; | ||
#isRecordId(property) { | ||
return property === 'id'; | ||
} | ||
#getKey() { | ||
return this.#model[$key]; | ||
getRecordId(record) { | ||
return record[$recordValue].id; | ||
} | ||
#getKeyValue(record) { | ||
return record[$recordValue][this.#model[$key].name]; | ||
} | ||
#hasField(property) { | ||
@@ -301,3 +225,4 @@ return this.#model[$fields].has(property); | ||
const value = this.#model[$properties].get(property)( | ||
record[$wrappedRecordValue] | ||
record[$wrappedRecordValue], | ||
Schema[$schemaObject] | ||
); | ||
@@ -307,3 +232,6 @@ record[$cachedProperties].set(property, value); | ||
} | ||
return this.#model[$properties].get(property)(record[$wrappedRecordValue]); | ||
return this.#model[$properties].get(property)( | ||
record[$wrappedRecordValue], | ||
Schema[$schemaObject] | ||
); | ||
} | ||
@@ -317,3 +245,4 @@ | ||
const methodFn = this.#model[$methods].get(method); | ||
return (...args) => methodFn(record[$wrappedRecordValue], ...args); | ||
return (...args) => | ||
methodFn(record[$wrappedRecordValue], ...args, Schema[$schemaObject]); | ||
} | ||
@@ -348,3 +277,3 @@ | ||
#isKnownSymbol(property) { | ||
return [$recordModel, $recordTag, $recordValue, $isRecord, $key].includes( | ||
return [$recordModel, $recordTag, $recordValue, $isRecord].includes( | ||
property | ||
@@ -356,3 +285,2 @@ ); | ||
if (property === $isRecord) return true; | ||
if (property === $key) this.#getKey(); | ||
return record[property]; | ||
@@ -359,0 +287,0 @@ } |
export { default as Record } from './record'; | ||
export { default as PartialRecord } from './partial'; | ||
export { default as RecordFragment } from './fragment'; | ||
export { default as RecordGroup } from './group'; | ||
export { default as RecordHandler } from './handler'; | ||
export { default as RecordSet } from './set'; |
@@ -10,3 +10,2 @@ import symbols from 'src/symbols'; | ||
$cachedProperties, | ||
$key, | ||
} = symbols; | ||
@@ -57,4 +56,3 @@ | ||
const model = this[$recordModel]; | ||
const key = model[$key].name; | ||
return `${model.name}#${this[$recordValue][key]}`; | ||
return `${model.name}#${this[$recordValue].id}`; | ||
} | ||
@@ -61,0 +59,0 @@ |
@@ -1,17 +0,10 @@ | ||
import { allEqualBy } from 'src/utils'; | ||
import { NameError, DuplicationError } from 'src/errors'; | ||
import PartialRecord from './partial'; | ||
import RecordFragment from './fragment'; | ||
import RecordGroup from './group'; | ||
import symbols from 'src/symbols'; | ||
const { | ||
$recordModel, | ||
$recordTag, | ||
$scopes, | ||
$addScope, | ||
$removeScope, | ||
$isRecord, | ||
$key, | ||
$setRecordKey, | ||
$set, | ||
$delete, | ||
$clearRecordSetForTesting, | ||
} = symbols; | ||
@@ -24,77 +17,54 @@ | ||
class RecordSet extends Map { | ||
#frozen; | ||
#scopes; | ||
#keyName; | ||
#model; | ||
// TODO: V2 enhancements | ||
// Add some way to pass the handler to the record set to prevent adding new | ||
// values to the record set. Generally speaking calling `.set()` on a record | ||
// set should probably be disabled. | ||
constructor({ iterable = [], copyScopesFrom = null } = {}) { | ||
constructor({ iterable = [], model = null } = {}) { | ||
super(); | ||
for (const [key, value] of iterable) this.set(key, value); | ||
this.#scopes = new Map(); | ||
if (copyScopesFrom) this.#copyScopes(copyScopesFrom); | ||
if (!model) throw new TypeError('Model cannot be empty.'); | ||
this.#model = model; | ||
this.#frozen = false; | ||
} | ||
for (const [id, value] of iterable) this[$set](id, value); | ||
/** | ||
* Freezes a record set, preventing further modification. | ||
* @returns {RecordSet} The record set itself. | ||
*/ | ||
freeze() { | ||
this.#frozen = true; | ||
return this; | ||
this.#copyScopesFromModel(); | ||
} | ||
/** | ||
* | ||
* @param {*} key The key of the element to add to the record set. | ||
* @param {*} value The value of the element to add to the record set. | ||
* @returns {RecordSet} The record set itself. | ||
*/ | ||
set(key, value) { | ||
// TODO: V2 Enhancements | ||
// Ensure this is only ever called internally (maybe symbolize it?) | ||
// Schema[$handleExperimentalAPIMessage]( | ||
// 'Calling RecordSet.prototype.set() is discouraged as it may cause unexpected behavior. This method may be removed in a future version of the library.' | ||
// ); | ||
if (this.#frozen) throw new TypeError('Cannot modify a frozen RecordSet.'); | ||
super.set(key, value); | ||
return this; | ||
set() { | ||
throw new TypeError( | ||
'You cannot directly modify a RecordSet. Please use `Model.prototype.createRecord()` instead.' | ||
); | ||
} | ||
/** | ||
* @param {*} key The key of the element to remove from the record set. | ||
* @returns {boolean} True if the element was removed, false otherwise. | ||
*/ | ||
delete(key) { | ||
if (this.#frozen) throw new TypeError('Cannot modify a frozen RecordSet.'); | ||
return super.delete(key); | ||
delete() { | ||
throw new TypeError( | ||
'You cannot directly modify a RecordSet. Please use `Model.prototype.deleteRecord()` instead.' | ||
); | ||
} | ||
/** | ||
* Removes all elements from the record set. | ||
*/ | ||
clear() { | ||
if (this.#frozen) throw new TypeError('Cannot modify a frozen RecordSet.'); | ||
super.clear(); | ||
throw new TypeError( | ||
'You cannot directly modify a RecordSet Please use `Model.prototype.deleteRecord()` instead.' | ||
); | ||
} | ||
/** | ||
* Creates an object populated with the results of calling a provided function | ||
* on every element in the calling record set. | ||
* Creates an array or object populated with the results of calling a provided | ||
* function on every element in the calling record set. | ||
* @param {Function} callbackFn Function that is called for every element of | ||
* the record set. The callback is called with the following arguments: | ||
* - `value`: The value of the current element. | ||
* - `key`: The key of the current element. | ||
* - `id`: The id of the current element. | ||
* - `recordSet`: The record set itself. | ||
* @returns {Object} An object with each key mapped to the result of the | ||
* callback function on the corresponding element. | ||
* @param {Object} options An object with options for the map operation. | ||
* @param {Boolean} options.flat Whether to return an array or object. | ||
* @returns {Array/Object} An array or object with the results of the callback | ||
* function on each element. | ||
*/ | ||
map(callbackFn) { | ||
return [...this.entries()].reduce((newMap, [key, value]) => { | ||
newMap[key] = callbackFn(value, key, this); | ||
map(callbackFn, { flat = false } = {}) { | ||
if (flat) | ||
return [...this.entries()].map(([id, value]) => { | ||
return callbackFn(value, id, this); | ||
}); | ||
return [...this.entries()].reduce((newMap, [id, value]) => { | ||
newMap[id] = callbackFn(value, id, this); | ||
return newMap; | ||
@@ -105,19 +75,2 @@ }, {}); | ||
/** | ||
* Creates an array of values by running each element of the record set | ||
* through the provided transformation function. | ||
* @param {Function} callbackFn Function that is called for every element of | ||
* the record set. The callback is called with the following arguments: | ||
* - `value`: The value of the current element. | ||
* - `key`: The key of the current element. | ||
* - `recordSet`: The record set itself. | ||
* @returns {Array} An array with each element being the result of the | ||
* callback function on the corresponding element. | ||
*/ | ||
flatMap(callbackFn) { | ||
return [...this.entries()].map(([key, value]) => { | ||
return callbackFn(value, key, this); | ||
}); | ||
} | ||
/** | ||
* Executes a user-supplied “reducer” callback function on each element of the | ||
@@ -131,3 +84,3 @@ * record set, passing in the return value from the calculation on the preceding | ||
* - `value`: The value of the current element. | ||
* - `key`: The key of the current element. | ||
* - `id`: The id of the current element. | ||
* - `recordSet`: The record set itself. | ||
@@ -139,4 +92,4 @@ * @param {*} initialValue The initial value of the accumulator. | ||
reduce(callbackFn, initialValue) { | ||
return [...this.entries()].reduce((acc, [key, value]) => { | ||
return callbackFn(acc, value, key, this); | ||
return [...this.entries()].reduce((acc, [id, value]) => { | ||
return callbackFn(acc, value, id, this); | ||
}, initialValue); | ||
@@ -146,35 +99,24 @@ } | ||
/** | ||
* Creates a new record set with all elements that pass the test implemented | ||
* by the provided function. | ||
* Creates a new record set or array with all elements that pass the test | ||
* implemented by the provided function. | ||
* @param {Function} callbackFn Function that is called for every element of | ||
* the record set. The callback is called with the following arguments: | ||
* - `value`: The value of the current element. | ||
* - `key`: The key of the current element. | ||
* - `id`: The id of the current element. | ||
* - `recordSet`: The record set itself. | ||
* @returns {RecordSet} A new record set with all elements that pass the test. | ||
* @param {Boolean} options.flat Whether to return an array or object. | ||
* @returns {Array/RecordSet} An array or record set with all elements that | ||
* pass the test. | ||
*/ | ||
filter(callbackFn) { | ||
return [...this.entries()] | ||
.reduce((newMap, [key, value]) => { | ||
if (callbackFn(value, key, this)) newMap.set(key, value); | ||
return newMap; | ||
}, new RecordSet({ copyScopesFrom: this })) | ||
.freeze(); | ||
} | ||
filter(callbackFn, { flat = false } = {}) { | ||
if (flat) | ||
return [...this.entries()].reduce((arr, [id, value]) => { | ||
if (callbackFn(value, id, this)) arr.push(value); | ||
return arr; | ||
}, []); | ||
/** | ||
* Creates an array with all elements that pass the test implemente by the | ||
* provided function. | ||
* @param {Function} callbackFn Function that is called for every element of | ||
* the record set. The callback is called with the following arguments: | ||
* - `value`: The value of the current element. | ||
* - `key`: The key of the current element. | ||
* - `recordSet`: The record set itself. | ||
* @returns {Array} An array with all elements that pass the test. | ||
*/ | ||
flatFilter(callbackFn) { | ||
return [...this.entries()].reduce((arr, [key, value]) => { | ||
if (callbackFn(value, key, this)) arr.push(value); | ||
return arr; | ||
}, []); | ||
return [...this.entries()].reduce((newRecordSet, [id, record]) => { | ||
if (callbackFn(record, id, this)) newRecordSet[$set](id, record); | ||
return newRecordSet; | ||
}, new RecordSet({ model: this.#model })); | ||
} | ||
@@ -188,3 +130,3 @@ | ||
* - `value`: The value of the current element. | ||
* - `key`: The key of the current element. | ||
* - `id`: The id of the current element. | ||
* - `recordSet`: The record set itself. | ||
@@ -195,4 +137,4 @@ * @returns {Record} The value of the first element in the record set that | ||
find(callbackFn) { | ||
for (const [key, value] of this.entries()) { | ||
if (callbackFn(value, key, this)) return value; | ||
for (const [id, record] of this) { | ||
if (callbackFn(record, id, this)) return record; | ||
} | ||
@@ -203,3 +145,3 @@ return undefined; | ||
/** | ||
* Returns the key of the first element in the record set that satisfies the | ||
* Returns the id of the first element in the record set that satisfies the | ||
* provided testing function. | ||
@@ -209,10 +151,10 @@ * @param {Function} callbackFn Function that is called for every element of | ||
* - `value`: The value of the current element. | ||
* - `key`: The key of the current element. | ||
* - `id`: The id of the current element. | ||
* - `recordSet`: The record set itself. | ||
* @returns {*} The key of the first element in the record set that satisfies | ||
* @returns {*} The id of the first element in the record set that satisfies | ||
* the provided testing function or `undefined`. | ||
*/ | ||
findKey(callbackFn) { | ||
for (const [key, value] of this.entries()) { | ||
if (callbackFn(value, key, this)) return key; | ||
findId(callbackFn) { | ||
for (const [id, value] of this) { | ||
if (callbackFn(value, id, this)) return id; | ||
} | ||
@@ -223,32 +165,28 @@ return undefined; | ||
/** | ||
* Returns all elements in the record set whose keys match the provided | ||
* key/keys in order of appearance in the given keys. | ||
* @param {...any} keys A list of keys to exclude from the record set. | ||
* @returns {RecordSet} A new record set with all elements whose keys | ||
* match the provided key/keys. | ||
* Returns all elements in the record set whose ids match the provided | ||
* ids/ids in order of appearance in the given ids. | ||
* @param {...any} ids A list of ids to exclude from the record set. | ||
* @returns {RecordSet} A new record set with all elements whose ids | ||
* match the provided id/ids. | ||
*/ | ||
only(...keys) { | ||
return new RecordSet({ | ||
iterable: keys.reduce((itr, key) => { | ||
if (this.has(key)) itr.push([key, this.get(key)]); | ||
return itr; | ||
}, []), | ||
copyScopesFrom: this, | ||
}).freeze(); | ||
only(...ids) { | ||
return ids.reduce((newRecordSet, id) => { | ||
if (this.has(id)) newRecordSet[$set](id, this.get(id)); | ||
return newRecordSet; | ||
}, new RecordSet({ model: this.#model })); | ||
} | ||
/** | ||
* Returns all elements in the record set whose keys do not match the provided | ||
* key/keys. | ||
* @param {...any} keys A list of keys to exclude from the record set. | ||
* @returns {RecordSet} A new record set with all elements whose keys do not | ||
* match the provided key/keys. | ||
* Returns all elements in the record set whose ids do not match the provided | ||
* id/ids. | ||
* @param {...any} ids A list of ids to exclude from the record set. | ||
* @returns {RecordSet} A new record set with all elements whose ids do not | ||
* match the provided id/ids. | ||
*/ | ||
except(...keys) { | ||
return new RecordSet({ | ||
iterable: [...this.entries()].filter(([key]) => { | ||
return !keys.includes(key); | ||
}), | ||
copyScopesFrom: this, | ||
}).freeze(); | ||
except(...ids) { | ||
const newRecordSet = new RecordSet({ model: this.#model }); | ||
for (const [id, record] of this) { | ||
if (!ids.includes(id)) newRecordSet[$set](id, record); | ||
} | ||
return newRecordSet; | ||
} | ||
@@ -262,4 +200,4 @@ | ||
* - `secondValue`: The value of the second element for comparison. | ||
* - `firstKey`: The key of the first element for comparison. | ||
* - `secondKey`: The key of the second element for comparison. | ||
* - `firstId`: The id of the first element for comparison. | ||
* - `secondId`: The id of the second element for comparison. | ||
* @returns {RecordSet} A new record set with the elements of the original | ||
@@ -269,6 +207,8 @@ * record set sorted. | ||
sort(comparatorFn) { | ||
const sorted = [...this.entries()].sort(([key1, value1], [key2, value2]) => | ||
comparatorFn(value1, value2, key1, key2) | ||
const newRecordSet = new RecordSet({ model: this.#model }); | ||
const sorted = [...this.entries()].sort(([id1, value1], [id2, value2]) => | ||
comparatorFn(value1, value2, id1, id2) | ||
); | ||
return new RecordSet({ iterable: sorted, copyScopesFrom: this }).freeze(); | ||
for (const [id, record] of sorted) newRecordSet[$set](id, record); | ||
return newRecordSet; | ||
} | ||
@@ -282,3 +222,3 @@ | ||
* - `value`: The value of the current element. | ||
* - `key`: The key of the current element. | ||
* - `id`: The id of the current element. | ||
* - `recordSet`: The record set itself. | ||
@@ -290,4 +230,4 @@ * @returns {Boolean} `true` if all elements in the record set pass the test, | ||
if (this.size === 0) return true; | ||
return [...this.entries()].every(([key, value]) => | ||
callbackFn(value, key, this) | ||
return [...this.entries()].every(([id, value]) => | ||
callbackFn(value, id, this) | ||
); | ||
@@ -302,3 +242,3 @@ } | ||
* - `value`: The value of the current element. | ||
* - `key`: The key of the current element. | ||
* - `id`: The id of the current element. | ||
* - `recordSet`: The record set itself. | ||
@@ -310,4 +250,4 @@ * @returns {Boolean} `true` if any elements in the record set pass the test, | ||
if (this.size === 0) return false; | ||
return [...this.entries()].some(([key, value]) => | ||
callbackFn(value, key, this) | ||
return [...this.entries()].some(([id, value]) => | ||
callbackFn(value, id, this) | ||
); | ||
@@ -317,19 +257,2 @@ } | ||
/** | ||
* Returns a new record set with all elements mapped to the keys specified. | ||
* @param {...any} keys A list of keys to map each record to. | ||
* @returns {RecordSet} A new record set with all elements mapped to the | ||
* keys specified. | ||
*/ | ||
select(...keys) { | ||
return new RecordSet({ | ||
iterable: [...this.entries()].map(([key, value]) => { | ||
const obj = {}; | ||
keys.forEach(key => (obj[key] = value[key])); | ||
return [key, new PartialRecord(obj, value[$recordTag])]; | ||
}), | ||
copyScopesFrom: this, | ||
}).freeze(); | ||
} | ||
/** | ||
* Returns an array of objects with all elements mapped to the keys specified. | ||
@@ -339,3 +262,3 @@ * @param {...any} keys A list of keys to map each record to. | ||
*/ | ||
flatSelect(...keys) { | ||
select(...keys) { | ||
return [...this.values()].map(value => | ||
@@ -347,19 +270,2 @@ keys.reduce((obj, key) => ({ ...obj, [key]: value[key] }), {}) | ||
/** | ||
* Returns a new record set with records mapped to fragments containing | ||
* only the keys specified. | ||
* @param {...any} keys A list of keys to map each record to. | ||
* @returns {RecordSet} A new record set with all elements mapped to | ||
* fragments containing only the keys specified. | ||
*/ | ||
pluck(...keys) { | ||
return new RecordSet({ | ||
iterable: [...this.entries()].map(([key, value]) => { | ||
const values = keys.map(key => value[key]); | ||
return [key, new RecordFragment(values, value[$recordTag])]; | ||
}), | ||
copyScopesFrom: this, | ||
}).freeze(); | ||
} | ||
/** | ||
* Returns an array with records mapped to arrays containing only the | ||
@@ -369,10 +275,11 @@ * keys specified. If only one key is specified, the array contains the | ||
* @param {...any} keys A list of keys to map each record to. | ||
* @returns {RecordSet} A new record set with all elements mapped to | ||
* fragments containing only the keys specified. | ||
* @returns {Array} An array of arrays with records mapped to the values | ||
* of the keys specified. If only one key is specified, the array contains | ||
* the value of each element instead. | ||
*/ | ||
flatPluck(...keys) { | ||
pluck(...keys) { | ||
const isSingleKey = keys.length === 1; | ||
if (isSingleKey) { | ||
const key = keys[0]; | ||
if (this.#keyName === key) return [...this.keys()]; | ||
if (key === 'id') return [...this.ids()]; | ||
return [...this.values()].map(value => value[key]); | ||
@@ -386,83 +293,23 @@ } | ||
* @param {*} key A key to group the elements by. | ||
* @returns {RecordSet} A new record set containing groups of elements. | ||
* @returns {Object} An object with the keys being the values of the | ||
* specified key and the values being record sets containing the elements | ||
* of the original record set that have the same value for the specified key. | ||
*/ | ||
groupBy(key) { | ||
const res = new RecordSet({ copyScopesFrom: this, iterable: [] }); | ||
for (const [recordKey, value] of this.entries()) { | ||
let keyValue = value[key]; | ||
const res = {}; | ||
for (const [id, record] of this) { | ||
let keyValue = record[key]; | ||
if (keyValue !== undefined && keyValue !== null && keyValue[$isRecord]) { | ||
keyValue = value[key][$key]; | ||
keyValue = record[key].id; | ||
} | ||
if (!res.has(keyValue)) { | ||
res.set( | ||
keyValue, | ||
new RecordGroup({ | ||
copyScopesFrom: this, | ||
iterable: [], | ||
groupName: keyValue, | ||
}) | ||
); | ||
if (!res[keyValue]) { | ||
res[keyValue] = new RecordSet({ model: this.#model }); | ||
} | ||
res.get(keyValue).set(recordKey, value); | ||
res[keyValue][$set](id, record); | ||
} | ||
for (const value of res.values()) { | ||
value.freeze(); | ||
} | ||
return res.freeze(); | ||
return res; | ||
} | ||
/** | ||
* Duplicates the current record set. | ||
* @returns {RecordSet} A new record set with the same elements as the original. | ||
*/ | ||
duplicate() { | ||
return new RecordSet({ | ||
iterable: [...this.entries()], | ||
copyScopesFrom: this, | ||
}).freeze(); | ||
} | ||
/** | ||
* Merges one or more record sets into the current record set. | ||
* @param {...any} recordSets One or more record sets to merge. | ||
* @returns {RecordSet} A new record set with the elements of the original | ||
* record sets merged into it. | ||
*/ | ||
merge(...recordSets) { | ||
const res = new Map([...this.entries()]); | ||
for (const recordSet of recordSets) { | ||
for (const [key, value] of recordSet.entries()) { | ||
if (res.has(key)) | ||
throw new DuplicationError( | ||
`Key ${key} already exists in the record set.` | ||
); | ||
res.set(key, value); | ||
} | ||
} | ||
return new RecordSet({ | ||
iterable: [...res.entries()], | ||
copyScopesFrom: this, | ||
}).freeze(); | ||
} | ||
/** | ||
* Merges one or more records into the current record set. | ||
* @param {...any} records One or more records to merge. | ||
* @returns {RecordSet} A new record set with the elements of the original | ||
* record set and any given records merged into it. | ||
*/ | ||
append(...records) { | ||
const res = new RecordSet({ | ||
iterable: [...this.entries()], | ||
copyScopesFrom: this, | ||
}); | ||
for (const record of records) { | ||
res.set(record[$key], record); | ||
} | ||
return res.freeze(); | ||
} | ||
/** | ||
* Creates a new record set with all elements that pass the test implemented | ||
@@ -473,3 +320,3 @@ * by the provided function. | ||
* - `value`: The value of the current element. | ||
* - `key`: The key of the current element. | ||
* - `id`: The id of the current element. | ||
* - `recordSet`: The record set itself. | ||
@@ -488,3 +335,3 @@ * @returns {RecordSet} A new record set with all elements that pass the test. | ||
* - `value`: The value of the current element. | ||
* - `key`: The key of the current element. | ||
* - `id`: The id of the current element. | ||
* - `recordSet`: The record set itself. | ||
@@ -494,58 +341,38 @@ * @returns {RecordSet} A new record set with all elements that fail the test. | ||
whereNot(callbackFn) { | ||
return this.filter((value, key, map) => !callbackFn(value, key, map)); | ||
return this.filter((value, id, map) => !callbackFn(value, id, map)); | ||
} | ||
/** | ||
* Iterates over the record set's keys in array batches of the specified size. | ||
* Iterates over the record set in batches of the specified size. | ||
* @param {Number} batchSize The size of each batch. | ||
* @returns {Iterator} An iterator that yields array batches of the specified size. | ||
* @param {Object} options An object with options for the operation. | ||
* @param {Boolean} options.flat Whether to yield record set or array batches. | ||
* @returns {Iterator} An iterator that yields record set or array batches of | ||
* the specified size. | ||
*/ | ||
*flatBatchKeysIterator(batchSize) { | ||
let batch = []; | ||
for (const key of this.keys()) { | ||
batch.push(key); | ||
if (batch.length === batchSize) { | ||
yield batch; | ||
batch = []; | ||
*batchIterator(batchSize, { flat = false } = {}) { | ||
if (flat) { | ||
let batch = []; | ||
for (const [id, record] of this) { | ||
batch.push(flat ? record : [id, record]); | ||
if (batch.length === batchSize) { | ||
yield batch; | ||
batch = []; | ||
} | ||
} | ||
} | ||
if (batch.length) yield batch; | ||
} | ||
/** | ||
* Iterates over the record set in array batches of the specified size. | ||
* @param {Number} batchSize The size of each batch. | ||
* @returns {Iterator} An iterator that yields array batches of the specified size. | ||
*/ | ||
*flatBatchIterator(batchSize) { | ||
let batch = []; | ||
for (const [, value] of this) { | ||
batch.push(value); | ||
if (batch.length === batchSize) { | ||
yield batch; | ||
batch = []; | ||
if (batch.length) yield batch; | ||
} else { | ||
let newRecordSet = new RecordSet({ model: this.#model }); | ||
for (const [id, record] of this) { | ||
newRecordSet[$set](id, record); | ||
if (newRecordSet.size === batchSize) { | ||
yield newRecordSet; | ||
newRecordSet = new RecordSet({ model: this.#model }); | ||
} | ||
} | ||
if (newRecordSet.size) yield newRecordSet; | ||
} | ||
if (batch.length) yield batch; | ||
} | ||
/** | ||
* Iterates over the record set in batches of the specified size. | ||
* @param {Number} batchSize The size of each batch. | ||
* @returns {Iterator} An iterator that yields batches of the specified size. | ||
*/ | ||
*batchIterator(batchSize) { | ||
let batch = []; | ||
for (const [key, value] of this) { | ||
batch.push([key, value]); | ||
if (batch.length === batchSize) { | ||
yield new RecordSet({ copyScopesFrom: this, iterable: batch }).freeze(); | ||
batch = []; | ||
} | ||
} | ||
if (batch.length) | ||
yield new RecordSet({ copyScopesFrom: this, iterable: batch }).freeze(); | ||
} | ||
/** | ||
* Returns a new record set with only the first n elements. | ||
@@ -556,11 +383,8 @@ * @param {Number} n The number of elements to keep. | ||
limit(n) { | ||
let records = []; | ||
for (const [key, value] of this) { | ||
records.push([key, value]); | ||
if (records.length === n) break; | ||
const newRecordSet = new RecordSet({ model: this.#model }); | ||
for (const [id, record] of this) { | ||
newRecordSet[$set](id, record); | ||
if (newRecordSet.size === n) break; | ||
} | ||
return new RecordSet({ | ||
iterable: records, | ||
copyScopesFrom: this, | ||
}).freeze(); | ||
return newRecordSet; | ||
} | ||
@@ -574,12 +398,9 @@ | ||
offset(n) { | ||
const newRecordSet = new RecordSet({ model: this.#model }); | ||
let counter = 0; | ||
let records = []; | ||
for (const [key, value] of this) { | ||
for (const [id, record] of this) { | ||
if (counter < n) counter++; | ||
else records.push([key, value]); | ||
else newRecordSet[$set](id, record); | ||
} | ||
return new RecordSet({ | ||
iterable: records, | ||
copyScopesFrom: this, | ||
}).freeze(); | ||
return newRecordSet; | ||
} | ||
@@ -596,6 +417,8 @@ | ||
slice(start, end) { | ||
return new RecordSet({ | ||
iterable: [...this.entries()].slice(start, end), | ||
copyScopesFrom: this, | ||
}).freeze(); | ||
return [...this.entries()] | ||
.slice(start, end) | ||
.reduce((newRecordSet, [id, record]) => { | ||
newRecordSet[$set](id, record); | ||
return newRecordSet; | ||
}, new RecordSet({ model: this.#model })); | ||
} | ||
@@ -607,3 +430,3 @@ | ||
get first() { | ||
for (const [, value] of this) return value; | ||
for (const [, record] of this) return record; | ||
return undefined; | ||
@@ -635,18 +458,21 @@ } | ||
/** | ||
* Returns an array of the records contained in the record set. | ||
* @returns {Array<Record>} An array of the values contained in the record set. | ||
* Returns a new Iterator object that contains the ids for each element in the | ||
* record set. | ||
*/ | ||
toArray() { | ||
return [...this.values()]; | ||
get ids() { | ||
return this.keys; | ||
} | ||
/** | ||
* Returns an array of objects representing the records in the record set. | ||
* @returns {Array<Object>} An array of objects representing the records in | ||
* Returns an array of the records contained in the record set. | ||
* @param {Object} options An object with options for the operation. | ||
* @param {Boolean} options.flat Whether to convert the records to objects. | ||
* @returns {Array<Record>/Array{Object}} An array of the values contained in | ||
* the record set. | ||
*/ | ||
toFlatArray() { | ||
return [...this.values()].map(value => | ||
value instanceof RecordGroup ? value.toFlatArray() : value.toObject() | ||
); | ||
toArray({ flat = false } = {}) { | ||
const values = [...this.values()]; | ||
if (flat) return values.map(value => value.toObject()); | ||
return values; | ||
} | ||
@@ -656,20 +482,15 @@ | ||
* Returns an object representing the record set. | ||
* @param {Object} options An object with options for the operation. | ||
* @param {Boolean} options.flat Whether to convert the records to objects. | ||
* @returns {Object} An object representing the record set. | ||
*/ | ||
toObject() { | ||
return [...this.entries()].reduce((obj, [key, value]) => { | ||
obj[key] = value; | ||
return obj; | ||
}, {}); | ||
} | ||
toObject({ flat = false } = {}) { | ||
if (flat) | ||
return [...this.entries()].reduce((obj, [id, value]) => { | ||
obj[id] = value.toObject(); | ||
return obj; | ||
}, {}); | ||
/** | ||
* Returns a flattened object of objects representing the records in the | ||
* record set. | ||
* @returns {Object} An object representing the records in the record set. | ||
*/ | ||
toFlatObject() { | ||
return [...this.entries()].reduce((obj, [key, value]) => { | ||
obj[key] = | ||
value instanceof RecordGroup ? value.toFlatArray() : value.toObject(); | ||
return [...this.entries()].reduce((obj, [id, value]) => { | ||
obj[id] = value; | ||
return obj; | ||
@@ -690,11 +511,3 @@ }, {}); | ||
get [Symbol.toStringTag]() { | ||
const records = [...this.values()]; | ||
try { | ||
const firstModel = records[0][$recordModel].name; | ||
if (allEqualBy(records, value => value[$recordModel].name === firstModel)) | ||
return firstModel; | ||
} catch (e) { | ||
return ''; | ||
} | ||
return ''; | ||
return this.#model.name; | ||
} | ||
@@ -709,14 +522,15 @@ | ||
[$addScope](name, scope, sortFn) { | ||
RecordSet.#validateProperty('Scope', name, scope, this.#scopes); | ||
if (sortFn) RecordSet.#validateFunction('Scope comparator', name, sortFn); | ||
if ( | ||
this[name] || | ||
Object.getOwnPropertyNames(RecordSet.prototype).includes(name) | ||
) | ||
throw new NameError(`Scope name ${name} is already in use.`); | ||
[$set](id, value) { | ||
super.set(id, value); | ||
return this; | ||
} | ||
this.#scopes.set(name, [scope, sortFn]); | ||
[$delete](id) { | ||
super.delete(id); | ||
return this; | ||
} | ||
[$addScope](name) { | ||
Object.defineProperty(this, name, { | ||
configurable: true, // Allows deletion in $removeScope | ||
configurable: false, // Prevents deletion | ||
get: () => { | ||
@@ -728,26 +542,13 @@ return this.#scopedWhere(name); | ||
[$removeScope](name) { | ||
this.#scopes.delete( | ||
RecordSet.#validateContains('Scope', name, this.#scopes) | ||
); | ||
delete this[name]; | ||
[$clearRecordSetForTesting]() { | ||
super.clear(); | ||
} | ||
[$setRecordKey](keyName) { | ||
this.#keyName = keyName; | ||
} | ||
get [$scopes]() { | ||
return this.#scopes; | ||
} | ||
// Private | ||
#copyScopes(otherRecordSet) { | ||
otherRecordSet[$scopes].forEach((scope, name) => { | ||
// No need to verify that the scope is valid, it must be verified by the | ||
// other record set already. | ||
this.#scopes.set(name, scope); | ||
#copyScopesFromModel() { | ||
this.#model[$scopes].forEach((scope, name) => { | ||
if (this[name]) return; | ||
Object.defineProperty(this, name, { | ||
configurable: true, // Allows deletion in $removeScope | ||
configurable: false, // Prevents deletion | ||
get: () => { | ||
@@ -761,39 +562,23 @@ return this.#scopedWhere(name); | ||
#scopedWhere(scopeName) { | ||
const [matcherFn, comparatorFn] = this.#scopes.get(scopeName); | ||
let matches = []; | ||
for (const [key, value] of this.entries()) | ||
if (matcherFn(value, key, this)) matches.push([key, value]); | ||
if (comparatorFn) | ||
matches.sort(([key1, value1], [key2, value2]) => | ||
comparatorFn(value1, value2, key1, key2) | ||
); | ||
return new RecordSet({ iterable: matches, copyScopesFrom: this }).freeze(); | ||
} | ||
const [matcherFn, comparatorFn] = this.#model[$scopes].get(scopeName); | ||
const newRecordSet = new RecordSet({ model: this.#model }); | ||
static #validateProperty(callbackType, callbackName, callback, callbacks) { | ||
if (typeof callback !== 'function') | ||
throw new TypeError(`${callbackType} ${callbackName} is not a function.`); | ||
if (comparatorFn) { | ||
let matches = []; | ||
for (const [id, record] of this) | ||
if (matcherFn(record, id, this)) matches.push([id, record]); | ||
if (comparatorFn) | ||
matches.sort(([id1, value1], [id2, value2]) => | ||
comparatorFn(value1, value2, id1, id2) | ||
); | ||
for (const [id, record] of matches) newRecordSet[$set](id, record); | ||
} else { | ||
for (const [id, record] of this) | ||
if (matcherFn(record, id, this)) newRecordSet[$set](id, record); | ||
} | ||
if (callbacks.has(callbackName)) | ||
throw new DuplicationError( | ||
`${callbackType} ${callbackName} already exists.` | ||
); | ||
return callback; | ||
return newRecordSet; | ||
} | ||
static #validateFunction(callbackType, callbackName, callback) { | ||
if (typeof callback !== 'function') | ||
throw new TypeError(`${callbackType} ${callbackName} is not a function.`); | ||
return callback; | ||
} | ||
static #validateContains(objectType, objectName, objects) { | ||
if (!objects.has(objectName)) | ||
throw new ReferenceError(`${objectType} ${objectName} does not exist.`); | ||
return objectName; | ||
} | ||
} | ||
export default RecordSet; |
import { Field } from 'src/field'; | ||
import { Schema } from 'src/schema'; | ||
import { DefaultValueError, DuplicationError } from 'src/errors'; | ||
import { DuplicationError } from 'src/errors'; | ||
import { Model } from 'src/model'; | ||
import { validateName, reverseCapitalize } from 'src/utils'; | ||
import types from 'src/types'; | ||
import { recordId, recordIdArray } from 'src/types'; | ||
import symbols from 'src/symbols'; | ||
const { | ||
$key, | ||
$recordValue, | ||
@@ -16,3 +15,2 @@ $fields, | ||
$get, | ||
$defaultValue, | ||
$instances, | ||
@@ -62,7 +60,3 @@ $handleExperimentalAPIMessage, | ||
this.#relationshipField = Relationship.#createField( | ||
this.#name, | ||
this.#type, | ||
this.#to[$key] | ||
); | ||
this.#relationshipField = Relationship.#createField(this.#name, this.#type); | ||
@@ -123,3 +117,3 @@ this.#relationshipProperty = record => { | ||
#getAssociatedRecordsReverse(record) { | ||
const associationValue = record[this.#to[$key].name]; | ||
const associationValue = record.id; | ||
const matcher = Relationship.#isToOne(this.#type) | ||
@@ -170,19 +164,10 @@ ? associatedRecord => | ||
static #createField(name, relationshipType, foreignField) { | ||
static #createField(name, relationshipType) { | ||
// TODO: V2 enhancements | ||
// Potentially add a check if the other model contains the key(s)? | ||
const isSingleSource = Relationship.#isFromOne(relationshipType); | ||
// Potentially add a check if the other model contains the ids(s)? | ||
const isMultiple = Relationship.#isToMany(relationshipType); | ||
const type = isMultiple | ||
? types.arrayOf(value => foreignField.typeCheck(value)) | ||
: value => foreignField.typeCheck(value); | ||
const type = isMultiple ? recordIdArray : recordId; | ||
// TODO: V2 enhancements | ||
// Add a custom validator for symmetric relationships to ensure that a | ||
// Add a check for symmetric relationships to ensure that a | ||
// record does not reference itself in the relationship, creating a loop. | ||
const validators = {}; | ||
// oneToOne means that for each record in the to model, there is at most | ||
// one record in the from model. No overlap. | ||
if (isSingleSource && !isMultiple) validators.unique = true; | ||
// toMany relationships are not allowed to have duplicate values. | ||
if (isMultiple) validators.uniqueValues = true; | ||
@@ -192,14 +177,3 @@ const relationshipField = new Field({ | ||
type, | ||
required: false, | ||
defaultValue: isMultiple ? [] : null, | ||
validators, | ||
}); | ||
// Override the default value to throw an error | ||
Object.defineProperty(relationshipField, $defaultValue, { | ||
get() { | ||
throw new DefaultValueError( | ||
'Relationship field does not have a default value.' | ||
); | ||
}, | ||
}); | ||
@@ -237,5 +211,3 @@ return relationshipField; | ||
const name = | ||
typeof modelData === 'string' | ||
? null | ||
: validateName('Field', modelData.name); | ||
typeof modelData === 'string' ? null : validateName(modelData.name); | ||
if (name !== null && model[$fields].has(name)) | ||
@@ -242,0 +214,0 @@ throw new DuplicationError( |
@@ -1,2 +0,1 @@ | ||
import EventEmitter from 'events'; | ||
import { Model } from 'src/model'; | ||
@@ -6,7 +5,3 @@ import { Relationship } from 'src/relationship'; | ||
import { ExperimentalAPIUsageError } from 'src/errors'; | ||
import { | ||
capitalize, | ||
validateObjectWithUniqueName, | ||
validateName, | ||
} from 'src/utils'; | ||
import { validateObjectWithUniqueName, validateName } from 'src/utils'; | ||
import symbols from 'src/symbols'; | ||
@@ -18,19 +13,12 @@ | ||
$handleExperimentalAPIMessage, | ||
$key, | ||
$keyType, | ||
$instances, | ||
$clearCachedProperties, | ||
$clearSchemaForTesting, | ||
$schemaObject, | ||
} = symbols; | ||
/** | ||
* A Schema is a collection of models. | ||
* @extends EventEmitter | ||
* @param {Object} options Schema options | ||
* @param {String} options.name The name of the schema | ||
* @param {Array<Object>} options.models An object containing initial models for | ||
* the schema. | ||
*/ | ||
export class Schema extends EventEmitter { | ||
#name; | ||
#models; | ||
#serializers; | ||
export class Schema { | ||
static #models = new Map(); | ||
static #serializers = new Map(); | ||
static #schemaObject = {}; | ||
static #instantiated = false; | ||
@@ -45,6 +33,8 @@ static defaultConfig = { | ||
static #schemas = new Map(); | ||
constructor({ | ||
name, | ||
/** | ||
* Creates a new schema with the given name and options. | ||
* @param {Object} schemaData Data for the schema to be created. | ||
* @returns The schema singleton. | ||
*/ | ||
static create({ | ||
models = [], | ||
@@ -55,81 +45,55 @@ relationships = [], | ||
} = {}) { | ||
super(); | ||
this.#name = validateName('Schema', name); | ||
this.#models = new Map(); | ||
this.#serializers = new Map(); | ||
if (Schema.#instantiated) | ||
throw new Error('Only one schema can be created.'); | ||
Schema.#parseConfig(config); | ||
Schema.#schemas.set(this.#name, this); | ||
models.forEach(model => this.createModel(model)); | ||
models.forEach(modelData => { | ||
// Perform name validation for fields, properties and methods here | ||
// to exit if something is wrong. | ||
const { fields = {}, properties = {}, methods = {} } = modelData; | ||
const names = [ | ||
...Object.keys(fields), | ||
...Object.keys(properties), | ||
...Object.keys(methods), | ||
]; | ||
const uniqueNames = new Set(names); | ||
if (uniqueNames.size !== names.length) | ||
throw new Error( | ||
`Model ${modelData.name} has duplicate field, property or method names.` | ||
); | ||
names.forEach(name => validateName(name)); | ||
Schema.#createModel(modelData); | ||
}); | ||
relationships.forEach(relationship => | ||
this.createRelationship(relationship) | ||
Schema.#createRelationship(relationship) | ||
); | ||
serializers.forEach(serializer => this.createSerializer(serializer)); | ||
// Kind of experimental | ||
serializers.forEach(serializer => Schema.#createSerializer(serializer)); | ||
// Lazy properties, models and serializers require initial set up as they | ||
// depend on other models or serializers. | ||
const schemaData = { | ||
models: Object.fromEntries([...this.#models.entries()]), | ||
serializers: Object.fromEntries([...this.#serializers.entries()]), | ||
Schema.#schemaObject = { | ||
models: Object.fromEntries([...Schema.#models.entries()]), | ||
serializers: Object.fromEntries([...Schema.#serializers.entries()]), | ||
}; | ||
models.forEach(model => { | ||
const modelRecord = this.getModel(model.name); | ||
const cachedProperties = model.cacheProperties || []; | ||
if (model.lazyProperties) | ||
Object.entries(model.lazyProperties).forEach( | ||
([propertyName, propertyInitializer]) => { | ||
modelRecord.addProperty( | ||
propertyName, | ||
propertyInitializer(schemaData), | ||
cachedProperties.includes(propertyName) | ||
); | ||
} | ||
); | ||
if (model.lazyMethods) | ||
Object.entries(model.lazyMethods).forEach( | ||
([methodName, methodInitializer]) => { | ||
modelRecord.addMethod(methodName, methodInitializer(schemaData)); | ||
} | ||
); | ||
}); | ||
Schema.#instantiated = true; | ||
serializers.forEach(serializer => { | ||
const serializerRecord = this.getSerializer(serializer.name); | ||
if (serializer.lazyMethods) { | ||
Object.entries(serializer.lazyMethods).forEach( | ||
([methodName, methodInitializer]) => { | ||
serializerRecord.addMethod( | ||
methodName, | ||
methodInitializer(schemaData) | ||
); | ||
} | ||
); | ||
} | ||
}); | ||
return Schema; | ||
} | ||
/** | ||
* Creates a model and adds it to the schema. | ||
* @param {Object} modelData Data for the model to be added. | ||
* @returns The newly created model. | ||
* Clears all cached properties of all models. | ||
* @returns The schema singleton. | ||
*/ | ||
createModel(modelData) { | ||
this.emit('beforeCreateModel', { model: modelData, schema: this }); | ||
const model = Schema.#parseModel(this.#name, modelData, this.#models); | ||
static clearPropertyCache() { | ||
Schema[$handleExperimentalAPIMessage]( | ||
'Clearing the property cache of all models should only be done if something is known to have caused the cache to contain stale data. Please use with caution.' | ||
); | ||
Schema.#models.forEach(model => model[$clearCachedProperties]()); | ||
this.#models.set(model.name, model); | ||
model.on('change', ({ type, ...eventData }) => { | ||
this.emit('change', { | ||
type: `model${capitalize(type)}`, | ||
...eventData, | ||
schema: this, | ||
}); | ||
}); | ||
this.emit('modelCreated', { model, schema: this }); | ||
this.emit('change', { type: 'modelCreated', model, schema: this }); | ||
return model; | ||
return Schema; | ||
} | ||
@@ -142,82 +106,7 @@ | ||
*/ | ||
getModel(name) { | ||
return this.#models.get(name); | ||
static getModel(name) { | ||
return Schema.#models.get(name); | ||
} | ||
/** | ||
* Removes a model from the schema. | ||
* @param {String} name The name of the model to remove. | ||
*/ | ||
removeModel(name) { | ||
const model = this.getModel(name); | ||
this.emit('beforeRemoveModel', { model, schema: this }); | ||
if (!this.#models.has(name)) | ||
throw new ReferenceError( | ||
`Model ${name} does not exist in schema ${this.#name}.` | ||
); | ||
this.#models.delete(name); | ||
Model[$instances].delete(name); | ||
// TODO: V2 enhancements | ||
// Figure out a way to add cascade for relationships | ||
this.emit('modelRemoved', { model: { name }, schema: this }); | ||
this.emit('change', { type: 'modelRemoved', model, schema: this }); | ||
} | ||
/** | ||
* EXPERIMENTAL | ||
* Creates a relationship between two models and adds it to the schema. | ||
* @param {Object} relationshipData Data for the relationship to be added. | ||
* @returns The newly created relationship. | ||
*/ | ||
createRelationship(relationshipData) { | ||
this.emit('beforeCreateRelationship', { | ||
relationship: relationshipData, | ||
schema: this, | ||
}); | ||
const relationship = Schema.#applyRelationship( | ||
this.#name, | ||
relationshipData, | ||
this.#models | ||
); | ||
this.emit('relationshipCreated', { relationship, schema: this }); | ||
this.emit('change', { | ||
type: 'relationshipCreated', | ||
relationship, | ||
schema: this, | ||
}); | ||
return relationship; | ||
} | ||
/** | ||
* Creates a serializer and adds it to the schema. | ||
* @param {Object} serializerData Data for the serializer to be added. | ||
* @returns The newly created serializer. | ||
*/ | ||
createSerializer(serializerData) { | ||
this.emit('beforeCreateSerializer', { | ||
serializer: serializerData, | ||
schema: this, | ||
}); | ||
const serializer = Schema.#parseSerializer( | ||
this.#name, | ||
serializerData, | ||
this.#serializers | ||
); | ||
this.#serializers.set(serializer.name, serializer); | ||
this.emit('serializerCreated', { serializer, schema: this }); | ||
this.emit('change', { | ||
type: 'serializerCreated', | ||
serializer, | ||
schema: this, | ||
}); | ||
return serializer; | ||
} | ||
/** | ||
* Retrieves a serializer from the schema. | ||
@@ -227,38 +116,17 @@ * @param {String} name The name of the serializer to retrieve. | ||
*/ | ||
getSerializer(name) { | ||
return this.#serializers.get(name); | ||
static getSerializer(name) { | ||
return Schema.#serializers.get(name); | ||
} | ||
/** | ||
* Gets the schema's name. | ||
*/ | ||
get name() { | ||
return this.#name; | ||
} | ||
/** | ||
* Gets all models in the schema. | ||
*/ | ||
get models() { | ||
return this.#models; | ||
static get models() { | ||
return Schema.#models; | ||
} | ||
// TODO: Make users use this instead of the constructor, using a private flag. | ||
// Use another private flag to throw if more than one schema is created | ||
// (not supported for this release). | ||
/** | ||
* Creates a new schema with the given name and options. | ||
* @param {Object} schemaData Data for the schema to be created. | ||
* @returns The newly created schema. | ||
*/ | ||
static create(schemaData) { | ||
return new Schema(schemaData); | ||
static get [$schemaObject]() { | ||
return Schema.#schemaObject; | ||
} | ||
// TODO: V2 enhancements | ||
// Check validity of this. Currently it's not mentioned anywhere in the docs. | ||
static get(name) { | ||
return Schema.#schemas.get(name); | ||
} | ||
/** | ||
@@ -269,17 +137,13 @@ * Retrieves the data specified by the given pathName | ||
*/ | ||
get(pathName) { | ||
this.emit('beforeGet', { pathName, schema: this }); | ||
const [modelName, recordKey, ...rest] = pathName.split('.'); | ||
const model = this.getModel(modelName); | ||
static get(pathName) { | ||
const [modelName, recordId, ...rest] = pathName.split('.'); | ||
const model = Schema.getModel(modelName); | ||
if (!model) | ||
throw new ReferenceError( | ||
`Model ${modelName} does not exist in schema ${this.#name}.` | ||
`Model ${modelName} does not exist in the schema.` | ||
); | ||
if (recordKey === undefined) return model; | ||
const keyType = model[$key][$keyType]; | ||
const record = model.records.get( | ||
keyType === 'string' ? recordKey : Number.parseInt(recordKey) | ||
); | ||
if (recordId === undefined) return model; | ||
const record = model.records.get(recordId); | ||
@@ -290,7 +154,6 @@ if (!rest.length) return record; | ||
throw new ReferenceError( | ||
`Record ${recordKey} does not exist in model ${modelName}.` | ||
`Record ${recordId} does not exist in model ${modelName}.` | ||
); | ||
const result = rest.reduce((acc, key) => acc[key], record); | ||
this.emit('got', { pathName, result, schema: this }); | ||
return result; | ||
@@ -300,2 +163,3 @@ } | ||
// Protected (package internal-use only) | ||
/* istanbul ignore next */ | ||
@@ -311,18 +175,35 @@ static [$handleExperimentalAPIMessage](message) { | ||
/* istanbul ignore next */ | ||
static [$clearSchemaForTesting]() { | ||
Schema.#models.clear(); | ||
Schema.#serializers.clear(); | ||
Schema.#schemaObject = {}; | ||
Schema.#instantiated = false; | ||
} | ||
// Private | ||
static #parseModel(schemaName, modelData, models) { | ||
static #createModel(modelData) { | ||
const modelName = validateName(modelData.name); | ||
validateObjectWithUniqueName( | ||
{ | ||
objectType: 'Model', | ||
parentType: 'Schema', | ||
parentName: schemaName, | ||
}, | ||
{ objectType: 'Model', parentType: 'Schema' }, | ||
modelData, | ||
[...models.keys()] | ||
[...Schema.#models.keys()] | ||
); | ||
return new Model(modelData); | ||
const model = new Model(modelData); | ||
Schema.#models.set(modelName, model); | ||
} | ||
static #applyRelationship(schemName, relationshipData, models) { | ||
static #createSerializer(serializerData) { | ||
const serializerName = validateName(serializerData.name); | ||
validateObjectWithUniqueName( | ||
{ objectType: 'Serializer', parentType: 'Schema' }, | ||
serializerData, | ||
[...Schema.#serializers.keys()] | ||
); | ||
const serializer = new Serializer(serializerData); | ||
Schema.#serializers.set(serializerName, serializer); | ||
} | ||
static #createRelationship(relationshipData) { | ||
const { from, to, type /* , cascade */ } = relationshipData; | ||
@@ -337,11 +218,11 @@ [from, to].forEach(model => { | ||
const fromModel = models.get(fromModelName); | ||
const toModel = models.get(toModelName); | ||
const fromModel = Schema.#models.get(fromModelName); | ||
const toModel = Schema.#models.get(toModelName); | ||
if (!fromModel) | ||
throw new ReferenceError( | ||
`Model ${fromModelName} not found in schema ${schemName} when attempting to create a relationship.` | ||
`Model ${fromModelName} not found in schema when attempting to create a relationship.` | ||
); | ||
if (!toModel) | ||
throw new ReferenceError( | ||
`Model ${toModelName} not found in schema ${schemName} when attempting to create a relationship.` | ||
`Model ${toModelName} not found in schema when attempting to create a relationship.` | ||
); | ||
@@ -353,19 +234,4 @@ | ||
toModel[$addRelationshipAsProperty](relationship); | ||
return relationship; | ||
} | ||
static #parseSerializer(schemaName, serializerData, serializers) { | ||
validateObjectWithUniqueName( | ||
{ | ||
objectType: 'Serializer', | ||
parentType: 'Schema', | ||
parentName: schemaName, | ||
}, | ||
serializerData, | ||
[...serializers.keys()] | ||
); | ||
return new Serializer(serializerData); | ||
} | ||
static #parseConfig(config = {}) { | ||
@@ -380,15 +246,4 @@ if (!config) return; | ||
} | ||
// TODO: V2 enhancements | ||
// Add a mechanism here so that plugins can hook up to the schema via the | ||
// event API or other stuff. Generally, the Schema is the de facto entrypoint | ||
// of the library, so we should make sure that all plugins interface with it. | ||
// | ||
// Alternatively, we could have a wrapper around the Schema, which might be | ||
// preferable as we can have multiple schemas and hook up events more easily. | ||
// | ||
// We also need a way to modularize and granularize the logging/erroring. A | ||
// wrapper would allow us to specify this across. | ||
} | ||
export default Schema; |
import { DuplicationError } from 'src/errors'; | ||
import { validateName } from 'src/utils'; | ||
@@ -10,5 +9,3 @@ export class Serializer { | ||
constructor({ name, attributes = [], methods = {} }) { | ||
// TODO: V2 Enhancements | ||
// This check here is not necessary. We might be able to get rid of it. | ||
this.#name = validateName('Serializer', name); | ||
this.#name = name; | ||
this.#attributes = new Map(); | ||
@@ -28,17 +25,7 @@ this.#methods = new Map(); | ||
Object.entries(methods).forEach(([methodName, methodBody]) => { | ||
this.addMethod(methodName, methodBody); | ||
this.#addMethod(methodName, methodBody); | ||
}); | ||
} | ||
addMethod(methodName, methodBody) { | ||
const method = Serializer.#validateFunction(methodName, methodBody, [ | ||
...this.#methods.keys(), | ||
]); | ||
this.#methods.set(methodName, method); | ||
} | ||
serialize(object, options) { | ||
// TODO: V2 Enhancements | ||
// Add a way to bind the serializer to a specific model. Then add a check | ||
// here that validates that the passed object is a record of said model. | ||
const serialized = {}; | ||
@@ -56,3 +43,2 @@ this.#attributes.forEach((attributeValue, attributeName) => { | ||
// The result is an object with each key mapped to a serialized object. | ||
// Hidden feature I guess? | ||
serializeArray(objects, options) { | ||
@@ -78,2 +64,9 @@ return objects.map(object => this.serialize(object, options)); | ||
#addMethod(methodName, methodBody) { | ||
const method = Serializer.#validateFunction(methodName, methodBody, [ | ||
...this.#methods.keys(), | ||
]); | ||
this.#methods.set(methodName, method); | ||
} | ||
static #validateAttribute(attributeName, restrictedNames) { | ||
@@ -80,0 +73,0 @@ if (typeof attributeName !== 'string') |
@@ -14,4 +14,2 @@ /** | ||
'fields', | ||
'key', | ||
'keyType', | ||
'properties', | ||
@@ -23,3 +21,2 @@ 'cachedProperties', | ||
'relationshipField', | ||
'validators', | ||
'recordModel', | ||
@@ -30,4 +27,3 @@ 'recordValue', | ||
'recordTag', | ||
'setRecordKey', | ||
'defaultValue', | ||
'emptyRecordTemplate', | ||
'addScope', | ||
@@ -38,8 +34,14 @@ 'addRelationshipAsField', | ||
'getProperty', | ||
'removeScope', | ||
'isDateField', | ||
'instances', | ||
'isRecord', | ||
'groupTag', | ||
'set', | ||
'delete', | ||
'get', | ||
'handleExperimentalAPIMessage' | ||
'handleExperimentalAPIMessage', | ||
'clearSchemaForTesting', | ||
'clearCachedProperties', | ||
'clearRecordSetForTesting', | ||
'schemaObject' | ||
); |
@@ -19,85 +19,24 @@ const isBoolean = val => typeof val === 'boolean'; | ||
const isPositive = val => val >= 0; | ||
const isArrayOf = type => val => Array.isArray(val) && val.every(type); | ||
const isOrIsArrayOf = type => val => or(isArrayOf(type), type)(val); | ||
const isObject = val => typeof val === 'object'; | ||
const isObject = shape => { | ||
const props = Object.keys(shape); | ||
return val => { | ||
if (val === null || val === undefined || typeof val !== 'object') | ||
return false; | ||
if (props.length === 0) return true; | ||
const valProps = Object.keys(val); | ||
if (valProps.length !== props.length) return false; | ||
return props.every(prop => shape[prop](val[prop])); | ||
}; | ||
}; | ||
const isObjectOf = type => val => { | ||
if (val === null || val === undefined || typeof val !== 'object') | ||
return false; | ||
return Object.keys(val).every(prop => type(val[prop])); | ||
}; | ||
const isEnum = | ||
(...values) => | ||
val => | ||
values.includes(val); | ||
const isNull = val => val === null; | ||
const isUndefined = val => val === undefined; | ||
const hasUniqueValues = arr => new Set(arr).size === arr.length; | ||
const isNil = or(isNull, isUndefined); | ||
export const isUndefined = val => val === undefined; | ||
const isOptional = type => val => or(isNil, type)(val); | ||
export const isOptional = type => val => or(isNull, type)(val); | ||
export default { | ||
// Primitive types | ||
bool: isBoolean, | ||
number: isNumber, | ||
positiveNumber: and(isNumber, isPositive), | ||
string: isString, | ||
date: isDate, | ||
// Special types | ||
stringOrNumber: or(isString, isNumber), | ||
numberOrString: or(isString, isNumber), | ||
enum: isEnum, | ||
boolArray: isArrayOf(isBoolean), | ||
numberArray: isArrayOf(isNumber), | ||
stringArray: isArrayOf(isString), | ||
dateArray: isArrayOf(isDate), | ||
// Composition types | ||
oneOf: or, | ||
arrayOf: isArrayOf, | ||
oneOrArrayOf: isOrIsArrayOf, | ||
object: isObject, | ||
objectOf: isObjectOf, | ||
optional: isOptional, | ||
// Empty types | ||
null: isNull, | ||
undefined: isUndefined, | ||
nil: isNil, | ||
}; | ||
export const standardTypes = { | ||
boolean: { type: isBoolean, defaultValue: false }, | ||
number: { type: isNumber, defaultValue: 0 }, | ||
positiveNumber: { type: and(isNumber, isPositive), defaultValue: 0 }, | ||
string: { type: isString, defaultValue: '' }, | ||
date: { type: isDate, defaultValue: new Date() }, | ||
stringOrNumber: { type: or(isString, isNumber), defaultValue: '' }, | ||
numberOrString: { type: or(isString, isNumber), defaultValue: 0 }, | ||
booleanArray: { type: isArrayOf(isBoolean), defaultValue: [] }, | ||
numberArray: { type: isArrayOf(isNumber), defaultValue: [] }, | ||
stringArray: { type: isArrayOf(isString), defaultValue: [] }, | ||
dateArray: { type: isArrayOf(isDate), defaultValue: [] }, | ||
object: { type: isObject({}), defaultValue: {} }, | ||
booleanObject: { type: isObjectOf(isBoolean), defaultValue: { a: true } }, | ||
numberObject: { type: isObjectOf(isNumber), defaultValue: {} }, | ||
stringObject: { type: isObjectOf(isString), defaultValue: {} }, | ||
dateObject: { type: isObjectOf(isDate), defaultValue: {} }, | ||
objectArray: { type: isArrayOf(isObject({})), defaultValue: [] }, | ||
boolean: { type: isBoolean }, | ||
number: { type: isNumber }, | ||
string: { type: isString }, | ||
date: { type: isDate }, | ||
booleanArray: { type: isArrayOf(isBoolean) }, | ||
numberArray: { type: isArrayOf(isNumber) }, | ||
stringArray: { type: isArrayOf(isString) }, | ||
dateArray: { type: isArrayOf(isDate) }, | ||
object: { type: isObject }, | ||
}; | ||
@@ -107,2 +46,3 @@ | ||
const isNonEmptyString = val => val.trim().length !== 0; | ||
export const key = and(isString, isNonEmptyString); | ||
export const recordId = and(isString, isNonEmptyString); | ||
export const recordIdArray = and(isArrayOf(recordId), hasUniqueValues); |
import { DuplicationError, NameError } from 'src/errors'; | ||
// Name validation | ||
// TODO: 'records' can be a bit of a loose gun here. | ||
const restrictedNames = ['toString', 'toObject', 'toJSON', 'id']; | ||
const restrictedNames = { | ||
Model: ['toString', 'toObject', 'toJSON'], | ||
Field: ['toString', 'toObject', 'toJSON'], | ||
Property: ['toString', 'toObject', 'toJSON'], | ||
Method: ['toString', 'toObject', 'toJSON'], | ||
Relationship: ['toString', 'toObject', 'toJSON'], | ||
}; | ||
/** | ||
@@ -21,8 +15,7 @@ * Validates the name of a field or model. | ||
* @param {string} name The name of the field or model to validate. | ||
* @param {Array<string>} restrictedNames An array of restricted names. | ||
* @returns {boolean} Whether the name is valid. | ||
*/ | ||
const isValidName = (name, restrictedNames = []) => { | ||
const isValidName = name => { | ||
if (typeof name !== 'string') return [false, 'must be a string']; | ||
if (!name) return [false, 'is required']; | ||
if (!name) return [false, 'cannot be empty']; | ||
if (/^\d/.test(name)) return [false, 'cannot start with a number']; | ||
@@ -43,3 +36,2 @@ if (restrictedNames.includes(name)) return [false, 'is reserved']; | ||
* - Must contain only alphanumeric characters, numbers or underscores | ||
* @param {string} objectType The type of object to validate. | ||
* @param {string} name The name of the field or model to validate. | ||
@@ -49,5 +41,5 @@ * @throws {NameError} If the name is invalid. | ||
*/ | ||
export const validateName = (objectType, name) => { | ||
const [isValid, message] = isValidName(name, restrictedNames[objectType]); | ||
if (!isValid) throw new NameError(`${objectType} name ${message}.`); | ||
export const validateName = name => { | ||
const [isValid, message] = isValidName(name); | ||
if (!isValid) throw new NameError(`Name "${name}" is invalid - ${message}.`); | ||
return name; | ||
@@ -58,5 +50,2 @@ }; | ||
export const capitalize = ([first, ...rest]) => | ||
first.toUpperCase() + rest.join(''); | ||
export const reverseCapitalize = ([first, ...rest]) => | ||
@@ -66,2 +55,3 @@ first.toLowerCase() + rest.join(''); | ||
export const deepClone = obj => { | ||
if (typeof obj !== 'object') return obj; | ||
if (obj === null) return null; | ||
@@ -81,11 +71,4 @@ if (obj instanceof Date) return new Date(obj); | ||
export const allEqualBy = (arr, fn) => { | ||
const eql = fn(arr[0]); | ||
return arr.every(val => fn(val) === eql); | ||
}; | ||
export const isObject = obj => obj && typeof obj === 'object'; | ||
export const contains = (collection, item) => collection.includes(item); | ||
export const validateObjectWithUniqueName = ( | ||
@@ -98,9 +81,11 @@ { objectType, parentType, parentName }, | ||
throw new TypeError(`${objectType} ${obj} is not an object.`); | ||
if (contains(collection, obj.name)) | ||
if (collection.includes(obj.name)) { | ||
const namedType = parentName ? `${parentType} ${parentName}` : parentType; | ||
throw new DuplicationError( | ||
`${parentType} ${parentName} already has a ${objectType.toLowerCase()} named ${ | ||
`${namedType} already has a ${objectType.toLowerCase()} named ${ | ||
obj.name | ||
}.` | ||
); | ||
} | ||
return true; | ||
}; |
import { Field } from 'src/field'; | ||
import types, { standardTypes } from 'src/types'; | ||
import symbols from 'src/symbols'; | ||
import { standardTypes } from 'src/types'; | ||
const { $defaultValue, $validators } = symbols; | ||
describe('Field', () => { | ||
it('throws if "name" is invalid', () => { | ||
expect(() => new Field({ name: null })).toThrow(); | ||
expect(() => new Field({ name: undefined })).toThrow(); | ||
expect(() => new Field({ name: '' })).toThrow(); | ||
expect(() => new Field({ name: ' ' })).toThrow(); | ||
expect(() => new Field({ name: '1' })).toThrow(); | ||
expect(() => new Field({ name: 'a&1*b' })).toThrow(); | ||
}); | ||
it('throws if "required" is invalid', () => { | ||
expect(() => new Field({ name: 'a', required: null })).toThrow(); | ||
expect(() => new Field({ name: 'a', required: undefined })).toThrow(); | ||
expect(() => new Field({ name: 'a', required: 'a' })).toThrow(); | ||
}); | ||
it('throws if "type" is invalid', () => { | ||
expect(() => new Field({ type: null })).toThrow(); | ||
expect(() => new Field({ type: undefined })).toThrow(); | ||
expect(() => new Field({ type: 'a' })).toThrow(); | ||
}); | ||
describe('when arguments are valid', () => { | ||
@@ -39,6 +15,2 @@ let field; | ||
it('is not required', () => { | ||
expect(field.required).toBe(false); | ||
}); | ||
it('correctly checks values based on the given type', () => { | ||
@@ -51,130 +23,4 @@ expect(field.typeCheck('test')).toBe(true); | ||
expect(field.typeCheck(null)).toBe(true); | ||
expect(field.typeCheck(undefined)).toBe(true); | ||
expect(field.typeCheck(undefined)).toBe(false); | ||
}); | ||
describe('when the field is required', () => { | ||
beforeEach(() => { | ||
field = new Field({ | ||
name: 'myField', | ||
type: x => x === 'test', | ||
required: true, | ||
defaultValue: 'test', | ||
}); | ||
}); | ||
it('throws if "defaultValue" is invalid', () => { | ||
expect( | ||
() => | ||
new Field({ | ||
name: 'myField', | ||
type: x => x === 'test', | ||
required: true, | ||
}) | ||
).toThrow(); | ||
expect( | ||
() => | ||
new Field({ | ||
name: 'myField', | ||
type: x => x === 'test', | ||
required: true, | ||
defaultValue: 'test2', | ||
}) | ||
).toThrow(); | ||
}); | ||
it('is required', () => { | ||
expect(field.required).toBe(true); | ||
}); | ||
it('correctly checks values based on the given type', () => { | ||
expect(field.typeCheck('test')).toBe(true); | ||
expect(field.typeCheck('test2')).toBe(false); | ||
}); | ||
it('correctly checks empty values', () => { | ||
expect(field.typeCheck(null)).toBe(false); | ||
expect(field.typeCheck(undefined)).toBe(false); | ||
}); | ||
}); | ||
describe('when validators are specified', () => { | ||
it('throws if the validator is invalid', () => { | ||
expect( | ||
() => | ||
new Field({ | ||
name: 'myField', | ||
type: x => x === 'test', | ||
validators: { nonExistent: null }, | ||
}) | ||
).toThrow(); | ||
expect( | ||
() => | ||
new Field({ | ||
name: 'myField', | ||
type: x => x === 'test', | ||
validators: { nonExistent: 1 }, | ||
}) | ||
).toThrow(); | ||
}); | ||
it('adds an existing validator to the field validators', () => { | ||
field = new Field({ | ||
name: 'myField', | ||
type: x => typeof x === 'string', | ||
validators: { | ||
minLength: 2, | ||
}, | ||
}); | ||
expect(field[$validators].size).toBe(1); | ||
const validator = field[$validators].get('myFieldMinLength'); | ||
expect(validator).not.toBe(undefined); | ||
expect(validator({ myField: 'a' })).toBe(false); | ||
expect(validator({ myField: 'ab' })).toBe(true); | ||
}); | ||
it('adds a custom validator to the field validators', () => { | ||
field = new Field({ | ||
name: 'myField', | ||
type: x => typeof x === 'string', | ||
validators: { | ||
startsWithTest: x => x.startsWith('test'), | ||
}, | ||
}); | ||
expect(field[$validators].size).toBe(1); | ||
const validator = field[$validators].get('myFieldStartsWithTest'); | ||
expect(validator).not.toBe(undefined); | ||
expect(validator({ myField: 'a test' }, [])).toBe(false); | ||
expect(validator({ myField: 'test a' }, [])).toBe(true); | ||
}); | ||
}); | ||
// Indirectly cover any leftover types | ||
describe('with "objectOf" type', () => { | ||
beforeEach(() => { | ||
field = new Field({ | ||
name: 'myField', | ||
type: types.objectOf(types.number), | ||
}); | ||
}); | ||
it('correctly checks values based on the given type', () => { | ||
expect(field.typeCheck({ a: 1, b: 2 })).toBe(true); | ||
expect(field.typeCheck({ a: '1', b: 2 })).toBe(false); | ||
}); | ||
}); | ||
describe('with "object" type', () => { | ||
beforeEach(() => { | ||
field = new Field({ | ||
name: 'myField', | ||
type: types.object({ name: types.string }), | ||
}); | ||
}); | ||
it('correctly checks values based on the given type', () => { | ||
expect(field.typeCheck({ name: 'a' })).toBe(true); | ||
expect(field.typeCheck({ name: 1 })).toBe(false); | ||
expect(field.typeCheck({ name: 'a', b: 1 })).toBe(false); | ||
}); | ||
}); | ||
}); | ||
@@ -184,2 +30,13 @@ | ||
const standardTypesEntries = Object.entries(standardTypes); | ||
const standardTypesTestValues = { | ||
boolean: false, | ||
number: 0, | ||
string: '', | ||
date: new Date(), | ||
booleanArray: [], | ||
numberArray: [], | ||
stringArray: [], | ||
dateArray: [], | ||
object: {}, | ||
}; | ||
@@ -192,122 +49,10 @@ test.each(standardTypesEntries)('%s is defined', typeName => { | ||
'%s accepts a string as a name and returns a Field of the appropriate type', | ||
(typeName, standardType) => { | ||
typeName => { | ||
const field = Field[typeName]('myField'); | ||
expect(field).toBeInstanceOf(Field); | ||
expect(field.name).toBe('myField'); | ||
expect(field.required).toBe(false); | ||
expect(field.typeCheck(standardType.defaultValue)).toBe(true); | ||
expect(field.typeCheck(standardTypesTestValues[typeName])).toBe(true); | ||
} | ||
); | ||
test.each(standardTypesEntries)( | ||
'%s accepts a valid object and returns a Field of the appropriate type', | ||
(typeName, standardType) => { | ||
const field = Field[typeName]({ | ||
name: 'myField', | ||
defaultValue: standardType.defaultValue, | ||
}); | ||
expect(field).toBeInstanceOf(Field); | ||
expect(field.name).toBe('myField'); | ||
expect(field[$defaultValue]).toBe(standardType.defaultValue); | ||
expect(field.typeCheck(standardType.defaultValue)).toBe(true); | ||
} | ||
); | ||
test.each(standardTypesEntries)('%sRequired is defined', typeName => { | ||
expect(Field[`${typeName}Required`]).toBeDefined(); | ||
}); | ||
test.each(standardTypesEntries)( | ||
'%sRequired accepts a string as a name and returns a Field of the appropriate type', | ||
(typeName, standardType) => { | ||
const field = Field[`${typeName}Required`]('myField'); | ||
expect(field).toBeInstanceOf(Field); | ||
expect(field.name).toBe('myField'); | ||
expect(field.required).toBe(true); | ||
expect(field[$defaultValue]).toBe(standardType.defaultValue); | ||
expect(field.typeCheck(standardType.defaultValue)).toBe(true); | ||
expect(field.typeCheck(null)).toBe(false); | ||
} | ||
); | ||
test.each(standardTypesEntries)( | ||
'%sRequired accepts a valid object and returns a Field of the appropriate type', | ||
(typeName, standardType) => { | ||
const field = Field[`${typeName}Required`]({ | ||
name: 'myField', | ||
defaultValue: standardType.defaultValue, | ||
}); | ||
expect(field).toBeInstanceOf(Field); | ||
expect(field.name).toBe('myField'); | ||
expect(field.required).toBe(true); | ||
expect(field[$defaultValue]).toBe(standardType.defaultValue); | ||
expect(field.typeCheck(standardType.defaultValue)).toBe(true); | ||
expect(field.typeCheck(null)).toBe(false); | ||
} | ||
); | ||
}); | ||
describe('special types', () => { | ||
const specialTypes = ['enum', 'enumRequired', 'auto']; | ||
test.each(specialTypes)('%s is defined', typeName => { | ||
expect(Field[typeName]).toBeDefined(); | ||
}); | ||
it('enum accepts a valid object and returns a Field of the appropriate type', () => { | ||
const field = Field.enum({ | ||
name: 'myField', | ||
values: ['a', 'b'], | ||
}); | ||
expect(field).toBeInstanceOf(Field); | ||
expect(field.name).toBe('myField'); | ||
expect(field.required).toBe(false); | ||
expect(field.typeCheck('a')).toBe(true); | ||
expect(field.typeCheck('c')).toBe(false); | ||
}); | ||
it('enumRequired accepts a valid object and returns a Field of the appropriate type', () => { | ||
const field = Field.enumRequired({ | ||
name: 'myField', | ||
values: ['a', 'b'], | ||
defaultValue: 'b', | ||
}); | ||
expect(field).toBeInstanceOf(Field); | ||
expect(field.name).toBe('myField'); | ||
expect(field.required).toBe(true); | ||
expect(field[$defaultValue]).toBe('b'); | ||
expect(field.typeCheck('a')).toBe(true); | ||
expect(field.typeCheck('c')).toBe(false); | ||
}); | ||
it('enumRequired accepts a valid object without defaultValue and returns a Field of the appropriate type', () => { | ||
const field = Field.enumRequired({ | ||
name: 'myField', | ||
values: ['a', 'b'], | ||
}); | ||
expect(field).toBeInstanceOf(Field); | ||
expect(field.name).toBe('myField'); | ||
expect(field.required).toBe(true); | ||
expect(field[$defaultValue]).toBe('a'); | ||
expect(field.typeCheck('a')).toBe(true); | ||
expect(field.typeCheck('c')).toBe(false); | ||
}); | ||
it('auto accepts a string as a name and returns a Field of the appropriate type', () => { | ||
const field = Field.auto('myField'); | ||
expect(field).toBeInstanceOf(Field); | ||
expect(field.name).toBe('myField'); | ||
expect(field.required).toBe(true); | ||
expect(field[$defaultValue]).toBe(0); | ||
expect(field[$defaultValue]).toBe(1); | ||
}); | ||
it('auto accepts a valid object and returns a Field of the appropriate type', () => { | ||
const field = Field.auto({ name: 'myField' }); | ||
expect(field).toBeInstanceOf(Field); | ||
expect(field.name).toBe('myField'); | ||
expect(field.required).toBe(true); | ||
expect(field[$defaultValue]).toBe(0); | ||
expect(field[$defaultValue]).toBe(1); | ||
}); | ||
}); | ||
}); |
@@ -5,13 +5,4 @@ import { Model } from 'src/model'; | ||
const { | ||
$instances, | ||
$key, | ||
$keyType, | ||
$fields, | ||
$properties, | ||
$cachedProperties, | ||
$methods, | ||
$scopes, | ||
$validators, | ||
} = symbols; | ||
const { $instances, $fields, $properties, $cachedProperties, $scopes } = | ||
symbols; | ||
@@ -36,11 +27,2 @@ describe('Model', () => { | ||
it('throws if "name" is invalid', () => { | ||
expect(() => new Model({ name: null })).toThrow(); | ||
expect(() => new Model({ name: undefined })).toThrow(); | ||
expect(() => new Model({ name: '' })).toThrow(); | ||
expect(() => new Model({ name: ' ' })).toThrow(); | ||
expect(() => new Model({ name: '1' })).toThrow(); | ||
expect(() => new Model({ name: 'a&1*b' })).toThrow(); | ||
}); | ||
it('throws if a model with the same name already exists', () => { | ||
@@ -52,35 +34,40 @@ // eslint-disable-next-line no-unused-vars | ||
it('throws if "key" is invalid', () => { | ||
expect(() => new Model({ name: 'aModel', key: null })).toThrow(); | ||
expect(() => new Model({ name: 'aModel', key: 2 })).toThrow(); | ||
expect(() => new Model({ name: 'aModel', key: {} })).toThrow(); | ||
expect( | ||
() => new Model({ name: 'aModel', key: { name: 'id', type: 'test' } }) | ||
).toThrow(); | ||
expect(() => new Model({ name: 'aModel', key: '2' })).toThrow(); | ||
expect( | ||
() => new Model({ name: 'aModel', key: { name: '2', type: 'auto' } }) | ||
).toThrow(); | ||
}); | ||
it('throws if "fields" contain invalid values', () => { | ||
const modelParams = { name: 'aModel', key: 'id' }; | ||
const modelParams = { name: 'aModel' }; | ||
expect(() => new Model({ ...modelParams, fields: null })).toThrow(); | ||
expect(() => new Model({ ...modelParams, fields: [2] })).toThrow(); | ||
expect(() => new Model({ ...modelParams, fields: { a: 2 } })).toThrow(); | ||
expect( | ||
() => new Model({ ...modelParams, fields: [{ name: 'aField' }] }) | ||
() => new Model({ ...modelParams, fields: { name: 'aField' } }) | ||
).toThrow(); | ||
expect( | ||
() => | ||
new Model({ ...modelParams, fields: [{ name: 'id', type: 'test' }] }) | ||
() => new Model({ ...modelParams, fields: { id: 'test' } }) | ||
).toThrow(); | ||
expect( | ||
() => | ||
new Model({ ...modelParams, fields: [{ name: '2f', type: 'string' }] }) | ||
).toThrow(); | ||
}); | ||
it('creates a cached property if "cache" is true', () => { | ||
const model = new Model({ | ||
name: 'aModel', | ||
properties: { aProperty: { body: () => null, cache: true } }, | ||
}); | ||
expect(model[$properties].has('aProperty')).toEqual(true); | ||
expect(model[$cachedProperties].has('aProperty')).toEqual(true); | ||
}); | ||
it('creates a property and its inverse if a valid name is provided', () => { | ||
const model = new Model({ | ||
name: 'aModel', | ||
properties: { isReady: { body: () => false, inverse: 'isNotReady' } }, | ||
}); | ||
expect(model[$properties].has('isReady')).toEqual(true); | ||
expect(model[$properties].has('isNotReady')).toEqual(true); | ||
const rec = model.createRecord({ id: '1' }); | ||
expect(rec.isReady).toEqual(false); | ||
expect(rec.isNotReady).toEqual(true); | ||
}); | ||
it('throws if "properties" contain invalid values', () => { | ||
const modelParams = { name: 'aModel', key: 'id' }; | ||
const modelParams = { name: 'aModel' }; | ||
@@ -92,12 +79,6 @@ expect(() => new Model({ ...modelParams, properties: null })).toThrow(); | ||
).toThrow(); | ||
expect( | ||
() => new Model({ ...modelParams, properties: { id: () => null } }) | ||
).toThrow(); | ||
expect( | ||
() => new Model({ ...modelParams, properties: { '2d': () => null } }) | ||
).toThrow(); | ||
}); | ||
it('throws if "scopes" contain invalid values', () => { | ||
const modelParams = { name: 'aModel', key: 'id' }; | ||
const modelParams = { name: 'aModel' }; | ||
@@ -120,12 +101,2 @@ expect(() => new Model({ ...modelParams, scopes: null })).toThrow(); | ||
it('throws if "validators" contain invalid values', () => { | ||
const modelParams = { name: 'aModel', key: 'id' }; | ||
expect(() => new Model({ ...modelParams, validators: null })).toThrow(); | ||
expect(() => new Model({ ...modelParams, validators: [2] })).toThrow(); | ||
expect( | ||
() => new Model({ ...modelParams, validators: { aValidator: 'hi' } }) | ||
).toThrow(); | ||
}); | ||
describe('when arguments are valid', () => { | ||
@@ -136,26 +107,8 @@ it('has the appropriate name', () => { | ||
it('has the appropriate key name and type', () => { | ||
const defaultKeyModel = new Model({ name: 'aModel' }); | ||
expect(defaultKeyModel[$key].name).toBe('id'); | ||
expect(defaultKeyModel[$key][$keyType]).toBe('string'); | ||
const customKeyNameModel = new Model({ name: 'bModel', key: 'myKey' }); | ||
expect(customKeyNameModel[$key].name).toBe('myKey'); | ||
expect(customKeyNameModel[$key][$keyType]).toBe('string'); | ||
const customKeyNameTypeModel = new Model({ | ||
name: 'cModel', | ||
key: { name: 'aKey', type: 'auto' }, | ||
}); | ||
expect(customKeyNameTypeModel[$key].name).toBe('aKey'); | ||
expect(customKeyNameTypeModel[$key][$keyType]).toBe('auto'); | ||
}); | ||
it('has the correct fields', () => { | ||
const fields = [ | ||
{ name: 'aField', type: 'string' }, | ||
{ name: 'bField', type: 'number' }, | ||
{ name: 'cField', type: 'boolean' }, | ||
{ name: 'dField', type: () => true }, | ||
]; | ||
const fields = { | ||
aField: 'string', | ||
bField: 'number', | ||
cField: 'boolean', | ||
}; | ||
const model = new Model({ name: 'aModel', fields }); | ||
@@ -165,3 +118,2 @@ expect(model[$fields].has('aField')).toEqual(true); | ||
expect(model[$fields].has('cField')).toEqual(true); | ||
expect(model[$fields].has('dField')).toEqual(true); | ||
}); | ||
@@ -191,334 +143,10 @@ | ||
const model = new Model({ name: 'aModel', scopes }); | ||
expect(model.records[$scopes].has('aScope')).toEqual(true); | ||
expect(model.records[$scopes].has('bScope')).toEqual(true); | ||
expect(model.records[$scopes].has('cScope')).toEqual(true); | ||
expect(model[$scopes].has('aScope')).toEqual(true); | ||
expect(model[$scopes].has('bScope')).toEqual(true); | ||
expect(model[$scopes].has('cScope')).toEqual(true); | ||
}); | ||
it('has the correct validators', () => { | ||
const validators = { | ||
aValidator: () => null, | ||
bValidator: () => null, | ||
}; | ||
const model = new Model({ name: 'aModel', validators }); | ||
expect(model[$validators].has('aValidator')).toEqual(true); | ||
expect(model[$validators].has('bValidator')).toEqual(true); | ||
}); | ||
}); | ||
describe('addField', () => { | ||
let model; | ||
beforeEach(() => { | ||
model = new Model({ name: 'aModel', key: 'id' }); | ||
}); | ||
it('throws if "fieldOptions" are invalid', () => { | ||
expect(() => model.addField(null)).toThrow(); | ||
expect(() => model.addField(2)).toThrow(); | ||
expect(() => model.addField({ name: 'aField' })).toThrow(); | ||
expect(() => model.addField({ name: 'id', type: 'string' })).toThrow(); | ||
expect(() => model.addField({ name: 'aField', type: 'test' })).toThrow(); | ||
expect(() => model.addField({ name: '2f', type: 'string' })).toThrow(); | ||
}); | ||
it('creates the appropriate field', () => { | ||
model.addField({ name: 'aField', type: 'string' }); | ||
expect(model[$fields].has('aField')).toEqual(true); | ||
}); | ||
it('applies "retrofill" if it is a function', () => { | ||
model.createRecord({ id: 'a' }); | ||
model.addField({ name: 'aField', type: 'string' }, () => 'x'); | ||
expect(model[$fields].has('aField')).toEqual(true); | ||
expect(model.records.first.aField).toEqual('x'); | ||
}); | ||
it('applies "retrofill" if it is a value', () => { | ||
model.createRecord({ id: 'a' }); | ||
model.addField({ name: 'aField', type: 'string' }, 'x'); | ||
expect(model[$fields].has('aField')).toEqual(true); | ||
expect(model.records.first.aField).toEqual('x'); | ||
}); | ||
it('does not apply "retrofill" if undefined and the field is not required', () => { | ||
model.createRecord({ id: 'a' }); | ||
model.addField({ name: 'aField', type: 'string' }); | ||
expect(model[$fields].has('aField')).toEqual(true); | ||
expect(model.records.first.aField).toEqual(undefined); | ||
}); | ||
it('applies the default or existing value if "retrofill" is undefined and the field is required', () => { | ||
model.createRecord({ id: 'a' }); | ||
model.createRecord({ id: 'b', aField: 'y' }); | ||
model.addField({ | ||
name: 'aField', | ||
type: 'string', | ||
required: true, | ||
defaultValue: 'x', | ||
}); | ||
expect(model[$fields].has('aField')).toEqual(true); | ||
expect(model.records.first.aField).toEqual('x'); | ||
expect(model.records.last.aField).toEqual('y'); | ||
}); | ||
}); | ||
describe('removeField', () => { | ||
let model; | ||
beforeEach(() => { | ||
model = new Model({ | ||
name: 'aModel', | ||
key: 'id', | ||
fields: [{ name: 'aField', type: 'string' }], | ||
}); | ||
}); | ||
it('returns false if "fieldName" does not exist', () => { | ||
expect(model.removeField('bField')).toEqual(false); | ||
}); | ||
it('removes the appropriate field and returns true', () => { | ||
expect(model.removeField('aField')).toEqual(true); | ||
expect(model[$fields].has('aField')).toEqual(false); | ||
}); | ||
}); | ||
describe('updateField', () => { | ||
let model; | ||
beforeEach(() => { | ||
model = new Model({ | ||
name: 'aModel', | ||
key: 'id', | ||
fields: [{ name: 'aField', type: 'string' }], | ||
}); | ||
}); | ||
it('throws if fieldName does not match new field name', () => { | ||
expect(() => | ||
model.updateField('aField', { name: 'bField', type: 'number' }) | ||
).toThrow(); | ||
}); | ||
it('throws if "fieldName" does not exist', () => { | ||
expect(() => | ||
model.updateField('bField', { name: 'bField', type: 'number' }) | ||
).toThrow(); | ||
}); | ||
it('updates the appropriate field', () => { | ||
const oldField = model[$fields].get('aField'); | ||
model.updateField('aField', { name: 'aField', type: 'number' }); | ||
expect(model[$fields].has('aField')).toEqual(true); | ||
expect(model[$fields].get('aField')).not.toBe(oldField); | ||
}); | ||
}); | ||
describe('addProperty', () => { | ||
let model; | ||
beforeEach(() => { | ||
model = new Model({ name: 'aModel', key: 'id' }); | ||
}); | ||
it('throws if "name" is invalid', () => { | ||
expect(() => model.addProperty(null)).toThrow(); | ||
expect(() => model.addProperty(2)).toThrow(); | ||
expect(() => model.addProperty('2f')).toThrow(); | ||
expect(() => model.addProperty('id')).toThrow(); | ||
}); | ||
it('throws if "property" is not a function', () => { | ||
expect(() => model.addProperty('aProperty', null)).toThrow(); | ||
expect(() => model.addProperty('aProperty', 2)).toThrow(); | ||
}); | ||
it('creates the appropriate property', () => { | ||
model.addProperty('aProperty', () => null); | ||
expect(model[$properties].has('aProperty')).toEqual(true); | ||
}); | ||
it('creates a cached property if "cache" is true', () => { | ||
model.addProperty('aProperty', () => null, true); | ||
expect(model[$properties].has('aProperty')).toEqual(true); | ||
expect(model[$cachedProperties].has('aProperty')).toEqual(true); | ||
}); | ||
}); | ||
describe('removeProperty', () => { | ||
let model; | ||
beforeEach(() => { | ||
model = new Model({ | ||
name: 'aModel', | ||
key: 'id', | ||
properties: { aProperty: () => null }, | ||
}); | ||
}); | ||
it('returns false if "propertyName" does not exist', () => { | ||
expect(model.removeProperty('bProperty')).toEqual(false); | ||
}); | ||
it('removes the appropriate property and returns true', () => { | ||
expect(model.removeProperty('aProperty')).toEqual(true); | ||
expect(model[$properties].has('aProperty')).toEqual(false); | ||
}); | ||
it('removes the appropriate property and cache and returns true', () => { | ||
model.addProperty('bProperty', () => null, true); | ||
expect(model.removeProperty('bProperty')).toEqual(true); | ||
expect(model[$properties].has('bProperty')).toEqual(false); | ||
expect(model[$cachedProperties].has('bProperty')).toEqual(false); | ||
}); | ||
}); | ||
describe('addMethod', () => { | ||
let model; | ||
beforeEach(() => { | ||
model = new Model({ name: 'aModel', key: 'id' }); | ||
}); | ||
it('throws if "name" is invalid', () => { | ||
expect(() => model.addMethod(null)).toThrow(); | ||
expect(() => model.addMethod(2)).toThrow(); | ||
expect(() => model.addMethod('2f')).toThrow(); | ||
expect(() => model.addMethod('id')).toThrow(); | ||
}); | ||
it('throws if "method" is not a function', () => { | ||
expect(() => model.addMethod('aProperty', null)).toThrow(); | ||
expect(() => model.addMethod('aProperty', 2)).toThrow(); | ||
}); | ||
it('creates the appropriate property', () => { | ||
model.addMethod('aMethod', () => null); | ||
expect(model[$methods].has('aMethod')).toEqual(true); | ||
}); | ||
}); | ||
describe('removeMethod', () => { | ||
let model; | ||
beforeEach(() => { | ||
model = new Model({ | ||
name: 'aModel', | ||
key: 'id', | ||
methods: { aMethod: () => null }, | ||
}); | ||
}); | ||
it('returns false if "propertyName" does not exist', () => { | ||
expect(model.removeMethod('bMethod')).toEqual(false); | ||
}); | ||
it('removes the appropriate field and returns true', () => { | ||
expect(model.removeMethod('aMethod')).toEqual(true); | ||
expect(model[$methods].has('aMethod')).toEqual(false); | ||
}); | ||
}); | ||
describe('addScope', () => { | ||
let model; | ||
beforeEach(() => { | ||
model = new Model({ name: 'aModel', key: 'id' }); | ||
}); | ||
it('throws if "name" is invalid', () => { | ||
expect(() => model.addScope(null)).toThrow(); | ||
expect(() => model.addScope(2)).toThrow(); | ||
expect(() => model.addScope('2f')).toThrow(); | ||
}); | ||
it('throws if "scope" is not a function', () => { | ||
expect(() => model.addScope('aScope', null)).toThrow(); | ||
expect(() => model.addScope('aScope', 2)).toThrow(); | ||
}); | ||
it('creates the appropriate scope', () => { | ||
model.addScope('aScope', () => null); | ||
expect(model.records[$scopes].has('aScope')).toEqual(true); | ||
}); | ||
it('creates an appropriate scope with a sorter', () => { | ||
model.addScope( | ||
'aScope', | ||
() => null, | ||
(a, b) => a.id - b.id | ||
); | ||
expect(model.records[$scopes].has('aScope')).toEqual(true); | ||
}); | ||
}); | ||
describe('removeScope', () => { | ||
let model; | ||
beforeEach(() => { | ||
model = new Model({ | ||
name: 'aModel', | ||
key: 'id', | ||
scopes: { aScope: () => null }, | ||
}); | ||
}); | ||
it('returns false if "scopeName" does not exist', () => { | ||
expect(model.removeScope('bScope')).toEqual(false); | ||
}); | ||
it('removes the appropriate field and returns true', () => { | ||
expect(model.removeScope('aScope')).toEqual(true); | ||
expect(model.records[$scopes].has('aScope')).toEqual(false); | ||
}); | ||
}); | ||
describe('addValidator', () => { | ||
let model; | ||
beforeEach(() => { | ||
model = new Model({ name: 'aModel', key: 'id' }); | ||
}); | ||
it('throws if "name" is invalid', () => { | ||
expect(() => model.addValidator(null)).toThrow(); | ||
expect(() => model.addValidator(2)).toThrow(); | ||
}); | ||
it('throws if "validators" is not a function', () => { | ||
expect(() => model.addValidator('aValidator', null)).toThrow(); | ||
expect(() => model.addValidator('aValidator', 2)).toThrow(); | ||
}); | ||
it('creates the appropriate validator', () => { | ||
model.addValidator('aValidator', () => null); | ||
expect(model[$validators].has('aValidator')).toEqual(true); | ||
}); | ||
}); | ||
describe('removeValidator', () => { | ||
let model; | ||
beforeEach(() => { | ||
model = new Model({ | ||
name: 'aModel', | ||
key: 'id', | ||
validators: { aValidator: () => null }, | ||
}); | ||
}); | ||
it('returns false if "validatorName" does not exist', () => { | ||
expect(model.removeValidator('bValidator')).toEqual(false); | ||
}); | ||
it('removes the appropriate field and returns true', () => { | ||
expect(model.removeValidator('aValidator')).toEqual(true); | ||
expect(model[$validators].has('aValidator')).toEqual(false); | ||
}); | ||
}); | ||
describe('createRecord', () => { | ||
let model; | ||
let autoIncrementModel; | ||
@@ -528,13 +156,7 @@ beforeEach(() => { | ||
name: 'aModel', | ||
key: 'id', | ||
fields: [ | ||
{ | ||
name: 'name', | ||
type: 'string', | ||
validators: { | ||
unique: true, | ||
}, | ||
}, | ||
{ name: 'age', type: 'numberRequired', defaultValue: 18 }, | ||
], | ||
fields: { | ||
name: 'string', | ||
age: 'number', | ||
address: 'string', | ||
}, | ||
scopes: { | ||
@@ -549,12 +171,3 @@ adult: ({ age }) => age >= 18, | ||
}, | ||
validators: { | ||
nameNotEqualToId: record => record.id !== record.name, | ||
}, | ||
}); | ||
autoIncrementModel = new Model({ | ||
name: 'bModel', | ||
key: { name: 'id', type: 'auto' }, | ||
fields: [{ name: 'name', type: 'string' }], | ||
}); | ||
}); | ||
@@ -568,7 +181,7 @@ | ||
it('throws if key is not present', () => { | ||
it('throws if id is not present', () => { | ||
expect(() => model.createRecord({ name: 'aName' })).toThrow(); | ||
}); | ||
it('throws if key is not unique', () => { | ||
it('throws if id is not unique', () => { | ||
model.createRecord({ id: 'a', name: 'aName' }); | ||
@@ -578,9 +191,2 @@ expect(() => model.createRecord({ id: 'a', name: 'bName' })).toThrow(); | ||
it('auto-generates keys in auto-increment models', () => { | ||
const record = autoIncrementModel.createRecord({ name: 'aName' }); | ||
expect(record.id).toEqual(0); | ||
const otherRecord = autoIncrementModel.createRecord({ name: 'bName' }); | ||
expect(otherRecord.id).toEqual(1); | ||
}); | ||
it('throws if a field value is not of the correct type', () => { | ||
@@ -590,18 +196,8 @@ expect(() => model.createRecord({ id: 'a', name: 2 })).toThrow(); | ||
it('throws if a field value fails validation', () => { | ||
it('throws if a record with the same id exists', () => { | ||
// eslint-disable-next-line no-unused-vars | ||
const record = model.createRecord({ id: 'a', name: 'aName' }); | ||
expect(() => model.createRecord({ id: 'b', name: 'aName' })).toThrow(); | ||
}); | ||
it('throws if a record with the same key exists', () => { | ||
// eslint-disable-next-line no-unused-vars | ||
const record = model.createRecord({ id: 'a', name: 'aName' }); | ||
expect(() => model.createRecord({ id: 'a', name: 'bName' })).toThrow(); | ||
}); | ||
it('throws if a model validator fails for the new record', () => { | ||
expect(() => model.createRecord({ id: 'a', name: 'a' })).toThrow(); | ||
}); | ||
describe('with correct arguments', () => { | ||
@@ -611,3 +207,3 @@ let record; | ||
beforeEach(() => { | ||
record = model.createRecord({ id: 'a', name: 'aName' }); | ||
record = model.createRecord({ id: 'a', name: 'aName', age: 18 }); | ||
}); | ||
@@ -619,2 +215,3 @@ | ||
expect(record.age).toEqual(18); | ||
expect(record.address).toEqual(null); | ||
}); | ||
@@ -646,10 +243,7 @@ | ||
name: 'aModel', | ||
key: 'id', | ||
fields: [ | ||
{ name: 'name', type: 'string', validators: { minLength: 1 } }, | ||
], | ||
fields: { name: 'string' }, | ||
}); | ||
}); | ||
it('returns false if "recordKey" does not exist', () => { | ||
it('returns false if "recordId" does not exist', () => { | ||
expect(model.removeRecord('aRecord')).toEqual(false); | ||
@@ -672,14 +266,11 @@ }); | ||
name: 'aModel', | ||
key: 'id', | ||
fields: [ | ||
{ name: 'name', type: 'string', validators: { minLength: 1 } }, | ||
], | ||
fields: { name: 'string' }, | ||
}); | ||
}); | ||
it('throws if "recordKey" does not exist', () => { | ||
it('throws if "recordId" does not exist', () => { | ||
expect(() => model.updateRecord('a', { name: 'ab' })).toThrow(); | ||
}); | ||
it('throws if "recordKey" does not match the new record', () => { | ||
it('throws if "recordId" does not match the new record', () => { | ||
expect(() => model.updateRecord('a', { id: 'b', name: 'ab' })).toThrow(); | ||
@@ -692,9 +283,2 @@ }); | ||
it('throws if a field fails type checking or validation', () => { | ||
// eslint-disable-next-line no-unused-vars | ||
const record = model.createRecord({ id: 'a', name: 'aName' }); | ||
expect(() => model.updateRecord('a', { name: 2 })).toThrow(); | ||
expect(() => model.updateRecord('a', { name: '' })).toThrow(); | ||
}); | ||
it('updates the record with the correct values', () => { | ||
@@ -714,6 +298,3 @@ // eslint-disable-next-line no-unused-vars | ||
name: 'aModel', | ||
key: 'id', | ||
fields: [ | ||
{ name: 'name', type: 'string', validators: { minLength: 1 } }, | ||
], | ||
fields: { name: 'string' }, | ||
scopes: { | ||
@@ -752,3 +333,5 @@ nonExistentRecords: record => record.name === '', | ||
expect( | ||
model.records.nonExistentRecords.flatMap(record => record.id) | ||
model.records.nonExistentRecords.map(record => record.id, { | ||
flat: true, | ||
}) | ||
).toEqual([]); | ||
@@ -763,6 +346,5 @@ }); | ||
].forEach(record => model.createRecord(record)); | ||
expect(model.records.namedRecords.flatMap(record => record.id)).toEqual([ | ||
'a', | ||
'b', | ||
]); | ||
expect( | ||
model.records.namedRecords.map(record => record.id, { flat: true }) | ||
).toEqual(['a', 'b']); | ||
}); | ||
@@ -777,164 +359,8 @@ | ||
expect( | ||
model.records.sortedNamedRecords.flatMap(record => record.id) | ||
model.records.sortedNamedRecords.map(record => record.id, { | ||
flat: true, | ||
}) | ||
).toEqual(['b', 'a']); | ||
}); | ||
}); | ||
describe('events', () => { | ||
let model; | ||
beforeEach(() => { | ||
model = new Model({ | ||
name: 'aModel', | ||
key: 'id', | ||
fields: [ | ||
{ name: 'name', type: 'string', validators: { minLength: 1 } }, | ||
], | ||
}); | ||
}); | ||
// TODO: V2 enhancements | ||
// When we decide if the events API is going to stay as-is, let's check | ||
// all before and normal events. For now, 'change' events should do it. | ||
describe('a "change" of the correct type event is emitted', () => { | ||
it('when a field is added', () => { | ||
const spy = jest.fn(); | ||
model.on('change', spy); | ||
model.addField({ name: 'age', type: 'number' }); | ||
expect(spy).toHaveBeenCalledWith( | ||
expect.objectContaining({ type: 'fieldAdded' }) | ||
); | ||
}); | ||
it('when a field is removed', () => { | ||
const spy = jest.fn(); | ||
model.on('change', spy); | ||
model.removeField('name'); | ||
expect(spy).toHaveBeenCalledWith( | ||
expect.objectContaining({ type: 'fieldRemoved' }) | ||
); | ||
}); | ||
it('when a field is updated', () => { | ||
const spy = jest.fn(); | ||
model.on('change', spy); | ||
model.updateField('name', { name: 'name', type: 'string' }); | ||
expect(spy).toHaveBeenCalledWith( | ||
expect.objectContaining({ type: 'fieldUpdated' }) | ||
); | ||
}); | ||
it('when a property is added', () => { | ||
const spy = jest.fn(); | ||
model.on('change', spy); | ||
model.addProperty('nameAndId', () => 'aName_a'); | ||
expect(spy).toHaveBeenCalledWith( | ||
expect.objectContaining({ type: 'propertyAdded' }) | ||
); | ||
}); | ||
it('when a property is removed', () => { | ||
const spy = jest.fn(); | ||
model.addProperty('nameAndId', () => 'aName_a'); | ||
model.on('change', spy); | ||
model.removeProperty('nameAndId'); | ||
expect(spy).toHaveBeenCalledWith( | ||
expect.objectContaining({ type: 'propertyRemoved' }) | ||
); | ||
}); | ||
it('when a scope is added', () => { | ||
const spy = jest.fn(); | ||
model.on('change', spy); | ||
model.addScope('adult', () => true); | ||
expect(spy).toHaveBeenCalledWith( | ||
expect.objectContaining({ type: 'scopeAdded' }) | ||
); | ||
}); | ||
it('when a scope is removed', () => { | ||
const spy = jest.fn(); | ||
model.addScope('adult', () => true); | ||
model.on('change', spy); | ||
model.removeScope('adult'); | ||
expect(spy).toHaveBeenCalledWith( | ||
expect.objectContaining({ type: 'scopeRemoved' }) | ||
); | ||
}); | ||
it('when a validator is added', () => { | ||
const spy = jest.fn(); | ||
model.on('change', spy); | ||
model.addValidator('minLength', () => true); | ||
expect(spy).toHaveBeenCalledWith( | ||
expect.objectContaining({ type: 'validatorAdded' }) | ||
); | ||
}); | ||
it('when a validator is removed', () => { | ||
const spy = jest.fn(); | ||
model.addValidator('minLength', () => true); | ||
model.on('change', spy); | ||
model.removeValidator('minLength'); | ||
expect(spy).toHaveBeenCalledWith( | ||
expect.objectContaining({ type: 'validatorRemoved' }) | ||
); | ||
}); | ||
}); | ||
it('emits a "beforeCreateRecord" before creating a record', () => { | ||
const spy = jest.fn(); | ||
model.on('beforeCreateRecord', spy); | ||
model.createRecord({ id: 'a', name: 'aName' }); | ||
expect(spy).toHaveBeenCalledWith({ | ||
model, | ||
record: { id: 'a', name: 'aName' }, | ||
}); | ||
}); | ||
it('emits a "recordCreated" event when a record is created', () => { | ||
const spy = jest.fn(); | ||
model.on('recordCreated', spy); | ||
const record = model.createRecord({ id: 'a', name: 'aName' }); | ||
expect(spy).toHaveBeenCalledWith({ newRecord: record, model }); | ||
}); | ||
it('emits a "beforeRemoveRecord" before removing a record', () => { | ||
const spy = jest.fn(); | ||
model.on('beforeRemoveRecord', spy); | ||
const record = model.createRecord({ id: 'a', name: 'aName' }); | ||
model.removeRecord('a'); | ||
expect(spy).toHaveBeenCalledWith({ model, record }); | ||
}); | ||
it('emits a "recordRemoved" event when a record is removed', () => { | ||
const spy = jest.fn(); | ||
model.on('recordRemoved', spy); | ||
// eslint-disable-next-line no-unused-vars | ||
const record = model.createRecord({ id: 'a', name: 'aName' }); | ||
model.removeRecord('a'); | ||
expect(spy).toHaveBeenCalledWith({ record: { id: 'a' }, model }); | ||
}); | ||
it('emits a "beforeUpdateRecord" before updating a record', () => { | ||
const spy = jest.fn(); | ||
model.on('beforeUpdateRecord', spy); | ||
const record = model.createRecord({ id: 'a', name: 'aName' }); | ||
model.updateRecord('a', { name: 'bName' }); | ||
expect(spy).toHaveBeenCalledWith({ | ||
model, | ||
record, | ||
newRecord: { id: 'a', name: 'bName' }, | ||
}); | ||
}); | ||
it('emits a "recordUpdated" event when a record is updated', () => { | ||
const spy = jest.fn(); | ||
model.on('recordUpdated', spy); | ||
const record = model.createRecord({ id: 'a', name: 'aName' }); | ||
model.updateRecord('a', { name: 'bName' }); | ||
expect(spy).toHaveBeenCalledWith({ record, model }); | ||
}); | ||
}); | ||
}); |
@@ -6,3 +6,3 @@ import { Model } from 'src/model'; | ||
const { $instances } = symbols; | ||
const { $instances, $clearSchemaForTesting } = symbols; | ||
@@ -28,20 +28,17 @@ // Indirectly check the record handler here, too. | ||
beforeEach(() => { | ||
schema = new Schema({ name: 'test' }); | ||
model = schema.createModel({ | ||
name: 'aModel', | ||
key: 'id', | ||
fields: [ | ||
{ name: 'name', type: 'string' }, | ||
{ name: 'age', type: 'number' }, | ||
schema = Schema.create({ | ||
models: [ | ||
{ | ||
name: 'aModel', | ||
fields: { name: 'string', age: 'number' }, | ||
properties: { | ||
firstName: rec => rec.name.split(' ')[0], | ||
}, | ||
methods: { | ||
prefixedName: (rec, prefix) => `${prefix} ${rec.name}`, | ||
}, | ||
}, | ||
], | ||
properties: { | ||
firstName: rec => rec.name.split(' ')[0], | ||
}, | ||
methods: { | ||
prefixedName: (rec, prefix) => `${prefix} ${rec.name}`, | ||
}, | ||
validators: { | ||
nameNotSameAsId: rec => rec.name !== rec.id, | ||
}, | ||
}); | ||
model = schema.getModel('aModel'); | ||
}); | ||
@@ -52,2 +49,3 @@ | ||
Model[$instances].clear(); | ||
Schema[$clearSchemaForTesting](); | ||
}); | ||
@@ -60,9 +58,2 @@ | ||
it('throws if a record created by Model.prototype.createRecord() fails validation', () => { | ||
expect(() => model.createRecord({ id: 'jd', name: 5, age: 42 })).toThrow(); | ||
expect(() => | ||
model.createRecord({ id: 'John', name: 'John', age: 42 }) | ||
).toThrow(); | ||
}); | ||
it('getting/setting on a record goes through the RecordHandler', () => { | ||
@@ -73,3 +64,2 @@ const record = model.createRecord({ id: 'jd', name: 'John Doe', age: 42 }); | ||
expect(() => (record.firstName = null)).toThrow(); | ||
expect(() => (record.name = 'jd')).toThrow(); | ||
expect(() => (record.prefixedName = 'jd')).toThrow(); | ||
@@ -87,35 +77,2 @@ }); | ||
it('gets serialized correctly with custom includes', () => { | ||
let otherModel = schema.createModel({ | ||
name: 'bModel', | ||
fields: [{ name: 'data', type: 'string' }], | ||
}); | ||
schema.createRelationship({ | ||
from: 'aModel', | ||
to: 'bModel', | ||
type: 'oneToOne', | ||
}); | ||
const record = model.createRecord({ id: 'jd', name: 'John Doe', age: 42 }); | ||
expect(record.toObject({ include: ['firstName'] })).toEqual({ | ||
id: 'jd', | ||
name: 'John Doe', | ||
age: 42, | ||
firstName: 'John', | ||
}); | ||
const otherRecord = otherModel.createRecord({ id: 'b', data: 'b data' }); | ||
record.bModel = otherRecord.id; | ||
expect(record.toObject({ include: ['bModel'] })).toEqual({ | ||
id: 'jd', | ||
name: 'John Doe', | ||
age: 42, | ||
bModel: { | ||
data: 'b data', | ||
id: 'b', | ||
}, | ||
}); | ||
}); | ||
it('calling JSON.stringify() returns the correct result', () => { | ||
@@ -128,3 +85,3 @@ const record = model.createRecord({ id: 'jd', name: 'John Doe', age: 42 }); | ||
it('returns the key value when called with toString', () => { | ||
it('returns the id when called with toString', () => { | ||
const record = model.createRecord({ id: 'jd', name: 'John Doe', age: 42 }); | ||
@@ -139,17 +96,21 @@ expect(record.toString()).toBe('jd'); | ||
beforeEach(() => { | ||
model = schema.createModel({ | ||
name: 'bModel', | ||
key: 'id', | ||
fields: [ | ||
{ name: 'name', type: 'string' }, | ||
{ name: 'age', type: 'number' }, | ||
Schema[$clearSchemaForTesting](); | ||
schema.create({ | ||
models: [ | ||
{ | ||
name: 'bModel', | ||
fields: { name: 'string', age: 'number' }, | ||
properties: { | ||
firstName: { | ||
body: rec => { | ||
propertyCalls++; | ||
return rec.name.split(' ')[0]; | ||
}, | ||
cache: true, | ||
}, | ||
}, | ||
}, | ||
], | ||
properties: { | ||
firstName: rec => { | ||
propertyCalls++; | ||
return rec.name.split(' ')[0]; | ||
}, | ||
}, | ||
cacheProperties: ['firstName'], | ||
}); | ||
model = schema.getModel('bModel'); | ||
record = model.createRecord({ id: 'jd', name: 'John Doe', age: 42 }); | ||
@@ -156,0 +117,0 @@ propertyCalls = 0; |
@@ -5,3 +5,4 @@ import { Model } from 'src/model'; | ||
const { $instances } = symbols; | ||
const { $instances, $clearSchemaForTesting, $clearRecordSetForTesting } = | ||
symbols; | ||
@@ -26,20 +27,21 @@ // Indirectly check other record-related classes, too. | ||
beforeEach(() => { | ||
schema = new Schema({ name: 'test' }); | ||
model = schema.createModel({ | ||
name: 'person', | ||
key: { name: 'id', type: 'auto' }, | ||
fields: [ | ||
{ name: 'name', type: 'string' }, | ||
{ name: 'age', type: 'number' }, | ||
schema = Schema.create({ | ||
models: [ | ||
{ | ||
name: 'person', | ||
fields: { name: 'string', age: 'number' }, | ||
properties: { | ||
firstName: rec => rec.name.split(' ')[0], | ||
lastName: rec => rec.name.split(' ')[1], | ||
}, | ||
scopes: { | ||
adults: record => record.age >= 18, | ||
}, | ||
}, | ||
], | ||
properties: { | ||
firstName: rec => rec.name.split(' ')[0], | ||
lastName: rec => rec.name.split(' ')[1], | ||
}, | ||
scopes: { | ||
adults: record => record.age >= 18, | ||
}, | ||
}); | ||
model = schema.getModel('person'); | ||
model.createRecord({ | ||
id: '0', | ||
name: 'John Doe', | ||
@@ -49,2 +51,3 @@ age: 42, | ||
model.createRecord({ | ||
id: '1', | ||
name: 'Jane Doe', | ||
@@ -54,2 +57,3 @@ age: 34, | ||
model.createRecord({ | ||
id: '2', | ||
name: 'John Smith', | ||
@@ -59,2 +63,3 @@ age: 34, | ||
model.createRecord({ | ||
id: '3', | ||
name: 'Jane Smith', | ||
@@ -68,2 +73,3 @@ age: 15, | ||
Model[$instances].clear(); | ||
Schema[$clearSchemaForTesting](); | ||
}); | ||
@@ -77,3 +83,3 @@ | ||
it('should return null if no records', () => { | ||
model.records.clear(); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.first).toBeUndefined(); | ||
@@ -89,3 +95,3 @@ }); | ||
it('should return null if no records', () => { | ||
model.records.clear(); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.last).toBeUndefined(); | ||
@@ -101,3 +107,3 @@ }); | ||
it('should return 0 if no records', () => { | ||
model.records.clear(); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.count).toBe(0); | ||
@@ -113,3 +119,3 @@ }); | ||
it('should return 0 if no records', () => { | ||
model.records.clear(); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.length).toBe(0); | ||
@@ -119,35 +125,10 @@ }); | ||
describe('freeze', () => { | ||
it('should throw when mutating the record set', () => { | ||
model.records.freeze(); | ||
describe('set', () => { | ||
it('should throw', () => { | ||
expect(() => model.records.set(1, null)).toThrow(); | ||
expect(() => model.records.delete(1)).toThrow(); | ||
expect(() => model.records.clear()).toThrow(); | ||
}); | ||
}); | ||
describe('set', () => { | ||
// This is experimental, might have to update it later down the line. | ||
it('should set a record', () => { | ||
model.records.set(1, { name: 'Jane Smith', age: 15 }); | ||
expect(model.records.last.name).toBe('Jane Smith'); | ||
}); | ||
it('should throw if record set is frozen', () => { | ||
model.records.freeze(); | ||
expect(() => | ||
model.records.set(1, { name: 'Jane Smith', age: 15 }) | ||
).toThrow(); | ||
}); | ||
}); | ||
describe('delete', () => { | ||
it('should delete a record', () => { | ||
expect(model.records.count).toBe(4); | ||
model.records.delete(1); | ||
expect(model.records.count).toBe(3); | ||
}); | ||
it('should throw if record set is frozen', () => { | ||
model.records.freeze(); | ||
it('should throw', () => { | ||
expect(() => model.records.delete(1)).toThrow(); | ||
@@ -158,10 +139,3 @@ }); | ||
describe('clear', () => { | ||
it('should clear the record set', () => { | ||
expect(model.records.count).toBe(4); | ||
model.records.clear(); | ||
expect(model.records.count).toBe(0); | ||
}); | ||
it('should throw if record set is frozen', () => { | ||
model.records.freeze(); | ||
it('should throw', () => { | ||
expect(() => model.records.clear()).toThrow(); | ||
@@ -178,16 +152,16 @@ }); | ||
it('should return an empty object if no records', () => { | ||
model.records.clear(); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.map(rec => rec.age)).toEqual({}); | ||
}); | ||
}); | ||
describe('flatMap', () => { | ||
it('should flatMap over the records', () => { | ||
const result = model.records.flatMap(rec => rec.age); | ||
expect(result).toEqual([42, 34, 34, 15]); | ||
}); | ||
describe('when flat is true', () => { | ||
it('should flat map over the records', () => { | ||
const result = model.records.map(rec => rec.age, { flat: true }); | ||
expect(result).toEqual([42, 34, 34, 15]); | ||
}); | ||
it('should return an empty array if no records', () => { | ||
model.records.clear(); | ||
expect(model.records.flatMap(rec => rec.age)).toEqual([]); | ||
it('should return an empty array if no records', () => { | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.map(rec => rec.age, { flat: true })).toEqual([]); | ||
}); | ||
}); | ||
@@ -203,3 +177,3 @@ }); | ||
it('should return the initial value if no records', () => { | ||
model.records.clear(); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.reduce((acc, rec) => acc + rec.age, 0)).toBe(0); | ||
@@ -216,16 +190,20 @@ }); | ||
it('should return an empty record set if no records', () => { | ||
model.records.clear(); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.filter(rec => rec.age >= 18).count).toBe(0); | ||
}); | ||
}); | ||
describe('flatFilter', () => { | ||
it('should filter over the records', () => { | ||
const result = model.records.flatFilter(rec => rec.age >= 18); | ||
expect(result.length).toEqual(3); | ||
}); | ||
describe('when flat is true', () => { | ||
it('should filter over the records', () => { | ||
const result = model.records.filter(rec => rec.age >= 18, { | ||
flat: true, | ||
}); | ||
expect(result.length).toEqual(3); | ||
}); | ||
it('should return an empty array if no records', () => { | ||
model.records.clear(); | ||
expect(model.records.flatFilter(rec => rec.age >= 18)).toEqual([]); | ||
it('should return an empty array if no records', () => { | ||
model.records[$clearRecordSetForTesting](); | ||
expect( | ||
model.records.filter(rec => rec.age >= 18, { flat: true }) | ||
).toEqual([]); | ||
}); | ||
}); | ||
@@ -244,9 +222,9 @@ }); | ||
describe('findKey', () => { | ||
describe('findId', () => { | ||
it('should find a record', () => { | ||
expect(model.records.findKey(rec => rec.age === 42)).toBe(0); | ||
expect(model.records.findId(rec => rec.age === 42)).toBe('0'); | ||
}); | ||
it('should return undefined if no record found', () => { | ||
expect(model.records.findKey(rec => rec.age === 0)).toBeUndefined(); | ||
expect(model.records.findId(rec => rec.age === 0)).toBeUndefined(); | ||
}); | ||
@@ -256,4 +234,4 @@ }); | ||
describe('only', () => { | ||
it('should return a record set with only the given keys', () => { | ||
const result = model.records.only(0, 1); | ||
it('should return a record set with only the given ids', () => { | ||
const result = model.records.only('0', '1'); | ||
expect(result.count).toBe(2); | ||
@@ -264,4 +242,4 @@ expect(result.first.name).toBe('John Doe'); | ||
it('should return a record set with only the given keys in the correct order', () => { | ||
const result = model.records.only(1, 0); | ||
it('should return a record set with only the given ids in the correct order', () => { | ||
const result = model.records.only('1', '0'); | ||
expect(result.count).toBe(2); | ||
@@ -273,4 +251,4 @@ expect(result.first.name).toBe('Jane Doe'); | ||
it('should return an empty record set if no records', () => { | ||
model.records.clear(); | ||
expect(model.records.except(0, 1).count).toBe(0); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.except('0', '1').count).toBe(0); | ||
}); | ||
@@ -280,4 +258,4 @@ }); | ||
describe('except', () => { | ||
it('should return a record set with the given keys removed', () => { | ||
const result = model.records.except(0, 1); | ||
it('should return a record set with the given ids removed', () => { | ||
const result = model.records.except('0', '1'); | ||
expect(result.count).toBe(2); | ||
@@ -289,4 +267,4 @@ expect(result.first.name).toBe('John Smith'); | ||
it('should return an empty record set if no records', () => { | ||
model.records.clear(); | ||
expect(model.records.except(0, 1).count).toBe(0); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.except('0', '1').count).toBe(0); | ||
}); | ||
@@ -313,3 +291,3 @@ }); | ||
it('should return true if no records', () => { | ||
model.records.clear(); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.every(rec => rec.age >= 18)).toBe(true); | ||
@@ -329,3 +307,3 @@ }); | ||
it('should return false if no records', () => { | ||
model.records.clear(); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.some(rec => rec.age >= 18)).toBe(false); | ||
@@ -336,12 +314,4 @@ }); | ||
describe('select', () => { | ||
it('should return a record set of partial records', () => { | ||
it('should return an array of objects', () => { | ||
const result = model.records.select('age'); | ||
expect(result.first.name).toBe(undefined); | ||
expect(result.first.age).toBe(42); | ||
}); | ||
}); | ||
describe('flatSelect', () => { | ||
it('should return an array of objects', () => { | ||
const result = model.records.flatSelect('age'); | ||
expect(result).toEqual([ | ||
@@ -357,11 +327,4 @@ { age: 42 }, | ||
describe('pluck', () => { | ||
it('should return an record set of record fragments', () => { | ||
const result = model.records.pluck('age'); | ||
expect(result.toFlatArray()).toEqual([[42], [34], [34], [15]]); | ||
}); | ||
}); | ||
describe('flatPluck', () => { | ||
it('should return an array of arrays of values for multiple keys', () => { | ||
const result = model.records.flatPluck('age', 'name'); | ||
const result = model.records.pluck('age', 'name'); | ||
expect(result).toEqual([ | ||
@@ -376,3 +339,3 @@ [42, 'John Doe'], | ||
it('should return an array of values for a single key', () => { | ||
const result = model.records.flatPluck('age'); | ||
const result = model.records.pluck('age'); | ||
expect(result).toEqual([42, 34, 34, 15]); | ||
@@ -384,10 +347,16 @@ }); | ||
it('should group the records by the given key', () => { | ||
const result = model.records.groupBy('age'); | ||
expect(result.toFlatObject()).toEqual({ | ||
15: [{ id: 3, age: 15, name: 'Jane Smith' }], | ||
const result = Object.entries(model.records.groupBy('age')).reduce( | ||
(acc, [key, value]) => { | ||
acc[key] = value.toArray({ flat: true }); | ||
return acc; | ||
}, | ||
{} | ||
); | ||
expect(result).toEqual({ | ||
15: [{ id: '3', age: 15, name: 'Jane Smith' }], | ||
34: [ | ||
{ id: 1, age: 34, name: 'Jane Doe' }, | ||
{ id: 2, age: 34, name: 'John Smith' }, | ||
{ id: '1', age: 34, name: 'Jane Doe' }, | ||
{ id: '2', age: 34, name: 'John Smith' }, | ||
], | ||
42: [{ id: 0, age: 42, name: 'John Doe' }], | ||
42: [{ id: '0', age: 42, name: 'John Doe' }], | ||
}); | ||
@@ -397,34 +366,2 @@ }); | ||
describe('duplicate', () => { | ||
it('should duplicate the record set', () => { | ||
const result = model.records.duplicate(); | ||
expect(result.count).toBe(4); | ||
expect(result.first.id).toBe(0); | ||
expect(result.last.id).toBe(3); | ||
expect(result).not.toBe(model.records); | ||
}); | ||
}); | ||
describe('merge', () => { | ||
it('should merge the record sets', () => { | ||
const r1 = model.records.limit(2); | ||
const r2 = model.records.offset(2).limit(1); | ||
const result = r1.merge(r2); | ||
expect(result.count).toBe(3); | ||
expect(result.first.id).toBe(0); | ||
expect(result.last.id).toBe(2); | ||
}); | ||
}); | ||
describe('append', () => { | ||
it('should append the records', () => { | ||
const r1 = model.records.limit(2); | ||
const r = model.records.last; | ||
const result = r1.append(r); | ||
expect(result.count).toBe(3); | ||
expect(result.first.id).toBe(0); | ||
expect(result.last.id).toBe(3); | ||
}); | ||
}); | ||
describe('where', () => { | ||
@@ -437,3 +374,3 @@ it('should filter over the records', () => { | ||
it('should return an empty record set if no records', () => { | ||
model.records.clear(); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.where(rec => rec.age >= 18).count).toBe(0); | ||
@@ -450,3 +387,3 @@ }); | ||
it('should return an empty record set if no records', () => { | ||
model.records.clear(); | ||
model.records[$clearRecordSetForTesting](); | ||
expect(model.records.whereNot(rec => rec.age >= 18).count).toBe(0); | ||
@@ -456,10 +393,10 @@ }); | ||
describe('flatBatchIterator', () => { | ||
describe('batchIterator', () => { | ||
it('should iterate over the records', () => { | ||
const result = model.records.flatBatchIterator(2); | ||
expect(result.next().value.map(v => v.name)).toEqual([ | ||
const result = model.records.batchIterator(2); | ||
expect(result.next().value.pluck('name')).toEqual([ | ||
'John Doe', | ||
'Jane Doe', | ||
]); | ||
expect(result.next().value.map(v => v.name)).toEqual([ | ||
expect(result.next().value.pluck('name')).toEqual([ | ||
'John Smith', | ||
@@ -472,4 +409,4 @@ 'Jane Smith', | ||
it('should return the last batch with however many elements are left', () => { | ||
const result = model.records.flatBatchIterator(3); | ||
expect(result.next().value.map(v => v.name)).toEqual([ | ||
const result = model.records.batchIterator(3); | ||
expect(result.next().value.pluck('name')).toEqual([ | ||
'John Doe', | ||
@@ -479,49 +416,33 @@ 'Jane Doe', | ||
]); | ||
expect(result.next().value.map(v => v.name)).toEqual(['Jane Smith']); | ||
expect(result.next().value.pluck('name')).toEqual(['Jane Smith']); | ||
expect(result.next().value).toEqual(undefined); | ||
}); | ||
}); | ||
describe('flatBatchKeysIterator', () => { | ||
it('should iterate over the records', () => { | ||
const result = model.records.flatBatchKeysIterator(2); | ||
expect(result.next().value).toEqual([0, 1]); | ||
expect(result.next().value).toEqual([2, 3]); | ||
expect(result.next().value).toEqual(undefined); | ||
}); | ||
describe('when flat is true', () => { | ||
it('should iterate over the records', () => { | ||
const result = model.records.batchIterator(2, { flat: true }); | ||
expect(result.next().value.map(v => v.name)).toEqual([ | ||
'John Doe', | ||
'Jane Doe', | ||
]); | ||
expect(result.next().value.map(v => v.name)).toEqual([ | ||
'John Smith', | ||
'Jane Smith', | ||
]); | ||
expect(result.next().value).toEqual(undefined); | ||
}); | ||
it('should return the last batch with however many elements are left', () => { | ||
const result = model.records.flatBatchKeysIterator(3); | ||
expect(result.next().value).toEqual([0, 1, 2]); | ||
expect(result.next().value).toEqual([3]); | ||
expect(result.next().value).toEqual(undefined); | ||
it('should return the last batch with however many elements are left', () => { | ||
const result = model.records.batchIterator(3, { flat: true }); | ||
expect(result.next().value.map(v => v.name)).toEqual([ | ||
'John Doe', | ||
'Jane Doe', | ||
'John Smith', | ||
]); | ||
expect(result.next().value.map(v => v.name)).toEqual(['Jane Smith']); | ||
expect(result.next().value).toEqual(undefined); | ||
}); | ||
}); | ||
}); | ||
describe('batchIterator', () => { | ||
it('should iterate over the records', () => { | ||
const result = model.records.batchIterator(2); | ||
expect(result.next().value.flatPluck('name')).toEqual([ | ||
'John Doe', | ||
'Jane Doe', | ||
]); | ||
expect(result.next().value.flatPluck('name')).toEqual([ | ||
'John Smith', | ||
'Jane Smith', | ||
]); | ||
expect(result.next().value).toEqual(undefined); | ||
}); | ||
it('should return the last batch with however many elements are left', () => { | ||
const result = model.records.batchIterator(3); | ||
expect(result.next().value.flatPluck('name')).toEqual([ | ||
'John Doe', | ||
'Jane Doe', | ||
'John Smith', | ||
]); | ||
expect(result.next().value.flatPluck('name')).toEqual(['Jane Smith']); | ||
expect(result.next().value).toEqual(undefined); | ||
}); | ||
}); | ||
describe('limit', () => { | ||
@@ -547,7 +468,7 @@ it('should return the first n records from the record set', () => { | ||
it('should return the correct slice with only a start', () => { | ||
expect(model.records.slice(2).flatPluck('name')).toEqual([ | ||
expect(model.records.slice(2).pluck('name')).toEqual([ | ||
'John Smith', | ||
'Jane Smith', | ||
]); | ||
expect(model.records.slice(0).flatPluck('name')).toEqual([ | ||
expect(model.records.slice(0).pluck('name')).toEqual([ | ||
'John Doe', | ||
@@ -558,19 +479,19 @@ 'Jane Doe', | ||
]); | ||
expect(model.records.slice(4).flatPluck('name')).toEqual([]); | ||
expect(model.records.slice(5).flatPluck('name')).toEqual([]); | ||
expect(model.records.slice(-1).flatPluck('name')).toEqual(['Jane Smith']); | ||
expect(model.records.slice(4).pluck('name')).toEqual([]); | ||
expect(model.records.slice(5).pluck('name')).toEqual([]); | ||
expect(model.records.slice(-1).pluck('name')).toEqual(['Jane Smith']); | ||
}); | ||
it('should return the correct slice with a start and end', () => { | ||
expect(model.records.slice(2, 4).flatPluck('name')).toEqual([ | ||
expect(model.records.slice(2, 4).pluck('name')).toEqual([ | ||
'John Smith', | ||
'Jane Smith', | ||
]); | ||
expect(model.records.slice(0, 2).flatPluck('name')).toEqual([ | ||
expect(model.records.slice(0, 2).pluck('name')).toEqual([ | ||
'John Doe', | ||
'Jane Doe', | ||
]); | ||
expect(model.records.slice(4, 5).flatPluck('name')).toEqual([]); | ||
expect(model.records.slice(5, 6).flatPluck('name')).toEqual([]); | ||
expect(model.records.slice(-3, 3).flatPluck('name')).toEqual([ | ||
expect(model.records.slice(4, 5).pluck('name')).toEqual([]); | ||
expect(model.records.slice(5, 6).pluck('name')).toEqual([]); | ||
expect(model.records.slice(-3, 3).pluck('name')).toEqual([ | ||
'Jane Doe', | ||
@@ -584,44 +505,14 @@ 'John Smith', | ||
it('should return an array of the records', () => { | ||
const partialsSet = model.records.select('age'); | ||
const fragmentsSet = model.records.pluck('age'); | ||
const groupSet = model.records.groupBy('age'); | ||
expect(model.records.toArray().map(v => v.age)).toEqual([42, 34, 34, 15]); | ||
expect(partialsSet.toArray().map(v => v.age)).toEqual([42, 34, 34, 15]); | ||
expect(fragmentsSet.toArray().map(v => v[0])).toEqual([42, 34, 34, 15]); | ||
expect(groupSet.toArray().map(v => v.toArray().map(v => v.age))).toEqual([ | ||
[42], | ||
[34, 34], | ||
[15], | ||
]); | ||
}); | ||
}); | ||
describe('toFlatArray', () => { | ||
it('returns an array of objects', () => { | ||
const partialsSet = model.records.select('age'); | ||
const fragmentsSet = model.records.pluck('age'); | ||
const groupSet = model.records.groupBy('age'); | ||
expect(model.records.toFlatArray()).toEqual([ | ||
{ id: 0, name: 'John Doe', age: 42 }, | ||
{ id: 1, name: 'Jane Doe', age: 34 }, | ||
{ id: 2, name: 'John Smith', age: 34 }, | ||
{ id: 3, name: 'Jane Smith', age: 15 }, | ||
]); | ||
expect(partialsSet.toFlatArray()).toEqual([ | ||
{ age: 42 }, | ||
{ age: 34 }, | ||
{ age: 34 }, | ||
{ age: 15 }, | ||
]); | ||
expect(fragmentsSet.toFlatArray()).toEqual([[42], [34], [34], [15]]); | ||
expect(groupSet.toFlatArray()).toEqual([ | ||
[{ id: 0, name: 'John Doe', age: 42 }], | ||
[ | ||
{ id: 1, name: 'Jane Doe', age: 34 }, | ||
{ id: 2, name: 'John Smith', age: 34 }, | ||
], | ||
[{ id: 3, name: 'Jane Smith', age: 15 }], | ||
]); | ||
describe('when flat is true', () => { | ||
it('returns an array of objects', () => { | ||
expect(model.records.toArray({ flat: true })).toEqual([ | ||
{ id: '0', name: 'John Doe', age: 42 }, | ||
{ id: '1', name: 'Jane Doe', age: 34 }, | ||
{ id: '2', name: 'John Smith', age: 34 }, | ||
{ id: '3', name: 'Jane Smith', age: 15 }, | ||
]); | ||
}); | ||
}); | ||
@@ -632,70 +523,21 @@ }); | ||
it('should return an object of the records', () => { | ||
const partialsSet = model.records.select('age'); | ||
const fragmentsSet = model.records.pluck('age'); | ||
const groupSet = model.records.groupBy('age'); | ||
expect(JSON.stringify(model.records.toObject())).toEqual( | ||
JSON.stringify({ | ||
0: { id: 0, name: 'John Doe', age: 42 }, | ||
1: { id: 1, name: 'Jane Doe', age: 34 }, | ||
2: { id: 2, name: 'John Smith', age: 34 }, | ||
3: { id: 3, name: 'Jane Smith', age: 15 }, | ||
0: { id: '0', name: 'John Doe', age: 42 }, | ||
1: { id: '1', name: 'Jane Doe', age: 34 }, | ||
2: { id: '2', name: 'John Smith', age: 34 }, | ||
3: { id: '3', name: 'Jane Smith', age: 15 }, | ||
}) | ||
); | ||
expect(JSON.stringify(partialsSet.toObject())).toEqual( | ||
JSON.stringify({ | ||
0: { age: 42 }, | ||
1: { age: 34 }, | ||
2: { age: 34 }, | ||
3: { age: 15 }, | ||
}) | ||
); | ||
expect(JSON.stringify(fragmentsSet.toObject())).toEqual( | ||
JSON.stringify({ 0: [42], 1: [34], 2: [34], 3: [15] }) | ||
); | ||
expect(JSON.stringify(groupSet.toObject())).toEqual( | ||
JSON.stringify({ | ||
42: { 0: { id: 0, name: 'John Doe', age: 42 } }, | ||
34: { | ||
1: { id: 1, name: 'Jane Doe', age: 34 }, | ||
2: { id: 2, name: 'John Smith', age: 34 }, | ||
}, | ||
15: { 3: { id: 3, name: 'Jane Smith', age: 15 } }, | ||
}) | ||
); | ||
}); | ||
}); | ||
describe('toFlatObject', () => { | ||
it('should return an object of objects', () => { | ||
const partialsSet = model.records.select('age'); | ||
const fragmentsSet = model.records.pluck('age'); | ||
const groupSet = model.records.groupBy('age'); | ||
expect(model.records.toFlatObject()).toEqual({ | ||
0: { id: 0, name: 'John Doe', age: 42 }, | ||
1: { id: 1, name: 'Jane Doe', age: 34 }, | ||
2: { id: 2, name: 'John Smith', age: 34 }, | ||
3: { id: 3, name: 'Jane Smith', age: 15 }, | ||
describe('when flat is true', () => { | ||
it('should return an object of objects', () => { | ||
expect(model.records.toObject({ flat: true })).toEqual({ | ||
0: { id: '0', name: 'John Doe', age: 42 }, | ||
1: { id: '1', name: 'Jane Doe', age: 34 }, | ||
2: { id: '2', name: 'John Smith', age: 34 }, | ||
3: { id: '3', name: 'Jane Smith', age: 15 }, | ||
}); | ||
}); | ||
expect(partialsSet.toFlatObject()).toEqual({ | ||
0: { age: 42 }, | ||
1: { age: 34 }, | ||
2: { age: 34 }, | ||
3: { age: 15 }, | ||
}); | ||
expect(fragmentsSet.toFlatObject()).toEqual({ | ||
0: [42], | ||
1: [34], | ||
2: [34], | ||
3: [15], | ||
}); | ||
expect(groupSet.toFlatObject()).toEqual({ | ||
42: [{ id: 0, name: 'John Doe', age: 42 }], | ||
34: [ | ||
{ id: 1, name: 'Jane Doe', age: 34 }, | ||
{ id: 2, name: 'John Smith', age: 34 }, | ||
], | ||
15: [{ id: 3, name: 'Jane Smith', age: 15 }], | ||
}); | ||
}); | ||
@@ -706,37 +548,19 @@ }); | ||
it('should return an object of the records', () => { | ||
const partialsSet = model.records.select('age'); | ||
const fragmentsSet = model.records.pluck('age'); | ||
const groupSet = model.records.groupBy('age'); | ||
expect(JSON.stringify(model.records.toJSON())).toEqual( | ||
JSON.stringify({ | ||
0: { id: 0, name: 'John Doe', age: 42 }, | ||
1: { id: 1, name: 'Jane Doe', age: 34 }, | ||
2: { id: 2, name: 'John Smith', age: 34 }, | ||
3: { id: 3, name: 'Jane Smith', age: 15 }, | ||
0: { id: '0', name: 'John Doe', age: 42 }, | ||
1: { id: '1', name: 'Jane Doe', age: 34 }, | ||
2: { id: '2', name: 'John Smith', age: 34 }, | ||
3: { id: '3', name: 'Jane Smith', age: 15 }, | ||
}) | ||
); | ||
expect(JSON.stringify(partialsSet.toJSON())).toEqual( | ||
JSON.stringify({ | ||
0: { age: 42 }, | ||
1: { age: 34 }, | ||
2: { age: 34 }, | ||
3: { age: 15 }, | ||
}) | ||
); | ||
expect(JSON.stringify(fragmentsSet.toJSON())).toEqual( | ||
JSON.stringify({ 0: [42], 1: [34], 2: [34], 3: [15] }) | ||
); | ||
expect(JSON.stringify(groupSet.toJSON())).toEqual( | ||
JSON.stringify({ | ||
42: { 0: { id: 0, name: 'John Doe', age: 42 } }, | ||
34: { | ||
1: { id: 1, name: 'Jane Doe', age: 34 }, | ||
2: { id: 2, name: 'John Smith', age: 34 }, | ||
}, | ||
15: { 3: { id: 3, name: 'Jane Smith', age: 15 } }, | ||
}) | ||
); | ||
}); | ||
}); | ||
describe('#copyScopesFromModel', () => { | ||
it('should copy scopes when a new recordSet is created', () => { | ||
const newRecordSet = model.records.limit(4); | ||
expect(newRecordSet.adults.count).toBe(3); | ||
}); | ||
}); | ||
}); |
@@ -62,3 +62,3 @@ import { Schema } from 'src/schema'; | ||
name: 'foo', | ||
fields: [{ name: 'aField', type: 'string' }], | ||
fields: { aField: 'string' }, | ||
}); | ||
@@ -98,3 +98,3 @@ // eslint-disable-next-line no-unused-vars | ||
name: 'bar', | ||
fields: [{ name: 'aField', type: 'string' }], | ||
fields: { aField: 'string' }, | ||
}); | ||
@@ -466,7 +466,7 @@ expect( | ||
// Filled field | ||
expect(records.b1.modelGammaSet.flatPluck('id').flat()).toEqual([ | ||
expect(records.b1.modelGammaSet.pluck('id')).toEqual([ | ||
records.g2.id, | ||
records.g1.id, | ||
]); | ||
expect(records.b1.children.flatPluck('id').flat()).toEqual([ | ||
expect(records.b1.children.pluck('id')).toEqual([ | ||
records.g2.id, | ||
@@ -495,7 +495,7 @@ records.g3.id, | ||
// Filled property | ||
expect(records.d1.modelGammaSet.flatPluck('id').flat()).toEqual([ | ||
expect(records.d1.modelGammaSet.pluck('id')).toEqual([ | ||
records.g1.id, | ||
records.g2.id, | ||
]); | ||
expect(records.d2.children2.flatPluck('id').flat()).toEqual([ | ||
expect(records.d2.children2.pluck('id')).toEqual([ | ||
records.g1.id, | ||
@@ -514,11 +514,11 @@ records.g3.id, | ||
// Filled field | ||
expect(records.d1.modelAlphaSet.flatPluck('id').flat()).toEqual([ | ||
expect(records.d1.modelAlphaSet.pluck('id')).toEqual([ | ||
records.a1.id, | ||
records.a2.id, | ||
]); | ||
expect(records.d1.friends.flatPluck('id').flat()).toEqual([ | ||
expect(records.d1.friends.pluck('id')).toEqual([ | ||
records.a2.id, | ||
records.a3.id, | ||
]); | ||
expect(records.a1.friends2.flatPluck('id').flat()).toEqual([ | ||
expect(records.a1.friends2.pluck('id')).toEqual([ | ||
records.a2.id, | ||
@@ -528,11 +528,5 @@ records.a3.id, | ||
// Filled property | ||
expect(records.a1.modelDeltaSet.flatPluck('id').flat()).toEqual([ | ||
records.d1.id, | ||
]); | ||
expect(records.a2.friends.flatPluck('id').flat()).toEqual([ | ||
records.d1.id, | ||
]); | ||
expect(records.a2.colleagues.flatPluck('id').flat()).toEqual([ | ||
records.a1.id, | ||
]); | ||
expect(records.a1.modelDeltaSet.pluck('id')).toEqual([records.d1.id]); | ||
expect(records.a2.friends.pluck('id')).toEqual([records.d1.id]); | ||
expect(records.a2.colleagues.pluck('id')).toEqual([records.a1.id]); | ||
// Empty field | ||
@@ -539,0 +533,0 @@ expect(records.d2.modelAlphaSet.size).toBe(0); |
@@ -5,3 +5,3 @@ import { Schema } from 'src/schema'; | ||
const { $instances, $fields, $properties } = symbols; | ||
const { $instances, $fields, $properties, $clearSchemaForTesting } = symbols; | ||
@@ -24,12 +24,79 @@ describe('Schema', () => { | ||
Model[$instances].clear(); | ||
Schema[$clearSchemaForTesting](); | ||
}); | ||
// We prefer Schema.create as it's the "correct" way to create a schema. | ||
it('throws if "name" is invalid', () => { | ||
expect(() => Schema.create({ name: null })).toThrow(); | ||
expect(() => Schema.create({ name: undefined })).toThrow(); | ||
expect(() => Schema.create({ name: '' })).toThrow(); | ||
expect(() => Schema.create({ name: ' ' })).toThrow(); | ||
expect(() => Schema.create({ name: '1' })).toThrow(); | ||
expect(() => Schema.create({ name: 'a&1*b' })).toThrow(); | ||
it('throws if a model contains invalid or duplicate fields, properties or methods', () => { | ||
expect(() => | ||
Schema.create({ | ||
models: [ | ||
{ | ||
name: 'aModel', | ||
fields: { | ||
aField: 'string', | ||
id: 'string', | ||
}, | ||
}, | ||
], | ||
config: { | ||
experimentalAPIMessages: 'off', | ||
}, | ||
}) | ||
).toThrow(); | ||
expect(() => | ||
Schema.create({ | ||
models: [ | ||
{ | ||
name: 'aModel', | ||
fields: { | ||
aField: 'string', | ||
}, | ||
properties: { | ||
aProperty: 1, | ||
}, | ||
}, | ||
], | ||
config: { | ||
experimentalAPIMessages: 'off', | ||
}, | ||
}) | ||
).toThrow(); | ||
expect(() => | ||
Schema.create({ | ||
models: [ | ||
{ | ||
name: 'aModel', | ||
fields: { | ||
aField: 'string', | ||
}, | ||
methods: { | ||
aMethod: null, | ||
}, | ||
}, | ||
], | ||
config: { | ||
experimentalAPIMessages: 'off', | ||
}, | ||
}) | ||
).toThrow(); | ||
expect(() => | ||
Schema.create({ | ||
models: [ | ||
{ | ||
name: 'aModel', | ||
fields: { | ||
aField: 'string', | ||
}, | ||
properties: { | ||
aField: () => {}, | ||
}, | ||
}, | ||
], | ||
config: { | ||
experimentalAPIMessages: 'off', | ||
}, | ||
}) | ||
).toThrow(); | ||
}); | ||
@@ -50,6 +117,2 @@ | ||
it('creates a schema with the correct name', () => { | ||
expect(schema.name).toBe('test'); | ||
}); | ||
it('creates a schema with the appropriate models', () => { | ||
@@ -63,10 +126,6 @@ expect(schema.models.has('aModel')).toBe(true); | ||
it('adds the schema to the dictionary', () => { | ||
expect(Schema.get('test')).toBe(schema); | ||
}); | ||
it('creates lazy properties and methods correctly', () => { | ||
let count = 0; | ||
Schema[$clearSchemaForTesting](); | ||
schema = Schema.create({ | ||
name: 'test', | ||
models: [ | ||
@@ -76,20 +135,15 @@ { name: 'cModel' }, | ||
name: 'dModel', | ||
lazyProperties: { | ||
prop: | ||
({ models: { cModel } }) => | ||
rec => | ||
rec.id + cModel.name, | ||
other: | ||
({ models: { cModel } }) => | ||
rec => { | ||
properties: { | ||
prop: (rec, { models: { cModel } }) => rec.id + cModel.name, | ||
other: { | ||
body: (rec, { models: { cModel } }) => { | ||
count++; | ||
return rec.id + cModel.name; | ||
}, | ||
cache: true, | ||
}, | ||
}, | ||
cacheProperties: ['other'], | ||
lazyMethods: { | ||
method: | ||
({ models: { cModel } }) => | ||
(rec, value) => | ||
value + rec.id + cModel.name, | ||
methods: { | ||
method: (rec, value, { models: { cModel } }) => | ||
value + rec.id + cModel.name, | ||
}, | ||
@@ -109,4 +163,4 @@ }, | ||
it('creates lazy serializer methods correctly', () => { | ||
Schema[$clearSchemaForTesting](); | ||
schema = Schema.create({ | ||
name: 'test', | ||
models: [{ name: 'cModel' }], | ||
@@ -120,8 +174,2 @@ serializers: [ | ||
}, | ||
lazyMethods: { | ||
lazy: | ||
({ models: { cModel } }) => | ||
rec => | ||
rec.id + cModel.name, | ||
}, | ||
}, | ||
@@ -133,20 +181,14 @@ ], | ||
expect(serialized.normal).toBe('x!'); | ||
expect(serialized.lazy).toBe('xcModel'); | ||
}); | ||
}); | ||
describe('createModel', () => { | ||
let schema, model; | ||
beforeEach(() => { | ||
schema = Schema.create({ | ||
name: 'test', | ||
models: [{ name: 'aModel' }], | ||
}); | ||
model = schema.createModel({ name: 'bModel' }); | ||
describe('#createModel', () => { | ||
it('throws the model name is invalid', () => { | ||
expect(() => Schema.create({ models: [{ name: null }] })).toThrow(); | ||
expect(() => Schema.create({ models: [{ name: undefined }] })).toThrow(); | ||
expect(() => Schema.create({ models: [{ name: '' }] })).toThrow(); | ||
expect(() => Schema.create({ models: [{ name: ' ' }] })).toThrow(); | ||
expect(() => Schema.create({ models: [{ name: '1' }] })).toThrow(); | ||
expect(() => Schema.create({ models: [{ name: 'a&1*b' }] })).toThrow(); | ||
}); | ||
it('creates the appropriate model', () => { | ||
expect(schema.models.get('bModel')).toBe(model); | ||
}); | ||
}); | ||
@@ -159,3 +201,2 @@ | ||
schema = Schema.create({ | ||
name: 'test', | ||
models: [{ name: 'aModel' }], | ||
@@ -174,3 +215,3 @@ }); | ||
describe('removeModel', () => { | ||
describe('#createRelationship', () => { | ||
let schema; | ||
@@ -180,35 +221,16 @@ | ||
schema = Schema.create({ | ||
name: 'test', | ||
models: [{ name: 'aModel' }], | ||
}); | ||
schema.removeModel('aModel'); | ||
}); | ||
it('throws if the model does not exist', () => { | ||
expect(() => schema.removeModel('bModel')).toThrow(); | ||
}); | ||
it('removes the specified model', () => { | ||
expect(schema.models.get('aModel')).toBeUndefined(); | ||
}); | ||
}); | ||
describe('createRelationship', () => { | ||
let schema; | ||
beforeEach(() => { | ||
schema = Schema.create({ | ||
name: 'test', | ||
models: [{ name: 'aModel' }, { name: 'bModel' }], | ||
relationships: [ | ||
{ | ||
from: 'aModel', | ||
to: 'bModel', | ||
type: 'oneToOne', | ||
}, | ||
{ | ||
from: { model: 'bModel', name: 'parent' }, | ||
to: { model: 'aModel', name: 'children' }, | ||
type: 'manyToOne', | ||
}, | ||
], | ||
}); | ||
schema.createRelationship({ | ||
from: 'aModel', | ||
to: 'bModel', | ||
type: 'oneToOne', | ||
}); | ||
schema.createRelationship({ | ||
from: { model: 'bModel', name: 'parent' }, | ||
to: { model: 'aModel', name: 'children' }, | ||
type: 'manyToOne', | ||
}); | ||
}); | ||
@@ -218,13 +240,23 @@ | ||
expect(() => | ||
schema.createRelationship({ | ||
from: 1, | ||
to: 'bModel', | ||
type: 'oneToOne', | ||
Schema.create({ | ||
models: [{ name: 'aModel' }, { name: 'bModel' }], | ||
relationships: [ | ||
{ | ||
from: 1, | ||
to: 'bModel', | ||
type: 'oneToOne', | ||
}, | ||
], | ||
}) | ||
).toThrow(); | ||
expect(() => | ||
schema.createRelationship({ | ||
from: 'aModel', | ||
to: 2, | ||
type: 'oneToOne', | ||
Schema.create({ | ||
models: [{ name: 'aModel' }, { name: 'bModel' }], | ||
relationships: [ | ||
{ | ||
from: 'aModel', | ||
to: 2, | ||
type: 'oneToOne', | ||
}, | ||
], | ||
}) | ||
@@ -236,13 +268,23 @@ ).toThrow(); | ||
expect(() => | ||
schema.createRelationship({ | ||
from: 'cModel', | ||
to: 'bModel', | ||
type: 'oneToOne', | ||
Schema.create({ | ||
models: [{ name: 'aModel' }, { name: 'bModel' }], | ||
relationships: [ | ||
{ | ||
from: 'cModel', | ||
to: 'bModel', | ||
type: 'oneToOne', | ||
}, | ||
], | ||
}) | ||
).toThrow(); | ||
expect(() => | ||
schema.createRelationship({ | ||
from: 'aModel', | ||
to: 'cModel', | ||
type: 'oneToOne', | ||
Schema.create({ | ||
models: [{ name: 'aModel' }, { name: 'bModel' }], | ||
relationships: [ | ||
{ | ||
from: 'aModel', | ||
to: 'cModel', | ||
type: 'oneToOne', | ||
}, | ||
], | ||
}) | ||
@@ -260,15 +302,21 @@ ).toThrow(); | ||
describe('createSerializer', () => { | ||
let schema, serializer; | ||
describe('#createSerializer', () => { | ||
beforeEach(() => { | ||
schema = Schema.create({ | ||
name: 'test', | ||
Schema.create({ | ||
models: [{ name: 'aModel' }], | ||
}); | ||
serializer = schema.createSerializer({ name: 'aSerializer' }); | ||
}); | ||
it('creates the appropriate serializer', () => { | ||
expect(schema.getSerializer('aSerializer')).toBe(serializer); | ||
it('throws if the serializer name is invalid', () => { | ||
expect(() => Schema.create({ serializers: [{ name: 1 }] })).toThrow(); | ||
expect(() => Schema.create({ serializers: [{ name: null }] })).toThrow(); | ||
expect(() => | ||
Schema.create({ serializers: [{ name: undefined }] }) | ||
).toThrow(); | ||
expect(() => Schema.create({ serializers: [{ name: '' }] })).toThrow(); | ||
expect(() => Schema.create({ serializers: [{ name: ' ' }] })).toThrow(); | ||
expect(() => Schema.create({ serializers: [{ name: '1' }] })).toThrow(); | ||
expect(() => | ||
Schema.create({ serializers: [{ name: 'a&1*b' }] }) | ||
).toThrow(); | ||
}); | ||
@@ -282,3 +330,2 @@ }); | ||
schema = Schema.create({ | ||
name: 'test', | ||
models: [{ name: 'aModel' }], | ||
@@ -303,7 +350,3 @@ serializers: [{ name: 'aSerializer' }], | ||
schema = Schema.create({ | ||
name: 'test', | ||
models: [ | ||
{ name: 'aModel' }, | ||
{ name: 'bModel', key: { name: 'id', type: 'auto' } }, | ||
], | ||
models: [{ name: 'aModel' }, { name: 'bModel' }], | ||
}); | ||
@@ -314,3 +357,5 @@ | ||
}); | ||
schema.getModel('bModel').createRecord({}); | ||
schema.getModel('bModel').createRecord({ | ||
id: '0', | ||
}); | ||
}); | ||
@@ -332,3 +377,3 @@ | ||
expect(schema.get('aModel.a').id).toBe('a'); | ||
expect(schema.get('bModel.0').id).toBe(0); | ||
expect(schema.get('bModel.0').id).toBe('0'); | ||
}); | ||
@@ -342,5 +387,5 @@ | ||
expect(schema.get('aModel.a.id')).toBe('a'); | ||
expect(schema.get('bModel.0.id')).toBe(0); | ||
expect(schema.get('bModel.0.id')).toBe('0'); | ||
}); | ||
}); | ||
}); |
@@ -5,11 +5,2 @@ import { Serializer } from 'src/serializer'; | ||
describe('Serializer', () => { | ||
it('throws if "name" is invalid', () => { | ||
expect(() => new Serializer({ name: null })).toThrow(); | ||
expect(() => new Serializer({ name: undefined })).toThrow(); | ||
expect(() => new Serializer({ name: '' })).toThrow(); | ||
expect(() => new Serializer({ name: ' ' })).toThrow(); | ||
expect(() => new Serializer({ name: '1' })).toThrow(); | ||
expect(() => new Serializer({ name: 'a&1*b' })).toThrow(); | ||
}); | ||
it('throws if "attributes" is invalid', () => { | ||
@@ -43,5 +34,2 @@ expect( | ||
}, | ||
children: item => { | ||
return item.children.map(child => child.prettyName); | ||
}, | ||
customDescription: (item, { prefix }) => { | ||
@@ -61,14 +49,2 @@ return prefix + item.description; | ||
describe('addMethod', () => { | ||
it('throws if method already exists', () => { | ||
expect(() => | ||
serializer.addMethod('specialDescription', () => {}) | ||
).toThrow(); | ||
}); | ||
it('throws if method is not a function', () => { | ||
expect(() => serializer.addMethod('otherDescription', null)).toThrow(); | ||
}); | ||
}); | ||
// Indirectly check that the constructor sets the correct properties. | ||
@@ -80,8 +56,2 @@ describe('serialize', () => { | ||
description: 'my description', | ||
children: [ | ||
{ | ||
name: 'myChild', | ||
prettyName: 'my pretty child', | ||
}, | ||
], | ||
}; | ||
@@ -95,3 +65,2 @@ | ||
customDescription: 'prefixmy description', | ||
children: ['my pretty child'], | ||
}); | ||
@@ -107,8 +76,2 @@ }); | ||
description: 'my description', | ||
children: [ | ||
{ | ||
name: 'myChild', | ||
prettyName: 'my pretty child', | ||
}, | ||
], | ||
}; | ||
@@ -118,8 +81,2 @@ const object2 = { | ||
description: 'my description', | ||
children: [ | ||
{ | ||
name: 'myChild', | ||
prettyName: 'my pretty child', | ||
}, | ||
], | ||
}; | ||
@@ -136,3 +93,2 @@ | ||
customDescription: 'prefixmy description', | ||
children: ['my pretty child'], | ||
}, | ||
@@ -144,3 +100,2 @@ { | ||
customDescription: 'prefixmy description', | ||
children: ['my pretty child'], | ||
}, | ||
@@ -155,16 +110,6 @@ ]); | ||
name: 'myModel', | ||
fields: [ | ||
{ | ||
name: 'name', | ||
type: 'string', | ||
}, | ||
{ | ||
name: 'description', | ||
type: 'string', | ||
}, | ||
{ | ||
name: 'children', | ||
type: 'objectArrayRequired', | ||
}, | ||
], | ||
fields: { | ||
name: 'string', | ||
description: 'string', | ||
}, | ||
}); | ||
@@ -199,3 +144,2 @@ model.createRecord({ | ||
customDescription: 'prefixdescription of myItem1', | ||
children: [], | ||
}, | ||
@@ -202,0 +146,0 @@ }); |
import { | ||
deepClone, | ||
allEqualBy, | ||
validateName, | ||
@@ -28,14 +27,10 @@ validateObjectWithUniqueName, | ||
}); | ||
}); | ||
describe('allEqualBy', () => { | ||
it('returns true if all elements are equal', () => { | ||
const arr = [{ a: 1 }, { a: 1 }, { a: 1 }]; | ||
expect(allEqualBy(arr, val => val.a)).toBe(true); | ||
it('handles primitive values', () => { | ||
expect(deepClone(1)).toBe(1); | ||
expect(deepClone('test')).toBe('test'); | ||
expect(deepClone(true)).toBe(true); | ||
expect(deepClone(null)).toBe(null); | ||
expect(deepClone(undefined)).toBe(undefined); | ||
}); | ||
it('returns false if all elements are not equal', () => { | ||
const arr = [{ a: 1 }, { a: 2 }, { a: 3 }]; | ||
expect(allEqualBy(arr, val => val.a)).toBe(false); | ||
}); | ||
}); | ||
@@ -89,20 +84,19 @@ | ||
it('returns if name is valid', () => { | ||
expect(() => validateName('Field', 'test')).not.toThrow(); | ||
expect(() => validateName('Field', 'test1')).not.toThrow(); | ||
expect(() => validateName('Field', '_test')).not.toThrow(); | ||
expect(() => validateName('Other', 'toString')).not.toThrow(); | ||
expect(() => validateName('test')).not.toThrow(); | ||
expect(() => validateName('test1')).not.toThrow(); | ||
expect(() => validateName('_test')).not.toThrow(); | ||
}); | ||
it('throws if name is invalid', () => { | ||
expect(() => validateName('Field', 1)).toThrow(); | ||
expect(() => validateName('Field', '')).toThrow(); | ||
expect(() => validateName('Field', undefined)).toThrow(); | ||
expect(() => validateName('Field', '1test')).toThrow(); | ||
expect(() => validateName('Field', 'test.')).toThrow(); | ||
expect(() => validateName('Field', 'test test')).toThrow(); | ||
expect(() => validateName('Field', 'test-test')).toThrow(); | ||
expect(() => validateName('Field', 'test/test')).toThrow(); | ||
expect(() => validateName('Field', 'test\\test')).toThrow(); | ||
expect(() => validateName('Field', 'toJSON')).toThrow(); | ||
expect(() => validateName(1)).toThrow(); | ||
expect(() => validateName('')).toThrow(); | ||
expect(() => validateName(undefined)).toThrow(); | ||
expect(() => validateName('1test')).toThrow(); | ||
expect(() => validateName('test.')).toThrow(); | ||
expect(() => validateName('test test')).toThrow(); | ||
expect(() => validateName('test-test')).toThrow(); | ||
expect(() => validateName('test/test')).toThrow(); | ||
expect(() => validateName('test\\test')).toThrow(); | ||
expect(() => validateName('toJSON')).toThrow(); | ||
}); | ||
}); |
const path = require('path'); | ||
const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); | ||
const TerserPlugin = require("terser-webpack-plugin"); | ||
@@ -22,12 +22,12 @@ module.exports = { | ||
optimization: { | ||
minimize: true, | ||
minimizer: [ | ||
new UglifyJSPlugin({ | ||
cache: true, | ||
new TerserPlugin({ | ||
parallel: true, | ||
uglifyOptions: { | ||
terserOptions: { | ||
compress: true, | ||
ecma: 6, | ||
mangle: false, | ||
sourceMap: true, | ||
}, | ||
sourceMap: true, | ||
}), | ||
@@ -34,0 +34,0 @@ ], |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Minified code
QualityThis package contains minified code. This may be harmless in some cases where minified code is included in packaged libraries, however packages on npm should not minify code.
Found 1 instance in 1 package
11
198709
33
4097
593