activefacts-compositions 1.9.6 → 1.9.8

Sign up to get free protection for your applications and to get access to all the features.
@@ -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