mxrb 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. checksums.yaml +4 -4
  2. data/bin/mxrb +458 -10
  3. data/docs/de-DE/README.md +1 -0
  4. data/docs/de-DE/oql-sql.md +42 -0
  5. data/docs/de-DE/project-structure.md +201 -2
  6. data/docs/de-DE/runtime-ruby.md +104 -0
  7. data/docs/de-DE/runtime-schema-migrations.md +44 -0
  8. data/docs/de-DE/validation-matrix.md +42 -4
  9. data/docs/en-US/README.md +1 -0
  10. data/docs/en-US/oql-sql.md +41 -0
  11. data/docs/en-US/project-structure.md +192 -2
  12. data/docs/en-US/runtime-ruby.md +100 -0
  13. data/docs/en-US/runtime-schema-migrations.md +41 -0
  14. data/docs/en-US/validation-matrix.md +39 -4
  15. data/docs/pt-BR/README.md +1 -0
  16. data/docs/pt-BR/entity-dsl.md +8 -0
  17. data/docs/pt-BR/oql-sql.md +41 -0
  18. data/docs/pt-BR/project-structure.md +197 -2
  19. data/docs/pt-BR/runtime-ruby.md +108 -0
  20. data/docs/pt-BR/runtime-schema-migrations.md +40 -0
  21. data/docs/pt-BR/validation-matrix.md +42 -5
  22. data/docs/pt-BR/writing.md +28 -0
  23. data/lib/mxrb/cli/help.rb +426 -0
  24. data/lib/mxrb/cli/release.rb +137 -0
  25. data/lib/mxrb/compiler/compatibility_analyzer.rb +1 -1
  26. data/lib/mxrb/compiler/data_grid_bundle_compiler.rb +8 -1
  27. data/lib/mxrb/compiler/domain_document_compiler.rb +3 -0
  28. data/lib/mxrb/compiler/gallery_bundle_compiler.rb +11 -1
  29. data/lib/mxrb/compiler/generic_widget_bundle_compiler.rb +76 -19
  30. data/lib/mxrb/compiler/legacy_custom_widget_compiler.rb +159 -0
  31. data/lib/mxrb/compiler/legacy_data_grid_compiler.rb +43 -7
  32. data/lib/mxrb/compiler/legacy_page_builder.rb +1248 -23
  33. data/lib/mxrb/compiler/packager.rb +27 -13
  34. data/lib/mxrb/compiler/page_bundle_compiler.rb +13 -4
  35. data/lib/mxrb/compiler/portable_packager.rb +16 -11
  36. data/lib/mxrb/compiler/web_bundle_builder.rb +5 -1
  37. data/lib/mxrb/compiler/web_shell_materializer.rb +42 -2
  38. data/lib/mxrb/compiler/widget_package_extractor.rb +3 -0
  39. data/lib/mxrb/domain_diagram/lifecycle.rb +272 -0
  40. data/lib/mxrb/domain_diagram.rb +409 -0
  41. data/lib/mxrb/dsl/builder.rb +62 -11
  42. data/lib/mxrb/environment.rb +226 -0
  43. data/lib/mxrb/exporter.rb +265 -45
  44. data/lib/mxrb/http/server.rb +107 -0
  45. data/lib/mxrb/initializer.rb +80 -9
  46. data/lib/mxrb/integrity/validator.rb +32 -7
  47. data/lib/mxrb/io/bson_codec.rb +42 -1
  48. data/lib/mxrb/io/mpr_file.rb +146 -3
  49. data/lib/mxrb/model/attribute.rb +3 -0
  50. data/lib/mxrb/model/entity.rb +44 -2
  51. data/lib/mxrb/model/microflow.rb +3 -1
  52. data/lib/mxrb/model/module.rb +71 -0
  53. data/lib/mxrb/model/page.rb +126 -15
  54. data/lib/mxrb/model/project.rb +3 -0
  55. data/lib/mxrb/modeler/catalog.rb +178 -0
  56. data/lib/mxrb/official_marketplace/module_package_importer.rb +23 -12
  57. data/lib/mxrb/official_marketplace/widget_package_installer.rb +15 -11
  58. data/lib/mxrb/official_marketplace.rb +9 -3
  59. data/lib/mxrb/oql/reverse_translator.rb +394 -0
  60. data/lib/mxrb/oql/server.rb +4 -8
  61. data/lib/mxrb/oql.rb +32 -4
  62. data/lib/mxrb/progress.rb +242 -0
  63. data/lib/mxrb/project_lifecycle.rb +5 -2
  64. data/lib/mxrb/ruby_app/exporter.rb +1749 -0
  65. data/lib/mxrb/ruby_app/preset.rb +506 -0
  66. data/lib/mxrb/ruby_app/session_manager.rb +122 -0
  67. data/lib/mxrb/ruby_app.rb +1361 -0
  68. data/lib/mxrb/runtime/access_control.rb +408 -0
  69. data/lib/mxrb/runtime/native.rb +1015 -93
  70. data/lib/mxrb/runtime/scheduler.rb +396 -0
  71. data/lib/mxrb/runtime/schema_migrator.rb +598 -0
  72. data/lib/mxrb/runtime/shared_store.rb +335 -0
  73. data/lib/mxrb/runtime/sqlite_store.rb +729 -0
  74. data/lib/mxrb/scaffold/generator.rb +11 -3
  75. data/lib/mxrb/team_server.rb +10 -7
  76. data/lib/mxrb/uml/activity_diagram.rb +118 -0
  77. data/lib/mxrb/uml/class_diagram.rb +118 -0
  78. data/lib/mxrb/uml/sequence_diagram.rb +112 -0
  79. data/lib/mxrb/uml/server.rb +146 -0
  80. data/lib/mxrb/uml/support.rb +35 -0
  81. data/lib/mxrb/version.rb +1 -1
  82. data/lib/mxrb/web_ui/assets/abnfDiagram-N423BO3Z-C4JD3cEC.js +1 -0
  83. data/lib/mxrb/web_ui/assets/arc-3Z53kgFp.js +1 -0
  84. data/lib/mxrb/web_ui/assets/architecture-TIHT7OUA-BStBTLea.js +1 -0
  85. data/lib/mxrb/web_ui/assets/architectureDiagram-T3A2C74G-DBwqnM4J.js +36 -0
  86. data/lib/mxrb/web_ui/assets/array-BifhSqXX.js +1 -0
  87. data/lib/mxrb/web_ui/assets/blockDiagram-VBNYF7ZC-DLtuHt-j.js +132 -0
  88. data/lib/mxrb/web_ui/assets/c4Diagram-5PPSVZJV-ConSk_pe.js +10 -0
  89. data/lib/mxrb/web_ui/assets/channel-BhPvD7Dt.js +1 -0
  90. data/lib/mxrb/web_ui/assets/chunk-2GRJ4B5K-D2HpWsf_.js +1 -0
  91. data/lib/mxrb/web_ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  92. data/lib/mxrb/web_ui/assets/chunk-4I5QYGJK-CiIdNwaF.js +1 -0
  93. data/lib/mxrb/web_ui/assets/chunk-5RXB4S5H-Bu_AaEkE.js +231 -0
  94. data/lib/mxrb/web_ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  95. data/lib/mxrb/web_ui/assets/chunk-6Q2QTUOP-wQkNmt_X.js +88 -0
  96. data/lib/mxrb/web_ui/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
  97. data/lib/mxrb/web_ui/assets/chunk-GF5L2VYU-G8MmiBzU.js +206 -0
  98. data/lib/mxrb/web_ui/assets/chunk-I66GZJ75-CulcrLPh.js +127 -0
  99. data/lib/mxrb/web_ui/assets/chunk-JQJVKLGR-ukjzcdlL.js +156 -0
  100. data/lib/mxrb/web_ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  101. data/lib/mxrb/web_ui/assets/chunk-KBJHAD2P-Wm35lYov.js +1 -0
  102. data/lib/mxrb/web_ui/assets/chunk-KEIR6QF5-BICK3FdT.js +161 -0
  103. data/lib/mxrb/web_ui/assets/chunk-NSK5VX7P-D2nYrCY4.js +2 -0
  104. data/lib/mxrb/web_ui/assets/chunk-QR6OTTB3-BrdIzg4Q.js +62 -0
  105. data/lib/mxrb/web_ui/assets/chunk-RYQCIY6F-CCfZMcW6.js +1 -0
  106. data/lib/mxrb/web_ui/assets/chunk-UBXNYLIW-BPEVYsFK.js +1 -0
  107. data/lib/mxrb/web_ui/assets/chunk-W5SLKNZC-2E6OPhA4.js +1 -0
  108. data/lib/mxrb/web_ui/assets/chunk-WRU74C26-CBIvOyWp.js +70 -0
  109. data/lib/mxrb/web_ui/assets/chunk-XXDRQBXY-pH58XAyl.js +1 -0
  110. data/lib/mxrb/web_ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  111. data/lib/mxrb/web_ui/assets/classDiagram-JCYQIIEL-xiqo4D89.js +1 -0
  112. data/lib/mxrb/web_ui/assets/classDiagram-v2-OCEON4UE-xiqo4D89.js +1 -0
  113. data/lib/mxrb/web_ui/assets/cose-bilkent-JH36ORCC-t_flPpii.js +1 -0
  114. data/lib/mxrb/web_ui/assets/cynefin-VYW2F7L2-BIqktTEv.js +1 -0
  115. data/lib/mxrb/web_ui/assets/cynefinDiagram-MW4NZA55-C2d--lut.js +62 -0
  116. data/lib/mxrb/web_ui/assets/cytoscape.esm-B-NFISlW.js +321 -0
  117. data/lib/mxrb/web_ui/assets/dagre-Buvkdvvj.js +1 -0
  118. data/lib/mxrb/web_ui/assets/dagre-VZM6K2ZE-CSz2_tb5.js +4 -0
  119. data/lib/mxrb/web_ui/assets/defaultLocale-BFoDCU3G.js +1 -0
  120. data/lib/mxrb/web_ui/assets/diagram-7IWD3JNH-DCt7u7V_.js +30 -0
  121. data/lib/mxrb/web_ui/assets/diagram-B4RE2ZJO-DqZBqkMu.js +3 -0
  122. data/lib/mxrb/web_ui/assets/diagram-LBJQPF4R-ClIYbIsI.js +24 -0
  123. data/lib/mxrb/web_ui/assets/diagram-Q27KOJAE-DFxhvthJ.js +24 -0
  124. data/lib/mxrb/web_ui/assets/diagram-UB23O5K3-BSUTXptv.js +41 -0
  125. data/lib/mxrb/web_ui/assets/dist-D2qOEbeJ.js +1 -0
  126. data/lib/mxrb/web_ui/assets/domain-mGOZ1KCG.css +1 -0
  127. data/lib/mxrb/web_ui/assets/domain-qsjXhy4v.js +1 -0
  128. data/lib/mxrb/web_ui/assets/ebnfDiagram-BXEA7PRR-B5reICEi.js +1 -0
  129. data/lib/mxrb/web_ui/assets/erDiagram-JOGREHBK-Co5Mxn2p.js +85 -0
  130. data/lib/mxrb/web_ui/assets/eventmodeling-45OFAUF4-C1ylhSks.js +1 -0
  131. data/lib/mxrb/web_ui/assets/flowDiagram-UKHOOZJN-CHXaJIaS.js +1 -0
  132. data/lib/mxrb/web_ui/assets/ganttDiagram-PKOTCBZU-DPPPaWaz.js +292 -0
  133. data/lib/mxrb/web_ui/assets/gitGraph-TEB2WS4Q-qS77ywA9.js +1 -0
  134. data/lib/mxrb/web_ui/assets/gitGraphDiagram-DS77QQ5N-DpAv6-BT.js +106 -0
  135. data/lib/mxrb/web_ui/assets/graphlib-DS17s2tU.js +1 -0
  136. data/lib/mxrb/web_ui/assets/info-DKCQHKI2-C9VD1Y4e.js +1 -0
  137. data/lib/mxrb/web_ui/assets/infoDiagram-6WML65LV-Bk1kbcCy.js +2 -0
  138. data/lib/mxrb/web_ui/assets/init-C-OQMol4.js +1 -0
  139. data/lib/mxrb/web_ui/assets/ishikawaDiagram-WSZJBQD7-0gcISBFR.js +70 -0
  140. data/lib/mxrb/web_ui/assets/journeyDiagram-NVQOT4AX-Dt0RQnlh.js +139 -0
  141. data/lib/mxrb/web_ui/assets/jsx-runtime-DV0a5kSb.js +9 -0
  142. data/lib/mxrb/web_ui/assets/kanban-definition-27J2QSJJ-DIPQM7w9.js +89 -0
  143. data/lib/mxrb/web_ui/assets/katex-CXMH3UgJ.js +257 -0
  144. data/lib/mxrb/web_ui/assets/line-C0rYD-tL.js +1 -0
  145. data/lib/mxrb/web_ui/assets/linear-BPPxWorj.js +1 -0
  146. data/lib/mxrb/web_ui/assets/map-BaFkSB1l.js +1 -0
  147. data/lib/mxrb/web_ui/assets/mermaid-parser.core-B_UPzTxa.js +7 -0
  148. data/lib/mxrb/web_ui/assets/mindmap-definition-FAOFIHXS-CdJRSMuk.js +96 -0
  149. data/lib/mxrb/web_ui/assets/modeler-BNqHthwk.css +1 -0
  150. data/lib/mxrb/web_ui/assets/modeler-CQmqflbg.js +1 -0
  151. data/lib/mxrb/web_ui/assets/ordinal-BDEzSJ7C.js +1 -0
  152. data/lib/mxrb/web_ui/assets/packet-7NZHBO7P-CwQ4NSBs.js +1 -0
  153. data/lib/mxrb/web_ui/assets/path-fybaL0A-.js +1 -0
  154. data/lib/mxrb/web_ui/assets/pegDiagram-VL7TDLO6-D40S7ppx.js +1 -0
  155. data/lib/mxrb/web_ui/assets/pie-RZYD4A2V-CJroHMKh.js +1 -0
  156. data/lib/mxrb/web_ui/assets/pieDiagram-7S7Q4E2Y-QB54__BV.js +39 -0
  157. data/lib/mxrb/web_ui/assets/quadrantDiagram-CIZ2JOQS-DBhDS6ag.js +7 -0
  158. data/lib/mxrb/web_ui/assets/radar-I7S5WNFK-DdIUwd2d.js +1 -0
  159. data/lib/mxrb/web_ui/assets/railroad-3IZDKUUU-B1Lw6Xyq.js +1 -0
  160. data/lib/mxrb/web_ui/assets/railroad-abnf-AHOZXSZD-CQgQoeak.js +1 -0
  161. data/lib/mxrb/web_ui/assets/railroad-ebnf-EBAXGLYW-D7qEQ2x8.js +1 -0
  162. data/lib/mxrb/web_ui/assets/railroad-peg-LSFZ7HO6-C005Vxbb.js +1 -0
  163. data/lib/mxrb/web_ui/assets/railroadDiagram-AXF67PYL-BBP2qk7M.js +1 -0
  164. data/lib/mxrb/web_ui/assets/requirementDiagram-LRYGKXZP-D2cLsAoI.js +84 -0
  165. data/lib/mxrb/web_ui/assets/rough.esm-Dy-Kn_BL.js +1 -0
  166. data/lib/mxrb/web_ui/assets/sankeyDiagram-W5VNT64P-Bpi3FiP2.js +40 -0
  167. data/lib/mxrb/web_ui/assets/sequenceDiagram-SI44F4Z6-DCRcDlVe.js +162 -0
  168. data/lib/mxrb/web_ui/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
  169. data/lib/mxrb/web_ui/assets/src-CvxfUak2.js +1 -0
  170. data/lib/mxrb/web_ui/assets/stateDiagram-OKZ733FA-C2GCEoxl.js +1 -0
  171. data/lib/mxrb/web_ui/assets/stateDiagram-v2-UEYNNEHI-D2gWgedh.js +1 -0
  172. data/lib/mxrb/web_ui/assets/swimlanes-SLNWSIFB-CRI9irEW.js +2 -0
  173. data/lib/mxrb/web_ui/assets/swimlanesDiagram-ULZ7WXOC-C5Wb_-hZ.js +8 -0
  174. data/lib/mxrb/web_ui/assets/theme-_mxc7BdY.css +1 -0
  175. data/lib/mxrb/web_ui/assets/timeline-definition-Z64GVDOM-B1-mNg-h.js +120 -0
  176. data/lib/mxrb/web_ui/assets/treeView-QDETBFTQ-7CTvIwKq.js +1 -0
  177. data/lib/mxrb/web_ui/assets/treemap-6X3UGDF4-CtL22pq5.js +1 -0
  178. data/lib/mxrb/web_ui/assets/uml-C3KAFGTq.js +51 -0
  179. data/lib/mxrb/web_ui/assets/uml-Dn-qd01X.css +1 -0
  180. data/lib/mxrb/web_ui/assets/vennDiagram-T6HMQDX7-BzVnUEAy.js +34 -0
  181. data/lib/mxrb/web_ui/assets/wardley-OPB4EBWU-JNi-QNag.js +1 -0
  182. data/lib/mxrb/web_ui/assets/wardleyDiagram-T6FBY63Y-BUwmAQ-B.js +78 -0
  183. data/lib/mxrb/web_ui/assets/xychartDiagram-ELKLHX3M-DEQS8sKp.js +7 -0
  184. data/lib/mxrb/web_ui/domain.html +15 -0
  185. data/lib/mxrb/web_ui/modeler.html +16 -0
  186. data/lib/mxrb/web_ui/uml.html +31 -0
  187. data/lib/mxrb/web_ui.rb +89 -0
  188. data/lib/mxrb/widget_certification.rb +208 -0
  189. data/lib/mxrb/writer.rb +278 -41
  190. data/lib/mxrb.rb +38 -8
  191. metadata +157 -5
