activefacts-compositions 1.9.6 → 1.9.8

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.
@@ -0,0 +1,273 @@
1
+ #
2
+ # ActiveFacts Rails Schema Generator
3
+ #
4
+ # Copyright (c) 2009-2016 Clifford Heath. Read the LICENSE file.
5
+ #
6
+ require 'digest/sha1'
7
+ require 'activefacts/metamodel'
8
+ require 'activefacts/metamodel/datatypes'
9
+ require 'activefacts/registry'
10
+ require 'activefacts/compositions'
11
+ require 'activefacts/generator'
12
+ require 'activefacts/compositions/traits/rails'
13
+
14
+ module ActiveFacts
15
+ module Generators
16
+ module Rails
17
+ class Schema
18
+ MM = ActiveFacts::Metamodel unless const_defined?(:MM)
19
+ HEADER = "# Auto-generated from CQL, edits will be lost"
20
+ def self.options
21
+ ({
22
+ exclude_fks: ['Boolean', "Don't generate foreign key definitions"],
23
+ include_comments: ['Boolean', "Generate a comment for each column showing the absorption path"],
24
+ closed_world: ['Boolean', "Set this if your DBMS only allows one null in a unique index (MS SQL)"],
25
+ })
26
+ end
27
+
28
+ def initialize composition, options = {}
29
+ @composition = composition
30
+ @options = options
31
+ @option_exclude_fks = options.delete("exclude_fks")
32
+ @option_include_comments = options.delete("include_comments")
33
+ @option_closed_world = options.delete("closed_world")
34
+ end
35
+
36
+ def warn *a
37
+ $stderr.puts *a
38
+ end
39
+
40
+ def data_type_context
41
+ @data_type_context ||= RailsDataTypeContext.new
42
+ end
43
+
44
+ def generate
45
+ @foreign_keys = []
46
+ # If we get index names that need to be truncated, add a counter to ensure uniqueness
47
+ @dup_id = 0
48
+
49
+ tables =
50
+ @composition.
51
+ all_composite.
52
+ sort_by{|composite| composite.mapping.name}.
53
+ map{|composite| generate_composite composite}.
54
+ compact
55
+
56
+ header =
57
+ [
58
+ '#',
59
+ "# schema.rb auto-generated for #{@composition.name}",
60
+ '#',
61
+ '',
62
+ "ActiveRecord::Base.logger = Logger.new(STDOUT)",
63
+ "ActiveRecord::Schema.define(version: #{Time.now.strftime('%Y%m%d%H%M%S')}) do",
64
+ " enable_extension 'pgcrypto' unless extension_enabled?('pgcrypto')",
65
+ '',
66
+ ]
67
+ foreign_keys =
68
+ if @option_exclude_fks
69
+ [
70
+ 'end'
71
+ ]
72
+ else
73
+ [
74
+ ' unless ENV["EXCLUDE_FKS"]',
75
+ *@foreign_keys.sort,
76
+ ' end',
77
+ 'end'
78
+ ]
79
+ end
80
+
81
+ (
82
+ header +
83
+ tables +
84
+ foreign_keys
85
+ )*"\n"+"\n"
86
+ end
87
+
88
+ def generate_composite composite
89
+ ar_table_name = composite.rails.plural_name
90
+
91
+ pk = composite.primary_index.all_index_field.to_a
92
+ if pk[0].component.is_auto_assigned
93
+ identity_column = pk[0].component
94
+ warn "Warning: redundant column(s) after #{identity_column.name} in primary key of #{ar_table_name}" if pk.size > 1
95
+ end
96
+
97
+ # Detect if this table is a join table.
98
+ # Join tables have multi-part primary keys that are made up only of foreign keys
99
+ is_join_table = pk.length > 1 and
100
+ !pk.detect do |pk_field|
101
+ pk_field.component.all_foreign_key_field.size == 0
102
+ end
103
+ warn "Warning: #{table.name} has a multi-part primary key" if pk.length > 1 and !is_join_table
104
+
105
+ create_table = %Q{ create_table "#{ar_table_name}", id: false, force: true do |t|}
106
+ columns = generate_columns composite
107
+
108
+ unless @option_exclude_fks
109
+ composite.all_foreign_key_as_source_composite.each do |fk|
110
+ from_column_names = fk.all_foreign_key_field.map{|fxf| fxf.component.column_name.snakecase}
111
+ to_column_names = fk.all_index_field.map{|ixf| ixf.component.column_name.snakecase}
112
+
113
+ @foreign_keys.concat(
114
+ if (from_column_names.length == 1)
115
+ index_name = ACTR::name_trunc("index_#{ar_table_name}_on_#{from_column_names[0]}")
116
+ [
117
+ " add_foreign_key :#{ar_table_name}, :#{fk.composite.mapping.rails.plural_name}, column: :#{from_column_names[0]}, primary_key: :#{to_column_names[0]}, on_delete: :cascade",
118
+ # Index it non-uniquely only if it's not unique already:
119
+ fk.absorption && fk.absorption.child_role.is_unique ? nil :
120
+ " add_index :#{ar_table_name}, [:#{from_column_names[0]}], unique: false, name: :#{index_name}"
121
+ ].compact
122
+ else
123
+ [ ]
124
+ end
125
+ )
126
+ end
127
+ end
128
+
129
+ index_texts = []
130
+ composite.all_index.each do |index|
131
+ next if index.composite_as_primary_index && index.all_index_field.size == 1 # We've handled this already
132
+
133
+ index_column_names = index.all_index_field.map{|ixf| ixf.component.column_name.snakecase}
134
+ index_name = ACTR::name_trunc("index_#{ar_table_name}_on_#{index_column_names*'_'}")
135
+
136
+ index_texts << '' if index_texts.empty?
137
+
138
+ all_mandatory = index.all_index_field.to_a.all?{|ixf| ixf.component.path_mandatory}
139
+ index_texts << %Q{ add_index "#{ar_table_name}", #{index_column_names.inspect}, name: :#{index_name}#{
140
+ # Avoid problems with closed-world uniqueness: only all_mandatory indices can be unique on closed-world index semantics (MS SQL)
141
+ index.is_unique && (!@option_closed_world || all_mandatory) ? ", unique: true" : ''
142
+ }}
143
+ end
144
+
145
+ [
146
+ create_table,
147
+ *columns,
148
+ " end",
149
+ *index_texts.sort
150
+ ]*"\n"+"\n"
151
+ end
152
+
153
+ def generate_columns composite
154
+ composite.mapping.all_leaf.flat_map do |component|
155
+ # Absorbed empty subtypes appear as leaves
156
+ next [] if component.is_a?(MM::Absorption) && component.parent_role.fact_type.is_a?(MM::TypeInheritance)
157
+ generate_column component
158
+ end
159
+ end
160
+
161
+ def generate_column component
162
+ type_name, options = component.data_type(data_type_context)
163
+ options ||= {}
164
+ length = options[:length]
165
+ value_constraint = options[:value_constraint]
166
+ type, type_name = *normalise_type(type_name)
167
+
168
+ if a = options[:auto_assign]
169
+ case type_name
170
+ when 'integer'
171
+ type_name = 'primary_key' if a != 'assert'
172
+ when 'uuid'
173
+ type_name = "uuid, default: 'gen_random_uuid()', primary_key: true"
174
+ end
175
+ end
176
+
177
+ valid_parameters = MM::DataType::TypeParameters[type]
178
+ length_ok = valid_parameters &&
179
+ ![MM::DataType::TYPE_Real, MM::DataType::TYPE_Integer].include?(type) &&
180
+ (valid_parameters.include?(:length) || valid_parameters.include?(:precision))
181
+ scale_ok = length_ok && valid_parameters.include?(:scale)
182
+ length_option = length_ok && options[:length] ? ", limit: #{options[:length]}" : ''
183
+ scale_option = scale_ok && options[:scale] ? ", scale: #{options[:scale]}" : ''
184
+ null_option = ", null: #{!options[:mandatory]}"
185
+
186
+ (@option_include_comments ? [" \# #{component.comment}"] : []) +
187
+ [%Q{ t.column "#{component.column_name.snakecase}", :#{type_name}#{length_option}#{scale_option}#{null_option}}]
188
+ end
189
+
190
+ class RailsDataTypeContext < MM::DataType::Context
191
+ def integer_ranges
192
+ [
193
+ ['integer', -2**63, 2**63-1]
194
+ ]
195
+ end
196
+
197
+ def default_length data_type, type_name
198
+ case data_type
199
+ when MM::DataType::TYPE_Real
200
+ 53 # IEEE Double precision floating point
201
+ when MM::DataType::TYPE_Integer
202
+ 63
203
+ else
204
+ nil
205
+ end
206
+ end
207
+
208
+ def default_surrogate_length
209
+ 64
210
+ end
211
+
212
+ def boolean_type
213
+ 'boolean'
214
+ end
215
+
216
+ def surrogate_type
217
+ type_name, = choose_integer_type(0, 2**(default_surrogate_length-1)-1)
218
+ type_name
219
+ end
220
+
221
+ def valid_from_type
222
+ date_time_type
223
+ end
224
+
225
+ def date_time_type
226
+ 'datetime'
227
+ end
228
+
229
+ def default_char_type
230
+ 'string'
231
+ end
232
+
233
+ def default_varchar_type
234
+ 'string'
235
+ end
236
+
237
+ def default_text_type
238
+ default_varchar_type
239
+ end
240
+ end
241
+
242
+ # Return SQL type and (modified?) length for the passed base type
243
+ def normalise_type type_name
244
+ type = MM::DataType.normalise(type_name)
245
+
246
+ [
247
+ type,
248
+ case type
249
+ when MM::DataType::TYPE_Boolean; 'boolean'
250
+ when MM::DataType::TYPE_Integer; 'integer'
251
+ when MM::DataType::TYPE_Real; 'float'
252
+ when MM::DataType::TYPE_Decimal; 'decimal'
253
+ when MM::DataType::TYPE_Money; 'datatime'
254
+ when MM::DataType::TYPE_Char; 'string'
255
+ when MM::DataType::TYPE_String; 'string'
256
+ when MM::DataType::TYPE_Text; 'text'
257
+ when MM::DataType::TYPE_Date; 'datetime'
258
+ when MM::DataType::TYPE_Time; 'time'
259
+ when MM::DataType::TYPE_DateTime; 'datetime'
260
+ when MM::DataType::TYPE_Timestamp;'datetime'
261
+ when MM::DataType::TYPE_Binary; 'binary'
262
+ else
263
+ type_name
264
+ end
265
+ ]
266
+ end
267
+
268
+ end
269
+ end
270
+ publish_generator Rails::Schema
271
+ end
272
+ end
273
+
@@ -12,110 +12,118 @@ module ActiveFacts
12
12
  module Generators
13
13
  # Options are comma or space separated:
14
14
  class Ruby < ObjectOriented
15
+ def self.options
16
+ super.merge(
17
+ {
18
+ scope: [String, "Generate a Ruby module that's nested inside the module you name here"]
19
+ }
20
+ )
21
+ end
22
+
15
23
  def initialize composition, options = {}
16
- super
17
- @scope = options.delete('scope') || ''
18
- @scope = @scope.split(/::/)
19
- @scope_prefix = ' '*@scope.size
24
+ super
25
+ @scope = options.delete('scope') || ''
26
+ @scope = @scope.split(/::/)
27
+ @scope_prefix = ' '*@scope.size
20
28
  end
21
29
 
22
30
  def prelude composition
23
- "require 'activefacts/api'\n\n" +
24
- (0...@scope.size).map{|i| ' '*i + "module #{@scope[i]}\n"}*'' +
25
- "#{@scope_prefix}module #{composition.name}\n"
31
+ "require 'activefacts/api'\n\n" +
32
+ (0...@scope.size).map{|i| ' '*i + "module #{@scope[i]}\n"}*'' +
33
+ "#{@scope_prefix}module #{composition.name.words.capcase}\n"
26
34
  end
27
35
 
28
36
  def finale
29
- @scope.size.downto(0).map{|i| ' '*i+"end\n"}*''
37
+ @scope.size.downto(0).map{|i| ' '*i+"end\n"}*''
30
38
  end
31
39
 
32
40
  def generate_classes composites
33
- super(composites).
34
- gsub(/^/, ' '*@scope.size)
41
+ super(composites).
42
+ gsub(/^/, ' '*@scope.size)
35
43
  end
36
44
 
37
45
  def identified_by_roles identifying_roles
38
- " identified_by #{ identifying_roles.map{|m| ':'+ruby_role_name(m) }*', ' }\n"
46
+ " identified_by #{ identifying_roles.map{|m| ':'+ruby_role_name(m) }*', ' }\n"
39
47
  end
40
48
 
41
49
  def value_type_declaration object_type
42
- " value_type#{object_type.length ? " length: #{object_type.length}" : ''}\n" # REVISIT: Add other parameters and value restrictions
50
+ " value_type#{object_type.length ? " length: #{object_type.length}" : ''}\n" # REVISIT: Add other parameters and value restrictions
43
51
  end
44
52
 
45
53
  def class_prelude(object_type, supertype)
46
- global_qualifier = object_type == supertype ? '::' :''
47
- " class #{object_type.name.words.capcase}" + (supertype ? " < #{global_qualifier}#{supertype.name.words.capcase}" : '') + "\n"
54
+ global_qualifier = object_type == supertype ? '::' :''
55
+ " class #{object_type.name.words.capcase}" + (supertype ? " < #{global_qualifier}#{supertype.name.words.capcase}" : '') + "\n"
48
56
  end
49
57
 
50
58
  def class_finale(object_type)
51
- " end\n"
59
+ " end\n"
52
60
  end
53
61
 
54
62
  def role_definition component
55
- role_name = ruby_role_name component
56
-
57
- # Is the role mandatory?
58
- mandatory = component.is_mandatory ? ', mandatory: true' : ''
59
-
60
- # Does the role name imply the matching class name?
61
- if component.is_a?(MM::Absorption) and
62
- counterpart = component.object_type and
63
- counterpart_composite = composite_for(counterpart)
64
- counterpart_class_emitted = @composites_emitted[counterpart_composite]
65
-
66
- counterpart_class_name = ruby_class_name counterpart_composite
67
- counterpart_default_role = ruby_role_name counterpart_composite.mapping
68
- rolename_implies_class = role_name.words.capcase == counterpart_class_name
69
- class_ref = counterpart_class_emitted ? counterpart_class_name : counterpart_class_name.inspect
70
- class_spec = rolename_implies_class ? '' : ", class: #{class_ref}"
71
-
72
- # Does the reverse role need explicit specification?
73
- implied_reverse_role_name = ruby_role_name(component.root.mapping)
74
- actual_reverse_role_name = ruby_role_name component.reverse_absorption
75
-
76
- if implied_reverse_role_name != actual_reverse_role_name
77
- counterpart_spec = ", counterpart: :#{actual_reverse_role_name}"
78
- elsif !rolename_implies_class
79
- # _as_XYZ is added where the forward role does not imply the class, and :counterpart role name is not specified
80
- actual_reverse_role_name += "_as_#{role_name}"
81
- end
82
- all = component.child_role.is_unique ? '' : 'all_'
83
- see = ", see #{counterpart_class_name}\##{all+actual_reverse_role_name}"
84
- end
85
-
86
- counterpart_comment = "# #{comment component}#{see}"
87
-
88
- definition =
89
- " #{role_specifier component}"
90
- definition += ' '*(20-definition.length) if definition.length < 20
91
- definition += ":#{ruby_role_name component}#{mandatory}#{class_spec}#{counterpart_spec} "
92
- definition += ' '*(56-definition.length) if definition.length < 56
93
- definition += "#{counterpart_comment}"
94
- definition += "\n"
63
+ role_name = ruby_role_name component
64
+
65
+ # Is the role mandatory?
66
+ mandatory = component.is_mandatory ? ', mandatory: true' : ''
67
+
68
+ # Does the role name imply the matching class name?
69
+ if component.is_a?(MM::Absorption) and
70
+ counterpart = component.object_type and
71
+ counterpart_composite = composite_for(counterpart)
72
+ counterpart_class_emitted = @composites_emitted[counterpart_composite]
73
+
74
+ counterpart_class_name = ruby_class_name counterpart_composite
75
+ counterpart_default_role = ruby_role_name counterpart_composite.mapping
76
+ rolename_implies_class = role_name.words.capcase == counterpart_class_name
77
+ class_ref = counterpart_class_emitted ? counterpart_class_name : counterpart_class_name.inspect
78
+ class_spec = rolename_implies_class ? '' : ", class: #{class_ref}"
79
+
80
+ # Does the reverse role need explicit specification?
81
+ implied_reverse_role_name = ruby_role_name(component.root.mapping)
82
+ actual_reverse_role_name = ruby_role_name component.reverse_absorption
83
+
84
+ if implied_reverse_role_name != actual_reverse_role_name
85
+ counterpart_spec = ", counterpart: :#{actual_reverse_role_name}"
86
+ elsif !rolename_implies_class
87
+ # _as_XYZ is added where the forward role does not imply the class, and :counterpart role name is not specified
88
+ actual_reverse_role_name += "_as_#{role_name}"
89
+ end
90
+ all = component.child_role.is_unique ? '' : 'all_'
91
+ see = ", see #{counterpart_class_name}\##{all+actual_reverse_role_name}"
92
+ end
93
+
94
+ counterpart_comment = "# #{comment component}#{see}"
95
+
96
+ definition =
97
+ " #{role_specifier component}"
98
+ definition += ' '*(20-definition.length) if definition.length < 20
99
+ definition += ":#{ruby_role_name component}#{mandatory}#{class_spec}#{counterpart_spec} "
100
+ definition += ' '*(56-definition.length) if definition.length < 56
101
+ definition += "#{counterpart_comment}"
102
+ definition += "\n"
95
103
  end
96
104
 
97
105
  def role_specifier component
98
- if component.is_a?(MM::Indicator)
99
- 'maybe'
100
- else
101
- if component.is_a?(MM::Absorption) and component.child_role.is_unique
102
- 'one_to_one'
103
- else
104
- 'has_one'
105
- end
106
- end
106
+ if component.is_a?(MM::Indicator)
107
+ 'maybe'
108
+ else
109
+ if component.is_a?(MM::Absorption) and component.child_role.is_unique
110
+ 'one_to_one'
111
+ else
112
+ 'has_one'
113
+ end
114
+ end
107
115
  end
108
116
 
109
117
  def ruby_role_name component
110
- component.name.words.snakecase
118
+ component.name.words.snakecase
111
119
  end
112
120
 
113
121
  def ruby_class_name composite
114
- composite.mapping.name.words.capcase
122
+ composite.mapping.name.words.capcase
115
123
  end
116
124
 
117
125
  def comment component
118
- super
126
+ super
119
127
  end
120
128
  end
121
129
  publish_generator Ruby