daifuku 0.9.0

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,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