Skip to content

Latest commit

 

History

History
559 lines (433 loc) · 21.9 KB

entity.org

File metadata and controls

559 lines (433 loc) · 21.9 KB

Модуль сущностей, автоматов и их тесты

Ведение

Здесь напишем код, который развернет все описания сущностей в исполняемый код.

Начнем с определения сущностей системы, а потом расширим их поддержку так, чтобы они могли поддерживать Автоматное программирование:

;;;; entity.lisp

(in-package #:moto)

<<entity>>

<<automat>>

<<entity_test>>

<<automat_test>>

<<compute_outgoing_states>>

<<possible_trans>>

Сущности

Этот макрос генерирует код, который обслуживает все сущности. Я хочу, чтобы когда я определяю сущность, автоматически создавались функции, которые ее обслуживают:

  • {entity}-class определение класса
  • make-{entity}-table функция создания таблицы в бд, если она еще не создана
  • make-{entity} конструктор сущности, который создает ее и записывает в базу данных
  • upd-{entity} метод, который обновляет поля сущности (в т.ч. в базе данных)
  • del-{entity} деструктор, который удаляет сущность, в т.ч. и из базы данных
  • all-{entity} функция для получения коллекции сущностей
  • get-{entity} функция для получения сущности по ее идентификтору
  • find-{entity} функция для получения сущности по ее идентификтору
  • show-{entity} функция для показа сущности (переопределить при необходимости)

Нижеследующий код создает таблицы в базе данных, но не предполагает, что они будут изменяться в процессе работы, поэтому не обрабатывает последствия, требующие рассмотрения:

  • что будет, если переопределить класс с добавлением/удалением слотов?
  • что будет, если запустить новый образ лиспа - должна ли к каждому образу прилагаться своя БД?

При реализации этой недостающей функциональности следует помнить, что классы - это экземпляры класса STANDARD-CLASS, и прицепив что угодно на initialize-instance :after для этого класса, можно добиться выполнения произвольного кода во время исполнения defclass.

Лучше, конечно, определить свой метакласс (класс, экземплярами которого будут пользовательские классы).

(defmacro define-entity (name desc flds primaryes foreigns uniques)
  (let ((*package* (symbol-package name)))
    `(progn
       <<entity_class>>
       <<make_entity_table>>
       <<make_entity>>
       <<upd_entity>>
       <<del_entity>>
       <<all_entity>>
       <<get_entity>>
       <<find_entity>>
       <<show_entity>>
       )))

Генерируем entity-class:

;; entity-class
,(let ((table (intern (symbol-name name))))
  `(defclass ,name ()
    ,(mapcar #'(lambda (x)
                 (list
                  (car x)
                  :col-type (cadr x)
                  :initarg  (intern (symbol-name (car x)) :keyword)
                  :accessor (car x)))
             flds)
    (:metaclass dao-class)
    (:table-name ,table)
    (:keys ,(caar flds))))

Генерируем make_entity-table:

;; make-entity-table
,(let ((table (intern (symbol-name name))))
  `(defun ,(intern (concatenate 'string "MAKE-" (symbol-name name) "-TABLE")) ()
    (with-connection *db-spec*
      (unless (table-exists-p (string-downcase (symbol-name ',table)))
        (execute (dao-table-definition ',table))))))

Генерируем make_entity:

;; make-entity
,(let ((table (intern (symbol-name name))))
  `(defun ,(intern (concatenate 'string "MAKE-" (symbol-name name))) (&rest initargs)
    (with-connection *db-spec*
      (apply #'make-dao (list* ',table initargs)))))

Генерируем update_entity:

;; upd-entity
(defmethod ,(intern (concatenate 'string "UPD-" (symbol-name name))) ((obj ,name) &optional args)
  (progn
    ,@(loop :for accessor :in flds :collect
           `(setf (,(car accessor) obj)
                  (or (getf args ,(intern (symbol-name (car accessor)) :keyword))
                      (,(car accessor) obj))))
    (with-connection *db-spec*
      (update-dao obj))))

Генерируем del_entity:

;; del-entity
,(let ((table (intern (symbol-name name))))
  `(defun ,(intern (concatenate 'string "DEL-" (symbol-name name))) (id)
    (with-connection *db-spec*
      (delete-dao (get-dao ',table id)))))

Генерируем all_entity:

;; all-entity
,(let ((table (intern (symbol-name name))))
  `(defun ,(intern (concatenate 'string "ALL-" (symbol-name name))) ()
    (with-connection *db-spec*
      (select-dao ',table))))

Генерируем get_entity:

;; get-entity (by id)
,(let ((table      (intern (symbol-name name)))
       (get-entity (intern (concatenate 'string "GET-" (symbol-name name)))))
  `(defun ,get-entity (id &rest flds)
    (when (not (typep id 'integer))
      (err 'param-get-entity-is-not-integer))
    (with-connection *db-spec*
      (let ((obj (select-dao ',table (:= :id id)))
            (rs))
        (when (null obj)
          (return-from ,get-entity nil))
        (setf obj (car obj))
        (when (null obj)
          (return-from ,get-entity nil))
        (when (null flds)
          (return-from ,get-entity obj))
        (loop :for fld :in flds :collect
           (setf (getf rs (intern (symbol-name fld) :keyword))
                 (funcall (intern (symbol-name fld) (find-package ,(symbol-name name)))
                          obj)))
        rs))))

Генерируем find_entity:

;; find-entity
,(let ((table (intern (symbol-name name))))
  `(defun ,(intern (concatenate 'string "FIND-" (symbol-name name))) (&rest args)
    (with-connection *db-spec*
      (query-dao ',table
                 (sql-compile
                  (list :select :* :from ',table
                        :where (make-clause-list ':and ':= args)))))))

Генерируем show_entity:

;; show-entity
(defmethod ,(intern "TO-HTML") ((obj ,name) &optional &key filter)
  (with-connection *db-spec*
    (concatenate 'string
                 "<form id='"
                 ,(string-downcase (symbol-name name))
                 "-form'>"
                 ,@(loop :for (fld-name fld-type) :in flds :collect
                      (list
                       (intern (concatenate 'string
                                            "SHOW-FLD-"
                                            (if (symbolp fld-type)
                                                (symbol-name fld-type)
                                                (format nil "~{~A~^-~}"
                                                        (mapcar #'(lambda (x)
                                                                    (symbol-name x))
                                                                fld-type)))))
                       (list fld-name 'obj)))
                 "</form>")))

Тесты сущностей

Теперь у нас есть генератор всех необходимых функций для обслуживания любых сущностей. Мы можем это протестировать, для этого сформируем тест:

;; Тестируем сущности
(defun entity-test ()
  <<entity_test_contents>>
  (dbg "passed: entity-test~%"))
(entity-test)

Придумаем имя новой сущности и таблицы в которую она отображается. Пусть для простоты это будет entity123.

Сначала убедимся, что тестовой таблицы в базе нет. Если она все-таки есть - удалим.

(when (with-connection *db-spec*
          (query (:select 'table_name :from 'information_schema.tables :where
                          (:and (:= 'table_schema "public")
                                (:= 'table_name "entity123")))))
  (with-connection *db-spec*
    (query (:drop-table 'entity123))))

Определим новыю сущность и вызовем создание таблицы средствами наших сгенерированных функций. Проверим, что таблица успешно создана.

(define-entity entity123 "Тестовая сущность"
  ((id serial)
   (email varchar)
   (name (or db-null varchar)))
  (id)
  ()
  ())

(make-entity123-table)

(assert (not (null (with-connection *db-spec*
                     (query (:select 'table_name :from 'information_schema.tables :where
                                     (:and (:= 'table_schema "public")
                                           (:= 'table_name "entity123"))))))))

Сформируем сущность и проверим, что она появилась в таблице:

(make-entity123 :email "test-email-1" :name "test-name-1")

(assert (not (null (with-connection *db-spec*
                     (query (:select '* :from 'entity123))))))

Проверим, что ее можно получить из get-{entity}

(assert (not (null (get-entity123 1))))

Попробуем изменить в ней некоторые поля и проверим, что меняется сущность и ее отображение в таблице:

(upd-entity123 (get-entity123 1) (list :name "new-name"))

(assert (equal "new-name" (name (get-entity123 1))))

(assert (equal "new-name"
               (caar
                (with-connection *db-spec*
                  (query (:select 'name :from 'entity123 :where (:= 'id 1)))))))

Попробуем удалить сущность

(del-entity123 1)

(assert (null (with-connection *db-spec*
                (query (:select '* :from 'entity123 :where (:= 'id 1))))))

Создадим еще парочку разных сущностей проверим получение всех сущностей и проверим что по сущностям работает поиск.

(make-entity123 :email "test-email-2" :name "test-name-2")
(make-entity123 :email "test-email-3" :name "test-name-3")

(assert (equal 2 (length (all-entity123))))

(assert (equal "test-email-3"
               (email (car (find-entity123 :name "test-name-3")))))

Подчистим за собой - удалим таблицу

(with-connection *db-spec*
  (query (:drop-table 'entity123)))

Теперь мы можем быть уверенными, что сущности работают нормально.

Автоматы

Чтобы добавить состояние к нашим сущностям, мы должны обернуть их макросом, который добавит в сущность еще одно поле - state и создаст специализированные методы trans для каждого перехода между состояниями. Внутри себя trans вызывает функцию, одноименную с действием перехода - таким образом воплощаются действия на переходах.

Эти методы будут вызываться из метода takt, который принимает объект сущности и его новое состояние. Если переход из старого состояния в новое не описан - это закономерно вызывает ошибку.

(defmacro define-automat (name desc flds primaryes foreigns uniques states acts)
  (let ((package (symbol-package name)))
    (let ((upd-entity (intern (concatenate 'string "UPD-" (symbol-name name))))
          (fields (append flds '((state (or db-null varchar)))))
          (state  (intern "STATE" package))
          (trans  (intern "TRANS" package))
          (takt   (intern "TAKT" package))
          (make-table (intern (concatenate 'string "MAKE-"  (symbol-name name) "-TABLE"))))
      `(progn
         (define-entity ,name ,desc ,fields ,primaryes ,foreigns ,uniques)
         (,make-table)
         ,(let ((all-states states))
               `(progn
                  ,@(loop :for (from-state to-state event) :in acts :collect
                       (if (or (null (find from-state all-states))
                               (null (find to-state all-states)))
                           (err (format nil "unknown state: ~A -> ~A" from-state to-state))
                           `(defmethod ,trans ((obj ,name)
                                               (from-state (eql ,from-state))
                                               (to-state (eql ,to-state)))
                              (prog1 (,(intern (symbol-name event) *package*))
                                (,upd-entity obj (list :state ,(bprint to-state)))))))
                  (defmethod ,takt ((obj ,name) new-state)
                    (,trans obj (read-from-string (,state obj)) new-state))))))))

Тесты автоматов

Чтобы протестировать автоматы - формируем тест:

;; Тестируем автоматы
(defun automat-test ()
  <<automat_test_contents>>
  (dbg "passed: automat-test~%"))
(automat-test)

Придумаем имя новому автомату и таблицы в которую он отображается. Пусть для простоты это будет automat123.

Сначала убедимся, что тестовой таблицы в базе нет. Если она все-таки есть - удалим.

(when (with-connection *db-spec*
          (query (:select 'table_name :from 'information_schema.tables :where
                          (:and (:= 'table_schema "public")
                                (:= 'table_name "automat123")))))
  (with-connection *db-spec*
    (query (:drop-table 'automat123))))

Определим новыю сущность и вызовем создание таблицы средствами наших сгенерированных функций. Проверим, что таблица успешно создана. Проверим, что в таблице есть поле state.

(define-automat automat123 "Тестовый автомат"
  ((id serial)
   (email varchar)
   (name (or db-null varchar)))
  (id)
  ()
  ()
  (:on :off :broken)
  ((:on      :off     :switch-off)
   (:off     :on      :switch-on)
   (:on      :broken  :fault)
   (:broken  :off     :stop)))

(assert (not (null (with-connection *db-spec*
                     (query (:select 'table_name :from 'information_schema.tables :where
                                     (:and (:= 'table_schema "public")
                                           (:= 'table_name "automat123"))))))))

(assert (not (null
              (with-connection *db-spec*
                (query (:select 'column_name :from 'information_schema.columns :where
                                (:and (:= 'table_schema  "public")
                                      (:= 'table_name    "automat123")
                                      (:= 'column_name   "state"))))))))

Сформируем автомат, установим начальное состояние и определим функции перехода. Протестируем все верные переходы и убедимся, что в конце теста состояние внутри таблицы верно.

(make-automat123 :email "test-email-1" :name "test-name-1")

(upd-automat123 (get-automat123 1) (list :state ":off"))

(defun switch-off ()
  :switch-off)

(defun switch-on ()
  :switch-on)

(defun fault ()
  :fault)

(defun stop ()
  :stop)

(assert (equal '((:SWITCH-ON ":ON") (:SWITCH-OFF ":OFF") (:SWITCH-ON ":ON")
                 (:FAULT ":BROKEN") (:STOP ":OFF"))
               (loop :for new-state :in '(:on :off :on :broken :off) :collect
                  (list (takt (get-automat123 1) new-state)
                        (state (get-automat123 1))))))
(assert (not (null
              (with-connection *db-spec*
                (query (:select 'state :from 'automat123 :where
                                (:and
                                 (:= 'id 1)
                                 (:= 'state ":OFF"))))))))

Протестируем выброс ошибки в случае попытки неразрешенного перехода

(let ((test t) (err nil))
  (handler-case
      (progn
        (takt (get-automat123 1) :broken)
        (setf test nil))
    (simple-error ()
      (setf err t))
    (assert (and test err))))

Подчистим за собой - удалим таблицу

(with-connection *db-spec*
  (query (:drop-table 'automat123)))

Теперь мы можем быть уверенными, что автоматы работают нормально.

Определение возможных исходящих состояний автомата

Чтобы определить, в какое состояние можно перевести автомат из текущего - необходимо интроспективно проанализировать eql-спецификаторы метода trans и выбрать из них применимые для текущего состояния.

Что и делает эта функция:

(in-package #:moto)

(defun compute-outgoing-states (the-class source-state)
  (let ((applicable-methods))
    (loop :for trans-method :in (closer-mop:generic-function-methods #'trans) :do
       (let ((specializers (closer-mop:method-specializers trans-method)))
         (when (and (equal the-class (class-name (car specializers)))
                    (equal source-state (closer-mop:eql-specializer-object (cadr specializers))))
           (push (closer-mop:eql-specializer-object (nth 2 specializers)) applicable-methods))))
    applicable-methods))

;; (compute-outgoing-states 'vacancy :responded)

Для удобства использования можно вычислять возможные переходы для конкретного объекта автомата:

(in-package #:moto)

(defmethod possible-trans ((obj t))
  (compute-outgoing-states
   (class-name (class-of obj))
   (intern (subseq (state obj) 1) :keyword)))

;; (possible-trans (get-vacancy 1))