@@ -0,0 +1,729 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'securerandom'
5
+ require 'time'
6
+ require_relative 'native'
7
+ require_relative 'schema_migrator'
8
+
9
+ module Mxrb
10
+ module Runtime
11
+ # SQLite-backed replacement for Runtime::Native::Store. ObjectValue stays
12
+ # the public value type, so the native microflow interpreter can use this
13
+ # store without a persistence-specific object abstraction.
14
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity
15
+ # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
16
+ class SQLiteStore
17
+ EVENTS = %i[
18
+ before_create after_create before_update after_update
19
+ before_commit after_commit before_delete after_delete
20
+ ].freeze
21
+ SYSTEM_MEMBERS = {
22
+ owner: %w[Owner __owner_id], created_date: %w[createdDate __created_at],
23
+ changed_date: %w[changedDate __changed_at], changed_by: %w[changedBy __changed_by_id]
24
+ }.freeze
25
+
26
+ attr_reader :database, :schema
27
+
28
+ def initialize(project, path: ':memory:', defaults: {}, hooks: {}, allow_destructive: false)
29
+ @database = SQLite3::Database.new(path.to_s)
30
+ @database.results_as_hash = true
31
+ @database.execute('PRAGMA foreign_keys = ON')
32
+ @database.busy_timeout = 5_000
33
+ @schema = SchemaMigrator.new(
34
+ project, database: @database, allow_destructive:
35
+ ).tap(&:migrate!).schema
36
+ @defaults = defaults.transform_keys(&:to_s)
37
+ transient_defaults = project.modules.flat_map do |mod|
38
+ mod.entities.select { _1.persistable == false }.map do |entity|
39
+ ["#{mod.name}.#{entity.name}", defaults.fetch("#{mod.name}.#{entity.name}", {})]
40
+ end
41
+ end.to_h
42
+ @transient_entities = transient_defaults.keys.freeze
43
+ @transient = Native::Store.new(defaults: transient_defaults)
44
+ @hooks = Hash.new { |values, event| values[event] = [] }
45
+ @identity = {}
46
+ @persisted = {}
47
+ @staged_new = {}
48
+ @sequence_values = {}
49
+ @manual_transaction = false
50
+ hooks.each { |event, callbacks| Array(callbacks).each { on(event, &_1) } }
51
+ end
52
+
53
+ def on(event_or_entity, positional_event = nil, entity: nil, &block)
54
+ entity ||= event_or_entity if positional_event
55
+ event = positional_event || event_or_entity
56
+ key = event.to_sym
57
+ raise ArgumentError, "unsupported lifecycle event #{event}" unless EVENTS.include?(key)
58
+ raise ArgumentError, 'lifecycle hook requires a block' unless block
59
+
60
+ @hooks[key] << [entity&.to_s, block]
61
+ @transient.on(transient_name(entity), key) { |value| block.call(value, self) } if entity && transient?(entity)
62
+ unless entity
63
+ @transient_entities.each do |name|
64
+ @transient.on(name, key) { |value| block.call(value, self) }
65
+ end
66
+ end
67
+ self
68
+ end
69
+
70
+ def create(entity, events: true)
71
+ return @transient.create(transient_name(entity), events:) if transient?(entity)
72
+
73
+ definition = schema.entity(entity)
74
+ value = nil
75
+ atomic do
76
+ members = defaults_for(definition)
77
+ value = Native::ObjectValue.new(entity: definition.name, id: SecureRandom.uuid, members:)
78
+ run_hooks(:before_create, value) if events
79
+ stage(value)
80
+ run_hooks(:after_create, value) if events
81
+ end
82
+ value
83
+ rescue StandardError
84
+ @identity.delete(value&.id)
85
+ @persisted.delete(value&.id)
86
+ @staged_new.delete(value&.id)
87
+ raise
88
+ end
89
+
90
+ def retrieve(entity)
91
+ return @transient.retrieve(transient_name(entity)) if transient?(entity)
92
+
93
+ definition = schema.entity(entity)
94
+ rows = database.execute("SELECT * FROM #{quote(definition.table)} ORDER BY rowid")
95
+ values = rows.map { materialize(definition, _1) }
96
+ values.concat(@staged_new.values.select { _1.entity == definition.name })
97
+ load_direct_associations(values)
98
+ values.uniq(&:id)
99
+ end
100
+
101
+ def find(entity, id)
102
+ return @transient.find(transient_name(entity), id) if transient?(entity)
103
+
104
+ definition = schema.entity(entity)
105
+ staged = @staged_new[id.to_s]
106
+ return staged if staged&.entity == definition.name
107
+
108
+ row = database.get_first_row(
109
+ "SELECT * FROM #{quote(definition.table)} WHERE id = ?", id.to_s
110
+ )
111
+ return unless row
112
+
113
+ materialize(definition, row).tap { load_direct_associations([_1]) }
114
+ end
115
+
116
+ def release_cache!
117
+ @identity = @staged_new.dup
118
+ @persisted = {}
119
+ self
120
+ end
121
+
122
+ def retrieve_association(association, start)
123
+ return [] unless start
124
+
125
+ definition = begin
126
+ schema.association(association)
127
+ rescue ArgumentError
128
+ return @transient.retrieve_association(association, start) if transient?(start.entity)
129
+
130
+ raise
131
+ end
132
+ return volatile_association_values(definition, association, start) if hybrid_association?(definition)
133
+
134
+ return @transient.retrieve_association(association, start) if transient?(start.entity)
135
+
136
+ if start.entity == definition.from_entity
137
+ ids = database.execute(
138
+ "SELECT target_id FROM #{quote(definition.table)} WHERE source_id = ? ORDER BY rowid", [start.id]
139
+ ).map { _1['target_id'] }
140
+ materialize_ids(definition.to_entity, ids)
141
+ elsif start.entity == definition.to_entity
142
+ ids = database.execute(
143
+ "SELECT source_id FROM #{quote(definition.table)} WHERE target_id = ? ORDER BY rowid", [start.id]
144
+ ).map { _1['source_id'] }
145
+ materialize_ids(definition.from_entity, ids)
146
+ else
147
+ []
148
+ end
149
+ end
150
+
151
+ def delete(value, events: true)
152
+ transient, persistent = Array(value).compact.partition { transient?(_1.entity) }
153
+ @transient.delete(transient, events:) unless transient.empty?
154
+ persistent.each { delete_one(_1, events:) }
155
+ nil
156
+ end
157
+
158
+ def count(entity, predicate = nil)
159
+ return @transient.count(transient_name(entity), predicate) if transient?(entity)
160
+
161
+ return retrieve(entity).count { predicate.call(_1) } if predicate
162
+
163
+ definition = schema.entity(entity)
164
+ durable = database.get_first_value("SELECT COUNT(*) FROM #{quote(definition.table)}").to_i
165
+ durable + @staged_new.values.count { _1.entity == definition.name }
166
+ end
167
+
168
+ def commit(value = nil, events: true)
169
+ values = value.nil? ? dirty_values : Array(value)
170
+ transient, persistent = values.compact.partition { transient?(_1.entity) }
171
+ @transient.commit(transient, events:) unless transient.empty?
172
+ atomic { persistent.each { persist_update(_1, events:) } }
173
+ finish_manual_transaction if value.nil? && @manual_transaction
174
+ value
175
+ end
176
+
177
+ def begin_transaction
178
+ raise SQLite3::SQLException, 'transaction already active' if @manual_transaction
179
+
180
+ @manual_snapshot = transaction_snapshot
181
+ database.execute('BEGIN IMMEDIATE')
182
+ @manual_transaction = true
183
+ self
184
+ end
185
+
186
+ def transaction
187
+ begin_transaction
188
+ result = yield self
189
+ finish_manual_transaction
190
+ detach_uncommitted
191
+ result
192
+ rescue Exception # rubocop:disable Lint/RescueException
193
+ rollback if @manual_transaction
194
+ raise
195
+ end
196
+
197
+ def rollback(value = nil)
198
+ return rollback_values(value) unless value.nil?
199
+
200
+ state = @manual_snapshot
201
+ database.execute('ROLLBACK') if database.transaction_active?
202
+ @manual_transaction = false
203
+ @manual_snapshot = nil
204
+ restore_transaction_snapshot(state) if state
205
+ self
206
+ end
207
+
208
+ def snapshot
209
+ result = schema.entities.to_h do |entity|
210
+ [entity.name, retrieve(entity.name).map { duplicate_value(_1) }]
211
+ end
212
+ result['__mxrb_transient__'] = @transient.snapshot
213
+ result['__mxrb_uow__'] = {
214
+ persisted: @persisted.transform_values(&:dup),
215
+ staged_ids: @staged_new.keys,
216
+ associations: association_snapshot
217
+ }
218
+ result
219
+ end
220
+
221
+ def restore(snapshot)
222
+ unit_of_work = snapshot['__mxrb_uow__']
223
+ atomic do
224
+ schema.associations.each { database.execute("DELETE FROM #{quote(_1.table)}") }
225
+ schema.entities.each { database.execute("DELETE FROM #{quote(_1.table)}") }
226
+ clear_cache
227
+ if unit_of_work
228
+ restore_unit_of_work(snapshot, unit_of_work)
229
+ else
230
+ restore_legacy_snapshot(snapshot)
231
+ end
232
+ end
233
+ @transient.restore(snapshot.fetch('__mxrb_transient__', @transient.snapshot))
234
+ self
235
+ end
236
+
237
+ def close
238
+ rollback if @manual_transaction
239
+ database.close unless database.closed?
240
+ end
241
+
242
+ private
243
+
244
+ def transaction_snapshot
245
+ {
246
+ identity: @identity.dup,
247
+ members: @identity.to_h do |id, value|
248
+ [id, value.members.transform_values { transaction_member_snapshot(_1) }]
249
+ end,
250
+ persisted: @persisted.transform_values(&:dup),
251
+ staged_new: @staged_new.dup,
252
+ sequence_values: @sequence_values.dup,
253
+ transient: @transient.snapshot
254
+ }
255
+ end
256
+
257
+ def restore_transaction_snapshot(state)
258
+ state.fetch(:identity).each do |id, value|
259
+ value.members.replace(state.fetch(:members).fetch(id))
260
+ end
261
+ @identity = state.fetch(:identity)
262
+ @persisted = state.fetch(:persisted)
263
+ @staged_new = state.fetch(:staged_new)
264
+ @sequence_values = state.fetch(:sequence_values)
265
+ @transient.restore(state.fetch(:transient))
266
+ end
267
+
268
+ def transaction_member_snapshot(value)
269
+ return value.map { transaction_member_snapshot(_1) } if value.is_a?(Array)
270
+ return value.transform_values { transaction_member_snapshot(_1) } if value.is_a?(Hash)
271
+
272
+ value
273
+ end
274
+
275
+ def restore_legacy_snapshot(snapshot)
276
+ snapshot.each do |entity, values|
277
+ next if entity.start_with?('__mxrb_')
278
+
279
+ definition = schema.entity(entity)
280
+ Array(values).each do |value|
281
+ copy = duplicate_value(value)
282
+ insert_value(copy, definition, associations: false)
283
+ cache(copy)
284
+ end
285
+ end
286
+ @identity.each_value { persist_associations(_1, schema.entity(_1.entity)) }
287
+ end
288
+
289
+ def restore_unit_of_work(snapshot, unit_of_work)
290
+ working = snapshot.each_with_object({}) do |(entity, values), records|
291
+ next if entity.start_with?('__mxrb_')
292
+
293
+ Array(values).each { records[_1.id] = duplicate_value(_1) }
294
+ end
295
+ persisted = unit_of_work.fetch(:persisted, unit_of_work.fetch('persisted', {}))
296
+ persisted.each do |id, members|
297
+ current = working[id]
298
+ next unless current
299
+
300
+ definition = schema.entity(current.entity)
301
+ durable = Native::ObjectValue.new(entity: current.entity, id:, members: members.dup)
302
+ insert_value(durable, definition, associations: false)
303
+ end
304
+ restore_associations(unit_of_work.fetch(:associations, unit_of_work.fetch('associations', {})))
305
+ working.each_value do |value|
306
+ @identity[value.id] = value
307
+ if persisted.key?(value.id)
308
+ @persisted[value.id] = persisted.fetch(value.id).dup
309
+ else
310
+ @staged_new[value.id] = value
311
+ end
312
+ end
313
+ end
314
+
315
+ def delete_one(value, events: true)
316
+ definition = schema.entity(value.entity)
317
+ atomic do
318
+ run_hooks(:before_delete, value) if events
319
+ schema.associations.each do |association|
320
+ next unless [association.from_entity, association.to_entity].include?(definition.name)
321
+
322
+ database.execute(
323
+ "DELETE FROM #{quote(association.table)} WHERE source_id = ? OR target_id = ?", [value.id, value.id]
324
+ )
325
+ end
326
+ database.execute("DELETE FROM #{quote(definition.table)} WHERE id = ?", [value.id])
327
+ @identity.delete(value.id)
328
+ @persisted.delete(value.id)
329
+ @staged_new.delete(value.id)
330
+ run_hooks(:after_delete, value) if events
331
+ end
332
+ end
333
+
334
+ def persist_update(value, events: true)
335
+ definition = schema.entity(value.entity)
336
+ previous = @persisted[value.id]
337
+ return persist_new(value, definition, events:) if @staged_new.key?(value.id)
338
+ raise ArgumentError, "object #{value.id} is not persisted" unless previous
339
+
340
+ return value if comparable_members(value.members) == comparable_members(previous)
341
+
342
+ original = previous.dup
343
+ run_hooks(:before_update, value) if events
344
+ run_hooks(:before_commit, value) if events
345
+ update_system_members(value, definition)
346
+ fields, values = persisted_fields(value, definition)
347
+ assignments = fields.map { "#{quote(_1)} = ?" }.join(', ')
348
+ unless fields.empty?
349
+ database.execute(
350
+ "UPDATE #{quote(definition.table)} SET #{assignments} WHERE id = ?", [*values, value.id]
351
+ )
352
+ end
353
+ persist_associations(value, definition)
354
+ @persisted[value.id] = value.members.dup
355
+ run_hooks(:after_commit, value) if events
356
+ run_hooks(:after_update, value) if events
357
+ value
358
+ rescue StandardError
359
+ value.members.replace(original) if original
360
+ raise
361
+ end
362
+
363
+ def persist_new(value, definition, events: true)
364
+ run_hooks(:before_commit, value) if events
365
+ insert_value(value, definition)
366
+ advance_sequences(value, definition)
367
+ @staged_new.delete(value.id)
368
+ cache(value)
369
+ run_hooks(:after_commit, value) if events
370
+ value
371
+ rescue StandardError
372
+ @identity[value.id] = value
373
+ @staged_new[value.id] = value
374
+ @persisted.delete(value.id)
375
+ raise
376
+ end
377
+
378
+ def insert_value(value, definition, associations: true)
379
+ set_creation_system_members(value, definition)
380
+ fields, values = persisted_fields(value, definition)
381
+ fields.unshift('id')
382
+ values.unshift(value.id)
383
+ placeholders = Array.new(fields.size, '?').join(', ')
384
+ database.execute(
385
+ "INSERT INTO #{quote(definition.table)} (#{fields.map { quote(_1) }.join(', ')}) " \
386
+ "VALUES (#{placeholders})", values
387
+ )
388
+ persist_associations(value, definition) if associations
389
+ end
390
+
391
+ def persisted_fields(value, definition)
392
+ fields = []
393
+ values = []
394
+ definition.columns.each do |column|
395
+ fields << column.sql_name
396
+ values << serialize(value.members[column.name], column.type)
397
+ end
398
+ definition.system_members.each do |name, (sql_name, _type)|
399
+ fields << sql_name
400
+ values << serialize_system(value.members[SYSTEM_MEMBERS.fetch(name).first])
401
+ end
402
+ [fields, values]
403
+ end
404
+
405
+ def persist_associations(value, entity)
406
+ schema.associations.select { _1.from_entity == entity.name }.each do |association|
407
+ next if hybrid_association?(association)
408
+ next unless value.members.key?(association.name)
409
+
410
+ database.execute("DELETE FROM #{quote(association.table)} WHERE source_id = ?", [value.id])
411
+ targets = Array(value.members[association.name]).compact
412
+ targets = targets.first(1) if association.type == :Reference
413
+ targets.each do |target|
414
+ target_id = target.respond_to?(:id) ? target.id : target.to_s
415
+ database.execute(
416
+ "INSERT OR IGNORE INTO #{quote(association.table)} (source_id, target_id) VALUES (?, ?)",
417
+ [value.id, target_id]
418
+ )
419
+ end
420
+ end
421
+ end
422
+
423
+ def materialize(definition, row)
424
+ cached = @identity[row['id']]
425
+ return cached if cached
426
+
427
+ members = definition.columns.to_h do |column|
428
+ [column.name, deserialize(row[column.sql_name], column.type)]
429
+ end
430
+ definition.system_members.each_key do |name|
431
+ logical, sql_name = SYSTEM_MEMBERS.fetch(name)
432
+ members[logical] = deserialize_system(row[sql_name], name)
433
+ end
434
+ value = Native::ObjectValue.new(entity: definition.name, id: row['id'], members:)
435
+ cache(value)
436
+ value
437
+ end
438
+
439
+ def materialize_ids(entity, ids)
440
+ return [] if ids.empty?
441
+
442
+ definition = schema.entity(entity)
443
+ placeholders = Array.new(ids.size, '?').join(', ')
444
+ rows = database.execute(
445
+ "SELECT * FROM #{quote(definition.table)} WHERE id IN (#{placeholders})", ids
446
+ ).to_h { [_1['id'], _1] }
447
+ ids.filter_map { |id| rows[id] && materialize(definition, rows[id]) }
448
+ end
449
+
450
+ def load_direct_associations(values)
451
+ values.each do |value|
452
+ # A staged object can already carry associations that only reach the
453
+ # database when a later batch commit runs. Reloading those links from
454
+ # SQLite here would replace the in-memory values with an empty result.
455
+ next if @staged_new.key?(value.id)
456
+
457
+ schema.associations.select { _1.from_entity == value.entity }.each do |association|
458
+ next if association_dirty?(value, association.name)
459
+
460
+ related = retrieve_association(association.qualified_name, value)
461
+ value.members[association.name] = association.type == :Reference ? related.first : related
462
+ (@persisted[value.id] ||= {})[association.name] = value.members[association.name]
463
+ end
464
+ end
465
+ end
466
+
467
+ def association_dirty?(value, name)
468
+ persisted = @persisted[value.id]
469
+ return false unless persisted&.key?(name)
470
+
471
+ comparable_member(value.members[name]) != comparable_member(persisted[name])
472
+ end
473
+
474
+ def comparable_member(value)
475
+ return value.map { comparable_member(_1) } if value.is_a?(Array)
476
+
477
+ value.respond_to?(:id) ? value.id : value
478
+ end
479
+
480
+ def cache(value)
481
+ @identity[value.id] = value
482
+ @persisted[value.id] = value.members.dup
483
+ @staged_new.delete(value.id)
484
+ end
485
+
486
+ def stage(value)
487
+ @identity[value.id] = value
488
+ @staged_new[value.id] = value
489
+ end
490
+
491
+ def clear_cache
492
+ @identity.clear
493
+ @persisted.clear
494
+ @staged_new.clear
495
+ end
496
+
497
+ def dirty_values
498
+ @staged_new.values + @identity.values.reject do |value|
499
+ next true if @staged_new.key?(value.id)
500
+
501
+ comparable_members(value.members) == comparable_members(@persisted.fetch(value.id, {}))
502
+ end
503
+ end
504
+
505
+ def comparable_members(members)
506
+ members.transform_values { comparable_member(_1) }
507
+ end
508
+
509
+ def defaults_for(entity)
510
+ generated = entity.columns.to_h do |column|
511
+ default = column.type == :autonumber ? next_autonumber(entity, column) : column.default
512
+ [column.name, cast_default(default, column.type)]
513
+ end
514
+ generated.merge(@defaults.fetch(entity.name, @defaults.fetch(entity.name.split('.').last, {})).dup)
515
+ end
516
+
517
+ def next_autonumber(entity, column)
518
+ key = [entity.storage_key, column.storage_key]
519
+ stored = database.get_first_value(
520
+ 'SELECT next_value FROM mxrb_schema_sequences WHERE entity_key = ? AND attribute_key = ?', key
521
+ )
522
+ raise "missing AutoNumber sequence for #{entity.name}.#{column.name}" unless stored
523
+
524
+ value = [stored.to_i, @sequence_values.fetch(key, stored).to_i].max
525
+ @sequence_values[key] = value.to_i + 1
526
+ value.to_i
527
+ end
528
+
529
+ def advance_sequences(value, entity)
530
+ entity.columns.select { _1.type == :autonumber }.each do |column|
531
+ next_value = value.members[column.name].to_i + 1
532
+ database.execute(
533
+ 'UPDATE mxrb_schema_sequences SET next_value = MAX(next_value, ?) ' \
534
+ 'WHERE entity_key = ? AND attribute_key = ?',
535
+ [next_value, entity.storage_key, column.storage_key]
536
+ )
537
+ end
538
+ end
539
+
540
+ def cast_default(value, type)
541
+ return nil if value.nil? || value == ''
542
+ return value == true || value.to_s.casecmp?('true') if type == :boolean
543
+ return value.to_i if %i[integer long autonumber].include?(type)
544
+ return value.to_f if %i[float decimal].include?(type)
545
+
546
+ value
547
+ end
548
+
549
+ def set_creation_system_members(value, entity)
550
+ now = Time.now.utc
551
+ value.members['createdDate'] ||= now if entity.system_members.key?(:created_date)
552
+ value.members['changedDate'] ||= now if entity.system_members.key?(:changed_date)
553
+ end
554
+
555
+ def update_system_members(value, entity)
556
+ value.members['changedDate'] = Time.now.utc if entity.system_members.key?(:changed_date)
557
+ end
558
+
559
+ def serialize(value, type)
560
+ return nil if value.nil?
561
+ return value ? 1 : 0 if type == :boolean
562
+ return value.utc.iso8601(6) if type == :datetime && value.respond_to?(:utc)
563
+ return value.iso8601 if type == :datetime && value.respond_to?(:iso8601)
564
+
565
+ value
566
+ end
567
+
568
+ def deserialize(value, type)
569
+ return nil if value.nil?
570
+ return value.to_i != 0 if type == :boolean
571
+ return Time.parse(value) if type == :datetime
572
+
573
+ value
574
+ rescue ArgumentError
575
+ value
576
+ end
577
+
578
+ def serialize_system(value)
579
+ return value.id if value.respond_to?(:id)
580
+ return value.utc.iso8601(6) if value.respond_to?(:utc)
581
+
582
+ value
583
+ end
584
+
585
+ def deserialize_system(value, name)
586
+ return nil if value.nil?
587
+ return Time.parse(value) if %i[created_date changed_date].include?(name)
588
+
589
+ value
590
+ rescue ArgumentError
591
+ value
592
+ end
593
+
594
+ def duplicate_value(value)
595
+ Native::ObjectValue.new(entity: value.entity, id: value.id, members: value.members.dup)
596
+ end
597
+
598
+ def rollback_values(values)
599
+ transient, persistent = Array(values).compact.partition { transient?(_1.entity) }
600
+ @transient.rollback(transient) unless transient.empty?
601
+ persistent.each do |value|
602
+ if @staged_new.delete(value.id)
603
+ @identity.delete(value.id)
604
+ next
605
+ end
606
+
607
+ definition = schema.entity(value.entity)
608
+ row = database.get_first_row("SELECT * FROM #{quote(definition.table)} WHERE id = ?", value.id)
609
+ next unless row
610
+
611
+ @identity.delete(value.id)
612
+ persisted = materialize(definition, row)
613
+ load_direct_associations([persisted])
614
+ value.members.replace(persisted.members)
615
+ @identity[value.id] = value
616
+ @persisted[value.id] = value.members.dup
617
+ end
618
+ values
619
+ end
620
+
621
+ def transient?(entity)
622
+ !transient_name(entity).nil?
623
+ end
624
+
625
+ def transient_name(entity)
626
+ name = entity.to_s
627
+ return name if @transient_entities.include?(name)
628
+
629
+ matches = @transient_entities.select { _1.split('.').last == name }
630
+ matches.first if matches.one?
631
+ end
632
+
633
+ def hybrid_association?(association)
634
+ transient?(association.from_entity) || transient?(association.to_entity)
635
+ end
636
+
637
+ def volatile_association_values(definition, requested_name, start)
638
+ keys = [definition.name, definition.qualified_name, requested_name.to_s].uniq
639
+ direct = keys.flat_map { |key| Array(start.members[key]) }
640
+ candidates = @identity.values + @transient_entities.flat_map { @transient.retrieve(_1) }
641
+ inverse = candidates.select do |object|
642
+ keys.any? { |key| Array(object.members[key]).include?(start) }
643
+ end
644
+ (direct + inverse).uniq
645
+ end
646
+
647
+ def association_snapshot
648
+ schema.associations.to_h do |association|
649
+ rows = database.execute(
650
+ "SELECT source_id, target_id FROM #{quote(association.table)} ORDER BY rowid"
651
+ )
652
+ [association.storage_key, rows.map { [_1['source_id'], _1['target_id']] }]
653
+ end
654
+ end
655
+
656
+ def restore_associations(snapshot)
657
+ schema.associations.each do |association|
658
+ rows = snapshot.fetch(association.storage_key, snapshot.fetch(association.storage_key.to_s, []))
659
+ rows.each do |source, target|
660
+ database.execute(
661
+ "INSERT OR IGNORE INTO #{quote(association.table)} (source_id, target_id) VALUES (?, ?)",
662
+ [source, target]
663
+ )
664
+ end
665
+ end
666
+ end
667
+
668
+ def detach_uncommitted
669
+ dirty_values.each do |value|
670
+ @identity.delete(value.id)
671
+ @persisted.delete(value.id)
672
+ @staged_new.delete(value.id)
673
+ end
674
+ end
675
+
676
+ def run_hooks(event, value)
677
+ @hooks[event].each do |entity, callback|
678
+ next if entity && entity != value.entity && entity != value.entity.split('.').last
679
+
680
+ callback.call(value, self)
681
+ end
682
+ end
683
+
684
+ def atomic
685
+ state = memory_state
686
+ nested = @manual_transaction
687
+ if nested
688
+ begin
689
+ return yield
690
+ rescue Exception # rubocop:disable Lint/RescueException
691
+ rollback
692
+ raise
693
+ end
694
+ end
695
+
696
+ database.execute('BEGIN IMMEDIATE')
697
+ result = yield
698
+ database.execute('COMMIT')
699
+ result
700
+ rescue Exception # rubocop:disable Lint/RescueException
701
+ database.execute('ROLLBACK') if database.transaction_active?
702
+ reset_after_atomic_failure(state) unless nested
703
+ raise
704
+ end
705
+
706
+ def memory_state
707
+ { sequence_values: @sequence_values.dup }
708
+ end
709
+
710
+ def reset_after_atomic_failure(state)
711
+ @identity = @staged_new.dup
712
+ @persisted = {}
713
+ @sequence_values = state.fetch(:sequence_values)
714
+ end
715
+
716
+ def finish_manual_transaction
717
+ database.execute('COMMIT')
718
+ @manual_transaction = false
719
+ @manual_snapshot = nil
720
+ end
721
+
722
+ def quote(identifier)
723
+ %("#{identifier.to_s.gsub('"', '""')}")
724
+ end
725
+ end
726
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity
727
+ # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity
728
+ end
729
+ end