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.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/Rakefile +33 -0
- data/activefacts-compositions.gemspec +3 -3
- data/bin/schema_compositor +142 -85
- data/lib/activefacts/compositions/binary.rb +19 -15
- data/lib/activefacts/compositions/compositor.rb +126 -125
- data/lib/activefacts/compositions/constraints.rb +74 -54
- data/lib/activefacts/compositions/datavault.rb +545 -0
- data/lib/activefacts/compositions/names.rb +58 -58
- data/lib/activefacts/compositions/relational.rb +801 -692
- data/lib/activefacts/compositions/traits/rails.rb +180 -0
- data/lib/activefacts/compositions/version.rb +1 -1
- data/lib/activefacts/generator/doc/css/ldm.css +45 -0
- data/lib/activefacts/generator/doc/cwm.rb +764 -0
- data/lib/activefacts/generator/doc/glossary.rb +473 -0
- data/lib/activefacts/generator/doc/graphviz.rb +134 -0
- data/lib/activefacts/generator/doc/ldm.rb +698 -0
- data/lib/activefacts/generator/oo.rb +130 -124
- data/lib/activefacts/generator/rails/models.rb +237 -0
- data/lib/activefacts/generator/rails/schema.rb +273 -0
- data/lib/activefacts/generator/ruby.rb +75 -67
- data/lib/activefacts/generator/sql.rb +333 -351
- data/lib/activefacts/generator/sql/server.rb +100 -39
- data/lib/activefacts/generator/summary.rb +67 -59
- data/lib/activefacts/generator/validate.rb +19 -134
- metadata +18 -15
@@ -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
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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
|
-
|
24
|
-
|
25
|
-
|
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
|
-
|
37
|
+
@scope.size.downto(0).map{|i| ' '*i+"end\n"}*''
|
30
38
|
end
|
31
39
|
|
32
40
|
def generate_classes composites
|
33
|
-
|
34
|
-
|
41
|
+
super(composites).
|
42
|
+
gsub(/^/, ' '*@scope.size)
|
35
43
|
end
|
36
44
|
|
37
45
|
def identified_by_roles identifying_roles
|
38
|
-
|
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
|
-
|
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
|
-
|
47
|
-
|
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
|
-
|
59
|
+
" end\n"
|
52
60
|
end
|
53
61
|
|
54
62
|
def role_definition component
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
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
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
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
|
-
|
118
|
+
component.name.words.snakecase
|
111
119
|
end
|
112
120
|
|
113
121
|
def ruby_class_name composite
|
114
|
-
|
122
|
+
composite.mapping.name.words.capcase
|
115
123
|
end
|
116
124
|
|
117
125
|
def comment component
|
118
|
-
|
126
|
+
super
|
119
127
|
end
|
120
128
|
end
|
121
129
|
publish_generator Ruby
|