Search code examples
clojuremodeling

Clojure: Modeling simple many to many relationship


Since I'm learning Spanish at the moment I'm making a dead simple Flashcard application.

The application has two concepts:

  1. The cards themselves. Two strings, one for the front and one for the back. In addition each card is tagged with 0-m tags. E.g. the tags for a given card could be ["spanish" "verb"].
  2. Profiles. The profile stores two things: Which cards are included by defining the tags, and a "knowledge score" for each card.

The application works simply by choosing a profile to practice, gives you the front side of the card with the lowest knowledge score. When the user is ready it shows the back side. The user then inputs whether or not he remembered that card which modifies the knowledge score of that card.

For anyone whose used any Flashcard application before, this is super trivial stuff.

My question is: How do I model this idiomatically in Clojure? The challenge I'm getting at is the many-to-many relationship between the profiles and the cards.

I could create a state map like this:

{:card-universe [
  {:front "Correr" :back "To run" :tags ["spanish" "verb"]}
  {:front "Querer" :back "To want" :tags ["spanish" "verb"]}
  {:front "La mesa" :back "The table" :tags ["spanish" "noun"]}]

 :profiles [
  {
   :name "Spanish verbs"
   :tags ["spanish" "verb"] 
   :cards [{:front "Correr" :back "To want" :score 7}
           {:front "Querer" :back "To want" :score 10}]
  }
  {
   :name "Spanish"
   :tags ["spanish"] 
   :cards [{:front "Correr" :back "To run" :score 8}
           {:front "Querer" :back "To want" :score 3}
           {:front "La mesa" :back "The table" :score 2}]
  }
 ]
}

This to me seem silly. Say I edit a card because I made a mistake, then I would have to go through all the profiles and update them. I could fix this (somewhat) by creating identities for all the cards, and just use that to refer to the card instead:

{:card-universe [
  {:id "c1" :front "Correr" :back "To run" :tags ["spanish" "verb"]}
  {:id "c2" :front "Querer" :back "To want" :tags ["spanish" "verb"]}
  {:id "c3" :front "Mesa" :back "Table" :tags ["spanish" "noun"]}]

 :profiles [
  {
   :name "Spanish verbs"
   :tags ["spanish" "verb"] 
   :cards [{:id "c1" :score 7}
           {:id "c2" :score 10}]
  }
  {
   :name "Spanish words"
   :tags ["spanish"] 
   :cards [{:id "c1" :score 8}
           {:id "c2" :score 3}
           {:id "c3"  :score 2}]
  }
 ]
}

This is maybe a little better, but it would still mean that if I add more cards in a given tag I would have to fetch all the cards. Basically an outer-join between my :card-universe and the :cards in the profile.

Next question that pops up is storing the state. I could of course just store this state out-right to a file, but if I were to extend this to multi-user by creating web application an SQL database would be my go to. In my mind I should be able to code this all up and store to a file in the beginning and later be able to swap out how I store the data without touching the data structure the application uses to function.

Any tips and experiences would be greatly appreciated!

I have a feeling that the application is too simple to get any of the benefits Clojure. Especially when introducing a database - which would basically just make this a CRUD application.


Solution

  • I'd probably start by taking things apart a bit first

    (def card-data
      [{:id "c1" :front "Correr" :back "To run" :tags #{"spanish" "verb"}}
       {:id "c2" :front "Querer" :back "To want" :tags #{"spanish" "verb"}}
       {:id "c3" :front "Mesa" :back "Table" :tags #{"spanish" "noun"}}])
    
    (defn spanish-words [cards]
      (filter #(-> % :tags (every? ["spanish"])) cards))
    
    (defn spanish-verbs [cards]
      (filter #(-> % :tags (every? ["spanish" "verb"])) cards))
    

    Then make a little atom db for testing, with a function that can store state in it. You could later abstract this function over whatever db you end up using.

    (def db (atom {}))
    
    (defn remembered! [scores-db card]
      (swap! scores-db update (:id card) #(if % (inc %) 0)))
    

    Now we can test it out.

    #_user=> (->> card-data spanish-verbs first (remembered! db))
    {"c1" 0}
    #_user=> (->> card-data spanish-verbs second (remembered! db))
    {"c1" 0, "c2" 0}
    #_user=> (->> card-data spanish-verbs first (remembered! db))
    {"c1" 1, "c2" 0}
    

    That works. But we can further abstract out our filtering into a select-tags function.

    (defn select-tags [cards & tags]
      (filter #(-> % :tags (every? (->> tags flatten (remove nil?)))) cards))
    
    (defn spanish [cards & tags]
      (select-tags cards "spanish" tags))
    
    (defn verbs [cards & tags]
      (select-tags cards "verb" tags))
    
    #_user=> (spanish (verbs card-data))
    ({:id "c1", :front "Correr", :back "To run", :tags #{"verb" "spanish"}} {:id "c2", :front "Querer", :back "To want", :tags #{"verb" "spanish"}})
    #_user=> (verbs (spanish card-data))
    ({:id "c1", :front "Correr", :back "To run", :tags #{"verb" "spanish"}} {:id "c2", :front "Querer", :back "To want", :tags #{"verb" "spanish"}})
    

    And now we can just compose them.

    (defn spanish-verbs [cards & tags]
      ((comp spanish verbs) cards tags))
    ;; or (apply spanish cards "verb" tags)
    ;; or even (apply select-tags cards "verb" "spanish" tags)
    
    #_user=> (->> card-data spanish-verbs first (remembered! db))
    {"c1" 2, "c2" 0}