├── src ├── chainable.nix ├── default.nix ├── append.nix ├── prepend.nix ├── merge.nix ├── decorate.nix ├── chainMerge.nix ├── update.nix ├── _internal │ ├── decorateAt.nix │ └── mergeAt.nix └── updateOn.nix ├── .gitignore ├── tests ├── _snapshots │ ├── chainMerge │ ├── decorate │ ├── merge │ ├── append │ ├── updateOn │ ├── prepend │ └── update ├── chainMerge │ └── expr.nix ├── decorate │ └── expr.nix ├── merge │ └── expr.nix ├── append │ └── expr.nix ├── prepend │ └── expr.nix ├── update │ └── expr.nix └── updateOn │ └── expr.nix ├── .envrc ├── cog.toml ├── treefmt.toml ├── .github └── workflows │ └── ci.yaml ├── LICENSE ├── flake.nix ├── flake.lock ├── local ├── flake.nix └── flake.lock ├── CHANGELOG.md └── README.md /src/chainable.nix: -------------------------------------------------------------------------------- 1 | {lib}: lib.toFunction 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.direnv 2 | /.data 3 | result 4 | 5 | -------------------------------------------------------------------------------- /src/default.nix: -------------------------------------------------------------------------------- 1 | {super}: {__functor = self: super.merge;} 2 | -------------------------------------------------------------------------------- /tests/_snapshots/chainMerge: -------------------------------------------------------------------------------- 1 | #json 2 | {"answer":42,"foo":{"bar":{"baz":{}}}} -------------------------------------------------------------------------------- /src/append.nix: -------------------------------------------------------------------------------- 1 | {yants}: 2 | with yants "dmerge/append"; 3 | new: orig: cursor: orig ++ (list any new) 4 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | watch_file local/flake.nix 4 | watch_file local/flake.lock 5 | use_flake ./local 6 | -------------------------------------------------------------------------------- /src/prepend.nix: -------------------------------------------------------------------------------- 1 | {yants}: 2 | with yants "dmerge/prepend"; 3 | new: orig: cursor: (list any new) ++ orig 4 | -------------------------------------------------------------------------------- /tests/_snapshots/decorate: -------------------------------------------------------------------------------- 1 | #json 2 | {"nok":{"success":false,"value":false},"ok":{"success":true,"value":{"a":{"b":[{"c":"bc"}]}}}} -------------------------------------------------------------------------------- /src/merge.nix: -------------------------------------------------------------------------------- 1 | { 2 | root, 3 | yants, 4 | }: 5 | with yants "dmerge/decorate"; let 6 | inherit (root.internal) mergeAt; 7 | in 8 | lhs: rhs: (mergeAt [] lhs rhs) 9 | -------------------------------------------------------------------------------- /src/decorate.nix: -------------------------------------------------------------------------------- 1 | { 2 | root, 3 | yants, 4 | }: 5 | with yants "dmerge/decorate"; let 6 | inherit (root.internal) decorateAt; 7 | in 8 | rhs: dec: (decorateAt [] rhs dec) 9 | -------------------------------------------------------------------------------- /tests/_snapshots/merge: -------------------------------------------------------------------------------- 1 | #json 2 | {"nok-AttemtptedListOverride":{"success":false,"value":false},"nok-TypeMismatch":{"success":false,"value":false},"ok":{"success":true,"value":{"a":{"b":{"c":[]}}}}} -------------------------------------------------------------------------------- /tests/_snapshots/append: -------------------------------------------------------------------------------- 1 | #json 2 | {"FreshRHSWithArrayMerge":{"a":{"b":[],"new":["c"]}},"NormalMerge":{"a":{"b":["c"]}},"PartialMergeFunc":{"success":false,"value":false},"VillainMergeFunc":{"success":false,"value":false}} -------------------------------------------------------------------------------- /tests/_snapshots/updateOn: -------------------------------------------------------------------------------- 1 | #json 2 | {"nok-LhsDupliate":{"success":false,"value":false},"nok-RhsDupliate":{"success":false,"value":false},"ok":{"success":true,"value":{"a":[{"name":"foo","value":"barr"},{"name":"baz","value":"qux"}]}}} -------------------------------------------------------------------------------- /tests/_snapshots/prepend: -------------------------------------------------------------------------------- 1 | #json 2 | {"FreshRHSWithArrayMerge":{"a":{"b":["last"],"new":["new"]}},"NormalMerge":{"a":{"b":["first","last"]}},"PartialMergeFunc":{"success":false,"value":false},"VillainMergeFunc":{"success":false,"value":false}} -------------------------------------------------------------------------------- /tests/_snapshots/update: -------------------------------------------------------------------------------- 1 | #json 2 | {"nok-InstructionMismatch":{"success":false,"value":false},"nok-WrongIndex":{"success":false,"value":false},"ok":{"success":true,"value":{"a":{"b":[{"c":"bc"}]}}},"ok2":{"success":true,"value":{"foo.yaml":{"bar":[1]}}}} -------------------------------------------------------------------------------- /tests/chainMerge/expr.nix: -------------------------------------------------------------------------------- 1 | { 2 | chainMerge, 3 | chainable, 4 | }: let 5 | withA = chainable {foo = {};}; 6 | withB = chainable {foo.bar = {};}; 7 | withC = chainable {foo.bar.baz = {};}; 8 | in 9 | chainMerge withA withB withC {answer = 42;} 10 | -------------------------------------------------------------------------------- /src/chainMerge.nix: -------------------------------------------------------------------------------- 1 | { 2 | lib, 3 | root, 4 | yants, 5 | }: 6 | with yants "dmerge/chainMerge"; let 7 | inherit 8 | (lib) 9 | isFunction 10 | foldl' 11 | ; 12 | 13 | m = root.merge; 14 | 15 | f' = fs: x: 16 | if isFunction x 17 | then f' (fs ++ [(x null)]) 18 | else foldl' m {} (fs ++ [x]); 19 | in 20 | f' [] 21 | -------------------------------------------------------------------------------- /tests/decorate/expr.nix: -------------------------------------------------------------------------------- 1 | { 2 | merge, 3 | decorate, 4 | update, 5 | }: let 6 | lhs.a.b = [{c = "c";}]; 7 | rhs.a.b = [{c = "bc";}]; 8 | 9 | merged = { 10 | nok = merge lhs rhs; 11 | ok = merge lhs (decorate rhs {a.b = update [0];}); 12 | }; 13 | 14 | inherit (builtins) deepSeq mapAttrs tryEval; 15 | in 16 | mapAttrs (_: x: tryEval (deepSeq x x)) merged 17 | -------------------------------------------------------------------------------- /tests/merge/expr.nix: -------------------------------------------------------------------------------- 1 | {merge}: let 2 | lhs.a.b = {}; 3 | rhs.a.b.c = []; 4 | 5 | merged = { 6 | ok = merge lhs rhs; 7 | nok-AttemtptedListOverride = 8 | merge 9 | {a.b.c = [];} {a.b.c = ["c"];}; 10 | nok-TypeMismatch = 11 | merge 12 | {a = {};} {a = "a";}; 13 | }; 14 | 15 | inherit (builtins) deepSeq mapAttrs tryEval; 16 | in 17 | mapAttrs (_: x: tryEval (deepSeq x x)) merged 18 | -------------------------------------------------------------------------------- /cog.toml: -------------------------------------------------------------------------------- 1 | post_bump_hooks = [ 2 | "git push", 3 | "git push origin {{version}}", 4 | "echo Go to and post: https://discourse.nixos.org/t/dmerge-a-mini-dsl-for-data/27314", 5 | "cog -q changelog --at {{version}}", 6 | ] 7 | 8 | [changelog] 9 | path = "CHANGELOG.md" 10 | template = "remote" 11 | remote = "github.com" 12 | repository = "dmerge" 13 | owner = "divnix" 14 | authors = [{ username = "blaggacao", signature = "David Arnold" }] 15 | -------------------------------------------------------------------------------- /tests/append/expr.nix: -------------------------------------------------------------------------------- 1 | { 2 | merge, 3 | append, 4 | }: let 5 | lhs = { 6 | a.b = []; 7 | }; 8 | 9 | ok = { 10 | NormalMerge = merge lhs {a.b = append ["c"];}; 11 | FreshRHSWithArrayMerge = merge lhs {a.new = append ["c"];}; 12 | }; 13 | 14 | nok = { 15 | VillainMergeFunc = merge lhs {a.new = throw "";}; 16 | PartialMergeFunc = merge lhs {a.new = x: x;}; 17 | }; 18 | 19 | inherit (builtins) deepSeq mapAttrs tryEval; 20 | in 21 | ok // (mapAttrs (_: x: tryEval (deepSeq x x)) nok) 22 | -------------------------------------------------------------------------------- /tests/prepend/expr.nix: -------------------------------------------------------------------------------- 1 | { 2 | merge, 3 | prepend, 4 | }: let 5 | lhs = { 6 | a.b = ["last"]; 7 | }; 8 | 9 | ok = { 10 | NormalMerge = merge lhs {a.b = prepend ["first"];}; 11 | FreshRHSWithArrayMerge = merge lhs {a.new = prepend ["new"];}; 12 | }; 13 | 14 | nok = { 15 | VillainMergeFunc = merge lhs {a.new = throw "";}; 16 | PartialMergeFunc = merge lhs {a.new = x: x;}; 17 | }; 18 | 19 | inherit (builtins) deepSeq mapAttrs tryEval; 20 | in 21 | ok // (mapAttrs (_: x: tryEval (deepSeq x x)) nok) 22 | -------------------------------------------------------------------------------- /treefmt.toml: -------------------------------------------------------------------------------- 1 | # One CLI to format the code tree - https://github.com/numtide/treefmt 2 | [global] 3 | excludes = ["CHANGELOG.md"] 4 | 5 | [formatter.nix] 6 | command = "alejandra" 7 | includes = ["*.nix"] 8 | 9 | [formatter.prettier] 10 | command = "prettier" 11 | options = ["--plugin", "prettier-plugin-toml", "--write"] 12 | includes = ["*.md", "*.yaml", "*.toml"] 13 | 14 | [formatter.shell] 15 | command = "shfmt" 16 | options = [ 17 | "-i", 18 | "2", # indent 2 19 | "-s", # simplify the code 20 | "-w", # write back to the file 21 | 22 | ] 23 | includes = ["*.sh"] 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | check: 11 | name: check 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v3 16 | 17 | - name: Install nix 18 | uses: nixbuild/nix-quick-install-action@v22 19 | with: 20 | nix_conf: experimental-features = nix-command flakes 21 | 22 | - name: Run checks 23 | run: nix flake check 24 | 25 | - name: Check formatting 26 | run: nix develop ./local#check --command treefmt -- . --fail-on-change --no-cache 27 | -------------------------------------------------------------------------------- /tests/update/expr.nix: -------------------------------------------------------------------------------- 1 | { 2 | merge, 3 | update, 4 | append, 5 | }: let 6 | merged = { 7 | ok = 8 | merge { 9 | a.b = [ 10 | {c = "c";} # 0 11 | ]; 12 | } { 13 | a.b = update [0] [ 14 | {c = "bc";} 15 | ]; 16 | }; 17 | ok2 = 18 | merge { 19 | "foo.yaml" = {bar = [];}; 20 | } { 21 | "foo.yaml" = {bar = append [1];}; 22 | }; 23 | nok-WrongIndex = 24 | merge { 25 | a = [ 26 | {} # 0 27 | ]; 28 | } { 29 | a = update [1] [{}]; 30 | }; 31 | nok-InstructionMismatch = merge {a = [{}];} { 32 | a = update [0] [{} {}]; 33 | }; 34 | }; 35 | 36 | inherit (builtins) deepSeq mapAttrs tryEval; 37 | in 38 | mapAttrs (_: x: tryEval (deepSeq x x)) merged 39 | -------------------------------------------------------------------------------- /tests/updateOn/expr.nix: -------------------------------------------------------------------------------- 1 | { 2 | updateOn, 3 | merge, 4 | }: let 5 | lhs = { 6 | a = [ 7 | { 8 | name = "foo"; 9 | value = "bar"; 10 | } 11 | { 12 | name = "baz"; 13 | value = "qux"; 14 | } 15 | ]; 16 | }; 17 | 18 | rhs = { 19 | a = updateOn "name" [ 20 | { 21 | name = "foo"; 22 | value = "barr"; 23 | } 24 | ]; 25 | }; 26 | 27 | lhs' = {a = [{name = "duplicate";} {name = "duplicate";}];}; 28 | rhs' = {a = updateOn "name" [{name = "duplicate";} {name = "duplicate";}];}; 29 | 30 | merged = { 31 | ok = merge lhs rhs; 32 | nok-LhsDupliate = merge lhs' rhs; 33 | nok-RhsDupliate = merge lhs rhs'; 34 | }; 35 | 36 | inherit (builtins) deepSeq mapAttrs tryEval; 37 | in 38 | mapAttrs (_: x: tryEval (deepSeq x x)) merged 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 DivNix 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A mini merge DSL for data overlays"; 3 | inputs.nixlib.url = "github:nix-community/nixpkgs.lib"; 4 | inputs.yants = { 5 | url = "github:divnix/yants"; 6 | inputs.nixpkgs.follows = "nixlib"; 7 | }; 8 | 9 | inputs.haumea = { 10 | url = "github:nix-community/haumea/v0.2.2"; 11 | inputs.nixpkgs.follows = "nixlib"; 12 | }; 13 | # Incrementality of the Data Spine 14 | # -------------------------------- 15 | # To reduce mental complexity in chained merges, 16 | # we must ensure that the data spine of the left 17 | # hand side is not _destructively_ modified. 18 | # 19 | # This means, that like the keys of an attribute 20 | # set cannot be removed through a merge operation, 21 | # we also must ensure that no array element can 22 | # be removed either. 23 | # 24 | # In this reasoning, the composed types _arrays_ 25 | # and _attribute sets_ represent the "data spine". 26 | # And while individual simple type merges necessarily 27 | # destroy information, the spine itself shall not 28 | # allowed to be transformed itself destructively. 29 | 30 | outputs = { 31 | self, 32 | nixlib, 33 | yants, 34 | haumea, 35 | }: let 36 | inherit (haumea.lib.transformers) liftDefault; 37 | in 38 | haumea.lib.load { 39 | src = ./src; 40 | transformer = liftDefault; 41 | inputs = { 42 | inherit (nixlib) lib; 43 | inherit yants; 44 | }; 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/update.nix: -------------------------------------------------------------------------------- 1 | { 2 | yants, 3 | root, 4 | lib, 5 | }: 6 | with yants "dmerge/update"; let 7 | inherit (builtins) length listToAttrs elemAt foldl'; 8 | inherit (lib) zipListsWith imap0 assertMsg traceSeqN setAttrByPath getAttrFromPath max; 9 | inherit (lib.generators) toPretty; 10 | 11 | inherit (root.internal) mergeAt; 12 | in 13 | indices: updates: orig: cursor: let 14 | updateset = listToAttrs ( 15 | zipListsWith ( 16 | idx: upd: let 17 | # manufacture a "cursor" for display purposes 18 | tmplhs = setAttrByPath (cursor ++ [(toString idx)]) (elemAt orig idx); 19 | tmprhs = setAttrByPath (cursor ++ [(toString idx)]) upd; 20 | in { 21 | name = toString idx; 22 | value = getAttrFromPath (cursor ++ [(toString idx)]) ( 23 | # but start from an empty cursor on this commissioned merge operation 24 | mergeAt [] tmplhs tmprhs 25 | ); 26 | } 27 | ) 28 | (list int indices) 29 | (list any updates) 30 | ); 31 | in 32 | assert assertMsg (length indices == length updates) '' 33 | UPDATING ARRAY MERGE: for each index there must be one corresponding update value, 34 | got: ${traceSeqN 1 indices "(see first trace above)"} indices and 35 | ${traceSeqN 1 updates "(see second trace above)"} updates''; 36 | assert assertMsg (foldl' max 0 indices < length orig) '' 37 | UPDATING ARRAY MERGE: an update index exceeds the available elements on the left 38 | hand side. Lenght of left hand side array: ${toString (length orig)}, details: ${traceSeqN 2 orig ""}. 39 | Indices of the update function: ${toPretty {} indices}.''; 40 | imap0 ( 41 | i: v: 42 | if updateset ? ${toString i} 43 | then updateset.${toString i} 44 | else elemAt orig i 45 | ) 46 | orig 47 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "haumea": { 4 | "inputs": { 5 | "nixpkgs": [ 6 | "nixlib" 7 | ] 8 | }, 9 | "locked": { 10 | "lastModified": 1685133229, 11 | "narHash": "sha256-FePm/Gi9PBSNwiDFq3N+DWdfxFq0UKsVVTJS3cQPn94=", 12 | "owner": "nix-community", 13 | "repo": "haumea", 14 | "rev": "34dd58385092a23018748b50f9b23de6266dffc2", 15 | "type": "github" 16 | }, 17 | "original": { 18 | "owner": "nix-community", 19 | "ref": "v0.2.2", 20 | "repo": "haumea", 21 | "type": "github" 22 | } 23 | }, 24 | "nixlib": { 25 | "locked": { 26 | "lastModified": 1681001314, 27 | "narHash": "sha256-5sDnCLdrKZqxLPK4KA8+f4A3YKO/u6ElpMILvX0g72c=", 28 | "owner": "nix-community", 29 | "repo": "nixpkgs.lib", 30 | "rev": "367c0e1086a4eb4502b24d872cea2c7acdd557f4", 31 | "type": "github" 32 | }, 33 | "original": { 34 | "owner": "nix-community", 35 | "repo": "nixpkgs.lib", 36 | "type": "github" 37 | } 38 | }, 39 | "root": { 40 | "inputs": { 41 | "haumea": "haumea", 42 | "nixlib": "nixlib", 43 | "yants": "yants" 44 | } 45 | }, 46 | "yants": { 47 | "inputs": { 48 | "nixpkgs": [ 49 | "nixlib" 50 | ] 51 | }, 52 | "locked": { 53 | "lastModified": 1677285314, 54 | "narHash": "sha256-hlAcg2514zKrPu8jn24BUsIjjvXvCLdw1jvKgBTpqko=", 55 | "owner": "divnix", 56 | "repo": "yants", 57 | "rev": "9eab24b273ce021406c852166c216b86e2bb4ec4", 58 | "type": "github" 59 | }, 60 | "original": { 61 | "owner": "divnix", 62 | "repo": "yants", 63 | "type": "github" 64 | } 65 | } 66 | }, 67 | "root": "root", 68 | "version": 7 69 | } 70 | -------------------------------------------------------------------------------- /local/flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Data Merge development shell"; 3 | inputs.nosys.url = "github:divnix/nosys"; 4 | inputs.namaka.url = "github:nix-community/namaka/v0.2.0"; 5 | inputs.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; 6 | inputs.devshell.url = "github:numtide/devshell"; 7 | inputs.call-flake.url = "github:divnix/call-flake"; 8 | outputs = inputs @ { 9 | nosys, 10 | call-flake, 11 | ... 12 | }: 13 | nosys ((call-flake ../.).inputs // inputs) ( 14 | { 15 | self, 16 | namaka, 17 | nixpkgs, 18 | devshell, 19 | ... 20 | }: 21 | with nixpkgs.legacyPackages; 22 | with nixpkgs.legacyPackages.nodePackages; 23 | with devshell.legacyPackages; let 24 | inherit (lib.stringsWithDeps) noDepEntry; 25 | checkMod = { 26 | commands = [{package = treefmt;}]; 27 | packages = [alejandra shfmt nodePackages.prettier nodePackages.prettier-plugin-toml]; 28 | devshell.startup.nodejs-setuphook = noDepEntry '' 29 | export NODE_PATH=${nodePackages.prettier-plugin-toml}/lib/node_modules:$NODE_PATH 30 | ''; 31 | }; 32 | in { 33 | devShells = { 34 | check = mkShell { 35 | name = "Data Merge (Check)"; 36 | imports = [checkMod]; 37 | }; 38 | default = mkShell { 39 | name = "Data Merge"; 40 | imports = [checkMod]; 41 | commands = [ 42 | {package = namaka.packages.default;} 43 | { 44 | package = cocogitto; 45 | name = "cog"; 46 | } 47 | ]; 48 | }; 49 | }; 50 | checks = namaka.lib.load { 51 | src = ../tests; 52 | inputs = call-flake ../.; 53 | }; 54 | } 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/_internal/decorateAt.nix: -------------------------------------------------------------------------------- 1 | {lib}: let 2 | inherit (builtins) isAttrs hasAttr head tail typeOf concatStringsSep tryEval unsafeGetAttrPos; 3 | inherit (lib) zipAttrsWith isList isFunction getAttrFromPath; 4 | # 5 | # decorateAt: takes a given right-hand side and allows you to decorate its arrays with 6 | # array merge instructions. 7 | # 8 | # Type: 9 | # [ String ] -> rhs -> dec -> rhs' 10 | # 11 | in 12 | here: lhs: rhs: let 13 | f = attrPath: rhs_: lhs_: 14 | zipAttrsWith ( 15 | n: values: let 16 | here' = attrPath ++ [n]; 17 | rhs' = head values; 18 | lhs' = head (tail values); 19 | isSingleton = tail values == []; 20 | singleton = head values; 21 | lhsFilePos = let 22 | lhsPos = unsafeGetAttrPos n (getAttrFromPath attrPath lhs); 23 | in 24 | if lhsPos != null 25 | then "${lhsPos.file}:${toString lhsPos.line}:${toString lhsPos.column}" 26 | else "undetectable posision"; 27 | rhsFilePos = let 28 | rhsPos = unsafeGetAttrPos n (getAttrFromPath attrPath rhs); 29 | in 30 | if rhsPos != null 31 | then "${rhsPos.file}:${toString rhsPos.line}:${toString rhsPos.column}" 32 | else "undetectable posision"; 33 | in 34 | if isSingleton 35 | then 36 | if hasAttr n rhs_ # rhs-singleton 37 | then 38 | throw '' 39 | 40 | you can only decorate existing attr paths, the following doesn't exist in the decorated attrs 41 | at '${concatStringsSep "." here'}': 42 | - decor: ${typeOf rhs'} @ ${rhsFilePos} 43 | '' 44 | else singleton # lhs-singleton 45 | else if !(isAttrs lhs' && isAttrs rhs') 46 | then 47 | if (isList lhs' && isFunction rhs') 48 | then rhs' lhs' 49 | else 50 | throw '' 51 | 52 | The only thing you can do is to decorate an attrs' list with a function decorator at '${concatStringsSep "." here'}': 53 | - attrs: ${typeOf lhs'} @ ${lhsFilePos} 54 | - decor: ${typeOf rhs'} @ ${rhsFilePos} 55 | 56 | Available array merge functions decorators: 57 | - data-merge.update [ idx ... ] 58 | - data-merge.append 59 | '' 60 | else f here' rhs' lhs' 61 | ) 62 | [rhs_ lhs_]; 63 | in 64 | f here rhs lhs 65 | -------------------------------------------------------------------------------- /src/updateOn.nix: -------------------------------------------------------------------------------- 1 | { 2 | yants, 3 | root, 4 | lib, 5 | }: 6 | with yants "dmerge/updateOn"; let 7 | inherit (lib) assertMsg isAttrs all foldl' traceSeqN setAttrByPath getAttrFromPath unique length; 8 | inherit (lib.generators) toPretty; 9 | 10 | inherit (root.internal) mergeAt; 11 | in 12 | key: updates: orig: here: let 13 | updateset = 14 | foldl' ( 15 | acc: new: ( 16 | if acc ? ${new.${key}} 17 | then 18 | throw '' 19 | The key '${new.${key}}' must be unique in the update array, got: ${traceSeqN 2 updates ""} 20 | '' 21 | else acc // {"${new.${key}}" = new;} 22 | ) 23 | ) {} 24 | updates; 25 | in 26 | assert assertMsg (all isAttrs orig) '' 27 | UPDATING ASSOCIATIVE ARRAY: the left hand side of an associative array merged 28 | must only contain attribute sets, got: ${traceSeqN 1 orig ""}''; 29 | assert assertMsg (all isAttrs updates) '' 30 | UPDATING ASSOCIATIVE ARRAY: the right hand side of an associative array merged 31 | must only contain attribute sets, got: ${traceSeqN 1 updates ""}''; 32 | assert assertMsg (all (o: o ? ${key}) orig) '' 33 | UPDATING ASSOCIATIVE ARRAY: all items of the left hand side of an associative 34 | array merged must contain attribute sets with a key ${key}, got: ${traceSeqN 2 orig ""}''; 35 | assert assertMsg (all (u: u ? ${key}) updates) '' 36 | UPDATING ASSOCIATIVE ARRAY: all items of the right hand side of an associative 37 | array merged must contain attribute sets with a key ${key}, got: ${traceSeqN 2 updates ""}''; 38 | assert assertMsg (length (map (o: o.${key}) orig) == (length (unique (map (o: o.${key}) orig)))) '' 39 | UPDATING ASSOCIATIVE ARRAY: keys of the left hand side of an associative 40 | array merged must be unique on ${key}, got: ${traceSeqN 2 orig ""}''; 41 | map (o: ( 42 | if updateset ? ${o.${key}} 43 | then 44 | ( 45 | let 46 | # synthkey = "[${key}=${o.${key}}]"; 47 | synthkey = "my"; #[${key}=${o.${key}}]"; 48 | # manufacture a "here" for display purposes 49 | tmplhs = setAttrByPath (here ++ [synthkey]) o; 50 | tmprhs = setAttrByPath (here ++ [synthkey]) updateset.${o.${key}}; 51 | # but start from an empty here on this commissioned merge operation 52 | res = getAttrFromPath (here ++ [synthkey]) (mergeAt [] tmplhs tmprhs); 53 | in 54 | res 55 | ) 56 | else o 57 | )) 58 | orig 59 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. 3 | 4 | - - - 5 | ## [0.2.1](https://github.com/divnix/dmerge/compare/0.2.0..0.2.1) - 2023-06-15 6 | #### Bug Fixes 7 | - use new namaka in local flake only - ([60a2624](https://github.com/divnix/dmerge/commit/60a26246df9664334d89817cf12eb31c8655bdb9)) - [@blaggacao](https://github.com/blaggacao) 8 | #### Documentation 9 | - utilize cool repl command - ([a740c56](https://github.com/divnix/dmerge/commit/a740c5632789ff872a54a5b6595ef07287ff273e)) - figsoda 10 | - fix oversight in readme - ([eef0a89](https://github.com/divnix/dmerge/commit/eef0a89bb2efa7296317ce5e42e81964f6ae137d)) - [@blaggacao](https://github.com/blaggacao) 11 | 12 | - - - 13 | 14 | ## [0.2.0](https://github.com/divnix/dmerge/compare/0.1.0..0.2.0) - 2023-05-15 15 | #### Documentation 16 | - don't need lib in the repl to play - ([43d9d71](https://github.com/divnix/dmerge/commit/43d9d71b72ab7593cbe564d02536514c41c66302)) - [@blaggacao](https://github.com/blaggacao) 17 | #### Features 18 | - allow fresh rhs with prepend/append - ([06a9451](https://github.com/divnix/dmerge/commit/06a9451df16a6f127c9dff89f5e2c5213d9ef7d8)) - [@blaggacao](https://github.com/blaggacao) 19 | - add prepend function - ([1e0d48f](https://github.com/divnix/dmerge/commit/1e0d48f784ef68daf963e9802a22b84c4c42f3f8)) - [@blaggacao](https://github.com/blaggacao) 20 | - add cog file - ([e9013f2](https://github.com/divnix/dmerge/commit/e9013f296f0effe761bad6d4bd8861b8070c14ec)) - [@blaggacao](https://github.com/blaggacao) 21 | #### Miscellaneous Chores 22 | - adapt to namaka changes - ([8fddbab](https://github.com/divnix/dmerge/commit/8fddbabfbaa3b9d7c3793e36cea5dd679a84dc72)) - figsoda 23 | - adpot parent flake pattern that survives CI - ([4d54735](https://github.com/divnix/dmerge/commit/4d547351cac82814800d562f0f13686eb63077d9)) - [@blaggacao](https://github.com/blaggacao) 24 | - cleanup - ([2602e1b](https://github.com/divnix/dmerge/commit/2602e1b6af505941c4aaecf2ca144360102eee06)) - [@blaggacao](https://github.com/blaggacao) 25 | #### Refactoring 26 | - prepare array merge function value initialization - ([fbbb475](https://github.com/divnix/dmerge/commit/fbbb4750dd53df70be1e68b279f46637c23711f2)) - [@blaggacao](https://github.com/blaggacao) 27 | 28 | - - - 29 | 30 | ## 0.1.0 - 2023-04-15 31 | #### Bug Fixes 32 | - disallow duplicate keys for mergeOn on LHS as well - (8bc84e2) - David Arnold 33 | - error reporting on `update` array merge function - (0bbe0a6) - David Arnold 34 | - if no position detectable -> no position detectable - (b21bcf7) - David Arnold 35 | - #1 - (778298f) - David Arnold 36 | #### Continuous Integration 37 | - add cocogitto for releases - (3c78fc7) - David Arnold 38 | - fix ci and readme - (86257e6) - David Arnold 39 | - add gh - (79bb859) - David Arnold 40 | #### Documentation 41 | - add readme - (4fa2bbd) - David Arnold 42 | #### Features 43 | - add data merge chain - (8538f69) - David Arnold 44 | - add `updateOn` function - (1051f3c) - David Arnold 45 | #### Miscellaneous Chores 46 | - adopt haumea to separate and load lib functions - (c552678) - David Arnold 47 | - devshell -> local (tech neutrality!) - (aac19a6) - David Arnold 48 | - add namaka testing tool - (c24f5a6) - David Arnold 49 | #### Style 50 | - fix checks and treefmt - (9f938ab) - David Arnold 51 | 52 | - - - 53 | 54 | Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto). -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dmerge 2 | 3 | A mini merge DSL for data overlays. 4 | 5 | Dmerge is a lightweight alternative to the NixOS module system to wrangle data. 6 | It aims to give you alternative semantics, currently not available in the ecosystem, when you need them. 7 | 8 | ## Semantics 9 | 10 | - Simple semantics: `merge :: lhs -> rhs -> final` 11 | - Monotonistic on the Dataspine 12 | - Expressive Array Merge Decorations 13 | - Typed-by-Precedent 14 | 15 | ### Simple semantics 16 | 17 | ```nix 18 | # nix repl 19 | 20 | > :lf github:divnix/dmerge 21 | 22 | > :p merge { foo = "bar"; } { foo = "baz"; } 23 | { foo = "baz"; } 24 | 25 | > :p merge { foo = [1]; } { foo = append [2]; } 26 | { foo = [ 1 2 ]; } 27 | 28 | > :p merge { foo = [1]; } { foo = prepend [2]; } 29 | { foo = [ 2 1 ]; } 30 | 31 | # update [idx] updates idx on lhs 32 | > :p merge { foo = [1]; } { foo = update [0] [2]; } 33 | { foo = [ 2 ]; } 34 | 35 | # recurses the arrays and attribute sets 36 | > :p merge { foo = [{egg = {color = "yellow";};}]; } { foo = update [0] [{egg = {color = "green";};}]; } 37 | { foo = [ { egg = { color = "green"; }; } ]; } 38 | 39 | # supports associative array merges 40 | > :p merge { people = [{name = "bert"; age = 42;}]; } {people = updateOn "name" [{name = "bert"; age = 43;}]; } 41 | { people = [ { age = 43; name = "bert"; } ]; } 42 | 43 | # chaining 44 | > WithMichi = chainable {michelangelo = { age = 548; };} 45 | > WithRemi = chainable {rembrandt = { age = 417; };} 46 | > WithLeo = chainable {davinci = { age = 571; };} 47 | > mkParty = chainMerge 48 | > :p mkParty WithMichi WithRemi WithLeo {me = {age = 35;};} 49 | { davinci = { age = 571; }; me = { age = 35; }; michelangelo = { age = 548; }; rembrandt = { age = 417; }; } 50 | 51 | ``` 52 | 53 | ### Monotonistic on the Dataspine 54 | 55 | Dmerge never destroys an Attribute Set or an Array. 56 | Unlike simple values, they are considered the dataspine and must be protected. 57 | 58 | If you have a use case for destroying an Attribute Set or Array or plainly overriding an Array, 59 | consider better factorizing your left hand side, instead. It's a symptom of poor factorization. 60 | 61 | ### Expressive Array Merge Decorations 62 | 63 | In the above examples prooving its simplicity, you already got to know the full instruction set 64 | of Dmerge to specify the array merge strategy on the right hand side: `append`, `prepend`, `update` & `updateOn`. 65 | 66 | If you don't control the right hand side, but know it's structure, you can `decorate` it: 67 | 68 | ```nix 69 | merge lhs (decorate rhs { people = updateOn "name";}) 70 | ``` 71 | 72 | ### Typed by Precedent 73 | 74 | You can't modify the type of a left hand side node or leave in the right hand side. 75 | 76 | ## Tests 77 | 78 | Check out the [tests](https://github.com/divnix/dmerge/tree/main/tests) for more examples. 79 | 80 | ## Implementor's Note 81 | 82 | This repo has no `flake.lock`. Chose the dependencies you see fit; assert compatibilty — chances are high, you'll be fine, though. 83 | 84 | **Example Use:** 85 | 86 | ```nix 87 | # flake.nix 88 | { 89 | inputs.nixpkgs.url = "github:nixos/nixpkgs"; 90 | inputs.dmerge = { 91 | url = "github:divnix/dmerge"; 92 | inputs.nixpkgs.follows = "nixpkgs"; 93 | inputs.namaka.follows = ""; # testing only dependency 94 | }; 95 | 96 | /* ... */ 97 | } 98 | ``` 99 | 100 | ## Acknowledgements 101 | 102 | - [Haumea](https://github.com/nix-community/haumea) for help me chore 103 | - [Namaka](https://github.com/nix-community/namaka) for testing-made-easy (tm) 104 | - [Cocogitto](https://github.com/cocogitto/cocogitto) for taking the guesswork out of releases 105 | - [nix-quick-install-action](https://github.com/nixbuild/nix-quick-install-action) for making nix in CI a sure quick thing 106 | -------------------------------------------------------------------------------- /src/_internal/mergeAt.nix: -------------------------------------------------------------------------------- 1 | {lib}: let 2 | inherit (builtins) isAttrs head tail typeOf concatStringsSep tryEval unsafeGetAttrPos; 3 | inherit (lib) zipAttrsWith isList isFunction getAttrFromPath; 4 | # 5 | # mergeAt: recursively merges left- & right-hand side 6 | # while keeping a cursor for good error reporting. 7 | # 8 | # Type: 9 | # [ String ] -> lhs -> rhs -> merged 10 | # 11 | 12 | tryArrayMerge = cr: lhs: rhs: rhsFilePos: lhsFilePos: 13 | assert !(typeOf lhs == "list") 14 | -> throw '' 15 | 16 | During an attempted array merge, left-hand-side is not a list: 17 | - lhs: ${typeOf lhs} @ ${lhsFilePos} 18 | ''; 19 | assert !(isFunction rhs) 20 | -> throw '' 21 | 22 | During an attempted array merge, right-hand-side is not a function: 23 | - rhs: ${typeOf rhs} @ ${rhsFilePos} 24 | ''; 25 | assert !(isFunction (rhs lhs)) 26 | -> throw '' 27 | 28 | During an attempted array merge, the result of left-hand-side applied to right-hand-side is not a function: 29 | - lhs: ${typeOf lhs} @ ${lhsFilePos} 30 | - rhs: ${typeOf rhs} @ ${rhsFilePos} 31 | - (rhs lhs): ${typeOf (rhs lhs)} 32 | ''; let 33 | ex = tryEval (rhs lhs cr); 34 | in 35 | if ex.success 36 | then ex.value 37 | else 38 | throw '' 39 | 40 | Array merge function error (see trace above the error line for details) on the right-hand-side: 41 | - rhs: ${typeOf rhs} @ ${rhsFilePos} 42 | ''; 43 | in 44 | cursor: lhs: rhs: let 45 | f = attrPath: 46 | zipAttrsWith ( 47 | n: values: let 48 | cursor' = attrPath ++ [n]; 49 | rhs' = head values; 50 | lhs' = head (tail values); 51 | isSingleton = tail values == []; 52 | singleton = head values; 53 | lhsFilePos = let 54 | lhsPos = unsafeGetAttrPos n (getAttrFromPath attrPath lhs); 55 | in 56 | if lhsPos != null 57 | then "${lhsPos.file}:${toString lhsPos.line}:${toString lhsPos.column}" 58 | else "undetectable posision"; 59 | rhsFilePos = let 60 | rhsPos = unsafeGetAttrPos n (getAttrFromPath attrPath rhs); 61 | in 62 | if rhsPos != null 63 | then "${rhsPos.file}:${toString rhsPos.line}:${toString rhsPos.column}" 64 | else "undetectable posision"; 65 | in 66 | if (isSingleton && isFunction singleton) 67 | then tryArrayMerge cursor' [] rhs' rhsFilePos lhsFilePos # empty lhs 68 | else if isSingleton 69 | then 70 | if (isAttrs singleton) # descend if it's an attrset 71 | then f cursor' [{} singleton] 72 | else singleton 73 | else if !(isAttrs lhs' && isAttrs rhs') 74 | then 75 | if (typeOf lhs') != (typeOf rhs') && !(isList lhs' && isFunction rhs') 76 | then 77 | throw '' 78 | 79 | rigt-hand-side must be of the same type as left-hand-side 80 | at '${concatStringsSep "." cursor'}': 81 | - lhs: ${typeOf lhs'} @ ${lhsFilePos} 82 | - rhs: ${typeOf rhs'} @ ${rhsFilePos} 83 | '' 84 | else if isList lhs' && isList rhs' 85 | then 86 | throw '' 87 | 88 | rigt-hand-side list is not allowed to override left-hand-side list, 89 | this would break incrementality of the data spine. Use one of the array 90 | merge functions instead at '${concatStringsSep "." cursor'}': 91 | - lhs: ${typeOf lhs'} @ ${lhsFilePos} 92 | - rhs: ${typeOf rhs'} @ ${rhsFilePos} 93 | 94 | Available array merge functions: 95 | - data-merge.update [ idx ... ] [ v ... ] 96 | - data-merge.append [ v ] 97 | '' 98 | # array function merge 99 | else if isList lhs' && isFunction rhs' 100 | then tryArrayMerge cursor' lhs' rhs' rhsFilePos lhsFilePos 101 | else rhs' 102 | else f cursor' values 103 | ); 104 | in 105 | f cursor [rhs lhs] 106 | -------------------------------------------------------------------------------- /local/flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "call-flake": { 4 | "locked": { 5 | "lastModified": 1686448809, 6 | "narHash": "sha256-Cc+Krf2Ar5NjFMxUWaJFOAQkj8s40r6iEJwIfbxa0Vc=", 7 | "owner": "divnix", 8 | "repo": "call-flake", 9 | "rev": "a0b97f9c6d21997fe5f1ff9a5eedc05a7935a917", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "divnix", 14 | "repo": "call-flake", 15 | "type": "github" 16 | } 17 | }, 18 | "devshell": { 19 | "inputs": { 20 | "flake-utils": "flake-utils", 21 | "nixpkgs": "nixpkgs" 22 | }, 23 | "locked": { 24 | "lastModified": 1678957337, 25 | "narHash": "sha256-Gw4nVbuKRdTwPngeOZQOzH/IFowmz4LryMPDiJN/ah4=", 26 | "owner": "numtide", 27 | "repo": "devshell", 28 | "rev": "3e0e60ab37cd0bf7ab59888f5c32499d851edb47", 29 | "type": "github" 30 | }, 31 | "original": { 32 | "owner": "numtide", 33 | "repo": "devshell", 34 | "type": "github" 35 | } 36 | }, 37 | "flake-utils": { 38 | "locked": { 39 | "lastModified": 1642700792, 40 | "narHash": "sha256-XqHrk7hFb+zBvRg6Ghl+AZDq03ov6OshJLiSWOoX5es=", 41 | "owner": "numtide", 42 | "repo": "flake-utils", 43 | "rev": "846b2ae0fc4cc943637d3d1def4454213e203cba", 44 | "type": "github" 45 | }, 46 | "original": { 47 | "owner": "numtide", 48 | "repo": "flake-utils", 49 | "type": "github" 50 | } 51 | }, 52 | "haumea": { 53 | "inputs": { 54 | "nixpkgs": [ 55 | "namaka", 56 | "nixpkgs" 57 | ] 58 | }, 59 | "locked": { 60 | "lastModified": 1685133229, 61 | "narHash": "sha256-FePm/Gi9PBSNwiDFq3N+DWdfxFq0UKsVVTJS3cQPn94=", 62 | "owner": "nix-community", 63 | "repo": "haumea", 64 | "rev": "34dd58385092a23018748b50f9b23de6266dffc2", 65 | "type": "github" 66 | }, 67 | "original": { 68 | "owner": "nix-community", 69 | "ref": "v0.2.2", 70 | "repo": "haumea", 71 | "type": "github" 72 | } 73 | }, 74 | "namaka": { 75 | "inputs": { 76 | "haumea": "haumea", 77 | "nixpkgs": "nixpkgs_2" 78 | }, 79 | "locked": { 80 | "lastModified": 1685739139, 81 | "narHash": "sha256-CLGEW11Fo1v4vj0XSqiyW1EbhRZFO7dkgM43eKwItrk=", 82 | "owner": "nix-community", 83 | "repo": "namaka", 84 | "rev": "d9a2cc83c1d0f68bd613f1fc909d0ef2cfffcf2e", 85 | "type": "github" 86 | }, 87 | "original": { 88 | "owner": "nix-community", 89 | "ref": "v0.2.0", 90 | "repo": "namaka", 91 | "type": "github" 92 | } 93 | }, 94 | "nixpkgs": { 95 | "locked": { 96 | "lastModified": 1677383253, 97 | "narHash": "sha256-UfpzWfSxkfXHnb4boXZNaKsAcUrZT9Hw+tao1oZxd08=", 98 | "owner": "NixOS", 99 | "repo": "nixpkgs", 100 | "rev": "9952d6bc395f5841262b006fbace8dd7e143b634", 101 | "type": "github" 102 | }, 103 | "original": { 104 | "owner": "NixOS", 105 | "ref": "nixpkgs-unstable", 106 | "repo": "nixpkgs", 107 | "type": "github" 108 | } 109 | }, 110 | "nixpkgs_2": { 111 | "locked": { 112 | "lastModified": 1685655444, 113 | "narHash": "sha256-6EujQNAeaUkWvpEZZcVF8qSfQrNVWFNNGbUJxv/A5a8=", 114 | "owner": "nixos", 115 | "repo": "nixpkgs", 116 | "rev": "e635192892f5abbc2289eaac3a73cdb249abaefd", 117 | "type": "github" 118 | }, 119 | "original": { 120 | "owner": "nixos", 121 | "ref": "nixos-unstable", 122 | "repo": "nixpkgs", 123 | "type": "github" 124 | } 125 | }, 126 | "nixpkgs_3": { 127 | "locked": { 128 | "lastModified": 1681358109, 129 | "narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=", 130 | "owner": "nixos", 131 | "repo": "nixpkgs", 132 | "rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9", 133 | "type": "github" 134 | }, 135 | "original": { 136 | "owner": "nixos", 137 | "ref": "nixpkgs-unstable", 138 | "repo": "nixpkgs", 139 | "type": "github" 140 | } 141 | }, 142 | "nosys": { 143 | "locked": { 144 | "lastModified": 1668010795, 145 | "narHash": "sha256-JBDVBnos8g0toU7EhIIqQ1If5m/nyBqtHhL3sicdPwI=", 146 | "owner": "divnix", 147 | "repo": "nosys", 148 | "rev": "feade0141487801c71ff55623b421ed535dbdefa", 149 | "type": "github" 150 | }, 151 | "original": { 152 | "owner": "divnix", 153 | "repo": "nosys", 154 | "type": "github" 155 | } 156 | }, 157 | "root": { 158 | "inputs": { 159 | "call-flake": "call-flake", 160 | "devshell": "devshell", 161 | "namaka": "namaka", 162 | "nixpkgs": "nixpkgs_3", 163 | "nosys": "nosys" 164 | } 165 | } 166 | }, 167 | "root": "root", 168 | "version": 7 169 | } 170 | --------------------------------------------------------------------------------