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,598 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'sqlite3'
5
+
6
+ module Mxrb
7
+ module Runtime
8
+ SchemaColumn = Data.define(
9
+ :name, :storage_key, :sql_name, :sql_type, :type, :default, :required, :unique
10
+ )
11
+ EntitySchema = Data.define(:name, :storage_key, :table, :columns, :system_members)
12
+ AssociationSchema = Data.define(
13
+ :name, :qualified_name, :storage_key, :table, :from_entity, :to_entity, :type
14
+ )
15
+ RuntimeSchema = Data.define(:entities, :associations) do
16
+ def entity(name)
17
+ qualified = name.to_s
18
+ exact = entities.find { _1.name == qualified }
19
+ return exact if exact
20
+
21
+ matches = entities.select { _1.name.split('.').last == qualified }
22
+ return matches.first if matches.one?
23
+
24
+ raise ArgumentError, matches.empty? ? "unknown entity #{name}" : "ambiguous entity #{name}"
25
+ end
26
+
27
+ def association(name)
28
+ key = name.to_s
29
+ matches = associations.select do |candidate|
30
+ candidate.qualified_name == key || candidate.name == key ||
31
+ candidate.qualified_name.split('.').last == key
32
+ end
33
+ return matches.first if matches.one?
34
+
35
+ raise ArgumentError, matches.empty? ? "unknown association #{name}" : "ambiguous association #{name}"
36
+ end
37
+ end
38
+ MigrationResult = Data.define(:created_tables, :added_columns, :rebuilt_tables)
39
+ SchemaMigrationPlan = Data.define(
40
+ :removed_entities, :removed_attributes, :removed_associations
41
+ ) do
42
+ def destructive?
43
+ removed_entities.any? || removed_attributes.any? || removed_associations.any?
44
+ end
45
+
46
+ def changes = [*removed_entities, *removed_attributes, *removed_associations].freeze
47
+ end
48
+
49
+ # Raised before DDL when an evolution cannot preserve existing data safely.
50
+ class UnsafeSchemaMigrationError < StandardError
51
+ attr_reader :changes
52
+
53
+ def initialize(message, changes: [])
54
+ @changes = changes.freeze
55
+ super(message)
56
+ end
57
+ end
58
+
59
+ # Derives a deterministic SQLite schema from a parsed Mendix project and
60
+ # evolves an existing database without relying on the Java Runtime.
61
+ # rubocop:disable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity
62
+ # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity
63
+ class SchemaMigrator
64
+ SYSTEM_COLUMNS = {
65
+ owner: %w[__owner_id TEXT],
66
+ created_date: %w[__created_at TEXT],
67
+ changed_date: %w[__changed_at TEXT],
68
+ changed_by: %w[__changed_by_id TEXT]
69
+ }.freeze
70
+ TYPE_MAP = {
71
+ integer: 'INTEGER', long: 'INTEGER', autonumber: 'INTEGER', boolean: 'INTEGER',
72
+ float: 'REAL', decimal: 'REAL', binary: 'BLOB', datetime: 'TEXT', enum: 'TEXT',
73
+ hashstring: 'TEXT', string: 'TEXT'
74
+ }.freeze
75
+
76
+ attr_reader :schema, :migration_plan
77
+
78
+ def initialize(project, database:, allow_destructive: false)
79
+ @project = project
80
+ @allow_destructive = allow_destructive == true
81
+ @owns_database = !database.is_a?(SQLite3::Database)
82
+ @database = @owns_database ? SQLite3::Database.new(database.to_s) : database
83
+ @schema = self.class.derive(project)
84
+ end
85
+
86
+ def self.derive(project)
87
+ entities = []
88
+ associations = []
89
+ project.modules.each do |mod|
90
+ by_id = mod.entities.to_h { [_1.id.to_s, qualified_entity_name(mod, _1)] }
91
+ by_id.merge!(mod.entities.to_h { [qualified_entity_name(mod, _1), qualified_entity_name(mod, _1)] })
92
+ mod.entities.select { _1.persistable != false && !_1.oql_view? }.each do |entity|
93
+ entities << entity_schema(mod, entity)
94
+ end
95
+ mod.associations.each do |association|
96
+ from = by_id[association.from_entity_id.to_s] || association.from_entity_id.to_s
97
+ to = resolve_target(project, association.to_entity_id, by_id)
98
+ next if from.empty? || to.empty?
99
+
100
+ qualified = "#{mod.name}.#{association.name}"
101
+ key = association.id.to_s.empty? ? qualified : association.id.to_s
102
+ associations << AssociationSchema.new(
103
+ association.name.to_s, qualified, key, physical_name('association', key),
104
+ from, to, association.association_type.to_sym
105
+ )
106
+ end
107
+ end
108
+ RuntimeSchema.new(entities.freeze, associations.freeze)
109
+ end
110
+
111
+ def self.entity_schema(mod, entity)
112
+ qualified = qualified_entity_name(mod, entity)
113
+ key = entity.data_storage_guid.to_s
114
+ key = entity.id.to_s if key.empty?
115
+ key = qualified if key.empty?
116
+ columns = entity.attributes.map do |attribute|
117
+ attribute_key = attribute.data_storage_guid.to_s
118
+ attribute_key = attribute.id.to_s if attribute_key.empty?
119
+ attribute_key = "#{key}:#{attribute.name}" if attribute_key.empty?
120
+ type = attribute.type.to_sym
121
+ SchemaColumn.new(
122
+ attribute.name.to_s, attribute_key, physical_name('attribute', attribute_key),
123
+ TYPE_MAP.fetch(type, 'TEXT'), type, attribute.default_value, attribute.required == true,
124
+ attribute.unique == true || type == :autonumber
125
+ )
126
+ end
127
+ flags = (entity.system_members || {}).to_h.each_with_object({}) do |(name, enabled), selected|
128
+ selected[name.to_sym] = SYSTEM_COLUMNS.fetch(name.to_sym) if enabled && SYSTEM_COLUMNS.key?(name.to_sym)
129
+ end
130
+ EntitySchema.new(qualified, key, physical_name('entity', key), columns.freeze, flags.freeze)
131
+ end
132
+
133
+ def self.qualified_entity_name(mod, entity)
134
+ value = entity.qualified_name.to_s
135
+ value.empty? ? "#{mod.name}.#{entity.name}" : value
136
+ end
137
+
138
+ def self.resolve_target(project, pointer, local)
139
+ value = pointer.to_s
140
+ return local[value] if local.key?(value)
141
+ return value if value.include?('.')
142
+
143
+ project.modules.each do |mod|
144
+ entity = mod.entities.find { _1.id.to_s == value }
145
+ return qualified_entity_name(mod, entity) if entity
146
+ end
147
+ ''
148
+ end
149
+
150
+ def self.physical_name(kind, key)
151
+ "mxrb_#{kind}_#{Digest::SHA256.hexdigest(key.to_s)[0, 20]}"
152
+ end
153
+
154
+ def migrate!
155
+ created = []
156
+ added = []
157
+ rebuilt = []
158
+ transaction do
159
+ create_metadata_tables
160
+ @migration_plan = build_migration_plan
161
+ reject_destructive_migration! if migration_plan.destructive? && !@allow_destructive
162
+ apply_destructive_migration(rebuilt) if migration_plan.destructive?
163
+ schema.entities.each { migrate_entity(_1, created, added, rebuilt) }
164
+ schema.associations.each { migrate_association(_1, created, rebuilt) }
165
+ synchronize_metadata
166
+ prune_obsolete_metadata
167
+ end
168
+ MigrationResult.new(created.freeze, added.freeze, rebuilt.freeze)
169
+ ensure
170
+ @database.close if @owns_database
171
+ end
172
+
173
+ private
174
+
175
+ def transaction
176
+ @database.execute('BEGIN IMMEDIATE')
177
+ yield
178
+ @database.execute('COMMIT')
179
+ rescue StandardError
180
+ @database.execute('ROLLBACK') if @database.transaction_active?
181
+ raise
182
+ end
183
+
184
+ def create_metadata_tables
185
+ @database.execute(<<~SQL)
186
+ CREATE TABLE IF NOT EXISTS mxrb_schema_entities (
187
+ storage_key TEXT PRIMARY KEY, logical_name TEXT NOT NULL, table_name TEXT NOT NULL
188
+ )
189
+ SQL
190
+ @database.execute(<<~SQL)
191
+ CREATE TABLE IF NOT EXISTS mxrb_schema_attributes (
192
+ entity_key TEXT NOT NULL, storage_key TEXT NOT NULL, logical_name TEXT NOT NULL,
193
+ column_name TEXT NOT NULL, logical_type TEXT NOT NULL,
194
+ required INTEGER NOT NULL DEFAULT 0, unique_value INTEGER NOT NULL DEFAULT 0,
195
+ PRIMARY KEY (entity_key, storage_key)
196
+ )
197
+ SQL
198
+ @database.execute(<<~SQL)
199
+ CREATE TABLE IF NOT EXISTS mxrb_schema_associations (
200
+ storage_key TEXT PRIMARY KEY, logical_name TEXT NOT NULL, table_name TEXT NOT NULL,
201
+ from_entity TEXT NOT NULL, to_entity TEXT NOT NULL, association_type TEXT NOT NULL
202
+ )
203
+ SQL
204
+ @database.execute(<<~SQL)
205
+ CREATE TABLE IF NOT EXISTS mxrb_schema_sequences (
206
+ entity_key TEXT NOT NULL, attribute_key TEXT NOT NULL, next_value INTEGER NOT NULL,
207
+ PRIMARY KEY (entity_key, attribute_key)
208
+ )
209
+ SQL
210
+ ensure_metadata_column('mxrb_schema_attributes', 'required', 'INTEGER NOT NULL DEFAULT 0')
211
+ ensure_metadata_column('mxrb_schema_attributes', 'unique_value', 'INTEGER NOT NULL DEFAULT 0')
212
+ end
213
+
214
+ def ensure_metadata_column(table, name, definition)
215
+ return if table_columns(table).key?(name)
216
+
217
+ @database.execute("ALTER TABLE #{quote(table)} ADD COLUMN #{quote(name)} #{definition}")
218
+ end
219
+
220
+ def build_migration_plan
221
+ current_entities = schema.entities.to_h { [_1.storage_key, _1] }
222
+ current_attributes = schema.entities.flat_map do |entity|
223
+ entity.columns.map { [[entity.storage_key, _1.storage_key], [entity, _1]] }
224
+ end.to_h
225
+ current_associations = schema.associations.to_h { [_1.storage_key, _1] }
226
+
227
+ removed_entities = metadata_rows(
228
+ 'SELECT storage_key, logical_name, table_name FROM mxrb_schema_entities',
229
+ %w[storage_key logical_name table_name]
230
+ ).filter_map do |key, name, table|
231
+ { kind: :entity, storage_key: key, name:, table: } unless current_entities.key?(key)
232
+ end
233
+ removed_entity_keys = removed_entities.to_h { [_1.fetch(:storage_key), true] }
234
+ removed_attributes = metadata_rows(
235
+ 'SELECT entity_key, storage_key, logical_name, column_name FROM mxrb_schema_attributes',
236
+ %w[entity_key storage_key logical_name column_name]
237
+ ).filter_map do |entity_key, key, name, column|
238
+ next if removed_entity_keys.key?(entity_key) || current_attributes.key?([entity_key, key])
239
+
240
+ entity = current_entities[entity_key]
241
+ next unless entity
242
+
243
+ { kind: :attribute, storage_key: key, entity_key:, name:, table: entity.table, column: }
244
+ end
245
+ removed_associations = metadata_rows(
246
+ 'SELECT storage_key, logical_name, table_name FROM mxrb_schema_associations',
247
+ %w[storage_key logical_name table_name]
248
+ ).filter_map do |key, name, table|
249
+ { kind: :association, storage_key: key, name:, table: } unless current_associations.key?(key)
250
+ end
251
+ SchemaMigrationPlan.new(
252
+ removed_entities.freeze, removed_attributes.freeze, removed_associations.freeze
253
+ )
254
+ end
255
+
256
+ def metadata_rows(sql, columns)
257
+ @database.execute(sql).map do |row|
258
+ row.is_a?(Hash) ? columns.map { row[_1] } : row
259
+ end
260
+ end
261
+
262
+ def reject_destructive_migration!
263
+ descriptions = migration_plan.changes.map do |change|
264
+ "#{change.fetch(:kind)} #{change.fetch(:name)}"
265
+ end
266
+ raise UnsafeSchemaMigrationError.new(
267
+ "schema migration would remove #{descriptions.join(', ')}; " \
268
+ 'rerun with allow_destructive: true after backing up the database',
269
+ changes: migration_plan.changes
270
+ )
271
+ end
272
+
273
+ def apply_destructive_migration(rebuilt)
274
+ migration_plan.removed_associations.each { drop_managed_table(_1.fetch(:table)) }
275
+ migration_plan.removed_entities.each { drop_managed_table(_1.fetch(:table)) }
276
+ migration_plan.removed_attributes.group_by { _1.fetch(:entity_key) }.each_key do |entity_key|
277
+ entity = schema.entities.find { _1.storage_key == entity_key }
278
+ next unless entity && table?(entity.table)
279
+
280
+ rebuild_entity(entity, table_columns(entity.table))
281
+ rebuilt << entity.table unless rebuilt.include?(entity.table)
282
+ end
283
+ end
284
+
285
+ def drop_managed_table(table)
286
+ @database.execute("DROP TABLE #{quote(table)}") if table?(table)
287
+ end
288
+
289
+ def migrate_entity(entity, created, added, rebuilt)
290
+ unless table?(entity.table)
291
+ @database.execute(create_entity_sql(entity))
292
+ create_entity_indexes(entity)
293
+ created << entity.table
294
+ return
295
+ end
296
+
297
+ actual = table_columns(entity.table)
298
+ expected = entity_columns(entity)
299
+ missing = expected.keys - actual.keys
300
+ incompatible = (expected.keys & actual.keys).any? do |name|
301
+ normalize_type(actual.fetch(name).fetch(:type)) != normalize_type(expected.fetch(name)) ||
302
+ required_changed?(name, actual.fetch(name), expected.fetch(name))
303
+ end
304
+ required_missing = missing.any? { required_definition?(expected.fetch(_1)) }
305
+ validate_required_values!(entity, actual, missing) if incompatible || required_missing
306
+ if incompatible || required_missing
307
+ rebuild_entity(entity, actual)
308
+ rebuilt << entity.table
309
+ else
310
+ missing.each do |name|
311
+ @database.execute(
312
+ "ALTER TABLE #{quote(entity.table)} ADD COLUMN #{quote(name)} #{expected.fetch(name)}"
313
+ )
314
+ added << "#{entity.table}.#{name}"
315
+ end
316
+ end
317
+ create_entity_indexes(entity)
318
+ end
319
+
320
+ def migrate_association(association, created, rebuilt)
321
+ unless table?(association.table)
322
+ @database.execute(create_association_sql(association))
323
+ created << association.table
324
+ return
325
+ end
326
+ actual = table_columns(association.table)
327
+ required = %w[source_id target_id]
328
+ return if (required - actual.keys).empty?
329
+
330
+ temporary = "#{association.table}_new"
331
+ @database.execute("DROP TABLE IF EXISTS #{quote(temporary)}")
332
+ @database.execute(create_association_sql(association, table: temporary))
333
+ source = %w[source_id source owner_id].find { actual.key?(_1) }
334
+ target = %w[target_id target child_id].find { actual.key?(_1) }
335
+ if source && target
336
+ @database.execute(
337
+ "INSERT OR IGNORE INTO #{quote(temporary)} (source_id, target_id) " \
338
+ "SELECT #{quote(source)}, #{quote(target)} FROM #{quote(association.table)} " \
339
+ "WHERE #{quote(source)} IS NOT NULL AND #{quote(target)} IS NOT NULL"
340
+ )
341
+ end
342
+ @database.execute("DROP TABLE #{quote(association.table)}")
343
+ @database.execute("ALTER TABLE #{quote(temporary)} RENAME TO #{quote(association.table)}")
344
+ rebuilt << association.table
345
+ end
346
+
347
+ def rebuild_entity(entity, actual)
348
+ temporary = "#{entity.table}_new"
349
+ @database.execute("DROP TABLE IF EXISTS #{quote(temporary)}")
350
+ @database.execute(create_entity_sql(entity, table: temporary))
351
+ copies = copy_expressions(entity, actual)
352
+ unless copies.empty?
353
+ fields = copies.map { quote(_1.first) }.join(', ')
354
+ expressions = copies.map(&:last).join(', ')
355
+ @database.execute(
356
+ "INSERT INTO #{quote(temporary)} (#{fields}) " \
357
+ "SELECT #{expressions} FROM #{quote(entity.table)}"
358
+ )
359
+ end
360
+ @database.execute("DROP TABLE #{quote(entity.table)}")
361
+ @database.execute("ALTER TABLE #{quote(temporary)} RENAME TO #{quote(entity.table)}")
362
+ create_entity_indexes(entity)
363
+ end
364
+
365
+ def create_entity_sql(entity, table: entity.table)
366
+ definitions = entity_columns(entity).map do |name, definition|
367
+ suffix = name == 'id' ? ' PRIMARY KEY' : ''
368
+ "#{quote(name)} #{definition}#{suffix}"
369
+ end
370
+ "CREATE TABLE #{quote(table)} (#{definitions.join(', ')})"
371
+ end
372
+
373
+ def create_entity_indexes(entity)
374
+ entity.columns.select(&:unique).each do |column|
375
+ index = self.class.physical_name('unique', "#{entity.storage_key}:#{column.storage_key}")
376
+ @database.execute(
377
+ "CREATE UNIQUE INDEX IF NOT EXISTS #{quote(index)} " \
378
+ "ON #{quote(entity.table)} (#{quote(column.sql_name)})"
379
+ )
380
+ end
381
+ end
382
+
383
+ def create_association_sql(association, table: association.table)
384
+ uniqueness = association.type == :Reference ? ', UNIQUE (source_id)' : ''
385
+ <<~SQL.strip
386
+ CREATE TABLE #{quote(table)} (
387
+ source_id TEXT NOT NULL, target_id TEXT NOT NULL,
388
+ PRIMARY KEY (source_id, target_id)#{uniqueness}
389
+ )
390
+ SQL
391
+ end
392
+
393
+ def entity_columns(entity)
394
+ result = { 'id' => 'TEXT' }
395
+ entity.columns.each { result[_1.sql_name] = column_definition(_1) }
396
+ entity.system_members.each_value { |(name, type)| result[name] = type }
397
+ result
398
+ end
399
+
400
+ def column_definition(column)
401
+ definition = column.sql_type.dup
402
+ definition << ' NOT NULL' if column.required
403
+ definition << " DEFAULT #{sql_literal(column.default, column.type)}" unless column.default.nil?
404
+ definition
405
+ end
406
+
407
+ def required_definition?(definition) = definition.match?(/\bNOT NULL\b/i)
408
+
409
+ def required_changed?(name, actual, expected)
410
+ return false if name == 'id'
411
+
412
+ actual.fetch(:not_null) != required_definition?(expected)
413
+ end
414
+
415
+ def validate_required_values!(entity, actual, missing)
416
+ return unless table_row_count(entity.table).positive?
417
+
418
+ entity.columns.select(&:required).each do |column|
419
+ if missing.include?(column.sql_name)
420
+ reject_missing_required!(entity, column) if column.default.nil?
421
+ elsif actual.key?(column.sql_name) && column.default.nil? && null_values?(entity.table, column.sql_name)
422
+ raise UnsafeSchemaMigrationError.new(
423
+ "cannot make #{entity.name}.#{column.name} required while NULL values exist",
424
+ changes: [{ kind: :required, entity: entity.name, attribute: column.name }]
425
+ )
426
+ end
427
+ end
428
+ end
429
+
430
+ def reject_missing_required!(entity, column)
431
+ raise UnsafeSchemaMigrationError.new(
432
+ "cannot add required attribute #{entity.name}.#{column.name} to a populated table without a default",
433
+ changes: [{ kind: :required, entity: entity.name, attribute: column.name }]
434
+ )
435
+ end
436
+
437
+ def null_values?(table, column)
438
+ @database.get_first_value(
439
+ "SELECT 1 FROM #{quote(table)} WHERE #{quote(column)} IS NULL LIMIT 1"
440
+ ) == 1
441
+ end
442
+
443
+ def table_row_count(table)
444
+ @database.get_first_value("SELECT COUNT(*) FROM #{quote(table)}").to_i
445
+ end
446
+
447
+ def copy_expressions(entity, actual)
448
+ columns = entity.columns.to_h { [_1.sql_name, _1] }
449
+ entity_columns(entity).filter_map do |name, _definition|
450
+ if actual.key?(name)
451
+ column = columns[name]
452
+ expression = if column&.required && !column.default.nil?
453
+ "COALESCE(#{quote(name)}, #{sql_literal(column.default, column.type)})"
454
+ else
455
+ quote(name)
456
+ end
457
+ [name, expression]
458
+ elsif columns[name]&.required && !columns[name].default.nil?
459
+ [name, sql_literal(columns[name].default, columns[name].type)]
460
+ end
461
+ end
462
+ end
463
+
464
+ def sql_literal(value, type)
465
+ return value ? '1' : '0' if type == :boolean
466
+ return value.to_i.to_s if %i[integer long autonumber].include?(type)
467
+ return value.to_f.to_s if %i[float decimal].include?(type)
468
+
469
+ "'#{value.to_s.gsub("'", "''")}'"
470
+ end
471
+
472
+ def table?(name)
473
+ !@database.get_first_value(
474
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", name
475
+ ).nil?
476
+ end
477
+
478
+ def table_columns(name)
479
+ @database.execute("PRAGMA table_info(#{quote(name)})").to_h do |row|
480
+ if row.is_a?(Hash)
481
+ [row['name'], { type: row['type'], not_null: row['notnull'].to_i == 1 }]
482
+ else
483
+ [row[1], { type: row[2], not_null: row[3].to_i == 1 }]
484
+ end
485
+ end
486
+ end
487
+
488
+ def normalize_type(type)
489
+ type.to_s.upcase.split.first
490
+ end
491
+
492
+ def synchronize_metadata
493
+ schema.entities.each do |entity|
494
+ @database.execute(
495
+ 'INSERT INTO mxrb_schema_entities VALUES (?, ?, ?) ' \
496
+ 'ON CONFLICT(storage_key) DO UPDATE SET logical_name=excluded.logical_name, table_name=excluded.table_name',
497
+ [entity.storage_key, entity.name, entity.table]
498
+ )
499
+ entity.columns.each do |column|
500
+ @database.execute(
501
+ 'INSERT INTO mxrb_schema_attributes ' \
502
+ '(entity_key, storage_key, logical_name, column_name, logical_type, required, unique_value) ' \
503
+ 'VALUES (?, ?, ?, ?, ?, ?, ?) ' \
504
+ 'ON CONFLICT(entity_key, storage_key) DO UPDATE SET ' \
505
+ 'logical_name=excluded.logical_name, column_name=excluded.column_name, ' \
506
+ 'logical_type=excluded.logical_type, required=excluded.required, ' \
507
+ 'unique_value=excluded.unique_value',
508
+ [entity.storage_key, column.storage_key, column.name, column.sql_name,
509
+ column.type.to_s, column.required ? 1 : 0, column.unique ? 1 : 0]
510
+ )
511
+ synchronize_sequence(entity, column) if column.type == :autonumber
512
+ end
513
+ end
514
+ schema.associations.each do |association|
515
+ @database.execute(
516
+ 'INSERT INTO mxrb_schema_associations VALUES (?, ?, ?, ?, ?, ?) ' \
517
+ 'ON CONFLICT(storage_key) DO UPDATE SET logical_name=excluded.logical_name, ' \
518
+ 'table_name=excluded.table_name, from_entity=excluded.from_entity, ' \
519
+ 'to_entity=excluded.to_entity, association_type=excluded.association_type',
520
+ [association.storage_key, association.qualified_name, association.table,
521
+ association.from_entity, association.to_entity, association.type.to_s]
522
+ )
523
+ end
524
+ end
525
+
526
+ def prune_obsolete_metadata
527
+ entity_keys = schema.entities.map(&:storage_key)
528
+ association_keys = schema.associations.map(&:storage_key)
529
+ attribute_keys = schema.entities.flat_map do |entity|
530
+ entity.columns.map { [entity.storage_key, _1.storage_key] }
531
+ end
532
+ delete_missing_keys('mxrb_schema_associations', 'storage_key', association_keys)
533
+ delete_missing_attribute_keys(attribute_keys)
534
+ delete_missing_keys('mxrb_schema_entities', 'storage_key', entity_keys)
535
+ sequence_keys = schema.entities.flat_map do |entity|
536
+ entity.columns.select { _1.type == :autonumber }.map { [entity.storage_key, _1.storage_key] }
537
+ end
538
+ delete_missing_sequence_keys(sequence_keys)
539
+ end
540
+
541
+ def delete_missing_keys(table, column, keys)
542
+ if keys.empty?
543
+ @database.execute("DELETE FROM #{quote(table)}")
544
+ return
545
+ end
546
+
547
+ placeholders = Array.new(keys.size, '?').join(', ')
548
+ @database.execute(
549
+ "DELETE FROM #{quote(table)} WHERE #{quote(column)} NOT IN (#{placeholders})", keys
550
+ )
551
+ end
552
+
553
+ def delete_missing_attribute_keys(keys)
554
+ current = keys.to_h { [_1, true] }
555
+ metadata_rows(
556
+ 'SELECT entity_key, storage_key FROM mxrb_schema_attributes', %w[entity_key storage_key]
557
+ ).each do |entity_key, storage_key|
558
+ next if current.key?([entity_key, storage_key])
559
+
560
+ @database.execute(
561
+ 'DELETE FROM mxrb_schema_attributes WHERE entity_key = ? AND storage_key = ?',
562
+ [entity_key, storage_key]
563
+ )
564
+ end
565
+ end
566
+
567
+ def delete_missing_sequence_keys(attribute_keys)
568
+ current = attribute_keys.to_h { [_1, true] }
569
+ metadata_rows(
570
+ 'SELECT entity_key, attribute_key FROM mxrb_schema_sequences', %w[entity_key attribute_key]
571
+ ).each do |entity_key, attribute_key|
572
+ next if current.key?([entity_key, attribute_key])
573
+
574
+ @database.execute(
575
+ 'DELETE FROM mxrb_schema_sequences WHERE entity_key = ? AND attribute_key = ?',
576
+ [entity_key, attribute_key]
577
+ )
578
+ end
579
+ end
580
+
581
+ def synchronize_sequence(entity, column)
582
+ maximum = @database.get_first_value(
583
+ "SELECT COALESCE(MAX(#{quote(column.sql_name)}), 0) FROM #{quote(entity.table)}"
584
+ ).to_i
585
+ @database.execute(
586
+ 'INSERT OR IGNORE INTO mxrb_schema_sequences VALUES (?, ?, ?)',
587
+ [entity.storage_key, column.storage_key, maximum + 1]
588
+ )
589
+ end
590
+
591
+ def quote(identifier)
592
+ %("#{identifier.to_s.gsub('"', '""')}")
593
+ end
594
+ end
595
+ # rubocop:enable Metrics/AbcSize, Metrics/ClassLength, Metrics/CyclomaticComplexity
596
+ # rubocop:enable Metrics/MethodLength, Metrics/PerceivedComplexity
597
+ end
598
+ end