daifuku 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __dir__)
5
+ require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
6
+ require 'daifuku'
7
+ require 'erb'
8
+ require 'active_support/inflector'
9
+ require 'fileutils'
10
+
11
+ ROOT_PATH = File.absolute_path(File.join(__dir__, '../'))
12
+ LOG_DEFINITIONS = File.join(ROOT_PATH, 'LogDefinitions')
13
+ TEMPLATE_DIR = File.join(ROOT_PATH, 'iOS/Templates')
14
+ GENERATED_CODE_DIR = File.join(ROOT_PATH, 'AutoGenerated')
15
+ SWIFT_TYPE_MAPS = {
16
+ 'smallint' => 'Int16',
17
+ 'integer' => 'Int',
18
+ 'bigint' => 'Int64',
19
+ 'real' => 'Float',
20
+ 'double' => 'Double',
21
+ 'boolean' => 'Bool',
22
+ 'string' => 'String',
23
+ 'date' => 'JSTDateColumn',
24
+ 'timestampz' => 'ZonedTimestampColumn'
25
+ }
26
+ UNSUPPORTED_TYPES = %w(timestamp)
27
+
28
+ # Represents a Category consisting of multiple events
29
+ # ex) Category: RecipeDetail
30
+ class CategoryRepresentation
31
+ def initialize(category)
32
+ @category = category
33
+ end
34
+
35
+ def name
36
+ @category.name
37
+ end
38
+
39
+ def class_name
40
+ @category.name.camelize
41
+ end
42
+
43
+ def descriptions
44
+ @category.descriptions
45
+ end
46
+
47
+ def variable_name
48
+ @category.name.camelize(:lower)
49
+ end
50
+
51
+ def events
52
+ @events ||= @category.events.map { |_, event| EventRepresentation.new(event) }
53
+ end
54
+
55
+ def available_events
56
+ events.reject(&:obsolete?)
57
+ end
58
+ end
59
+
60
+ # Represents a Event consisting of multiple columns
61
+ # ex) Event: tap_sample_view
62
+ class EventRepresentation
63
+ def initialize(event)
64
+ @event = event
65
+ end
66
+
67
+ def name
68
+ @event.name
69
+ end
70
+
71
+ def variable_name
72
+ @event.name.camelize(:lower)
73
+ end
74
+
75
+ def columns
76
+ @columns ||= @event.columns.reject(&:obsolete?).map { |column| ColumnRepresentation.new(column) }
77
+ end
78
+
79
+ def descriptions
80
+ @event.descriptions
81
+ end
82
+
83
+ def associated_types
84
+ columns.map(&:as_argument)&.join(', ')
85
+ end
86
+
87
+ def pattern_matches
88
+ columns.map { |column| column.variable_name.to_string }&.join(', ')
89
+ end
90
+
91
+ def availability_annotation
92
+ obsolete? ? '@available(*, unavailable) ' : ''
93
+ end
94
+
95
+ def obsolete?
96
+ @event.obsolete?
97
+ end
98
+ end
99
+
100
+ # Represents a column
101
+ class ColumnRepresentation
102
+ def initialize(column)
103
+ @column = column
104
+ end
105
+
106
+ def variable_name
107
+ @column.name.camelize(:lower)
108
+ end
109
+
110
+ def original_name
111
+ @column.name
112
+ end
113
+
114
+ def swift_type
115
+ convert_to_swift_type(@column)
116
+ end
117
+
118
+ def descriptions
119
+ @column.descriptions
120
+ end
121
+
122
+ def as_argument
123
+ "#{variable_name}: #{swift_type}"
124
+ end
125
+
126
+ def call_dump
127
+ caller = if @column.type.optional?
128
+ "#{variable_name}?"
129
+ else
130
+ variable_name.to_s
131
+ end
132
+ type = @column.type
133
+ if type.name == 'string' && !type.str_length.nil?
134
+ "#{caller}.validateLength(within: #{type.str_length}).dump()"
135
+ else
136
+ "#{caller}.dump()"
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ def convert_to_swift_type(column)
143
+ type = column.type
144
+ raise "Code builder currently doesn't support #{type.name}." if UNSUPPORTED_TYPES.include?(type.name)
145
+
146
+ if type.custom?
147
+ swift_type ||= type.name # custom type
148
+ else
149
+ swift_type = SWIFT_TYPE_MAPS[type.name] # primitive types
150
+ end
151
+ if type.optional?
152
+ "#{swift_type}?"
153
+ else
154
+ swift_type
155
+ end
156
+ end
157
+ end
158
+
159
+ # Generating Swift files from Markdown files using Daifuku
160
+ class Generator
161
+ def initialize
162
+ check_category_names
163
+ @common_category = categories[COMMON_CATEGORY_NAME]
164
+ raise 'Could not found common category. Please define common.md' unless @common_category
165
+ end
166
+
167
+ def generate_common_payload!(destination)
168
+ FileUtils.mkdir_p(File.dirname(destination))
169
+ template = IO.read(File.join(TEMPLATE_DIR, 'CommonPayload.swift.erb'))
170
+ columns = @common_category.common_columns.reject(&:obsolete?).map { |column| ColumnRepresentation.new(column) }
171
+ result = render(template, columns: columns)
172
+ IO.write(destination, result)
173
+ end
174
+
175
+ def generate_all_log_categories!(destination)
176
+ FileUtils.mkdir_p(File.dirname(destination))
177
+ template = IO.read(File.join(TEMPLATE_DIR, 'LogCategories.swift.erb'))
178
+ categories_to_generate = categories.map { |_, category| CategoryRepresentation.new(category) }
179
+ result = render(template, categories: categories_to_generate)
180
+ IO.write(destination, result)
181
+ end
182
+
183
+ def check_category_names
184
+ # Daifuku uses the file name as the category name, the category name in the Markdown file is simply ignored.
185
+ # But for consistency, here we make sure they are the same.
186
+ Dir.glob(File.join(LOG_DEFINITIONS, '*.md')) do |path|
187
+ content = File.read(path)
188
+ md_category_name = /^#\s*(.+)/.match(content)
189
+ raise "Could not find category name in #{path}" unless md_category_name
190
+
191
+ category_name = md_category_name[1]
192
+ file_name = File.basename(path, '.md')
193
+ unless file_name == category_name
194
+ raise %(The file name "#{file_name}" does not match the category name "#{category_name}" in #{path})
195
+ end
196
+ end
197
+ end
198
+
199
+ private
200
+
201
+ def render(template, hash)
202
+ ERB.new(template, trim_mode: '-').result_with_hash(hash)
203
+ end
204
+
205
+ def categories
206
+ @categories ||= Daifuku::Compiler.new.compile(LOG_DEFINITIONS)
207
+ end
208
+ end
209
+
210
+ generator = Generator.new
211
+ generator.generate_common_payload!(File.join(GENERATED_CODE_DIR, 'CommonPayload.swift'))
212
+ generator.generate_all_log_categories!(File.join(GENERATED_CODE_DIR, 'LogCategories.swift'))
@@ -0,0 +1,43 @@
1
+ module Daifuku
2
+ DEFAULT_NAME_LENGTH = 64
3
+
4
+ class Compiler
5
+ attr_reader :options
6
+
7
+ # Compile markdowns into Ruby objects.
8
+ # @param directory_path String Directory path containing log definitions.
9
+ # @param options [String: Object] Compiler options
10
+ # @return [String: Category]
11
+ def compile(directory_path, options = {})
12
+ @options = options
13
+ categories = Dir.entries(directory_path).map do |file|
14
+ if file.end_with?('.md')
15
+ category_name = File.basename(file, '.md')
16
+ path = File.join(directory_path, file)
17
+ # This situation implicates missing LANG, use UTF-8 by default.
18
+ enc = (Encoding.default_external == Encoding::US_ASCII) ? Encoding::UTF_8 : Encoding.default_external
19
+ str = File.read(path, encoding: enc)
20
+ parser.parse(str, category_name)
21
+ end
22
+ end.compact.map { |category| [category.name, category] }.to_h
23
+ validator.validate!(categories)
24
+ categories
25
+ end
26
+
27
+ def parser
28
+ @parser ||= Parser.new
29
+ end
30
+
31
+ private
32
+ def validator
33
+ @validator ||= Validator.new([MultipleTypesRule.new,
34
+ ReservedColumnsRule.new,
35
+ ShadowingRule.new,
36
+ NameLengthRule.new(name_length)])
37
+ end
38
+
39
+ def name_length
40
+ options[:name_length] || DEFAULT_NAME_LENGTH
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,171 @@
1
+ module Daifuku
2
+ class Category
3
+ # @return String
4
+ attr_reader :name
5
+ # @return [String: Event]
6
+ attr_reader :events
7
+ # @return String
8
+ attr_reader :descriptions
9
+ # @return [Column]
10
+ attr_reader :common_columns
11
+
12
+ def initialize(name, events, descriptions, common_columns)
13
+ @name = name
14
+ @events = events
15
+ @descriptions = descriptions
16
+ @common_columns = common_columns
17
+ end
18
+
19
+ def dump
20
+ {
21
+ 'name' => name,
22
+ 'events' => events.to_h { |name, event| [name, event.dump] },
23
+ 'descriptions' => descriptions,
24
+ 'common_columns' => common_columns.map(&:dump),
25
+ }
26
+ end
27
+ end
28
+
29
+ class Event
30
+ # @return String
31
+ attr_reader :name
32
+ # @return [Column]
33
+ attr_reader :columns
34
+ # @return String
35
+ attr_reader :descriptions
36
+
37
+ def obsolete?
38
+ @is_obsolete
39
+ end
40
+
41
+ def initialize(name, columns, descriptions, is_obsolete)
42
+ @name = name
43
+ @columns = columns
44
+ @descriptions = descriptions
45
+ @is_obsolete = is_obsolete
46
+ end
47
+
48
+ def dump
49
+ {
50
+ 'name' => name,
51
+ 'columns' => columns.map(&:dump),
52
+ 'descriptions' => descriptions,
53
+ 'is_obsolete' => obsolete?,
54
+ }
55
+ end
56
+ end
57
+
58
+ class Column
59
+ # @return String
60
+ attr_reader :name
61
+ # @return Type
62
+ attr_reader :type
63
+ # @return [String]
64
+ attr_reader :descriptions
65
+
66
+ def obsolete?
67
+ @is_obsolete
68
+ end
69
+
70
+ def initialize(name, type, descriptions, is_obsolete = false)
71
+ @name = name
72
+ @type = type
73
+ @descriptions = descriptions
74
+ @is_obsolete = is_obsolete
75
+ end
76
+
77
+ def self.parse(descriptor, descriptions)
78
+ # column_name: !bigint
79
+ # column_name: !bigint?
80
+ # column_name: !string 256
81
+ # column_name: !string? 256
82
+ # column_name: CustomType
83
+ # [obsolete] column_type: !bigint
84
+ if descriptor =~ /(\[obsolete\])?\s*(\w+):\s*(!?\w+\??(?:\s(\d+))?)/
85
+ obsolete = $1 != nil
86
+ name = $2
87
+ type = Type.parse($3)
88
+ Column.new(name, type, descriptions, obsolete)
89
+ else
90
+ raise "Could not parse column '#{descriptor}'"
91
+ end
92
+ end
93
+
94
+ def strdef
95
+ if type.name == 'string' && type.str_length
96
+ "#{name}: !#{type.name} #{type.str_length}"
97
+ elsif !type.custom?
98
+ "#{name}: !#{type.name}"
99
+ else
100
+ "#{name}: #{type.name}"
101
+ end
102
+ end
103
+
104
+ def dump
105
+ {'name' => name, 'type' => type.dump, 'descriptions' => descriptions}
106
+ end
107
+ end
108
+
109
+ class Type
110
+ AVAILABLE_BUILTIN_TYPES = %w(
111
+ smallint
112
+ integer
113
+ bigint
114
+ real
115
+ double
116
+ boolean
117
+ string
118
+ date
119
+ timestamp
120
+ timestamptz
121
+ )
122
+ # @return String
123
+ attr_reader :name
124
+ # Length for built-in String types
125
+ # This parameter returns nil when type is not String
126
+ # @return Integer?
127
+ attr_reader :str_length
128
+
129
+ def initialize(name, optional, custom, str_length = nil)
130
+ @name = name
131
+ @is_optional = optional
132
+ @is_custom = custom
133
+ @str_length = str_length
134
+ unless custom
135
+ raise "Invalid built-in type #{name}" unless AVAILABLE_BUILTIN_TYPES.include?(name)
136
+ end
137
+ end
138
+
139
+ # @return Boolean
140
+ def optional?
141
+ @is_optional
142
+ end
143
+
144
+ # Boolean value which indicates whether this type is user defined types or not.
145
+ # @return Boolean
146
+ def custom?
147
+ @is_custom
148
+ end
149
+
150
+ def self.parse(descriptor)
151
+ type_name, str_length = descriptor.split
152
+ is_optional = type_name.end_with?('?')
153
+ type_without_optional = type_name.gsub(/\?$/, '')
154
+ custom = !type_name.start_with?('!')
155
+ if custom
156
+ raise "Doesn't support str_length for custom types #{type_name}" unless str_length.nil?
157
+ Type.new(type_without_optional, is_optional, custom, nil)
158
+ else
159
+ built_in_type_name = type_without_optional.gsub(/^!/, '')
160
+ raise "type_name '#{built_in_type_name}' is not allowed" unless AVAILABLE_BUILTIN_TYPES.include?(built_in_type_name)
161
+ raise "length for '#{built_in_type_name}' is not supported" if built_in_type_name != 'string' && !str_length.nil?
162
+
163
+ Type.new(built_in_type_name, is_optional, custom, str_length&.to_i)
164
+ end
165
+ end
166
+
167
+ def dump
168
+ {'name' => name, 'optional' => optional?, 'is_custom' => custom?, 'str_length' => str_length}
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,76 @@
1
+ require 'redcarpet'
2
+ require 'nokogiri'
3
+
4
+ module Daifuku
5
+
6
+ class Parser
7
+ def parse(markdown_str, category_name)
8
+ html_str = markdown.render(markdown_str)
9
+ html = Nokogiri::HTML(html_str).css('body')
10
+
11
+ top_level_nodes = html.children
12
+ nodes_per_event = {}
13
+ common_columns_nodes = []
14
+ current_event_name = nil
15
+ top_level_nodes.each do |node|
16
+ case node.name
17
+ when 'h2'
18
+ current_event_name = node.content
19
+ when 'p', 'ul'
20
+ if current_event_name
21
+ nodes_per_event[current_event_name] ||= []
22
+ nodes_per_event[current_event_name] << node
23
+ else
24
+ common_columns_nodes << node
25
+ end
26
+ else
27
+ next
28
+ end
29
+ end
30
+
31
+ events = nodes_per_event.map { |name_with_annotation, nodes|
32
+ event = parse_event(name_with_annotation, nodes)
33
+ [event.name, event]
34
+ }.to_h
35
+ common_event = parse_event(nil, common_columns_nodes)
36
+ descriptions = common_event&.descriptions || []
37
+ Category.new(category_name, events, descriptions, common_event&.columns || [])
38
+ end
39
+
40
+ private
41
+
42
+ def parse_event(event_name, nodes)
43
+ if event_name_match = event_name&.match(/\s*\[obsolete\]\s*(.*)/)
44
+ event_name = event_name_match[1]
45
+ is_obsolete = true
46
+ else
47
+ is_obsolete = false
48
+ end
49
+ descriptions = nodes.select { |node| node.name == 'p' }
50
+ .map { |node| node.content }
51
+ columns_node = nodes.select { |node| node.name == 'ul' }.first
52
+ columns = []
53
+ columns = parse_columns(columns_node) if columns_node
54
+ Event.new(event_name, columns, descriptions, is_obsolete)
55
+ end
56
+
57
+ def parse_columns(columns_node)
58
+ descriptor_nodes = columns_node.css('> li')
59
+ descriptor_nodes.map do |node|
60
+ descriptor = node.content
61
+ descriptions = node.css("ul li").map(&:content)
62
+ column = Column.parse(descriptor, descriptions)
63
+ raise "Could not parse '#{descriptor}'" unless column
64
+ column
65
+ end
66
+ end
67
+
68
+ def markdown
69
+ @markdown ||= Redcarpet::Markdown.new(renderer, underline: false, emphasis: false)
70
+ end
71
+
72
+ def renderer
73
+ @renderer ||= Daifuku::Renderer.new
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,11 @@
1
+ module Daifuku
2
+ class Renderer < Redcarpet::Render::HTML
3
+ def underline(text)
4
+ nil
5
+ end
6
+
7
+ def emphasis(text)
8
+ nil
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,196 @@
1
+ module Daifuku
2
+ # This class is used to generate .strdef file from .md files.
3
+ # .strdef file is used to define the structure of the log.
4
+ # https://techlife.cookpad.com/entry/2019/10/18/090000
5
+ # Since the `.strdef` generation for Redshift Spectrum is for Cookpad's internal reasons,
6
+ # we initially decided it would be more natural not to include it in the core of daifuku,
7
+ # but we added it as a reference implementation.
8
+ class StrdefGenerator
9
+ class StreamDefinition
10
+ def self.parse(strdef)
11
+ columns = {}
12
+ strdef.each_line do |line|
13
+ case line
14
+ when /\A\s*-\s+(\w+):\s*(!domain\s+\w+|!\w+)(?:\s+(\d+))?/
15
+ name = $1
16
+ type = $2.sub(/\s+/, ' ').sub(/\A!/, '')
17
+ length = $3
18
+ columns[name] = StreamColumn.new(name, type, length&.to_i)
19
+ end
20
+ end
21
+ new(columns)
22
+ end
23
+
24
+ include Enumerable
25
+
26
+ def initialize(columns)
27
+ @columns = columns
28
+ end
29
+
30
+ def each_column(&block)
31
+ @columns.each_value(&block)
32
+ end
33
+
34
+ def [](name)
35
+ @columns[name]
36
+ end
37
+ end
38
+
39
+ class StreamColumn
40
+ def initialize(name, type, length = nil)
41
+ @name = name
42
+ @type = type
43
+ @length = length
44
+ end
45
+
46
+ attr_reader :name
47
+ attr_reader :type
48
+ attr_reader :length
49
+
50
+ def strdef
51
+ if length
52
+ "#{name}: !#{type} #{length}"
53
+ else
54
+ "#{name}: !#{type}"
55
+ end
56
+ end
57
+ end
58
+
59
+ class LogTable
60
+ PREDEFINED = %w[id log_id event_category event_name]
61
+
62
+ def self.predefined_column?(name)
63
+ PREDEFINED.include?(name)
64
+ end
65
+
66
+ def initialize
67
+ @columns = {}
68
+ end
69
+
70
+ def add_common_column(column)
71
+ @columns[column.name] = LogColumn.new(column.name, column.type, true)
72
+ end
73
+
74
+ def add_column(column, location = '-')
75
+ if prev = @columns[column.name]
76
+ if prev.common?
77
+ raise "common column is being overwritten: #{column.name}: location=#{location}"
78
+ end
79
+ unless column.type.name == prev.type.name
80
+ raise "column type mismatch: name=#{column.name}, curr=#{column.type.name}, prev=#{prev.type.name}"
81
+ end
82
+ end
83
+ @columns[column.name] = LogColumn.new(column.name, column.type)
84
+ end
85
+
86
+ def [](name)
87
+ @columns[name]
88
+ end
89
+
90
+ def each_column(&block)
91
+ @columns.each_value(&block)
92
+ end
93
+ end
94
+
95
+ class LogColumn
96
+ def initialize(name, type, is_common = false)
97
+ @name = name
98
+ @type = type
99
+ @is_common = is_common
100
+ end
101
+
102
+ attr_reader :name
103
+ attr_reader :type
104
+
105
+ def common?
106
+ @is_common
107
+ end
108
+
109
+ def strdef
110
+ if type.str_length
111
+ "#{name}: !#{type.name} #{type.str_length}"
112
+ else
113
+ "#{name}: !#{type.name}"
114
+ end
115
+ end
116
+ end
117
+
118
+ # @param definition_path A directory path it contains log definitions
119
+ def initialize(definition_path)
120
+ @definition_path = definition_path
121
+ end
122
+
123
+ # Generate whole strdef from current log definitions
124
+ # @return String
125
+ def generate
126
+ table = load_table_definition(@definition_path)
127
+ generate_strdef(table)
128
+ end
129
+
130
+ # Generate strdef diff with existing strdef
131
+ # @param String current_strdef strdef to diff with
132
+ # @return String diff
133
+ def diff(current_strdef)
134
+ table = load_table_definition(@definition_path)
135
+ generate_strdef_diff(current_strdef, table)
136
+ end
137
+
138
+ private
139
+ def load_table_definition(path_prefix)
140
+ table = LogTable.new
141
+
142
+ categories = Daifuku::Compiler.new.compile(path_prefix)
143
+
144
+ if common = categories.delete('common')
145
+ common.common_columns.each do |column|
146
+ table.add_common_column(column)
147
+ end
148
+ end
149
+
150
+ categories.each do |category_name, category|
151
+ category.common_columns.each do |column|
152
+ table.add_column column, "#{category_name}:common"
153
+ end
154
+ category.events.each do |event_name, event|
155
+ event.columns.each do |column|
156
+ table.add_column column, category_name
157
+ end
158
+ end
159
+ end
160
+
161
+ table
162
+ end
163
+
164
+ def generate_strdef(table)
165
+ lines = ["columns:"]
166
+ table.each_column do |column|
167
+ lines << " - #{column.strdef}"
168
+ end
169
+ lines.join("\n") + "\n"
170
+ end
171
+
172
+ def generate_strdef_diff(strdef, table)
173
+ strdef = StreamDefinition.parse(strdef)
174
+
175
+ lines = []
176
+ found = false
177
+ table.each_column do |column|
178
+ unless strdef[column.name]
179
+ lines << "- #{column.strdef}"
180
+ found = true
181
+ end
182
+ end
183
+
184
+ unless found
185
+ $stderr.puts %Q(INFO: no new column added)
186
+ end
187
+
188
+ strdef.each_column do |column|
189
+ if not table[column.name] and not LogTable.predefined_column?(column.name)
190
+ $stderr.puts %Q(INFO: column "#{column.name}" is no longer generated)
191
+ end
192
+ end
193
+ lines.join("\n") + "\n"
194
+ end
195
+ end
196
+ end