nmunro.github.io

Common Lisp and other programming related things from NMunro

View on GitHub
20 November 2025

Ningle Tutorial 13: Adding Comments

by NMunro

Contents

Introduction

Hello and welcome back, I hope you are well! In this tutorial we will be exploring how to work with comments, I originally didn’t think I would add too many Twitter like features, but I realised that having a self-referential model would actually be a useful lesson. In addition to demonstrating how to achieve this, we can look at how to complete a migration successfully.

This will involve us adjusting our models, adding a form (and respective validator), improving and expanding our controllers, adding the appropriate controller to our app and tweak our templates to accomodate the changes.

Note: There is also an improvement to be made in our models code, mito provides a convenience method to get the id, created-at, and updated-at slots. We will integrate it as we alter our models.

src/models.lisp

When it comes to changes to the post model it is very important that the :col-type is set to (or :post :null) and that :initform nil is also set. This is because when you run the migrations, existing rows will not have data for the parent column and so in the process of migration we have to provide a default. It should be possible to use (or :post :integer) and set :initform 0 if you so wished, but I chose to use :null and nil as my migration pattern.

This also ensures that new posts default to having no parent, which is the right design choice here.

Package and Post model

(defpackage ningle-tutorial-project/models
  (:use :cl :mito :sxql)
  (:import-from :ningle-auth/models #:user)
  (:export #:post
           #:id
           #:content
+          #:comments
           #:likes
           #:user
           #:liked-post-p
-          #:logged-in-posts
-          #:not-logged-in-posts
+          #:posts
+          #:parent
           #:toggle-like))

(in-package ningle-tutorial-project/models)

(deftable post ()
  ((user    :col-type ningle-auth/models:user :initarg :user    :accessor user)
+  (parent  :col-type (or :post :null)        :initarg :parent  :reader parent :initform nil)
   (content :col-type (:varchar 140)          :initarg :content :accessor content)))

Comments

Comments are really a specialist type of post that happens to have a non-nil parent value, we will take what we previously learned from working with post objects and extend it. In reality the only real difference is (sxql:where (:= parent :?)), perhaps I shall see if this could support conditionals inside it, but that’s another experiment for another day.

I want to briefly remind you of what the :? does, as security is important!

The :? is a placeholder, it is a way to ensure that values are not placed in the SQL without being escaped, this prevents SQL Injection attacks, the retrieve-by-sql takes a key argument :binds which takes a list of values that will be interpolated into the right parts of the SQL query with the correct quoting.

We used this previously, but I want to remind you to not just inject values into a SQL query without quoting them.

(defmethod likes ((post post))
  (mito:count-dao 'likes :post post))

+(defgeneric comments (post user)
+ (:documentation "Gets the comments for a logged in user"))
+
+(defmethod comments ((post post) (user user))
+    (mito:retrieve-by-sql
+        (sxql:yield
+            (sxql:select
+                (:post.*
+                    (:as :user.username :username)
+                    (:as (:count :likes.id) :like_count)
+                    (:as (:count :user_likes.id) :liked_by_user))
+                (sxql:from :post)
+                (sxql:where (:= :parent :?))
+                (sxql:left-join :user :on (:= :post.user_id :user.id))
+                (sxql:left-join :likes :on (:= :post.id :likes.post_id))
+                (sxql:left-join (:as :likes :user_likes)
+                                :on (:and (:= :post.id :user_likes.post_id)
+                                          (:= :user_likes.user_id :?)))
+                (sxql:group-by :post.id)
+                (sxql:order-by (:desc :post.created_at))
+                (sxql:limit 50)))
+            :binds (list (mito:object-id post) (mito:object-id user))))
+
+(defmethod comments ((post post) (user null))
+    (mito:retrieve-by-sql
+       (sxql:yield
+       (sxql:select
+           (:post.*
+             (:as :user.username :username)
+             (:as (:count :likes.id) :like_count))
+           (sxql:from :post)
+           (sxql:where (:= :parent :?))
+           (sxql:left-join :user :on (:= :post.user_id :user.id))
+           (sxql:left-join :likes :on (:= :post.id :likes.post_id))
+           (sxql:group-by :post.id)
+           (sxql:order-by (:desc :post.created_at))
+           (sxql:limit 50)))
+       :binds (list (mito:object-id post))))

Posts refactor

I had not originally planned on this, but as I was writing the comments code it became clear that I was creating lots of duplication, and maybe I still am, but I hit upon a way to simplify the model interface, at least. Ideally it makes no difference if a user is logged in or not at the point the route is hit, the api should be to give the user object (whatever that might be, because it may be nil) and let a specialised method figure out what to do there. So in addition to adding comments (which is what prompted this change) we will also slightly refactor the posts logged-in-posts and not-logged-in-posts into a single, unified posts method cos it’s silly of me to have split them like that.

(defmethod liked-post-p ((ningle-auth/models:user user) (post post))
  (mito:find-dao 'likes :user user :post post))

-(defgeneric logged-in-posts (user)
-  (:documentation "Gets the posts for a logged in user"))
+(defgeneric posts (user)
+  (:documentation "Gets the posts"))
+
-(defmethod logged-in-posts ((user user))
-  (let ((uuid (slot-value user 'mito.dao.mixin::id)))
+(defmethod posts ((user user))
+   (mito:retrieve-by-sql
+        (sxql:yield
+            (sxql:select
+                (:post.*
+                  (:as :user.username :username)
+                  (:as (:count :likes.id) :like_count)
+                  (:as (:count :user_likes.id) :liked_by_user))
+                (sxql:from :post)
+                (sxql:left-join :user :on (:= :post.user_id :user.id))
+                (sxql:left-join :likes :on (:= :post.id :likes.post_id))
+                (sxql:left-join (:as :likes :user_likes)
+                                :on (:and (:= :post.id :user_likes.post_id)
+                                          (:= :user_likes.user_id :?)))
+                (sxql:group-by :post.id)
+                (sxql:order-by (:desc :post.created_at))
+                (sxql:limit 50)))
+            :binds (list (mito:object-id user))))
+
-(defun not-logged-in-posts ()
+(defmethod posts ((user null))
+    (mito:retrieve-by-sql
+        (sxql:yield
+        (sxql:select
+            (:post.*
+              (:as :user.username :username)
+              (:as (:count :likes.id) :like_count))
+            (sxql:from :post)
+            (sxql:left-join :user :on (:= :post.user_id :user.id))
+            (sxql:left-join :likes :on (:= :post.id :likes.post_id))
+            (sxql:group-by :post.id)
+            (sxql:order-by (:desc :post.created_at))
+            (sxql:limit 50)))))

There is also another small fix in this code, turns out there’s a set of convenience methods that mito provides:

Previously we used mito.dao.mixin::id (and could have done the same for create-at, and updated-at), in combination with slot-value, which means (slot-value user 'mito.dao.mixin::id') simply becomes (mito:object-id user), which is much nicer!

Full Listing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
(defpackage ningle-tutorial-project/models
  (:use :cl :mito :sxql)
  (:import-from :ningle-auth/models #:user)
  (:export #:post
           #:id
           #:content
           #:comments
           #:likes
           #:user
           #:liked-post-p
           #:posts
           #:parent
           #:toggle-like))

(in-package ningle-tutorial-project/models)

(deftable post ()
  ((user    :col-type ningle-auth/models:user :initarg :user    :accessor user)
   (parent  :col-type (or :post :null)        :initarg :parent  :reader parent :initform nil)
   (content :col-type (:varchar 140)          :initarg :content :accessor content)))

(deftable likes ()
  ((user :col-type ningle-auth/models:user :initarg :user :reader user)
   (post :col-type post                    :initarg :post :reader post))
  (:unique-keys (user post)))

(defgeneric likes (post)
  (:documentation "Returns the number of likes a post has"))

(defmethod likes ((post post))
  (mito:count-dao 'likes :post post))

(defgeneric comments (post user)
  (:documentation "Gets the comments for a logged in user"))

(defmethod comments ((post post) (user user))
    (mito:retrieve-by-sql
        (sxql:yield
            (sxql:select
                (:post.*
                    (:as :user.username :username)
                    (:as (:count :likes.id) :like_count)
                    (:as (:count :user_likes.id) :liked_by_user))
                (sxql:from :post)
                (sxql:where (:= :parent :?))
                (sxql:left-join :user :on (:= :post.user_id :user.id))
                (sxql:left-join :likes :on (:= :post.id :likes.post_id))
                (sxql:left-join (:as :likes :user_likes)
                                :on (:and (:= :post.id :user_likes.post_id)
                                          (:= :user_likes.user_id :?)))
                (sxql:group-by :post.id)
                (sxql:order-by (:desc :post.created_at))
                (sxql:limit 50)))
            :binds (list (mito:object-id post) (mito:object-id user))))

(defmethod comments ((post post) (user null))
    (mito:retrieve-by-sql
        (sxql:yield
        (sxql:select
            (:post.*
              (:as :user.username :username)
              (:as (:count :likes.id) :like_count))
            (sxql:from :post)
            (sxql:where (:= :parent :?))
            (sxql:left-join :user :on (:= :post.user_id :user.id))
            (sxql:left-join :likes :on (:= :post.id :likes.post_id))
            (sxql:group-by :post.id)
            (sxql:order-by (:desc :post.created_at))
            (sxql:limit 50)))
        :binds (list (mito:object-id post))))

(defgeneric toggle-like (user post)
  (:documentation "Toggles the like of a user to a given post"))

(defmethod toggle-like ((ningle-auth/models:user user) (post post))
  (let ((liked-post (liked-post-p user post)))
    (if liked-post
        (mito:delete-dao liked-post)
        (mito:create-dao 'likes :post post :user user))
    (not liked-post)))

(defgeneric liked-post-p (user post)
  (:documentation "Returns true if a user likes a given post"))

(defmethod liked-post-p ((ningle-auth/models:user user) (post post))
  (mito:find-dao 'likes :user user :post post))

(defgeneric posts (user)
  (:documentation "Gets the posts"))

(defmethod posts ((user user))
    (mito:retrieve-by-sql
        (sxql:yield
            (sxql:select
                (:post.*
                  (:as :user.username :username)
                  (:as (:count :likes.id) :like_count)
                  (:as (:count :user_likes.id) :liked_by_user))
                (sxql:from :post)
                (sxql:left-join :user :on (:= :post.user_id :user.id))
                (sxql:left-join :likes :on (:= :post.id :likes.post_id))
                (sxql:left-join (:as :likes :user_likes)
                                :on (:and (:= :post.id :user_likes.post_id)
                                          (:= :user_likes.user_id :?)))
                (sxql:group-by :post.id)
                (sxql:order-by (:desc :post.created_at))
                (sxql:limit 50)))
            :binds (list (mito:object-id user))))

(defmethod posts ((user null))
    (mito:retrieve-by-sql
        (sxql:yield
        (sxql:select
            (:post.*
              (:as :user.username :username)
              (:as (:count :likes.id) :like_count))
            (sxql:from :post)
            (sxql:left-join :user :on (:= :post.user_id :user.id))
            (sxql:left-join :likes :on (:= :post.id :likes.post_id))
            (sxql:group-by :post.id)
            (sxql:order-by (:desc :post.created_at))
            (sxql:limit 50)))))

src/forms.lisp

All we have to do here is define our form and validators and ensure they are exported, not really a lot of work!

(defpackage ningle-tutorial-project/forms
  (:use :cl :cl-forms)
  (:export #:post
           #:content
-          #:submit))
+          #:submit
+          #:comment
+          #:parent))

(in-package ningle-tutorial-project/forms)

(defparameter *post-validator* (list (clavier:not-blank)
                                     (clavier:is-a-string)
                                     (clavier:len :max 140)))

+(defparameter *post-parent-validator* (list (clavier:not-blank)
+                                            (clavier:fn (lambda (x) (> (parse-integer x) 0)) "Checks positive integer")))

(defform post (:id "post" :csrf-protection t :csrf-field-name "csrftoken" :action "/post")
  ((content  :string   :value "" :constraints *post-validator*)
   (submit   :submit   :label "Post")))

+(defform comment (:id "post" :csrf-protection t :csrf-field-name "csrftoken" :action "/post/comment")
+  ((content  :string   :value "" :constraints *post-validator*)
+   (parent   :hidden   :value 0  :constraints *post-parent-validator*)
+   (submit   :submit   :label "Post")))

In our *post-parent-validator* we validate that the content of the parent field is not blank (as it is a comment and needs a reference to a parent) and we used a custom validator using clavier:fn and passing a lambda to verify the item is a positive integer.

We then create our comment form, which is very similar to our existing post form, with the difference of pointing to a different http endpoint /post/comment rather than just /post, and we have a hidden parent slot, which we set to 0 by default, so by default the form will be invalid, but that’s ok, because we can’t possibly know what the parent id would be until the form is rendered and we can set the parent id value at the point we render the form, so it really is nothing to worry about.

Full Listing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(defpackage ningle-tutorial-project/forms
  (:use :cl :cl-forms)
  (:export #:post
           #:content
           #:submit
           #:comment
           #:parent))

(in-package ningle-tutorial-project/forms)

(defparameter *post-validator* (list (clavier:not-blank)
                                     (clavier:is-a-string)
                                     (clavier:len :max 140)))

(defparameter *post-parent-validator* (list (clavier:not-blank)
                                            (clavier:fn (lambda (x) (> (parse-integer x) 0)) "Checks positive integer")))

(defform post (:id "post" :csrf-protection t :csrf-field-name "csrftoken" :action "/post")
  ((content  :string   :value "" :constraints *post-validator*)
   (submit   :submit   :label "Post")))

(defform comment (:id "post" :csrf-protection t :csrf-field-name "csrftoken" :action "/post/comment")
  ((content  :string   :value "" :constraints *post-validator*)
   (parent   :hidden   :value 0  :constraints *post-parent-validator*)
   (submit   :submit   :label "Post")))

src/controllers.lisp

Having simplified the models, we can also simplify the controllers!

Let’s start by setting up our package information:

(defpackage ningle-tutorial-project/controllers
- (:use :cl :sxql :ningle-tutorial-project/forms)
+ (:use :cl :sxql)
+ (:import-from :ningle-tutorial-project/forms
+               #:post
+               #:content
+               #:parent
+               #:comment)
- (:export #:logged-in-index
-          #:index
+ (:export #:index
           #:post-likes
           #:single-post
           #:post-content
+          #:post-comment
           #:logged-in-profile
           #:unauthorized-profile
           #:people
           #:person))

(in-package ningle-tutorial-project/controllers)

The index and logged-in-index can now be consolidated:

-(defun logged-in-index (params)
+(defun index (params)
(let* ((user (gethash :user ningle:*session*))
-     (form (cl-forms:find-form 'post))
-     (posts (ningle-tutorial-project/models:logged-in-posts user)))
-  (djula:render-template* "main/index.html" nil :title "Home" :user user :posts posts :form form)))
-
-
-(defun index (params))
-(let ((posts (ningle-tutorial-project/models:not-logged-in-posts)))
-  (djula:render-template* "main/index.html" nil :title "Home" :user (gethash :user ningle:*session*) :posts posts)))
+      (posts (ningle-tutorial-project/models:posts user))
+  (djula:render-template* "main/index.html" nil :title "Home" :user user :posts posts :form (if user (cl-forms:find-form 'post) nil))))

Our post-likes controller comes next:

(defun post-likes (params)
  (let* ((user (gethash :user ningle:*session*))
         (post (mito:find-dao 'ningle-tutorial-project/models:post :id (parse-integer (ingle:get-param :id params))))
         (res (make-hash-table :test 'equal)))
-    (setf (gethash :post res) (parse-integer (ingle:get-param :id params)) )
-    (setf (gethash :likes res) (ningle-tutorial-project/models:likes post))
-    (setf (gethash :liked res) (ningle-tutorial-project/models:toggle-like user post))
+   ;; Bail out if post does not exist
+   (unless post
+     (setf (gethash "error" res) "post not found")
+     (setf (getf (lack.response:response-headers ningle:*response*) :content-type) "application/json")
+     (setf (lack.response:response-status ningle:*response*) 404)
+     (return-from post-likes (com.inuoe.jzon.stringify res)))
+
+   (setf (gethash "post" res) (mito:object-id post))
+   (setf (gethash "liked" res) (ningle-tutorial-project/models:toggle-like user post))
+   (setf (gethash "likes" res) (ningle-tutorial-project/models:likes post))
+   (setf (getf (lack.response:response-headers ningle:*response*) :content-type) "application/json")
+   (setf (lack.response:response-status ningle:*response*) 201)
+   (com.inuoe.jzon:stringify res)))

Here we begin by first checking that the post exists, if for some reason someone sent a request to our server without a valid post an error might be thrown and no response would be sent at all, which is not good, so we use unless as our “if not” check to return the standard http code for not found, the good old 404!

If however there is no error (a post matching the id exists) we can continue, we build up the hash-table, including the “post”, “liked”, and “likes” properties of a post. Remember these are not direct properties of a post model, but calculated based on information in other tables, especially the toggle-like (actually it’s very important to ensure you call toggle-like first, as it changes the db state that calling likes will depend on), as it returns the toggled status, that is, if a user clicks it once it will like the post, but if they click it again it will “unlike” the post.

Now, with our single post, we have implemented a lot more information, comments, likes, our new comment form, etc so we have to really build up a more comprehensive single-post controller.

(defun single-post (params)
    (handler-case
-       (let ((post (mito:find-dao 'ningle-tutorial-project/models:post :id (parse-integer (ingle:get-param :id params)))))
-           (djula:render-template* "main/post.html" nil :title "Post" :post post))
+
+       (let* ((post-id (parse-integer (ingle:get-param :id params)))
+              (post (mito:find-dao 'ningle-tutorial-project/models:post :id post-id))
+              (comments (ningle-tutorial-project/models:comments post (gethash :user ningle:*session*)))
+              (likes (ningle-tutorial-project/models:likes post))
+              (form (cl-forms:find-form 'comment))
+              (user (gethash :user ningle:*session*)))
+         (cl-forms:set-field-value form 'ningle-tutorial-project/forms:parent post-id)
+         (djula:render-template* "main/post.html" nil
+                                 :title "Post"
+                                 :post post
+                                 :comments comments
+                                 :likes likes
+                                 :form form
+                                 :user user))

        (parse-error (err)
            (setf (lack.response:response-status ningle:*response*) 404)
            (djula:render-template* "error.html" nil :title "Error" :error err))))

Where previously we just rendered the template, we now do a lot more! We can get the likes, comments etc which is a massive step up in functionality.

The next function to look at is post-content, thankfully there isn’t too much to change here, all we need to do is ensure we pass through the parent (which will be nil).

(when valid
    (cl-forms:with-form-field-values (content) form
-       (mito:create-dao 'ningle-tutorial-project/models:post :content content :user user)
+       (mito:create-dao 'ningle-tutorial-project/models:post :content content :user user :parent nil)
        (ingle:redirect "/")))))

Now, finally in our controllers we add the post-comment controller.

+(defun post-comment (params)
+   (let ((user (gethash :user ningle:*session*))
+         (form (cl-forms:find-form 'comment)))
+       (handler-case
+           (progn
+               (cl-forms:handle-request form) ; Can throw an error if CSRF fails
+
+               (multiple-value-bind (valid errors)
+                   (cl-forms:validate-form form)
+
+                   (when errors
+                       (format t "Errors: ~A~%" errors))
+
+                   (when valid
+                       (cl-forms:with-form-field-values (content parent) form
+                           (mito:create-dao 'ningle-tutorial-project/models:post :content content :user user :parent (parse-integer parent))
+                           (ingle:redirect "/")))))
+
+           (simple-error (err)
+               (setf (lack.response:response-status ningle:*response*) 403)
+               (djula:render-template* "error.html" nil :title "Error" :error err)))))

We have seen this pattern before, but with some minor differences in which form to load (comment instead of post), and setting the parent from the value injected into the form at the point the form is rendered.

Full Listing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
(defpackage ningle-tutorial-project/controllers
  (:use :cl :sxql)
  (:import-from :ningle-tutorial-project/forms
                #:post
                #:content
                #:parent
                #:comment)
  (:export #:index
           #:post-likes
           #:single-post
           #:post-content
           #:post-comment
           #:logged-in-profile
           #:unauthorized-profile
           #:people
           #:person))

(in-package ningle-tutorial-project/controllers)


(defun index (params)
    (let* ((user (gethash :user ningle:*session*))
           (posts (ningle-tutorial-project/models:posts user)))
        (djula:render-template* "main/index.html" nil :title "Home" :user user :posts posts :form (if user (cl-forms:find-form 'post) nil))))


(defun post-likes (params)
  (let* ((user (gethash :user ningle:*session*))
         (post (mito:find-dao 'ningle-tutorial-project/models:post :id (parse-integer (ingle:get-param :id params))))
         (res (make-hash-table :test 'equal)))
    ;; Bail out if post does not exist
    (unless post
      (setf (getf (lack.response:response-headers ningle:*response*) :content-type) "application/json")
      (setf (gethash "error" res) "post not found")
      (setf (lack.response:response-status ningle:*response*) 404)
      (return-from post-likes (com.inuoe.jzon.stringify res)))

    ;; success, continue
    (setf (gethash "post" res) (mito:object-id post))
    (setf (gethash "liked" res) (ningle-tutorial-project/models:toggle-like user post))
    (setf (gethash "likes" res) (ningle-tutorial-project/models:likes post))
    (setf (getf (lack.response:response-headers ningle:*response*) :content-type) "application/json")
    (setf (lack.response:response-status ningle:*response*) 201)
    (com.inuoe.jzon:stringify res)))


(defun single-post (params)
    (handler-case
        (let ((post (mito:find-dao 'ningle-tutorial-project/models:post :id (parse-integer (ingle:get-param :id params))))
              (form (cl-forms:find-form 'comment)))
          (cl-forms:set-field-value form 'ningle-tutorial-project/forms:parent (mito:object-id post))
          (djula:render-template* "main/post.html" nil
                                  :title "Post"
                                  :post post
                                  :comments (ningle-tutorial-project/models:comments post (gethash :user ningle:*session*))
                                  :likes (ningle-tutorial-project/models:likes post)
                                  :form form
                                  :user (gethash :user ningle:*session*)))

        (parse-error (err)
            (setf (lack.response:response-status ningle:*response*) 404)
            (djula:render-template* "error.html" nil :title "Error" :error err))))


(defun post-content (params)
    (let ((user (gethash :user ningle:*session*))
          (form (cl-forms:find-form 'post)))
        (handler-case
            (progn
                (cl-forms:handle-request form) ; Can throw an error if CSRF fails

                (multiple-value-bind (valid errors)
                    (cl-forms:validate-form form)

                    (when errors
                        (format t "Errors: ~A~%" errors))

                    (when valid
                        (cl-forms:with-form-field-values (content) form
                            (mito:create-dao 'ningle-tutorial-project/models:post :content content :user user :parent nil)
                            (ingle:redirect "/")))))

            (simple-error (err)
                (setf (lack.response:response-status ningle:*response*) 403)
                (djula:render-template* "error.html" nil :title "Error" :error err)))))


(defun post-comment (params)
    (let ((user (gethash :user ningle:*session*))
          (form (cl-forms:find-form 'comment)))
        (handler-case
            (progn
                (cl-forms:handle-request form) ; Can throw an error if CSRF fails

                (multiple-value-bind (valid errors)
                    (cl-forms:validate-form form)

                    (when errors
                        (format t "Errors: ~A~%" errors))

                    (when valid
                        (cl-forms:with-form-field-values (content parent) form
                            (mito:create-dao 'ningle-tutorial-project/models:post :content content :user user :parent (parse-integer parent))
                            (ingle:redirect "/")))))

            (simple-error (err)
                (setf (lack.response:response-status ningle:*response*) 403)
                (djula:render-template* "error.html" nil :title "Error" :error err)))))


(defun logged-in-profile (params)
    (let ((user (gethash :user ningle:*session*)))
        (djula:render-template* "main/profile.html" nil :title "Profile" :user user)))


(defun unauthorized-profile (params)
    (setf (lack.response:response-status ningle:*response*) 403)
    (djula:render-template* "error.html" nil :title "Error" :error "Unauthorized"))


(defun people (params)
    (let ((users (mito:retrieve-dao 'ningle-auth/models:user)))
        (djula:render-template* "main/people.html" nil :title "People" :users users :user (cu-sith:logged-in-p))))


(defun person (params)
    (let* ((username-or-email (ingle:get-param :person params))
           (person (first (mito:select-dao
                            'ningle-auth/models:user
                            (where (:or (:= :username username-or-email)
                                        (:= :email username-or-email)))))))
        (djula:render-template* "main/person.html" nil :title "Person" :person person :user (cu-sith:logged-in-p))))

src/main.lisp

The change to our main.lisp file is a single line that connects our controller to the urls we have declared we are using.

(setf (ningle:route *app* "/post" :method :POST :logged-in-p t) #'post-content)
+(setf (ningle:route *app* "/post/comment" :method :POST :logged-in-p t) #'post-comment)
(setf (ningle:route *app* "/profile" :logged-in-p t) #'logged-in-profile)

Full Listing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
(defpackage ningle-tutorial-project
  (:use :cl :ningle-tutorial-project/controllers)
  (:export #:start
           #:stop))

(in-package ningle-tutorial-project)

(defvar *app* (make-instance 'ningle:app))

;; requirements
(setf (ningle:requirement *app* :logged-in-p)
      (lambda (value)
        (and (cu-sith:logged-in-p) value)))

;; routes
(setf (ningle:route *app* "/") #'index)
(setf (ningle:route *app* "/post/:id/likes" :method :POST :logged-in-p t) #'post-likes)
(setf (ningle:route *app* "/post/:id") #'single-post)
(setf (ningle:route *app* "/post" :method :POST :logged-in-p t) #'post-content)
(setf (ningle:route *app* "/post/comment" :method :POST :logged-in-p t) #'post-comment)
(setf (ningle:route *app* "/profile" :logged-in-p t) #'logged-in-profile)
(setf (ningle:route *app* "/profile") #'unauthorized-profile)
(setf (ningle:route *app* "/people") #'people)
(setf (ningle:route *app* "/people/:person") #'person)

(defmethod ningle:not-found ((app ningle:<app>))
    (declare (ignore app))
    (setf (lack.response:response-status ningle:*response*) 404)
    (djula:render-template* "error.html" nil :title "Error" :error "Not Found"))

(defun start (&key (server :woo) (address "127.0.0.1") (port 8000))
    (djula:add-template-directory (asdf:system-relative-pathname :ningle-tutorial-project "src/templates/"))
    (djula:set-static-url "/public/")
    (clack:clackup
     (lack.builder:builder (envy-ningle:build-middleware :ningle-tutorial-project/config *app*))
     :server server
     :address address
     :port port))

(defun stop (instance)
    (clack:stop instance))

src/templates/main/index.html

There are some small changes needed in the index.html file, they’re largely just optimisations. The first is changing a boolean around likes to integer, this gets into the weeds of JavaScript types, and ensuring things were of the Number type in JS just made things easier. Some of the previous code even treated booleans as strings, which was pretty bad, I don’t write JS in any real capacity, so I often make mistakes with it, because it so very often appears to work instead of just throwing an error.

~ Lines 28 - 30

    data-logged-in="true"
-   data-liked="false"
+   data-liked="0"
    aria-label="Like post ">

~ Lines 68 - 70

    const icon = btn.querySelector("i");
-   const liked = btn.dataset.liked === "true";
+   const liked = Number(btn.dataset.liked) === 1;
    const previous = parseInt(countSpan.textContent, 10) || 0;

~ Lines 96 - 100

    if (!resp.ok) {
        // Revert optimistic changes on error
        countSpan.textContent = previous;
        countSpan.textContent = previous;
-       btn.dataset.liked = liked ? "true" : "false";
+       btn.dataset.liked = liked ? 1 : 0;
        if (liked) {

~ Lines 123 - 129

      console.error("Like failed:", err);
      // Revert optimistic changes on error
      countSpan.textContent = previous;
-     btn.dataset.liked = liked ? "true" : "false";
+     btn.dataset.liked = liked ? 1 : 0;
      if (liked) {
        icon.className = "bi bi-hand-thumbs-up-fill text-primary";
      } else {

src/templates/main/post.html

The changes to this file as so substantial that the file might as well be brand new, so in the interests of clarity, I will simply show the file in full.

Full Listing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
{% extends "base.html" %}

{% block content %}
<div class="container">
    <div class="row">
        <div class="col-12">
            <div class="card post mb-3" data-href="/post/{{ post.id }}">
                <div class="card-body">
                <h5 class="card-title mb-2">{{ post.content }}</h5>
                <p class="card-subtitle text-muted mb-0">@{{ post.user.username }}</p>
                </div>

                <div class="card-footer d-flex justify-content-between align-items-center">
                <button type="button"
                        class="btn btn-sm btn-outline-primary like-button"
                        data-post-id="{{ post.id }}"
                        data-logged-in="{% if user.username != "" %}true{% else %}false{% endif %}"
                        data-liked="{% if post.liked-by-user == 1 %}1{% else %}0{% endif %}"
                        aria-label="Like post {{ post.id }}">
                    {% if post.liked-by-user == 1 %}
                      <i class="bi bi-hand-thumbs-up-fill text-primary" aria-hidden="true"></i>
                    {% else %}
                      <i class="bi bi-hand-thumbs-up text-muted" aria-hidden="true"></i>
                    {% endif %}
                    <span class="ms-1 like-count">{{ likes }}</span>
                </button>

                <small class="text-muted">Posted on: {{ post.created-at }}</small>
                </div>
            </div>
        </div>
    </div>

    <!-- Post form -->
    {% if user %}
        <div class="row mb-4">
            <div class="col">
                {% if form %}
                    {% form form %}
                {% endif %}
            </div>
        </div>
    {% endif %}

    {% if comments %}
    <div class="row mb-4">
        <div class="col-12">
            <h2>Comments</h2>
        </div>
    </div>
    {% endif %}

    {% for comment in comments %}
        <div class="row mb-4">
            <div class="col-12">
                <div class="card post mb-3" data-href="/post/{{ comment.id }}">
                    <div class="card-body">
                        <h5 class="card-title mb-2">{{ comment.content }}</h5>
                        <p class="card-subtitle text-muted mb-0">@{{ comment.username }}</p>
                    </div>

                    <div class="card-footer d-flex justify-content-between align-items-center">
                        <button type="button"
                                class="btn btn-sm btn-outline-primary like-button"
                                data-post-id="{{ comment.id }}"
                                data-logged-in="{% if user.username != "" %}true{% else %}false{% endif %}"
                                data-liked="{% if comment.liked-by-user == 1 %}1{% else %}0{% endif %}"
                                aria-label="Like post {{ comment.id }}">
                            {% if comment.liked-by-user == 1 %}
                                <i class="bi bi-hand-thumbs-up-fill text-primary" aria-hidden="true"></i>
                            {% else %}
                                <i class="bi bi-hand-thumbs-up text-muted" aria-hidden="true"></i>
                            {% endif %}
                            <span class="ms-1 like-count">{{ comment.like-count }}</span>
                        </button>
                        <small class="text-muted">Posted on: {{ comment.created-at }}</small>
                    </div>
                </div>
            </div>
        </div>
    {% endfor %}
</div>
{% endblock %}

{% block js %}
document.querySelectorAll(".like-button").forEach(btn => {
  btn.addEventListener("click", function (e) {
    e.stopPropagation();
    e.preventDefault();

    // Check login
    if (btn.dataset.loggedIn !== "true") {
      alert("You must be logged in to like posts.");
      return;
    }

    const postId = btn.dataset.postId;
    const countSpan = btn.querySelector(".like-count");
    const icon = btn.querySelector("i");
    const liked = Number(btn.dataset.liked) === 1;
    const previous = parseInt(countSpan.textContent, 10) || 0;
    const url = `/post/${postId}/likes`;

    // Optimistic UI toggle
    countSpan.textContent = liked ? previous - 1 : previous + 1;
    btn.dataset.liked = liked ? 0 : 1;

    // Toggle icon classes optimistically
    if (liked) {
      // Currently liked, so unlike it
      icon.className = "bi bi-hand-thumbs-up text-muted";
    } else {
      // Currently not liked, so like it
      icon.className = "bi bi-hand-thumbs-up-fill text-primary";
    }

    const csrfTokenMeta = document.querySelector('meta[name="csrf-token"]');
    const headers = { "Content-Type": "application/json" };
    if (csrfTokenMeta) headers["X-CSRF-Token"] = csrfTokenMeta.getAttribute("content");

    fetch(url, {
      method: "POST",
      headers: headers,
      body: JSON.stringify({ toggle: true })
    })
    .then(resp => {
      if (!resp.ok) {
        // Revert optimistic changes on error
        countSpan.textContent = previous;
        btn.dataset.liked = liked ? 1 : 0;
        icon.className = liked ? "bi bi-hand-thumbs-up-fill text-primary" : "bi bi-hand-thumbs-up text-muted";
        throw new Error("Network response was not ok");
      }
      return resp.json();
    })
    .then(data => {
      if (data && typeof data.likes !== "undefined") {
        countSpan.textContent = data.likes;
        btn.dataset.liked = data.liked ? 1 : 0;
        icon.className = data.liked ? "bi bi-hand-thumbs-up-fill text-primary" : "bi bi-hand-thumbs-up text-muted";
      }
    })
    .catch(err => {
      console.error("Like failed:", err);
      // Revert optimistic changes on error
      countSpan.textContent = previous;
      btn.dataset.liked = liked ? 1 : 0;
      icon.className = liked ? "bi bi-hand-thumbs-up-fill text-primary" : "bi bi-hand-thumbs-up text-muted";
    });
  });
});

document.querySelectorAll(".card.post").forEach(card => {
  card.addEventListener("click", function () {
    const href = card.dataset.href;
    if (href) {
      window.location.href = href;
    }
  });
});
{% endblock %}

Conclusion

Learning Outcomes

Level Learning Outcome
Understand Understand how to model a self-referential post table in Mito (using a nullable parent column) and why (or :post :null)/:initform nil are important for safe migrations and representing “top-level” posts versus comments.
Apply Apply Mito, SXQL, and cl-forms to implement a comment system end-to-end: defining comments/posts generics, adding validators (including a custom clavier:fn), wiring controllers and routes, and rendering comments and like-buttons in templates.
Analyse Analyse and reduce duplication in the models/controllers layer by consolidating separate code paths (logged-in vs anonymous) into generic functions specialised on user/null, and by examining how SQL joins and binds shape the returned data.
Evaluate Evaluate different design and safety choices in the implementation (nullable vs sentinel parents, optimistic UI vs server truth, HTTP status codes, SQL placeholders, CSRF and login checks) and judge which approaches are more robust and maintainable.

Github

Common Lisp HyperSpec

Symbol Type Why it appears in this lesson CLHS
defpackage Macro Define project packages like ningle-tutorial-project/models, /forms, /controllers, and the main system package. http://www.lispworks.com/documentation/HyperSpec/Body/m_defpac.htm
in-package Macro Enter each package before defining tables, forms, controllers, and the main app functions. http://www.lispworks.com/documentation/HyperSpec/Body/m_in_pkg.htm
defvar Special Operator Define *app* as a global Ningle application object. http://www.lispworks.com/documentation/HyperSpec/Body/s_defvar.htm
defparameter Special Operator Define validator configuration variables like *post-validator* and *post-parent-validator*. http://www.lispworks.com/documentation/HyperSpec/Body/s_defpar.htm
defgeneric Macro Declare generic functions such as likes, comments, toggle-like, liked-post-p, and posts. http://www.lispworks.com/documentation/HyperSpec/Body/m_defgen.htm
defmethod Macro Specialise behaviour for likes, comments, toggle-like, liked-post-p, posts, and ningle:not-found. http://www.lispworks.com/documentation/HyperSpec/Body/m_defmet.htm
defun Macro Define controller functions like index, post-likes, single-post, post-content, post-comment, people, person, start, etc. http://www.lispworks.com/documentation/HyperSpec/Body/m_defun.htm
make-instance Generic Function Create the Ningle app object: (make-instance 'ningle:app). http://www.lispworks.com/documentation/HyperSpec/Body/f_mk_ins.htm
let / let* Special Operator Introduce local bindings like user, posts, post, comments, likes, form, and res in controllers. http://www.lispworks.com/documentation/HyperSpec/Body/s_let_l.htm
lambda Special Operator Used for the :logged-in-p requirement: (lambda (value) (and (cu-sith:logged-in-p) value)). http://www.lispworks.com/documentation/HyperSpec/Body/s_fn_lam.htm
setf Macro Set routes, response headers/status codes, and update hash-table entries in the JSON response. http://www.lispworks.com/documentation/HyperSpec/Body/m_setf.htm
gethash Function Access session values (e.g. the :user from ningle:*session*) and JSON keys in result hash-tables. http://www.lispworks.com/documentation/HyperSpec/Body/f_gethas.htm
make-hash-table Function Build the hash-table used as the JSON response body in post-likes. http://www.lispworks.com/documentation/HyperSpec/Body/f_mk_has.htm
equal Function Used as the :test function for the JSON response hash-table. http://www.lispworks.com/documentation/HyperSpec/Body/f_equal.htm
list Function Build the :binds list for mito:retrieve-by-sql and other list values. http://www.lispworks.com/documentation/HyperSpec/Body/f_list.htm
first Accessor Take the first result from mito:select-dao in the person controller. http://www.lispworks.com/documentation/HyperSpec/Body/f_firstc.htm
slot-value Function Discussed when explaining the old pattern (slot-value user '…:id) that was replaced by mito:object-id. http://www.lispworks.com/documentation/HyperSpec/Body/f_slot__.htm
parse-integer Function Convert route params and hidden form parent values into integers (post-id, parent, etc.). http://www.lispworks.com/documentation/HyperSpec/Body/f_parse_.htm
format Function Print validation error information in the controllers ((format t "Errors: ~A~%" errors)). http://www.lispworks.com/documentation/HyperSpec/Body/f_format.htm
handler-case Macro Handle parse-error for invalid ids and simple-error for CSRF failures, mapping them to 404 / 403 responses. http://www.lispworks.com/documentation/HyperSpec/Body/m_hand_1.htm
parse-error Condition Type Signalled when parsing fails (e.g. malformed :id route parameters), caught in single-post. http://www.lispworks.com/documentation/HyperSpec/Body/e_parse_.htm
simple-error Condition Type Used to represent CSRF and similar failures caught in post-content and post-comment. http://www.lispworks.com/documentation/HyperSpec/Body/e_smp_er.htm
multiple-value-bind Macro Bind the (valid errors) results from cl-forms:validate-form. http://www.lispworks.com/documentation/HyperSpec/Body/m_mpv_bn.htm
progn Special Operator Group side-effecting calls (handle request, validate, then create/redirect) under a single handler in handler-case. http://www.lispworks.com/documentation/HyperSpec/Body/s_progn.htm
when Macro Conditionally log validation errors and perform DAO creation only when the form is valid. http://www.lispworks.com/documentation/HyperSpec/Body/m_when_.htm
unless Macro Early-exit error path in post-likes when the post cannot be found ((unless post … (return-from …))). http://www.lispworks.com/documentation/HyperSpec/Body/m_when_.htm
return-from Special Operator Non-locally return from post-likes after sending a 404 JSON response. http://www.lispworks.com/documentation/HyperSpec/Body/s_ret_fr.htm
declare Special Operator Used with (declare (ignore app)) in the ningle:not-found method to silence unused-argument warnings. http://www.lispworks.com/documentation/HyperSpec/Body/s_declar.htm
and / or Macro Logical composition in the login requirement and in the where clause for username/email matching. http://www.lispworks.com/documentation/HyperSpec/Body/a_and.htm
tags: CommonLisp - Lisp - tutorial - YouTube - web - dev