StackService.vala 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Invercargill.Expressions;
  4. using InvercargillSql.Orm;
  5. using Inversion;
  6. using ClankerSaddle.Data;
  7. namespace ClankerSaddle.Services {
  8. /**
  9. * Stack storage (goal 8 task 2 B): CRUD over `stacks`, the stack's
  10. * project members in `stack_projects` (relationship 'co-developed'
  11. * or 'reference', validated like the project link graph), and the
  12. * two apply paths — a stack's members land as `project_links` rows
  13. * on a project or as `goal_links` rows on a goal, skipping exact
  14. * duplicates (same target, same relationship) so re-applying is
  15. * idempotent.
  16. */
  17. public class StackService : Object {
  18. private OrmSession db = inject<OrmSession>();
  19. private ProjectService projects = inject<ProjectService>();
  20. private GoalService goals = inject<GoalService>();
  21. private StatePulse pulse = inject<StatePulse>();
  22. /** Whether `relationship` is a valid stack-member relationship. */
  23. private void require_valid_relationship(string relationship) throws Error {
  24. if (relationship != LINK_CO_DEVELOPED && relationship != LINK_REFERENCE) {
  25. throw new Error(Quark.from_string("clanker-saddle"), 90,
  26. @"Relationship must be '$(LINK_CO_DEVELOPED)' or '$(LINK_REFERENCE)'");
  27. }
  28. }
  29. /** Inserts a stack; `summary` may be empty. */
  30. public async Stack create_stack(string name, string? summary) throws Error {
  31. string trimmed = name.strip();
  32. if (trimmed.length == 0) {
  33. throw new Error(Quark.from_string("clanker-saddle"), 91,
  34. "A stack name is required");
  35. }
  36. var existing = yield get_stack_by_name(trimmed);
  37. if (existing != null) {
  38. throw new Error(Quark.from_string("clanker-saddle"), 92,
  39. @"A stack named '$(trimmed)' already exists");
  40. }
  41. var stack = new Stack() {
  42. name = trimmed,
  43. summary = summary != null && summary.strip().length > 0
  44. ? summary.strip() : null,
  45. created_at = new DateTime.now_local()
  46. };
  47. yield db.insert_async<Stack>(stack);
  48. yield pulse.bump(0, "stack created");
  49. return stack;
  50. }
  51. /** Loads the stack identified by `id`, or null. */
  52. public async Stack? get_stack(int64 id) throws Error {
  53. return yield db.query<Stack>()
  54. .where(expr("id == $0", elem<int64?>(id)))
  55. .first_async();
  56. }
  57. /** Loads the stack identified by `name`, or null. */
  58. public async Stack? get_stack_by_name(string name) throws Error {
  59. return yield db.query<Stack>()
  60. .where(expr("name == $0", elem(name)))
  61. .first_async();
  62. }
  63. /** Every stack, newest first. */
  64. public async ImmutableLot<Stack> list_stacks() throws Error {
  65. return yield db.query<Stack>()
  66. .order_by_desc(expr("id"))
  67. .materialise_async();
  68. }
  69. /**
  70. * Saves the editable fields of the stack identified by `id`;
  71. * null/empty arguments keep their current values. Returns the
  72. * saved stack, or null when no such stack exists.
  73. */
  74. public async Stack? update_stack(int64 id, string? name, string? summary) throws Error {
  75. var stack = yield get_stack(id);
  76. if (stack == null) {
  77. return null;
  78. }
  79. if (name != null && name.strip().length > 0) {
  80. string trimmed = name.strip();
  81. var clash = yield get_stack_by_name(trimmed);
  82. if (clash != null && ((!)clash).id != id) {
  83. throw new Error(Quark.from_string("clanker-saddle"), 93,
  84. @"A stack named '$(trimmed)' already exists");
  85. }
  86. ((!)stack).name = trimmed;
  87. }
  88. if (summary != null) {
  89. ((!)stack).summary = summary.strip().length > 0 ? summary.strip() : null;
  90. }
  91. yield db.update_async<Stack>((!)stack);
  92. yield pulse.bump(0, "stack saved");
  93. return stack;
  94. }
  95. /** Deletes the stack and its members. */
  96. public async void delete_stack(int64 id) throws Error {
  97. var stack = yield get_stack(id);
  98. if (stack == null) {
  99. return;
  100. }
  101. foreach (var member in yield list_members(id)) {
  102. yield db.delete_async<StackProject>(member);
  103. }
  104. yield db.delete_async<Stack>((!)stack);
  105. yield pulse.bump(0, "stack deleted");
  106. }
  107. /** The stack's member rows, oldest first. */
  108. public async ImmutableLot<StackProject> list_members(int64 stack_id) throws Error {
  109. return yield db.query<StackProject>()
  110. .where(expr("stack_id == $0", elem<int64?>(stack_id)))
  111. .order_by(expr("id"))
  112. .materialise_async();
  113. }
  114. /**
  115. * Adds a project to the stack with `relationship`;
  116. * duplicates (same stack, same project) are rejected.
  117. */
  118. public async StackProject add_member(int64 stack_id, int64 project_id,
  119. string relationship) throws Error {
  120. require_valid_relationship(relationship);
  121. var stack = yield get_stack(stack_id);
  122. if (stack == null) {
  123. throw new Error(Quark.from_string("clanker-saddle"), 94,
  124. @"No stack exists with id $(stack_id)");
  125. }
  126. var project = yield projects.get_project(project_id);
  127. if (project == null) {
  128. throw new Error(Quark.from_string("clanker-saddle"), 95,
  129. @"No project exists with id $(project_id)");
  130. }
  131. var existing = yield db.query<StackProject>()
  132. .where(expr("stack_id == $0 && project_id == $1",
  133. elem<int64?>(stack_id), elem<int64?>(project_id)))
  134. .materialise_async();
  135. if (existing.length > 0) {
  136. throw new Error(Quark.from_string("clanker-saddle"), 96,
  137. "That project is already in the stack");
  138. }
  139. var member = new StackProject() {
  140. stack_id = stack_id,
  141. project_id = project_id,
  142. relationship = relationship,
  143. created_at = new DateTime.now_local()
  144. };
  145. yield db.insert_async<StackProject>(member);
  146. yield pulse.bump(0, "stack member added");
  147. return member;
  148. }
  149. /** Removes the stack member identified by `id`. */
  150. public async void remove_member(int64 member_id) throws Error {
  151. var member = yield db.query<StackProject>()
  152. .where(expr("id == $0", elem<int64?>(member_id)))
  153. .first_async();
  154. if (member != null) {
  155. yield db.delete_async<StackProject>((!)member);
  156. yield pulse.bump(0, "stack member removed");
  157. }
  158. }
  159. /**
  160. * Applies the stack to a project: every member becomes a
  161. * `project_links` row on `parent_id`, same relationship, as
  162. * long as the parent does not already carry that exact pair
  163. * (same target AND same relationship — a different
  164. * relationship on the same target stays). Returns how many
  165. * links landed. Never throws on duplicates: re-applying is
  166. * idempotent by design.
  167. */
  168. public async int64 apply_to_project(int64 stack_id, int64 parent_id) throws Error {
  169. var stack = yield require_stack(stack_id);
  170. var parent = yield projects.get_project(parent_id);
  171. if (parent == null) {
  172. throw new Error(Quark.from_string("clanker-saddle"), 97,
  173. @"No project exists with id $(parent_id)");
  174. }
  175. int64 added = 0;
  176. foreach (var member in yield list_members(stack_id)) {
  177. if (member.project_id == parent_id) {
  178. continue; // a project never links to itself
  179. }
  180. bool duplicate = false;
  181. foreach (var link in yield projects.list_links(parent_id)) {
  182. if (link.linked_project_id == member.project_id
  183. && link.relationship == member.relationship) {
  184. duplicate = true;
  185. break;
  186. }
  187. }
  188. if (duplicate) {
  189. continue;
  190. }
  191. yield db.insert_async<ProjectLink>(new ProjectLink() {
  192. parent_project_id = parent_id,
  193. linked_project_id = member.project_id,
  194. relationship = member.relationship
  195. });
  196. added++;
  197. }
  198. yield pulse.bump(0, @"stack $(((!)stack).name) applied to project");
  199. return added;
  200. }
  201. /**
  202. * Applies the stack to a goal: every member becomes a
  203. * `goal_links` row on `goal_id`, same relationship, skipping
  204. * exact duplicates. The primary project is implicitly linked
  205. * and never duplicated. Returns how many links landed.
  206. */
  207. public async int64 apply_to_goal(int64 stack_id, int64 goal_id) throws Error {
  208. var stack = yield require_stack(stack_id);
  209. var goal = yield goals.get_goal(goal_id);
  210. if (goal == null) {
  211. throw new Error(Quark.from_string("clanker-saddle"), 98,
  212. @"No goal exists with id $(goal_id)");
  213. }
  214. int64 added = 0;
  215. foreach (var member in yield list_members(stack_id)) {
  216. if (member.project_id == ((!)goal).project_id) {
  217. continue; // the primary project is implicitly linked
  218. }
  219. bool duplicate = false;
  220. foreach (var link in yield goals.list_links(goal_id)) {
  221. if (link.project_id == member.project_id
  222. && link.relationship == member.relationship) {
  223. duplicate = true;
  224. break;
  225. }
  226. }
  227. if (duplicate) {
  228. continue;
  229. }
  230. yield db.insert_async<GoalLink>(new GoalLink() {
  231. goal_id = goal_id,
  232. project_id = member.project_id,
  233. relationship = member.relationship
  234. });
  235. added++;
  236. }
  237. yield pulse.bump(0, @"stack $(((!)stack).name) applied to goal");
  238. return added;
  239. }
  240. /** Loads the stack or throws (the apply paths' guard). */
  241. private async Stack require_stack(int64 id) throws Error {
  242. var stack = yield get_stack(id);
  243. if (stack == null) {
  244. throw new Error(Quark.from_string("clanker-saddle"), 94,
  245. @"No stack exists with id $(id)");
  246. }
  247. return (!)stack;
  248. }
  249. }
  250. }