diff --git a/docs/api.md b/docs/api.md index 998a371..45f24b8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,6 +1,6 @@ # api -* [createPureProduct()](#createpureproduct) +* [pure](#pure) * [isProduct(value)](#isproductvalue) * [mixin(product, ...talents)](#mixinproduct-talents) * [hasTalent(talent, product)](#hastalenttalent-product) @@ -8,14 +8,12 @@ * [isProducedBy(factory, value)](#isproducedbyfactory-value) * [replicate(product)](#replicateproduct) -## createPureProduct() +## pure -Returns a product without any talent (see this as an empty object). +An object without any talent ```javascript -import { createPureProduct } from "@dmail/mixin" - -const pureProduct = createPureProduct() +import { pure } from "@dmail/mixin" ``` [source](../src/mixin.js) | [test](../src/mixin.test.js) @@ -23,11 +21,11 @@ const pureProduct = createPureProduct() ## isProduct(value) ```javascript -import { isProduct, createPureProduct } from "@dmail/mixin" +import { isProduct, pure } from "@dmail/mixin" isProduct(null) // false isProduct({}) // false -isProduct(createPureProduct()) // true +isProduct(pure) // true ``` [source](../src/mixin.js) | [test](../src/mixin.test.js) @@ -35,17 +33,13 @@ isProduct(createPureProduct()) // true ## mixin(product, ...talents) ```javascript -import { createPureProduct, mixin } from "@dmail/mixin" - -const product = mixin( - createPureProduct(), - () => { - return { getAnswer: () => 42 } - }, - ({ getAnswer }) => { - return { getAnswerOpposite: () => getAnswer() * -1 } - }, -) +import { pure, mixin } from "@dmail/mixin" + +const answerToEverythingTalent = () => ({ getAnswer: () => 42 }) +const oppositeAnswerTalent = ({ getAnswer }) => ({ getAnswerOpposite: () => getAnswer() * -1 }) + +const intermediateProduct = mixin(pure, answerToEverythingTalent) +const product = mixin(intermediateProduct, oppositeAnswerTalent) product.getAnswer() // 42 product.getAnswerOpposite() // -42 @@ -56,34 +50,35 @@ product.getAnswerOpposite() // -42 ## hasTalent(talent, product) ```javascript -import { createPureProduct, mixin, hasTalent } from "@dmail/mixin" +import { pure, mixin, hasTalent } from "@dmail/mixin" -const pureProduct = createPureProduct() const talent = () => null -const talentedProduct = mixin(pureProduct, talent) +const talentedProduct = mixin(pure, talent) -hasTalent(talent, pureProduct) // false +hasTalent(talent, pure) // false hasTalent(talent, talentedProduct) // true ``` [source](../src/mixin.js) | [test](../src/mixin.test.js) -## createFactory(fn) +## createFactory(talent) + +Returns a function which, when called, will return a talented product ```javascript import { createFactory } from "@dmail/mixin" const createCounter = createFactory(({ count = 0 }) => { - const get = () => count const increment = () => { count++ return count } - return { get, increment } + return { increment } }) -const counter = createCounter() +const counter = createCounter({ count: 1 }) +counter.increment() // 2 ``` [source](../src/factory.js) | [test](../src/factory.test.js) @@ -91,14 +86,12 @@ const counter = createCounter() ## isProducedBy(factory, product) ```javascript -import { createFactory, createPureProduct } from "@dmail/mixin" +import { createFactory, pure } from "@dmail/mixin" const factory = createFactory() - -const pureProduct = createPureProduct() const factoryProduct = factory() -isProducedBy(factory, pureProduct) // false +isProducedBy(factory, pure) // false isProducedBy(factory, factoryProduct) // true ``` @@ -127,6 +120,27 @@ const counterClone = replicate(counter) counterClone.increment() // 11 ``` -[source](../src/factory.js) | [test](../src/factory.test.js) +[source](../src/mixin.js) | [test](../src/mixin.test.js) + +### Replicate expect talents to be pure functions + +To replicate a product, replicate will reuse talents. +Consequently if some talent functions behaves differently when reused, the resulting product will inherit thoose differences. + +#### Unpure talent example + +```javascript +import { miwin, pure, replicate } from "@dmail/mixin" + +const properties = {} +const unpureTalent = () => properties + +const productModel = mixin(pure, unpureTalent) +properties.foo = true // mutate talent return value +const productCopy = replicate(productModel) + +productModel.foo // undefined +productCopy.foo // true +``` -Please note you can also use replicate on product returned by createPureProduct() or mixin() +Because of the mutation `productCopy` is not equivalent to `productModel` diff --git a/docs/talent.md b/docs/talent.md new file mode 100644 index 0000000..a6f5390 --- /dev/null +++ b/docs/talent.md @@ -0,0 +1,27 @@ +# Talent + +A talent is a function that should + +* read properties from argument using param destructuring +* write properties returning an object + +```javascript +// wrong +const talentWithoutPattern = (product) => { + product.value++ +} + +// right +const talentWithPattern = ({ value }) => { + return { + value: value + 1, + } +} +``` + +## Talent pattern advantages + +* Destructuring param shows in a glimpse what you talent needs to read +* Returned object show in a glimpse what you talent needs to write +* Destructuring param prevent temptation to mutate talent argument (the product) +* Returned object is internally used to set non configurable and non writable properties diff --git a/package.json b/package.json index 2736574..735c8d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dmail/mixin", - "version": "2.1.0", + "version": "3.0.0", "license": "MIT", "repository": { "type": "git", diff --git a/readme.md b/readme.md index 83e5d18..4898083 100644 --- a/readme.md +++ b/readme.md @@ -4,34 +4,34 @@ [![build](https://travis-ci.org/dmail/mixin.svg?branch=master)](http://travis-ci.org/dmail/mixin) [![codecov](https://codecov.io/gh/dmail/mixin/branch/master/graph/badge.svg)](https://codecov.io/gh/dmail/mixin) -Factory functions composition +Object composition helpers ## Example ```javascript -import { createFactory, mixin } from "@dmail/mixin" +import { mixin, pure } from "@dmail/mixin" -const walkTalent = ({ getName }) => { +const walkTalent = ({ name }) => { return { - walk: () => `${getName()} walk`, + walk: () => `${name} walk`, } } -const flyTalent = ({ getName }) => { +const flyTalent = ({ name }) => { return { - fly: () => `${getName()} fly`, + fly: () => `${name} fly`, } } -const createAnimal = createFactory(({ name }) => { - const getName = () => name - return { getName } -}) +const dog = mixin(pure, () => ({ name: "dog" }), walkTalent) +const duck = mixin(pure, () => ({ name: "duck" }), walkTalent, flyTalent) -const animal = mixin(createAnimal({ name: "foo" }), walkTalent, flyTalent) +dog.walk() // dog walk +dog.fly // undefined -animal.walk() // foo walk -animal.fly() // foo fly +duck.walk() // duck walk +duck.fly() // duck fly ``` -Check the [API Documentation](./docs/api.md) for more +* [API documentation](./docs/api.md) +* [Talent documentation](./docs/talent.md) diff --git a/src/factory.js b/src/factory.js index 0934312..4fd5ac5 100644 --- a/src/factory.js +++ b/src/factory.js @@ -1,11 +1,19 @@ -import { createPureProduct, mixin, hasTalent } from "./mixin.js" +import { pure, mixin, hasTalent } from "./mixin.js" export const createFactory = (talent) => { - const pureProduct = createPureProduct() const factory = (...args) => { - const parametrizedTalent = () => talent(...args) - parametrizedTalent.wrappedTalent = talent - return mixin(pureProduct, parametrizedTalent) + const { length } = args + if (length === 0) { + return mixin(pure, talent) + } + if (length === 1) { + const [arg] = args + if (typeof arg !== "object") { + throw new TypeError(`factory first argument must be an object`) + } + return mixin(pure, () => arg, talent) + } + throw new Error(`factory must be called with 1 or zero argument`) } factory.wrappedTalent = talent return factory diff --git a/src/factory.test.js b/src/factory.test.js index e4bc83f..7301552 100644 --- a/src/factory.test.js +++ b/src/factory.test.js @@ -1,21 +1,55 @@ import { createFactory, isProducedBy } from "./factory.js" import { replicate } from "./mixin.js" import { createTest } from "@dmail/test" -import { expectMatch, expectFunction, expectProperties, expectChain } from "@dmail/expect" +import { + expectMatch, + expectFunction, + expectChain, + expectThrowWith, + matchErrorWith, + matchTypeErrorWith, +} from "@dmail/expect" export const test = createTest({ "createFactory(fn) returns a function": () => { const factory = createFactory(() => {}) return expectFunction(factory) }, - "createFactory(fn) returned function args are passed to factory": () => { - let passedArgs - const factory = createFactory((...args) => { - passedArgs = args + "createFactory(fn) calling returned factory with an object": () => { + let passedObject + const factory = createFactory((arg) => { + passedObject = arg }) - const args = [0, 1] - factory(...args) - return expectProperties(passedArgs, args) + const object = { foo: true } + factory(object) + + return expectChain( + () => expectMatch(passedObject.foo, true), + () => expectMatch(Object.isExtensible(passedObject), false), + () => { + // you can still mutate original object (but should not do it) + object.bar = true + return expectMatch(object.bar, true) + }, + ) + }, + "calling returned factory with 1 argument which is not an object": () => { + const factory = createFactory(() => {}) + return expectThrowWith( + () => factory(true), + matchTypeErrorWith({ + message: "factory first argument must be an object", + }), + ) + }, + "calling factory with more than 2 argument": () => { + const factory = createFactory(() => {}) + return expectThrowWith( + () => factory(true, true), + matchErrorWith({ + message: "factory must be called with 1 or zero argument", + }), + ) }, "createFactory can return properties which are set on return value": () => { const method = () => {} diff --git a/src/helper.js b/src/helper.js index a5212a2..e2877a4 100644 --- a/src/helper.js +++ b/src/helper.js @@ -1,10 +1,10 @@ -export const defineReadOnlyHiddenProperty = (object, name, value) => { - Object.defineProperty(object, name, { - configurable: true, - enumerable: false, - writable: false, - value, - }) +const frozenDescriptor = { + configurable: false, + enumerable: false, + writable: false, +} +export const defineFrozenProperty = (object, name, value) => { + Object.defineProperty(object, name, { ...frozenDescriptor, value }) } // in case object is created by Object.create(null) it does not have hasOwnProperty @@ -15,21 +15,46 @@ export const hasOwnProperty = (object, property) => { return Object.prototype.hasOwnProperty.call(object, property) } -const installMethod = (object, name, value) => { - if (typeof value !== "function") { - // do not forget value can be a symbol, that's why there is String(value) - throw new Error( - `installMethod third argument must be a function (got ${String(value)} for ${String(name)})`, - ) - } - defineReadOnlyHiddenProperty(object, name, value) -} - -export const installMethods = (methods, object) => { - Object.getOwnPropertyNames(methods).forEach((name) => { - installMethod(object, name, methods[name]) +export const installProperties = (source, object) => { + Object.getOwnPropertyNames(source).forEach((name) => { + defineFrozenProperty(object, name, source[name]) }) - Object.getOwnPropertySymbols(methods).forEach((symbol) => { - installMethod(object, symbol, methods[symbol]) + Object.getOwnPropertySymbols(source).forEach((symbol) => { + defineFrozenProperty(object, symbol, source[symbol]) }) } + +// bro you should not use array or function here +// function would not be cloned and their properties would not be frozen +// about array they would become strange objects instead of being true array +// and their values won't be frozen +// export const cloneAndFreezeDeep = (object) => { +// const references = [] +// const getReference = (value) => references.find(({ from }) => from === value) +// const setReference = (from, to) => references.push({ from, to }) + +// const cloneAndFreeze = (object) => { +// const clone = Object.create(Object.getPrototypeOf(object)) +// setReference(object, clone) + +// const cloneProperty = (nameOrSymbol) => { +// const propertyValue = object[nameOrSymbol] +// if (typeof propertyValue === "object" && propertyValue !== null) { +// const reference = getReference(propertyValue) +// if (reference) { +// defineFrozenProperty(clone, nameOrSymbol, reference.target) +// } else { +// defineFrozenProperty(clone, nameOrSymbol, cloneAndFreeze(propertyValue)) +// } +// } else { +// defineFrozenProperty(clone, nameOrSymbol, propertyValue) +// } +// } + +// Object.getOwnPropertyNames(object).forEach((name) => cloneProperty(name)) +// Object.getOwnPropertySymbols(object).forEach((symbol) => cloneProperty(symbol)) +// Object.preventExtensions(clone) +// } + +// return cloneAndFreeze(object) +// } diff --git a/src/index.js b/src/index.js index 6148959..7d02fc1 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,6 @@ import { isProduct, - createPureProduct, + pure, hasTalent as optimistHasTalent, mixin as optimistMixin, replicate as optimistReplicate, @@ -10,7 +10,7 @@ import { isProducedBy as optimistIsProducedBy, } from "./factory.js" -export { isProduct, createPureProduct } +export { isProduct, pure } export const hasTalent = (firstArg, secondArg) => { if (typeof firstArg !== "function") { diff --git a/src/mixin.js b/src/mixin.js index 874b211..6fce925 100644 --- a/src/mixin.js +++ b/src/mixin.js @@ -1,76 +1,56 @@ /* talent, trait, mixin, stampit - // https://gist.github.com/petsel/7677638 */ -import { installMethods, hasOwnProperty, defineReadOnlyHiddenProperty } from "./helper.js" +import { installProperties, hasOwnProperty, defineFrozenProperty } from "./helper.js" -// this is an helper to get the value from which destructured methods come from -// without it, you cannot use param destructuring and get the destructured object at the same time -// it may disappear in favor of somehting more explicit such as -// replacing test(valueOf()) by test({ foo, bar}) -// const valueOfName = "valueOf" -// const installValueOfHelper = (object) => { -// const valueOf = () => object -// Object.defineProperty(object, valueOfName, { -// configurable: true, -// value: valueOf, -// }) -// } +const talentSymbol = Symbol.for("talent") -// // gives a pointer to the object having the exported functions as well -// // may disappear too in case it's not that usefull -// const lastValueOfName = "lastValueOf" -// const installLastValueOfHelper = (object) => { -// let lastValue = object -// const lastValueOf = () => lastValue +const createPureProduct = () => { + // should be Object.create(null), for now it's not well supported + // by my other libraries + const pureProduct = {} + defineFrozenProperty(pureProduct, talentSymbol, null) + Object.preventExtensions(pureProduct) + return pureProduct +} -// const currentLastValueOf = object[lastValueOfName] -// if (currentLastValueOf) { -// currentLastValueOf.override(lastValue) -// defineReadOnlyHiddenProperty(object, lastValueOfName, currentLastValueOf) -// } else { -// lastValueOf.override = (value) => { -// lastValue = value -// } -// defineReadOnlyHiddenProperty(object, lastValueOfName, lastValueOf) -// } -// } +export const pure = createPureProduct() -const talentsSymbol = Symbol.for("talents") +export const isProduct = (arg) => hasOwnProperty(arg, talentSymbol) -// chaque produit peut n'avoir qu'un seul talent puisqu'on -// ne mutate rien, chaque valeur pourrait donc avoir un symbol talent qui ne soit pas un tableau -// mais juste le talent lui même -// comme chaque produit est lié par prototype au précédent -// hasTalent pourrait utiliser Object.getPrototypeOf() pour voir si -// un des prototype parent possède ce talent -// précisons que cette implémentation a un avantage majeur: -// en case de rédéfinition de la propriété on peut toujours accéder -// à lancienne propriété puisqu'elle est définie dans le parent -// override est inutile du coup -export const createPureProduct = () => { - const pureProduct = {} // should be Object.create(null), for now it's not well supported - defineReadOnlyHiddenProperty(pureProduct, talentsSymbol, []) - return pureProduct +const getModel = (product) => Object.getPrototypeOf(product) + +const findModel = (product, predicate) => { + let model = getModel(product) + while (model) { + if (predicate(model)) { + return model + } + model = getModel(model) + } + return null } -export const isProduct = (arg) => hasOwnProperty(arg, talentsSymbol) +const hasOwnTalent = (talent, product) => product[talentSymbol] === talent export const hasTalent = (talent, product) => { - return product[talentsSymbol].some( - (productTalent) => talent === productTalent || talent === productTalent.wrappedTalent, - ) + if (hasOwnTalent(talent, product)) { + return true + } + return Boolean(findModel(product, (model) => hasOwnTalent(talent, model))) } const addTalent = (talent, product) => { - const returnValue = talent(product) const talentedProduct = Object.create(product) - defineReadOnlyHiddenProperty(talentedProduct, talentsSymbol, [...product[talentsSymbol], talent]) + defineFrozenProperty(talentedProduct, "valueOf", () => talentedProduct) + const returnValue = talent(talentedProduct) + defineFrozenProperty(talentedProduct, talentSymbol, talent) if (returnValue !== null && typeof returnValue === "object") { - installMethods(returnValue, talentedProduct) + installProperties(returnValue, talentedProduct) } + Object.preventExtensions(talentedProduct) return talentedProduct } @@ -81,8 +61,16 @@ export const mixin = (product, ...talents) => { } export const replicate = (product) => { - return product[talentsSymbol].reduce( - (accumulator, talent) => addTalent(talent, accumulator), - createPureProduct(), - ) + const talents = [] + const unshiftTalent = (product) => { + const talent = product[talentSymbol] + if (talent) { + talents.unshift(talent) + } + } + + unshiftTalent(product) + findModel(product, unshiftTalent) + + return mixin(pure, ...talents) } diff --git a/src/mixin.test.js b/src/mixin.test.js index c9d959a..f428a92 100644 --- a/src/mixin.test.js +++ b/src/mixin.test.js @@ -1,42 +1,37 @@ -import { createPureProduct, isProduct, mixin, replicate } from "./mixin.js" +import { pure, isProduct, mixin, replicate, hasTalent } from "./mixin.js" import { createTest } from "@dmail/test" -import { - expectMatch, - matchNot, - expectProperties, - expectThrowWith, - matchErrorWith, - expectChain, -} from "@dmail/expect" +import { expectMatch, matchNot, expectProperties, expectChain } from "@dmail/expect" export const test = createTest({ - "mixin does not mutate product": () => { - const pureProduct = createPureProduct() - const product = mixin(pureProduct, () => {}) - return expectMatch(pureProduct, matchNot(product)) + "pure is a non extensible obect": () => { + return expectMatch(Object.isExtensible(pure), false) }, - "mixin on talent returning null": () => { - return expectProperties(mixin(createPureProduct(), () => null), {}) + "mixin returns a new product": () => { + const product = mixin(pure, () => {}) + return expectMatch(pure, matchNot(product)) + }, + "mixin return a non extensible object": () => { + const product = mixin(pure, () => {}) + return expectMatch(Object.isExtensible(product), false) }, - "mixin return product with hidden method returned by talent": () => { + "mixin return product with frozen property returned by talent": () => { const method = () => {} - const product = mixin(createPureProduct(), () => ({ method })) + const product = mixin(pure, () => ({ method })) const methodDescriptor = Object.getOwnPropertyDescriptor(product, "method") return expectProperties(methodDescriptor, { enumerable: false, - configurable: true, + configurable: false, writable: false, value: method, }) }, + "mixin on talent returning null": () => { + return expectProperties(mixin(pure, () => null), {}) + }, "mixin with talent returning existing property name": () => { const firstMethod = () => {} const secondMethod = () => {} - const product = mixin( - createPureProduct(), - () => ({ foo: firstMethod }), - () => ({ foo: secondMethod }), - ) + const product = mixin(pure, () => ({ foo: firstMethod }), () => ({ foo: secondMethod })) return expectProperties(product, { foo: secondMethod, }) @@ -46,7 +41,7 @@ export const test = createTest({ const secondMethod = () => {} const propertySymbol = Symbol() const product = mixin( - createPureProduct(), + pure, () => ({ [propertySymbol]: firstMethod }), () => ({ [propertySymbol]: secondMethod }), ) @@ -57,18 +52,13 @@ export const test = createTest({ // should also be tested with anonymous symbol & named symbol // to check the produced error message "mixin with talent returning something a boolean value for a property": () => { - return expectThrowWith( - () => mixin(createPureProduct(), () => ({ foo: true })), - matchErrorWith({ - message: "installMethod third argument must be a function (got true for foo)", - }), - ) + return expectProperties(mixin(pure, () => ({ foo: true })), { foo: true }) }, - "isProduct() on createPureProduct()": () => { - return expectMatch(isProduct(createPureProduct()), true) + "isProduct() on pure": () => { + return expectMatch(isProduct(pure), true) }, "isProduct() on mixed pure product": () => { - return expectMatch(isProduct(mixin(createPureProduct(), () => {})), true) + return expectMatch(isProduct(mixin(pure, () => {})), true) }, "isProduct(null)": () => { return expectMatch(isProduct(null), false) @@ -76,7 +66,20 @@ export const test = createTest({ "isProduct(undefined)": () => { return expectMatch(isProduct(undefined), false) }, - // hasTalent must be tested + "hasTalent()": () => { + const talent = () => {} + + return expectChain( + () => expectMatch(hasTalent(talent, pure), false), + () => expectMatch(hasTalent(talent, mixin(pure, talent)), true), + () => expectMatch(hasTalent(talent, mixin(pure, talent, () => {})), true), + ) + }, + "valueOf()": () => { + const talent = ({ valueOf }) => ({ self: valueOf() }) + const product = mixin(pure, talent) + return expectMatch(product.self, product) + }, "replicate() a product with many talents": () => { const zeroValueTalent = () => { let value = 0 @@ -92,7 +95,6 @@ export const test = createTest({ return { increment } } - const pure = createPureProduct() const product = mixin(pure, zeroValueTalent, incrementTalent) return expectChain(