[json] Provide Better Support for Modifying JSON Files

I'd like to explore how the json library could provide a more convenient way to modify JSON files. My previous approach was to read JSON data using read-json, convert it into mutable hashtables for objects, and then modify the target key-value pairs. With the introduction of read-json*, this can now be achieved directly. [json] Add `#:mhash?` to `read-json` by NoahStoryM · Pull Request #5163 · racket/racket · GitHub

@notjack suggested designing auxiliary functions like jsexpr-ref and jsexpr-set to handle such tasks. To evaluate this idea, I simplified some scenarios I've encountered and tried to find ways to modify JSON files without relying on mutable hashtables.

Here is an example JSON file and corresponding Racket code illustrating the problem:

data.json

{
  "root": {
    "items": {
      "n1": {"data-1": {"d1": 0, "d3": 0}},
      "n2": {"data-2": {"d1": 0, "d3": 0}},
      "n3": {"data-3": {"d1": 0, "d3": 0}},
      "n4": {"data-4": {"d1": 0, "d3": 0}}
    },
    "metadata": {
      "has-key1": false,
      "has-key2": true,
      "info-state": {
        "key": "value"
      }
    }
  }
}

test.rkt

#lang racket/base

(require racket/hash
         racket/string
         json (submod json for-extension))

(define (read-js [i (current-input-port)])
  (read-json* 'read-json i
              #:null (json-null)
              #:make-object make-hasheq
              #:make-list values
              #:make-key string->symbol
              #:make-string values))

(define data (call-with-input-file "data.json" read-js))
(define root (hash-ref data 'root))

(define items (hash-ref root 'items))
(for ([i (in-range 1 5)])
  (define ni (hash-ref items (string->symbol (format "n~a" i))))
  (define data-i (hash-ref ni (string->symbol (format "data-~a" i))))
  (for ([k '(d1 d2 d3)])
    (define v (* 11 i))
    (hash-set! data-i k v)))

(define metadata (hash-ref root 'metadata))
(for ([(k v) (in-hash metadata)])
  (define s (symbol->string k))
  (when (string-prefix? s "has-")
    (hash-set! metadata k (not v)))
  (when (string-suffix? s "-state")
    (for ([i (in-range 1 4)])
      (define k (string->symbol (format "s~a" i)))
      (hash-set! v k i))))

(hash-union! metadata
             #hasheq([has-key1 . #t]
                     [has-key2 . #t]
                     [has-key3 . #t]
                     [new-key1 . "value1"]
                     [new-key2 . "value2"]
                     [new-key3 . "value3"])
             #:combine (λ (a b) a))

(write-json data #:indent 2)

I also tried solving the problem using lenses (this is my first time using them, so I'm not sure if this is the most appropriate approach). Here's the lens-based implementation:

#lang racket/base

(require racket/hash
         racket/string
         json
         lens
         data/queue)

(define q (make-queue))

(define data identity-lens)
(define root (lens-compose (hash-ref-lens 'root) data))

(define items (lens-compose (hash-ref-lens 'items) root))
(for ([i (in-range 1 5)])
  (define ni (lens-compose (hash-ref-lens (string->symbol (format "n~a" i))) items))
  (define data-i (lens-compose (hash-ref-lens (string->symbol (format "data-~a" i))) ni))
  (for ([k '(d1 d2 d3)])
    (define v (* 11 i))
    (define (get h) h)
    (define (set h _) (hash-set h k v))
    (enqueue! q (lens-compose (make-lens get set) data-i))))

(define metadata (lens-compose (hash-ref-lens 'metadata) root))
(let ()
  (define (get h) h)
  (define (set h _)
    (for ([(k v) (in-hash h)])
      (define s (symbol->string k))
      (when (string-prefix? s "has-")
        (define (get h) h)
        (define (set h _) (hash-set h k (not v)))
        (enqueue! q (lens-compose (make-lens get set) metadata)))
      (when (string-suffix? s "-state")
        (for ([i (in-range 1 4)])
          (define k (string->symbol (format "s~a" i)))
          (define (get h) h)
          (define (set h _) (hash-set v k i))
          (enqueue! q (lens-compose (make-lens get set) metadata)))))
    h)
  (enqueue! q (lens-compose (make-lens get set) metadata)))

(let ()
  (define (get h) h)
  (define (set h _)
    (hash-union h
                #hasheq([has-key1 . #t]
                        [has-key2 . #t]
                        [has-key3 . #t]
                        [new-key1 . "value1"]
                        [new-key2 . "value2"]
                        [new-key3 . "value3"])
                #:combine (λ (a b) a)))
  (enqueue! q (lens-compose (make-lens get set) metadata)))

(for/fold ([data (call-with-input-file "data.json" read-json)]
           #:result (write-json data #:indent 2))
          ([l (in-queue q)])
  (lens-set l data 'start))

Hi, @NoahStoryM.

I am a bit of a broken record on the subject, because I often reach for zippers in these situations.

Very rudimentary, but at least no mutable edits, although it can be a bit cumbersome to thread this way.

#lang racket/base

(require
  json
  (only-in
   racket/match match-define)
  (only-in
   racket/string string-prefix? string-suffix?)
  (only-in
   racket/hash hash-union))

(struct js-zipper [head path hole] #:transparent)

(define (done edit key*) edit)
(define ((busy head key path hole) edit key*)
  (define none (hash-remove head key))
  (js-zipper (hash-set none (or key* key) edit) path hole))

(define (js-pull ex [key #false] [fail (hash)])
  (cond
    [(not key) (js-zipper ex null done)]
    [else
     (match-define (js-zipper head path hole) ex)
     (js-zipper
      (hash-ref head key fail)
      (cons key path)
      (busy head key path hole))]))

(define (js-push jz [key* #false])
  (match-define (js-zipper head path hole) jz)
  (hole head key*))

(define (js-edit jz proc)
  (match-define (js-zipper head path hole) jz)
  (js-zipper (proc head) path hole))

(define-syntax with-js
  (syntax-rules ()
    [(_ ex (cmd arg ...) rest ...)
     (with-js (cmd ex arg ...) rest ...)]
    
    [(_ ex) ex]))
(define data
  (string->jsexpr
   #<<qqq
{
    "root": {
      "items": {
        "n1": {"data-1": {"d1": 0, "d3": 0}},
        "n2": {"data-2": {"d1": 0, "d3": 0}},
        "n3": {"data-3": {"d1": 0, "d3": 0}},
        "n4": {"data-4": {"d1": 0, "d3": 0}}
      },
      "metadata": {
        "has-key1": false,
        "has-key2": true,
        "info-state": {
          "key": "value"
      }
    }
  }
}
qqq
   ))

(with-js data
  (js-pull)
  
  (js-pull 'root)
  (js-pull 'items)
  (js-edit
   (lambda (items)
     (for/fold ([items items])
               ([i     (in-range 1 5)])
       (define ki     (format "n~a"     i))
       (define ni     (hash-ref items (string->symbol ki)))
       (define data-i (hash-ref ni    (string->symbol (format "data-~a" i))))
       (hash-set
        items ki
        (for/fold ([data-i data-i])
                  ([k '(d1 d2 d3)])
          (define v (* 11 i))
          (hash-set data-i k v))))))
  (js-push)

  (js-pull 'metadata)
  (js-edit
   (lambda (metadata)
     (for/fold ([metadata metadata])
               ([(k v) (in-hash metadata)])
       (define s (symbol->string k))
       (cond
         [(string-prefix? s "has-")
          (hash-set metadata k (not v))]

         [(string-suffix? s "-state")
          (hash-set
           metadata k
           (for/fold ([v v])
                     ([i (in-range 1 4)])
             (define k (string->symbol (format "s~a" i)))
             (hash-set v k i)))]

         [else metadata]))))
  (js-edit
   (lambda (metadata)
     (hash-union metadata
                 #hasheq([has-key1 . #t]
                         [has-key2 . #t]
                         [has-key3 . #t]
                         [new-key1 . "value1"]
                         [new-key2 . "value2"]
                         [new-key3 . "value3"])
                 #:combine (λ (a b) a))))
  
  (js-push)
  (js-push)
  (js-push))

Which produces (no idea if this is correct):

'#hasheq((root
          .
          #hasheq((items
                   .
                   #hasheq((n1 . #hasheq((data-1 . #hasheq((d1 . 0) (d3 . 0)))))
                           (n2 . #hasheq((data-2 . #hasheq((d1 . 0) (d3 . 0)))))
                           (n3 . #hasheq((data-3 . #hasheq((d1 . 0) (d3 . 0)))))
                           (n4 . #hasheq((data-4 . #hasheq((d1 . 0) (d3 . 0)))))
                           ("n1" . #hasheq((d1 . 11) (d2 . 11) (d3 . 11)))
                           ("n2" . #hasheq((d1 . 22) (d2 . 22) (d3 . 22)))
                           ("n3" . #hasheq((d1 . 33) (d2 . 33) (d3 . 33)))
                           ("n4" . #hasheq((d1 . 44) (d2 . 44) (d3 . 44)))))
                  (metadata
                   .
                   #hasheq((has-key1 . #t)
                           (has-key2 . #f)
                           (has-key3 . #t)
                           (info-state . #hasheq((key . "value") (s1 . 1) (s2 . 2) (s3 . 3)))
                           (new-key1 . "value1")
                           (new-key2 . "value2")
                           (new-key3 . "value3"))))))

This is an interesting topic. Thanks for posting.

2 Likes

Thank you so much for introducing zippers as a solution! This is my first time encountering the concept, and your example is incredibly insightful. I'll take some time to dive deeper into zippers and experiment with your approach. It's always exciting to learn a new perspective for solving these kinds of problems. Thanks again for sharing this!

2 Likes

This is rather silly, but it is killing me: The (define k ...) should be,

(define ki (string->symbol (format "n~a" i)))
(define ni (hash-ref ... ki))

I knew the strings didn't make sense but couldn't put my finger on it.