├── .prettierignore ├── .gitignore ├── .prettierrc ├── jest.config.js ├── examples ├── types.ts ├── debug-visually.ts └── build-graph.ts ├── .github └── workflows │ ├── build.yml │ └── publish.yml ├── .cspell.json ├── package.json ├── src ├── ImmutableMap.ts └── index.ts ├── tests ├── ImmutableMap.ts └── index.ts ├── graph.svg ├── README.md └── tsconfig.json /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | docs/ 3 | dist/ 4 | yarn-error.log 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "plugins": ["prettier-plugin-jsdoc"] 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | testRegex: 'tests', 5 | }; 6 | -------------------------------------------------------------------------------- /examples/types.ts: -------------------------------------------------------------------------------- 1 | import { Graph } from '../src'; 2 | import { Codec } from 'io-ts/Codec'; 3 | import * as Cod from 'io-ts/Codec'; 4 | 5 | // First, let's define some custom Id, Edge and Node type for our Graph 6 | 7 | export type MyId = string; 8 | 9 | export type MyNode = { firstName: string; lastName: string; age: number }; 10 | 11 | export type MyEdge = { items: number[] }; 12 | 13 | // With this we can define a customized Graph type 14 | 15 | export type MyGraph = Graph; 16 | 17 | export const MyIdCodec: Codec = Cod.string; 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: ['main', $default-branch] 6 | pull_request: 7 | branches: ['main', $default-branch] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [12.x, 14.x, 15.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | - run: yarn install 24 | - run: yarn spell 25 | - run: yarn pretty 26 | - run: yarn build 27 | - run: yarn test 28 | - run: yarn docs 29 | -------------------------------------------------------------------------------- /.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1", 3 | "language": "en", 4 | "ignorePaths": [ 5 | "**/node_modules/**", 6 | "**/.cache/**", 7 | "**/dist/**", 8 | "**/.git/**", 9 | "**/.cache/**", 10 | "**/*.tsbuildinfo", 11 | "tmp/", 12 | "yarn.lock", 13 | "./package.json", 14 | "./package-lock.json", 15 | "**/yarn-error.log", 16 | "docs/_config.yml", 17 | "docs/modules/*.ts.md", 18 | "graph.svg", 19 | ".vscode" 20 | ], 21 | "words": [ 22 | "combinators", 23 | "Crowther", 24 | "doctoc", 25 | "dotfile", 26 | "graphviz", 27 | "Khushi", 28 | "Rian", 29 | "roadmap", 30 | "Samual", 31 | "struct", 32 | "Tonicha", 33 | "Tsvg", 34 | "upsert" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v1 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 12 15 | registry-url: https://registry.npmjs.org/ 16 | - run: yarn install 17 | - run: npm publish --access public 18 | env: 19 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 20 | 21 | deploy-docs: 22 | runs-on: ubuntu-latest 23 | needs: build 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v2.3.1 27 | 28 | - name: Install and Build 29 | run: | 30 | yarn install 31 | yarn docs 32 | - name: Deploy 33 | uses: JamesIves/github-pages-deploy-action@4.0.0 34 | with: 35 | branch: gh-pages 36 | folder: docs 37 | -------------------------------------------------------------------------------- /examples/debug-visually.ts: -------------------------------------------------------------------------------- 1 | import * as G from '../src'; 2 | import * as O from 'fp-ts/Option'; 3 | import { flow, pipe } from 'fp-ts/function'; 4 | 5 | // We import our graph from the previous section 6 | import { myGraph } from './build-graph'; 7 | import { MyIdCodec } from './types'; 8 | 9 | pipe( 10 | myGraph, 11 | 12 | // We need to map over the graph as it may be invalid 13 | O.map( 14 | flow( 15 | // Then turn the edges into strings 16 | G.mapEdge(({ items }) => items.join(', ')), 17 | 18 | // The same we do with the nodes 19 | G.map( 20 | ({ firstName, lastName, age }) => `${lastName}, ${firstName} (${age})` 21 | ), 22 | 23 | // For debugging, we generate a simple dot file 24 | G.toDotFile(MyIdCodec)((_) => _.toString()) 25 | ) 26 | ), 27 | 28 | // Depending on if the graph was valid 29 | O.fold( 30 | // We either print an error 31 | () => console.error('invalid graph!'), 32 | 33 | // Or output the dot file 34 | console.log 35 | ) 36 | ); 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@no-day/fp-ts-graph", 3 | "version": "0.3.1", 4 | "homepage": "https://github.com/no-day/fp-ts-graph", 5 | "main": "dist/src/index.js", 6 | "module": "src/index.ts", 7 | "types": "dist/src/index.d.ts", 8 | "license": "MIT", 9 | "peerDependencies": { 10 | "fp-ts": "^2.11.1" 11 | }, 12 | "devDependencies": { 13 | "@no-day/fp-ts-graph": "git+https://github.com/no-day/fp-ts-graph", 14 | "@types/jest": "^27.0.1", 15 | "@types/node": "^14.14.31", 16 | "cspell": "^5.8.2", 17 | "docs-ts": "^0.6.10", 18 | "doctoc": "^2.0.1", 19 | "fp-ts": "^2.11.1", 20 | "jest": "^26.6.3", 21 | "prettier": "^2.3.2", 22 | "prettier-plugin-jsdoc": "^0.3.23", 23 | "ts-jest": "^26.5.6", 24 | "ts-node": "^9.1.1", 25 | "typescript": "^4.2.4" 26 | }, 27 | "scripts": { 28 | "spell": "yarn cspell '**/*.*'", 29 | "build": "tsc", 30 | "prepublish": "yarn build", 31 | "docs": "yarn docs-ts", 32 | "test": "yarn jest", 33 | "pretty": "yarn run prettier --check .", 34 | "doctoc": "yarn doctoc" 35 | }, 36 | "dependencies": { 37 | "io-ts": "^2.2.16", 38 | "immutable": "^4.0.0-rc.14" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ImmutableMap.ts: -------------------------------------------------------------------------------- 1 | /** @since 0.3.0 */ 2 | 3 | import { Option } from 'fp-ts/Option'; 4 | import * as O from 'fp-ts/Option'; 5 | import { Encoder } from 'io-ts/Encoder'; 6 | import { Map } from 'immutable'; 7 | import { pipe } from 'fp-ts/function'; 8 | 9 | /** 10 | * Insert or replace a key/value pair in an immutable `Map`. 11 | * 12 | * @since 0.3.0 13 | * @category Combinators 14 | */ 15 | export const upsertAt = 16 | (E: Encoder) => 17 | (k: K, a: A) => 18 | (m: Map): Map => { 19 | const encodedKey = E.encode(k); 20 | if (m.has(encodedKey) && m.get(encodedKey) === a) { 21 | return m; 22 | } 23 | return m.set(encodedKey, a); 24 | }; 25 | 26 | /** 27 | * Update a key/value pair in an immutable `Map`. 28 | * 29 | * @since 0.3.0 30 | * @category Combinators 31 | */ 32 | export const modifyAt = 33 | (E: Encoder) => 34 | (k: K, f: (a: A) => A) => 35 | (m: Map): Option> => { 36 | const encodedKey = E.encode(k); 37 | return pipe( 38 | m.get(encodedKey, null), 39 | O.fromNullable, 40 | O.map((value) => m.set(encodedKey, f(value))) 41 | ); 42 | }; 43 | 44 | /** 45 | * Lookup the value for a key in an immutable `Map`. 46 | * 47 | * @since 0.3.0 48 | * @category Utils 49 | */ 50 | export const lookup = 51 | (E: Encoder) => 52 | (k: K) => 53 | (m: Map): Option => 54 | pipe(m.get(E.encode(k), null), O.fromNullable); 55 | -------------------------------------------------------------------------------- /examples/build-graph.ts: -------------------------------------------------------------------------------- 1 | import * as G from '../src'; 2 | import { pipe } from 'fp-ts/function'; 3 | import * as O from 'fp-ts/Option'; 4 | import { Option } from 'fp-ts/Option'; 5 | 6 | // We import our types from the previous section 7 | import { MyEdge, MyId, MyNode, MyGraph, MyIdCodec } from './types'; 8 | 9 | // To save some writing, we define partially applied versions of the builder functions 10 | 11 | const empty = G.empty(); 12 | const insertNode = G.insertNode(MyIdCodec); 13 | const insertEdge = G.insertEdge(MyIdCodec); 14 | 15 | // Then, let's fill the graph with Data. 16 | 17 | export const myGraph: Option = pipe( 18 | // We start out with and empty graph. 19 | empty, 20 | 21 | // And add some nodes to it. 22 | insertNode('TC', { 23 | firstName: 'Tonicha', 24 | lastName: 'Crowther', 25 | age: 45, 26 | }), 27 | insertNode('SS', { 28 | firstName: 'Samual', 29 | lastName: 'Sierra', 30 | age: 29, 31 | }), 32 | insertNode('KW', { 33 | firstName: 'Khushi', 34 | lastName: 'Walter', 35 | age: 40, 36 | }), 37 | insertNode('RR', { 38 | firstName: 'Rian', 39 | lastName: 'Ruiz', 40 | age: 56, 41 | }), 42 | 43 | // Then we connect them with edges, which can have data, too 44 | 45 | O.of, 46 | O.chain(insertEdge('TC', 'SS', { items: [2, 3] })), 47 | O.chain(insertEdge('SS', 'KW', { items: [4] })), 48 | O.chain(insertEdge('TC', 'KW', { items: [9, 4, 3] })), 49 | O.chain(insertEdge('KW', 'RR', { items: [2, 3] })) 50 | ); 51 | -------------------------------------------------------------------------------- /tests/ImmutableMap.ts: -------------------------------------------------------------------------------- 1 | import { Map } from 'immutable'; 2 | import { pipe } from 'fp-ts/function'; 3 | import * as O from 'fp-ts/Option'; 4 | import * as C from 'io-ts/Codec'; 5 | import * as IM from '../src/ImmutableMap'; 6 | 7 | describe('ImmutableMap', () => { 8 | describe('upsertAt', () => { 9 | test('Should insert two records in a map', () => { 10 | expect( 11 | pipe( 12 | Map(), 13 | IM.upsertAt(C.string)('hello', 'world'), 14 | IM.upsertAt(C.string)('foo', 'bar') 15 | ) 16 | ).toStrictEqual( 17 | Map(<[string, string][]>[ 18 | ['hello', 'world'], 19 | ['foo', 'bar'], 20 | ]) 21 | ); 22 | }); 23 | }); 24 | 25 | describe('modifyAt', () => { 26 | test('Should update record in a map', () => { 27 | expect( 28 | pipe( 29 | Map(), 30 | IM.upsertAt(C.string)('hello', 'world'), 31 | IM.upsertAt(C.string)('foo', 'bar'), 32 | IM.modifyAt(C.string)('hello', () => 'yellow') 33 | ) 34 | ).toStrictEqual( 35 | O.some( 36 | Map(<[string, string][]>[ 37 | ['hello', 'yellow'], 38 | ['foo', 'bar'], 39 | ]) 40 | ) 41 | ); 42 | }); 43 | 44 | test('Should fail updating missing record in a map', () => { 45 | expect( 46 | pipe( 47 | Map(), 48 | IM.upsertAt(C.string)('hello', 'world'), 49 | IM.upsertAt(C.string)('foo', 'bar'), 50 | IM.modifyAt(C.string)('world', () => 'hello') 51 | ) 52 | ).toStrictEqual(O.none); 53 | }); 54 | }); 55 | 56 | describe('lookup', () => { 57 | test('Should lookup existing record in a map', () => { 58 | expect( 59 | pipe( 60 | Map(), 61 | IM.upsertAt(C.string)('hello', 'world'), 62 | IM.upsertAt(C.string)('foo', 'bar'), 63 | IM.lookup(C.string)('foo') 64 | ) 65 | ).toStrictEqual(O.some('bar')); 66 | }); 67 | 68 | test('Should fail when looking up missing record in a map', () => { 69 | expect( 70 | pipe( 71 | Map(), 72 | IM.upsertAt(C.string)('hello', 'world'), 73 | IM.upsertAt(C.string)('foo', 'bar'), 74 | IM.lookup(C.string)('bar') 75 | ) 76 | ).toStrictEqual(O.none); 77 | }); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /graph.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | %3 11 | 12 | 13 | 14 | 1001 15 | 16 | Crowther, Tonicha (45) 17 | 18 | 19 | 20 | 1002 21 | 22 | Sierra, Samual (29) 23 | 24 | 25 | 26 | 1001->1002 27 | 28 | 29 | 2, 3 30 | 31 | 32 | 33 | 1003 34 | 35 | Walter, Khushi (40) 36 | 37 | 38 | 39 | 1001->1003 40 | 41 | 42 | 9, 4, 3 43 | 44 | 45 | 46 | 1002->1003 47 | 48 | 49 | 4 50 | 51 | 52 | 53 | 1004 54 | 55 | Ruiz, Rian (56) 56 | 57 | 58 | 59 | 1003->1004 60 | 61 | 62 | 2, 3 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fp-ts-graph 2 | 3 | Immutable, functional, highly performant graph data structure for [fp-ts](https://github.com/gcanti/fp-ts). 4 | 5 | ```ts 6 | type Graph = ... 7 | ``` 8 | 9 | | Quality | y/n | 10 | | -------------- | --- | 11 | | directed | yes | 12 | | cyclic | yes | 13 | | multiple edges | no | 14 | 15 | In the future a granular distinction of graph qualities may be supported, see roadmap. 16 | 17 | ### Table of Contents 18 | 19 | 20 | 21 | 22 | - [What it's not](#what-its-not) 23 | - [Install](#install) 24 | - [Docs](#docs) 25 | - [Examples](#examples) 26 | - [Define Types](#define-types) 27 | - [Build Graph](#build-graph) 28 | - [Debug graph visually](#debug-graph-visually) 29 | - [Roadmap / to do](#roadmap--to-do) 30 | 31 | 32 | 33 | ## What it is 34 | 35 | ## What it's not 36 | 37 | A rendering engine or anything that has to do with a visual representation of a graph. However, for for debugging purpose we provide simple graphviz dotfile generator. 38 | 39 | ## Install 40 | 41 | ```bash 42 | npm install fp-ts @no-day/fp-ts-graph 43 | ``` 44 | 45 | ## Docs 46 | 47 | [API Docs](https://no-day.github.io/fp-ts-graph/modules/index.ts.html) 48 | 49 | ## Examples 50 | 51 | ### Define Types 52 | 53 | ```ts 54 | // examples/types.ts 55 | 56 | import { Graph } from '@no-day/fp-ts-graph'; 57 | import { Codec } from 'io-ts/Codec'; 58 | import * as Cod from 'io-ts/Codec'; 59 | 60 | // First, let's define some custom Id, Edge and Node type for our Graph 61 | 62 | export type MyId = string; 63 | 64 | export type MyNode = { firstName: string; lastName: string; age: number }; 65 | 66 | export type MyEdge = { items: number[] }; 67 | 68 | // Define codec for encoding and decoding Id to string 69 | 70 | export const MyIdCodec: Codec = Cod.string; 71 | 72 | // With this we can define a customized Graph type 73 | 74 | export type MyGraph = Graph; 75 | ``` 76 | 77 | ### Build Graph 78 | 79 | ```ts 80 | // examples/build-graph.ts 81 | 82 | import Graph, * as G from '../src'; 83 | import * as N from 'fp-ts/number'; 84 | import { pipe } from 'fp-ts/function'; 85 | import * as O from 'fp-ts/Option'; 86 | import { Option } from 'fp-ts/Option'; 87 | 88 | // We import our types from the previous section 89 | import { MyEdge, MyId, MyNode, MyGraph, MyIdCodec } from './types'; 90 | 91 | // To save some writing, we define partially applied versions of the builder functions 92 | 93 | const empty = G.empty(); 94 | const insertNode = G.insertNode(MyIdCodec); 95 | const insertEdge = G.insertEdge(MyIdCodec); 96 | 97 | // Then, let's fill the graph with Data. 98 | 99 | export const myGraph: Option = pipe( 100 | // We start out with and empty graph. 101 | empty, 102 | 103 | // And add some nodes to it. 104 | insertNode('TC', { 105 | firstName: 'Tonicha', 106 | lastName: 'Crowther', 107 | age: 45, 108 | }), 109 | insertNode('SS', { 110 | firstName: 'Samual', 111 | lastName: 'Sierra', 112 | age: 29, 113 | }), 114 | insertNode('KW', { 115 | firstName: 'Khushi', 116 | lastName: 'Walter', 117 | age: 40, 118 | }), 119 | insertNode('RR', { 120 | firstName: 'Rian', 121 | lastName: 'Ruiz', 122 | age: 56, 123 | }), 124 | 125 | // Then we connect them with edges, which can have data, too 126 | 127 | O.of, 128 | O.chain(insertEdge('TC', 'SS', { items: [2, 3] })), 129 | O.chain(insertEdge('SS', 'KW', { items: [4] })), 130 | O.chain(insertEdge('TC', 'KW', { items: [9, 4, 3] })), 131 | O.chain(insertEdge('KW', 'RR', { items: [2, 3] })) 132 | ); 133 | ``` 134 | 135 | ### Debug graph visually 136 | 137 | ```ts 138 | // examples/debug-visually.ts 139 | 140 | import * as G from '../src'; 141 | import * as O from 'fp-ts/Option'; 142 | import { flow, pipe } from 'fp-ts/function'; 143 | 144 | // We import our graph from the previous section 145 | import { myGraph } from './build-graph'; 146 | 147 | // We import Id codec 148 | import { MyIdCodec } from './types'; 149 | 150 | pipe( 151 | myGraph, 152 | 153 | // We need to map over the graph as it may be invalid 154 | O.map( 155 | flow( 156 | // Then turn the edges into strings 157 | G.mapEdge(({ items }) => items.join(', ')), 158 | 159 | // The same we do with the nodes 160 | G.map( 161 | ({ firstName, lastName, age }) => `${lastName}, ${firstName} (${age})` 162 | ), 163 | 164 | // For debugging, we generate a simple dot file 165 | G.toDotFile(MyIdCodec)((_) => _.toString()) 166 | ) 167 | ), 168 | 169 | // Depending on if the graph was valid 170 | O.fold( 171 | // We either print an error 172 | () => console.error('invalid graph!'), 173 | 174 | // Or output the dot file 175 | console.log 176 | ) 177 | ); 178 | ``` 179 | 180 | If you have [graphviz](https://graphviz.org) installed you can run the following in the terminal: 181 | 182 | ```bash 183 | ts-node examples/debug-visually.ts | dot -Tsvg > graph.svg 184 | chromium graph.svg 185 | ``` 186 | 187 | 188 | 189 | ## Roadmap / to do 190 | 191 | - Add instances 192 | - `Functor` 193 | - `Eq` 194 | - `Ord` 195 | - `Foldable` 196 | - ... 197 | - Add functions 198 | - `deleteNode` 199 | - `deleteEdge` 200 | - `outgoingIds` 201 | - `incomingIds` 202 | - `mapEdgeWithIndex` 203 | - `mapWithIndex` 204 | - `topologicalSort` 205 | - ... 206 | - Ideas 207 | - Represent different qualities of a graph on the type level 208 | Like: `Graph<{directed: true, multiEdge: true, cyclic: false}, Id, Edge, Node>` 209 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 8 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 9 | "lib": [ 10 | "DOM", 11 | "ES2015" 12 | ] /* Specify library files to be included in the compilation. */, 13 | // "allowJs": true, /* Allow javascript files to be compiled. */ 14 | // "checkJs": true, /* Report errors in .js files. */ 15 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 16 | "declaration": true /* Generates corresponding '.d.ts' file. */, 17 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 18 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 19 | // "outFile": "./", /* Concatenate and emit output to single file. */ 20 | "outDir": "./dist" /* Redirect output structure to the directory. */, 21 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 22 | // "composite": true, /* Enable project compilation */ 23 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 24 | // "removeComments": true, /* Do not emit comments to output. */ 25 | // "noEmit": true, /* Do not emit outputs. */ 26 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 27 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 28 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 29 | 30 | /* Strict Type-Checking Options */ 31 | "strict": true /* Enable all strict type-checking options. */, 32 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 33 | // "strictNullChecks": true, /* Enable strict null checks. */ 34 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 35 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 36 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 37 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 38 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 39 | 40 | /* Additional Checks */ 41 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 42 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 43 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 44 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 45 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 46 | 47 | /* Module Resolution Options */ 48 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 49 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 50 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 51 | "rootDirs": [] /* List of root folders whose combined content represents the structure of the project at runtime. */, 52 | // "typeRoots": [], /* List of folders to include type definitions from. */ 53 | // "types": [] /* Type declaration files to be included in compilation. */, 54 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 55 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 56 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 57 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 58 | 59 | /* Source Map Options */ 60 | //"sourceRoot": "" /* Specify the location where debugger should locate TypeScript files instead of source locations. */, 61 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 62 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 63 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 64 | 65 | /* Experimental Options */ 66 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 67 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 68 | 69 | /* Advanced Options */ 70 | "skipLibCheck": true /* Skip type checking of declaration files. */, 71 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 72 | }, 73 | "include": ["src/**/*.ts", "examples/**/*.ts"] 74 | } 75 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** @since 0.1.0 */ 2 | 3 | import { pipe } from 'fp-ts/function'; 4 | import * as A from 'fp-ts/Array'; 5 | import * as E from 'fp-ts/Either'; 6 | import * as O from 'fp-ts/Option'; 7 | import { Option } from 'fp-ts/Option'; 8 | import { Codec } from 'io-ts/Codec'; 9 | import { Decoder } from 'io-ts/Decoder'; 10 | import { Encoder } from 'io-ts/Encoder'; 11 | import { Map, Set } from 'immutable'; 12 | import * as IM from './ImmutableMap'; 13 | 14 | // ----------------------------------------------------------------------------- 15 | // model 16 | // ----------------------------------------------------------------------------- 17 | 18 | /** 19 | * Graph data structure. Currently we still expose the internal implementation 20 | * but those details may become opaque in the future. 21 | * 22 | * - Id means `Id` of a node, 23 | * - `Node` is the data/label attached to a node 24 | * - `Edge` is the data/label attached to a an edge 25 | * - `nodes` key is encoded `Id` to `string` using 'io-ts/Encoder' 26 | * - `edges` outer key is encoded from node `Id`, inner `Map` key is encoded to node `Id` 27 | * 28 | * @since 0.1.0 29 | * @category Model 30 | */ 31 | export interface Graph { 32 | readonly _brand: unique symbol; 33 | readonly nodes: Map>; 34 | readonly edges: Map>; 35 | } 36 | 37 | export { 38 | /** 39 | * @since 0.1.0 40 | * @category Model 41 | */ 42 | Graph as default, 43 | }; 44 | 45 | /** 46 | * A general type that describes a directed connection from an origin to a target 47 | * 48 | * @since 0.1.0 49 | * @category Model 50 | */ 51 | export type Direction = { from: T; to: T }; 52 | 53 | type NodeContext = { 54 | data: Node; 55 | outgoing: Set; 56 | incoming: Set; 57 | }; 58 | 59 | // ----------------------------------------------------------------------------- 60 | // constructors 61 | // ----------------------------------------------------------------------------- 62 | 63 | /** 64 | * Creates an empty graph. 65 | * 66 | * @since 0.1.0 67 | * @category Constructors 68 | * @example 69 | * import Graph, * as G from '@no-day/fp-ts-graph'; 70 | * 71 | * type MyGraph = Graph; 72 | * 73 | * // `G.empty()` will give you a `Graph` and as you'll 74 | * // insert nodes and edges of a specific type later, it makes sense to already 75 | * // provide the types. 76 | * 77 | * const myGraph: MyGraph = G.empty(); 78 | */ 79 | export const empty = (): Graph => 80 | unsafeMkGraph({ 81 | nodes: Map>(), 82 | edges: Map>(), 83 | }); 84 | 85 | // ----------------------------------------------------------------------------- 86 | // combinators 87 | // ----------------------------------------------------------------------------- 88 | 89 | /** 90 | * Inserts node data to a graph under a given id. If the id already exists in 91 | * the graph, the data is replaced. 92 | * 93 | * @since 0.1.0 94 | * @category Combinators 95 | * @example 96 | * import * as G from '@no-day/fp-ts-graph'; 97 | * import { pipe } from 'fp-ts/function'; 98 | * import * as C from 'io-ts/Codec'; 99 | * 100 | * const myGraph = pipe( 101 | * G.empty(), 102 | * G.insertNode(C.string)('54', 'n1'), 103 | * G.insertNode(C.string)('3', 'n2') 104 | * ); 105 | * 106 | * assert.deepStrictEqual( 107 | * pipe(myGraph, G.nodeEntries(C.string), (ent) => new Set(ent)), 108 | * new Set([ 109 | * ['54', 'n1'], 110 | * ['3', 'n2'], 111 | * ]) 112 | * ); 113 | * assert.deepStrictEqual(pipe(myGraph, G.edgeEntries(C.string)), []); 114 | */ 115 | export const insertNode = 116 | (E: Encoder) => 117 | (id: Id, data: Node) => 118 | (graph: Graph): Graph => 119 | unsafeMkGraph({ 120 | nodes: pipe( 121 | graph.nodes, 122 | IM.modifyAt(E)(id, ({ incoming, outgoing }) => ({ 123 | incoming, 124 | outgoing, 125 | data, 126 | })), 127 | O.getOrElse(() => 128 | pipe( 129 | graph.nodes, 130 | IM.upsertAt(E)(id, { 131 | data, 132 | incoming: Set(), 133 | outgoing: Set(), 134 | }) 135 | ) 136 | ) 137 | ), 138 | edges: graph.edges, 139 | }); 140 | 141 | /** 142 | * Tries to insert an edge with some data into a given graph. Only succeeds if 143 | * the specified start and end node id do exists in the graph. 144 | * 145 | * @since 0.1.0 146 | * @category Combinators 147 | * @example 148 | * import Graph, * as G from '@no-day/fp-ts-graph'; 149 | * import { pipe } from 'fp-ts/function'; 150 | * import * as O from 'fp-ts/Option'; 151 | * import * as C from 'io-ts/Codec'; 152 | * 153 | * type MyGraph = Graph; 154 | * 155 | * const myGraph: MyGraph = pipe( 156 | * G.empty(), 157 | * G.insertNode(C.string)('n1', 'Node 1'), 158 | * G.insertNode(C.string)('n2', 'Node 2') 159 | * ); 160 | * 161 | * assert.deepStrictEqual( 162 | * pipe( 163 | * myGraph, 164 | * G.insertEdge(C.string)('n1', 'n2', 'Edge 1'), 165 | * O.map(G.entries(C.string)) 166 | * ), 167 | * O.some({ 168 | * nodes: [ 169 | * ['n1', 'Node 1'], 170 | * ['n2', 'Node 2'], 171 | * ], 172 | * edges: [[{ from: 'n1', to: 'n2' }, 'Edge 1']], 173 | * }) 174 | * ); 175 | */ 176 | export const insertEdge = 177 | (E: Encoder) => 178 | (from: Id, to: Id, data: Edge) => 179 | (graph: Graph): Option> => 180 | pipe( 181 | graph.nodes, 182 | modifyEdgeInNodes(E)(from, to), 183 | O.map((nodes) => 184 | unsafeMkGraph({ 185 | nodes, 186 | edges: insertEdgeInEdges(E)(from, to, data)(graph.edges), 187 | }) 188 | ) 189 | ); 190 | 191 | /** 192 | * Maps over the graph's edges 193 | * 194 | * @since 0.1.0 195 | * @category Combinators 196 | */ 197 | export const mapEdge = 198 | (fn: (edge: Edge1) => Edge2) => 199 | (graph: Graph): Graph => 200 | unsafeMkGraph({ 201 | nodes: graph.nodes, 202 | edges: graph.edges.map((from) => from.map(fn)), 203 | }); 204 | 205 | /** 206 | * Maps over the graph's nodes. 207 | * 208 | * @since 0.1.0 209 | * @category Combinators 210 | */ 211 | export const mapNode = 212 | (fn: (node: Node1) => Node2) => 213 | (graph: Graph): Graph => 214 | unsafeMkGraph({ 215 | nodes: pipe( 216 | graph.nodes.map(({ incoming, outgoing, data }) => ({ 217 | incoming, 218 | outgoing, 219 | data: fn(data), 220 | })) 221 | ), 222 | edges: graph.edges, 223 | }); 224 | 225 | /** 226 | * Alias for `mapNode`. 227 | * 228 | * @since 0.1.0 229 | * @category Combinators 230 | */ 231 | export const map = mapNode; 232 | 233 | /** 234 | * Modifies a single edge in the graph. 235 | * 236 | * @since 0.2.0 237 | * @category Combinators 238 | */ 239 | export const modifyAtEdge = 240 | (E: Encoder) => 241 | (from: Id, to: Id, update: (e: Edge) => Edge) => 242 | (graph: Graph): Option> => 243 | pipe( 244 | graph.edges, 245 | IM.lookup(E)(from), 246 | O.chain(IM.modifyAt(E)(to, update)), 247 | O.chain((updatedTo) => 248 | pipe( 249 | graph.edges, 250 | IM.modifyAt(E)(from, () => updatedTo) 251 | ) 252 | ), 253 | O.map((edges) => unsafeMkGraph({ nodes: graph.nodes, edges })) 254 | ); 255 | 256 | /** 257 | * Modifies a single node in the graph. 258 | * 259 | * @since 0.2.0 260 | * @category Combinators 261 | */ 262 | export const modifyAtNode = 263 | (E: Encoder) => 264 | (id: Id, update: (n: Node) => Node) => 265 | (graph: Graph): Option> => 266 | pipe( 267 | graph.nodes, 268 | IM.modifyAt(E)(id, ({ incoming, outgoing, data }) => ({ 269 | incoming, 270 | outgoing, 271 | data: update(data), 272 | })), 273 | O.map((nodes) => unsafeMkGraph({ nodes, edges: graph.edges })) 274 | ); 275 | 276 | // ----------------------------------------------------------------------------- 277 | // utils 278 | // ----------------------------------------------------------------------------- 279 | 280 | /** 281 | * Retrieves an edge from the graph. 282 | * 283 | * @since 0.2.0 284 | * @category Utils 285 | * @example 286 | * import Graph, * as G from '@no-day/fp-ts-graph'; 287 | * import { pipe } from 'fp-ts/function'; 288 | * import * as O from 'fp-ts/Option'; 289 | * import * as C from 'io-ts/Codec'; 290 | * 291 | * type MyGraph = Graph; 292 | * 293 | * const myGraph: MyGraph = pipe( 294 | * G.empty(), 295 | * G.insertNode(C.string)('n1', 'Node 1'), 296 | * G.insertNode(C.string)('n2', 'Node 2'), 297 | * O.of, 298 | * O.chain(G.insertEdge(C.string)('n1', 'n2', 'Edge 1')), 299 | * O.getOrElse(() => G.empty()) 300 | * ); 301 | * 302 | * assert.deepStrictEqual( 303 | * pipe(myGraph, G.lookupEdge(C.string)('n1', 'n2')), 304 | * O.some('Edge 1') 305 | * ); 306 | */ 307 | export const lookupEdge = 308 | (E: Encoder) => 309 | (from: Id, to: Id) => 310 | (graph: Graph): Option => 311 | pipe(graph.edges, IM.lookup(E)(from), O.chain(IM.lookup(E)(to))); 312 | 313 | /** 314 | * Retrieves a node from the graph. 315 | * 316 | * @since 0.2.0 317 | * @category Utils 318 | * @example 319 | * import Graph, * as G from '@no-day/fp-ts-graph'; 320 | * import { pipe } from 'fp-ts/function'; 321 | * import * as O from 'fp-ts/Option'; 322 | * import * as C from 'io-ts/Codec'; 323 | * 324 | * type MyGraph = Graph; 325 | * 326 | * const myGraph: MyGraph = pipe( 327 | * G.empty(), 328 | * G.insertNode(C.string)('n1', 'Node 1'), 329 | * G.insertNode(C.string)('n2', 'Node 2') 330 | * ); 331 | * 332 | * assert.deepStrictEqual( 333 | * pipe(myGraph, G.lookupNode(C.string)('n2')), 334 | * O.some('Node 2') 335 | * ); 336 | */ 337 | export const lookupNode = 338 | (E: Encoder) => 339 | (id: Id) => 340 | (graph: Graph): Option => 341 | pipe( 342 | graph.nodes, 343 | IM.lookup(E)(id), 344 | O.map((node) => node.data) 345 | ); 346 | 347 | // ----------------------------------------------------------------------------- 348 | // destructors 349 | // ----------------------------------------------------------------------------- 350 | 351 | /** 352 | * Get nodes as "id"-"value" pairs 353 | * 354 | * @since 0.1.0 355 | * @category Destructors 356 | */ 357 | export const nodeEntries = 358 | (D: Decoder) => 359 | (graph: Graph): [Id, Node][] => 360 | pipe( 361 | graph.nodes.map((_) => _.data), 362 | mapEntries(D) 363 | ); 364 | 365 | /** 366 | * Get edges as "edge id"-"value" pairs. As currently multi-edges are not 367 | * supported, we use node connections as edge ids. 368 | * 369 | * @since 0.1.0 370 | * @category Destructors 371 | */ 372 | export const edgeEntries = 373 | (D: Decoder) => 374 | (graph: Graph): [Direction, Edge][] => 375 | pipe( 376 | graph.edges.toArray(), 377 | A.chain(([encodedFrom, toMap]) => 378 | pipe( 379 | encodedFrom, 380 | D.decode, 381 | E.map((from) => 382 | pipe( 383 | toMap, 384 | mapEntries(D), 385 | A.map(([to, edge]) => <[Direction, Edge]>[{ from, to }, edge]) 386 | ) 387 | ), 388 | E.getOrElse(() => <[Direction, Edge][]>[]) 389 | ) 390 | ) 391 | ); 392 | 393 | /** 394 | * @since 0.1.0 395 | * @category Destructors 396 | */ 397 | export const entries = 398 | (C: Codec) => 399 | ( 400 | graph: Graph 401 | ): { nodes: [Id, Node][]; edges: [Direction, Edge][] } => ({ 402 | nodes: nodeEntries(C)(graph), 403 | edges: edgeEntries(C)(graph), 404 | }); 405 | 406 | // ----------------------------------------------------------------------------- 407 | // debug 408 | // ----------------------------------------------------------------------------- 409 | 410 | /** 411 | * For debugging purpose we provide a simple and dependency free dot file 412 | * generator as its sort of the standard CLI tool to layout graphs visually. See 413 | * [graphviz](https://graphviz.org) for more details. 414 | * 415 | * If your your edges and nodes are not of type string, you can use `mapEdge` 416 | * and `mapNode` to convert them. That's not possible with the id, as it would 417 | * possible change the structure of the graph, thus you need to provide a 418 | * function that stringifies the ids. 419 | * 420 | * @since 0.1.0 421 | * @category Debug 422 | */ 423 | export const toDotFile = 424 | (D: Decoder) => 425 | (printId: (id: Id) => string) => 426 | (graph: Graph): string => 427 | pipe( 428 | [ 429 | ...pipe( 430 | nodeEntries(D)(graph), 431 | A.map(([id, label]) => `"${printId(id)}" [label="${label}"]`) 432 | ), 433 | ...pipe( 434 | edgeEntries(D)(graph), 435 | A.map( 436 | ([{ from, to }, label]) => 437 | `"${printId(from)}" -> "${printId(to)}" [label="${label}"]` 438 | ) 439 | ), 440 | ], 441 | (_) => ['digraph {', ..._, '}'], 442 | (_) => _.join('\n') 443 | ); 444 | 445 | // ----------------------------------------------------------------------------- 446 | // internal 447 | // ----------------------------------------------------------------------------- 448 | 449 | const unsafeMkGraph = ( 450 | graphData: Omit, '_brand'> 451 | ): Graph => graphData as Graph; 452 | 453 | const mapEntries = 454 | (decoder: Decoder) => 455 | (map_: Map): [Id, V][] => 456 | pipe( 457 | map_.toArray(), 458 | A.traverse(E.Applicative)(([encodedKey, value]) => 459 | pipe( 460 | encodedKey, 461 | decoder.decode, 462 | E.map((key) => <[Id, V]>[key, value]) 463 | ) 464 | ), 465 | E.getOrElse(() => <[Id, V][]>[]) 466 | ); 467 | 468 | const insertIncoming = 469 | (E: Encoder) => 470 | (from: Id) => 471 | (nodeContext: NodeContext): NodeContext => ({ 472 | data: nodeContext.data, 473 | outgoing: nodeContext.outgoing, 474 | incoming: nodeContext.incoming.add(E.encode(from)), 475 | }); 476 | 477 | const insertOutgoing = 478 | (E: Encoder) => 479 | (to: Id) => 480 | (nodeContext: NodeContext): NodeContext => ({ 481 | data: nodeContext.data, 482 | outgoing: nodeContext.outgoing.add(E.encode(to)), 483 | incoming: nodeContext.incoming, 484 | }); 485 | 486 | const modifyEdgeInNodes = 487 | (E: Encoder) => 488 | (from: Id, to: Id) => 489 | ( 490 | nodes: Graph['nodes'] 491 | ): Option['nodes']> => 492 | pipe( 493 | nodes, 494 | IM.modifyAt(E)(from, insertOutgoing(E)(to)), 495 | O.chain(IM.modifyAt(E)(to, insertIncoming(E)(from))) 496 | ); 497 | 498 | const insertEdgeInEdges = 499 | (E: Encoder) => 500 | (from: Id, to: Id, data: Edge) => 501 | ( 502 | edges: Graph['edges'] 503 | ): Graph['edges'] => 504 | pipe( 505 | edges, 506 | IM.lookup(E)(from), 507 | O.getOrElse(() => Map()), 508 | IM.upsertAt(E)(to, data), 509 | (toMap) => IM.upsertAt(E)(from, toMap)(edges) 510 | ); 511 | -------------------------------------------------------------------------------- /tests/index.ts: -------------------------------------------------------------------------------- 1 | import * as graph from '../src'; 2 | import { deepStrictEqual } from 'assert'; 3 | import * as fp from 'fp-ts'; 4 | import * as Codec from 'io-ts/Codec'; 5 | 6 | describe('index', () => { 7 | describe('Constructors', () => { 8 | describe('empty', () => { 9 | it('should return an empty graph', () => { 10 | deepStrictEqual( 11 | fp.function.pipe( 12 | graph.empty(), 13 | graph.entries(Codec.string) 14 | ), 15 | { 16 | nodes: [], 17 | edges: [], 18 | } 19 | ); 20 | }); 21 | }); 22 | }); 23 | 24 | describe('Combinators', () => { 25 | describe('insertNode', () => { 26 | it('should add new nodes', () => { 27 | deepStrictEqual( 28 | fp.function.pipe( 29 | graph.empty(), 30 | graph.insertNode(Codec.string)('n1', 'Node 1'), 31 | graph.insertNode(Codec.string)('n2', 'Node 2'), 32 | graph.entries(Codec.string) 33 | ), 34 | { 35 | nodes: [ 36 | ['n1', 'Node 1'], 37 | ['n2', 'Node 2'], 38 | ], 39 | edges: [], 40 | } 41 | ); 42 | }); 43 | 44 | it('should update an existing node', () => { 45 | deepStrictEqual( 46 | fp.function.pipe( 47 | graph.empty(), 48 | graph.insertNode(Codec.string)('n1', 'Node 1'), 49 | graph.insertNode(Codec.string)('n1', 'Node 1'), 50 | graph.entries(Codec.string) 51 | ), 52 | { 53 | nodes: [['n1', 'Node 1']], 54 | edges: [], 55 | } 56 | ); 57 | }); 58 | }); 59 | 60 | describe('modifyAtEdge', () => { 61 | it('should modify an existing edge', () => { 62 | deepStrictEqual( 63 | fp.function.pipe( 64 | graph.empty(), 65 | graph.insertNode(Codec.string)('n1', 'Node 1'), 66 | graph.insertNode(Codec.string)('n2', 'Node 2'), 67 | fp.option.of, 68 | fp.option.chain( 69 | graph.insertEdge(Codec.string)('n1', 'n2', 'Edge 1') 70 | ), 71 | fp.option.chain( 72 | graph.modifyAtEdge(Codec.string)( 73 | 'n1', 74 | 'n2', 75 | (e) => `${e} updated` 76 | ) 77 | ), 78 | fp.option.map(graph.edgeEntries(Codec.string)) 79 | ), 80 | fp.option.some([[{ from: 'n1', to: 'n2' }, 'Edge 1 updated']]) 81 | ); 82 | }); 83 | 84 | it('should not modify a non-existing edge', () => { 85 | deepStrictEqual( 86 | fp.function.pipe( 87 | graph.empty(), 88 | graph.insertNode(Codec.string)('n1', 'Node 1'), 89 | graph.insertNode(Codec.string)('n2', 'Node 2'), 90 | fp.option.of, 91 | fp.option.chain( 92 | graph.insertEdge(Codec.string)('n1', 'n2', 'Edge 1') 93 | ), 94 | fp.option.chain( 95 | graph.modifyAtEdge(Codec.string)( 96 | 'n2', 97 | 'n1', 98 | (e) => `${e} updated` 99 | ) 100 | ), 101 | fp.option.map(graph.edgeEntries(Codec.string)) 102 | ), 103 | fp.option.none 104 | ); 105 | }); 106 | }); 107 | 108 | describe('modifyAtNode', () => { 109 | it('should modify an existing node', () => { 110 | deepStrictEqual( 111 | fp.function.pipe( 112 | graph.empty(), 113 | graph.insertNode(Codec.string)('n1', 'Node 1'), 114 | graph.insertNode(Codec.string)('n2', 'Node 2'), 115 | graph.modifyAtNode(Codec.string)('n2', (n) => `${n} updated`), 116 | fp.option.map(graph.nodeEntries(Codec.string)) 117 | ), 118 | fp.option.some([ 119 | ['n1', 'Node 1'], 120 | ['n2', 'Node 2 updated'], 121 | ]) 122 | ); 123 | }); 124 | 125 | it("shouldn't modify a non-existing node", () => { 126 | deepStrictEqual( 127 | fp.function.pipe( 128 | graph.empty(), 129 | graph.insertNode(Codec.string)('n1', 'Node 1'), 130 | graph.insertNode(Codec.string)('n2', 'Node 2'), 131 | graph.modifyAtNode(Codec.string)('n3', (n) => `${n} updated`), 132 | fp.option.map(graph.nodeEntries(Codec.string)) 133 | ), 134 | fp.option.none 135 | ); 136 | }); 137 | }); 138 | }); 139 | 140 | describe('insertEdge', () => { 141 | it('should insert an edge between existing nodes', () => { 142 | deepStrictEqual( 143 | fp.function.pipe( 144 | graph.empty(), 145 | graph.insertNode(Codec.string)('n1', 'Node 1'), 146 | graph.insertNode(Codec.string)('n2', 'Node 2'), 147 | graph.insertEdge(Codec.string)('n1', 'n2', 'Edge 1'), 148 | fp.option.map(graph.entries(Codec.string)) 149 | ), 150 | fp.option.some({ 151 | nodes: [ 152 | ['n1', 'Node 1'], 153 | ['n2', 'Node 2'], 154 | ], 155 | edges: [[{ from: 'n1', to: 'n2' }, 'Edge 1']], 156 | }) 157 | ); 158 | }); 159 | 160 | it('should insert an edges in both directions between two nodes', () => { 161 | deepStrictEqual( 162 | fp.function.pipe( 163 | graph.empty(), 164 | graph.insertNode(Codec.string)('n1', 'Node 1'), 165 | graph.insertNode(Codec.string)('n2', 'Node 2'), 166 | fp.option.of, 167 | fp.option.chain(graph.insertEdge(Codec.string)('n1', 'n2', 'Edge 1')), 168 | fp.option.chain(graph.insertEdge(Codec.string)('n2', 'n1', 'Edge 2')), 169 | fp.option.map(graph.entries(Codec.string)) 170 | ), 171 | fp.option.some({ 172 | nodes: [ 173 | ['n1', 'Node 1'], 174 | ['n2', 'Node 2'], 175 | ], 176 | edges: [ 177 | [{ from: 'n1', to: 'n2' }, 'Edge 1'], 178 | [{ from: 'n2', to: 'n1' }, 'Edge 2'], 179 | ], 180 | }) 181 | ); 182 | }); 183 | 184 | it('should insert an edge from a node to itself', () => { 185 | deepStrictEqual( 186 | fp.function.pipe( 187 | graph.empty(), 188 | graph.insertNode(Codec.string)('n1', 'Node 1'), 189 | graph.insertEdge(Codec.string)('n1', 'n1', 'Edge 1'), 190 | fp.option.map(graph.entries(Codec.string)) 191 | ), 192 | fp.option.some({ 193 | nodes: [['n1', 'Node 1']], 194 | edges: [[{ from: 'n1', to: 'n1' }, 'Edge 1']], 195 | }) 196 | ); 197 | }); 198 | 199 | it('should not insert and edge to a non existent node', () => { 200 | deepStrictEqual( 201 | fp.function.pipe( 202 | graph.empty(), 203 | graph.insertNode(Codec.string)('n1', 'Node 1'), 204 | graph.insertEdge(Codec.string)('n1', 'n2', 'Edge 1'), 205 | fp.option.map(graph.entries(Codec.string)) 206 | ), 207 | fp.option.none 208 | ); 209 | }); 210 | 211 | it('prevents regression of incorrect incoming nodes', () => 212 | deepStrictEqual( 213 | fp.function.pipe( 214 | graph.empty(), 215 | graph.insertNode(Codec.string)('n1', 'Node 1'), 216 | graph.insertNode(Codec.string)('n2', 'Node 2'), 217 | graph.insertNode(Codec.string)('n3', 'Node 3'), 218 | graph.insertNode(Codec.string)('n4', 'Node 4'), 219 | graph.insertNode(Codec.string)('n5', 'Node 5'), 220 | fp.option.of, 221 | fp.option.chain(graph.insertEdge(Codec.string)('n3', 'n1', 'Edge 1')), 222 | fp.option.chain(graph.insertEdge(Codec.string)('n3', 'n2', 'Edge 2')), 223 | fp.option.chain(graph.insertEdge(Codec.string)('n4', 'n3', 'Edge 3')), 224 | fp.option.chain(graph.insertEdge(Codec.string)('n5', 'n3', 'Edge 3')), 225 | fp.option.chain((g) => 226 | fp.function.pipe( 227 | g.nodes.get(Codec.string.encode('n3'), null), 228 | fp.option.fromNullable 229 | ) 230 | ), 231 | fp.option.map((node) => ({ 232 | data: node.data, 233 | incoming: node.incoming.toArray().sort(), 234 | outgoing: node.outgoing.toArray().sort(), 235 | })) 236 | ), 237 | fp.option.of({ 238 | data: 'Node 3', 239 | incoming: ['n4', 'n5'], 240 | outgoing: ['n1', 'n2'], 241 | }) 242 | )); 243 | }); 244 | 245 | describe('mapEdge', () => { 246 | it("should map edge's type and values", () => { 247 | deepStrictEqual( 248 | fp.function.pipe( 249 | graph.empty(), 250 | graph.insertNode(Codec.string)('n1', 'Node 1'), 251 | graph.insertNode(Codec.string)('n2', 'Node 2'), 252 | graph.insertNode(Codec.string)('n3', 'Node 3'), 253 | fp.option.of, 254 | fp.option.chain(graph.insertEdge(Codec.string)('n1', 'n2', 1)), 255 | fp.option.chain(graph.insertEdge(Codec.string)('n2', 'n3', 2)), 256 | fp.option.map(graph.mapEdge((n) => `Edge ${n}`)), 257 | fp.option.map(graph.entries(Codec.string)) 258 | ), 259 | fp.option.some({ 260 | nodes: [ 261 | ['n1', 'Node 1'], 262 | ['n2', 'Node 2'], 263 | ['n3', 'Node 3'], 264 | ], 265 | edges: [ 266 | [{ from: 'n1', to: 'n2' }, 'Edge 1'], 267 | [{ from: 'n2', to: 'n3' }, 'Edge 2'], 268 | ], 269 | }) 270 | ); 271 | }); 272 | }); 273 | 274 | describe('mapNode', () => { 275 | it("should map nodes's type and values", () => { 276 | deepStrictEqual( 277 | fp.function.pipe( 278 | graph.empty(), 279 | graph.insertNode(Codec.string)('n1', 1), 280 | graph.insertNode(Codec.string)('n2', 2), 281 | fp.option.of, 282 | fp.option.chain(graph.insertEdge(Codec.string)('n1', 'n2', 'Edge 1')), 283 | fp.option.map(graph.mapNode((n) => `Node ${n}`)), 284 | fp.option.map(graph.entries(Codec.string)) 285 | ), 286 | fp.option.some({ 287 | nodes: [ 288 | ['n1', 'Node 1'], 289 | ['n2', 'Node 2'], 290 | ], 291 | edges: [[{ from: 'n1', to: 'n2' }, 'Edge 1']], 292 | }) 293 | ); 294 | }); 295 | }); 296 | 297 | describe('nodeEntries', () => { 298 | it('should return all node entries (ids and values)', () => { 299 | deepStrictEqual( 300 | fp.function.pipe( 301 | graph.empty(), 302 | graph.insertNode(Codec.string)('n1', 'Node 1'), 303 | graph.insertNode(Codec.string)('n2', 'Node 2'), 304 | fp.option.of, 305 | fp.option.chain(graph.insertEdge(Codec.string)('n1', 'n2', 'Edge 1')), 306 | fp.option.map(graph.nodeEntries(Codec.string)) 307 | ), 308 | fp.option.some([ 309 | ['n1', 'Node 1'], 310 | ['n2', 'Node 2'], 311 | ]) 312 | ); 313 | }); 314 | }); 315 | 316 | describe('edgeEntries', () => { 317 | it('should return all edge entries (edge ids and values)', () => { 318 | deepStrictEqual( 319 | fp.function.pipe( 320 | graph.empty(), 321 | graph.insertNode(Codec.string)('n1', 'Node 1'), 322 | graph.insertNode(Codec.string)('n2', 'Node 2'), 323 | graph.insertNode(Codec.string)('n3', 'Node 3'), 324 | fp.option.of, 325 | fp.option.chain(graph.insertEdge(Codec.string)('n1', 'n2', 'Edge 1')), 326 | fp.option.chain(graph.insertEdge(Codec.string)('n2', 'n3', 'Edge 2')), 327 | fp.option.map(graph.mapNode((n) => `Node ${n}`)), 328 | fp.option.map(graph.edgeEntries(Codec.string)) 329 | ), 330 | fp.option.some([ 331 | [{ from: 'n1', to: 'n2' }, 'Edge 1'], 332 | [{ from: 'n2', to: 'n3' }, 'Edge 2'], 333 | ]) 334 | ); 335 | }); 336 | }); 337 | 338 | describe('edgeEntries', () => { 339 | it('should return all node and edge entries', () => { 340 | deepStrictEqual( 341 | fp.function.pipe( 342 | graph.empty(), 343 | graph.insertNode(Codec.string)('n1', 'Node 1'), 344 | graph.insertNode(Codec.string)('n2', 'Node 2'), 345 | graph.insertNode(Codec.string)('n3', 'Node 3'), 346 | fp.option.of, 347 | fp.option.chain(graph.insertEdge(Codec.string)('n1', 'n2', 'Edge 1')), 348 | fp.option.chain(graph.insertEdge(Codec.string)('n2', 'n3', 'Edge 2')), 349 | fp.option.map(graph.entries(Codec.string)) 350 | ), 351 | fp.option.some({ 352 | nodes: [ 353 | ['n1', 'Node 1'], 354 | ['n2', 'Node 2'], 355 | ['n3', 'Node 3'], 356 | ], 357 | edges: [ 358 | [{ from: 'n1', to: 'n2' }, 'Edge 1'], 359 | [{ from: 'n2', to: 'n3' }, 'Edge 2'], 360 | ], 361 | }) 362 | ); 363 | }); 364 | }); 365 | 366 | describe('toDotFile', () => { 367 | it('should generate a valid dot file', () => { 368 | deepStrictEqual( 369 | fp.function.pipe( 370 | graph.empty(), 371 | graph.insertNode(Codec.string)('n1', 'Node 1'), 372 | graph.insertNode(Codec.string)('n2', 'Node 2'), 373 | graph.insertNode(Codec.string)('n3', 'Node 3'), 374 | fp.option.of, 375 | fp.option.chain(graph.insertEdge(Codec.string)('n1', 'n2', 'Edge 1')), 376 | fp.option.chain(graph.insertEdge(Codec.string)('n2', 'n3', 'Edge 2')), 377 | fp.option.map(graph.toDotFile(Codec.string)(fp.function.identity)) 378 | ), 379 | fp.option.some(`digraph { 380 | "n1" [label="Node 1"] 381 | "n2" [label="Node 2"] 382 | "n3" [label="Node 3"] 383 | "n1" -> "n2" [label="Edge 1"] 384 | "n2" -> "n3" [label="Edge 2"] 385 | }`) 386 | ); 387 | }); 388 | }); 389 | 390 | describe('Utils', () => { 391 | describe('lookupEdge', () => { 392 | it('should return an existing edge', () => { 393 | deepStrictEqual( 394 | fp.function.pipe( 395 | graph.empty(), 396 | graph.insertNode(Codec.string)('n1', 'Node 1'), 397 | graph.insertNode(Codec.string)('n2', 'Node 2'), 398 | graph.insertNode(Codec.string)('n3', 'Node 3'), 399 | fp.option.of, 400 | fp.option.chain( 401 | graph.insertEdge(Codec.string)('n1', 'n2', 'Edge 1') 402 | ), 403 | fp.option.chain( 404 | graph.insertEdge(Codec.string)('n2', 'n3', 'Edge 2') 405 | ), 406 | fp.option.chain(graph.lookupEdge(Codec.string)('n1', 'n2')) 407 | ), 408 | fp.option.some('Edge 1') 409 | ); 410 | }); 411 | 412 | it('should return none for non-existing edge', () => { 413 | deepStrictEqual( 414 | fp.function.pipe( 415 | graph.empty(), 416 | graph.insertNode(Codec.string)('n1', 'Node 1'), 417 | graph.insertNode(Codec.string)('n2', 'Node 2'), 418 | graph.insertNode(Codec.string)('n3', 'Node 3'), 419 | fp.option.of, 420 | fp.option.chain( 421 | graph.insertEdge(Codec.string)('n1', 'n2', 'Edge 1') 422 | ), 423 | fp.option.chain( 424 | graph.insertEdge(Codec.string)('n2', 'n3', 'Edge 2') 425 | ), 426 | fp.option.chain(graph.lookupEdge(Codec.string)('n3', 'n2')) 427 | ), 428 | fp.option.none 429 | ); 430 | }); 431 | }); 432 | 433 | describe('lookupNode', () => { 434 | it('should return an existing node value', () => { 435 | deepStrictEqual( 436 | fp.function.pipe( 437 | graph.empty(), 438 | graph.insertNode(Codec.string)('n1', 'Node 1'), 439 | graph.lookupNode(Codec.string)('n1') 440 | ), 441 | fp.option.some('Node 1') 442 | ); 443 | }); 444 | 445 | it('should lookup none for non-existing node', () => { 446 | deepStrictEqual( 447 | fp.function.pipe( 448 | graph.empty(), 449 | graph.insertNode(Codec.string)('n1', 'Node 1'), 450 | graph.lookupNode(Codec.string)('n2') 451 | ), 452 | fp.option.none 453 | ); 454 | }); 455 | }); 456 | }); 457 | }); 458 | --------------------------------------------------------------------------------