Здесь напишем код, который развернет все описания сущностей в исполняемый код.
Начнем с определения сущностей системы, а потом расширим их поддержку так, чтобы они могли поддерживать Автоматное программирование:
;;;; 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))