pg_graph 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +18 -0
- data/.rspec +3 -0
- data/.ruby-version +1 -0
- data/.travis.yml +6 -0
- data/Gemfile +7 -0
- data/README.md +36 -0
- data/Rakefile +6 -0
- data/TODO +8 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/doc/diagram.drawio +1 -0
- data/exe/pg_graph +152 -0
- data/lib/data/association.rb +98 -0
- data/lib/data/data.rb +551 -0
- data/lib/data/dimension.rb +51 -0
- data/lib/data/read.rb +44 -0
- data/lib/data/render.rb +237 -0
- data/lib/data/value.rb +96 -0
- data/lib/ext/meta.rb +56 -0
- data/lib/ext/module.rb +18 -0
- data/lib/pg_graph/inflector.rb +105 -0
- data/lib/pg_graph/reflector.rb +187 -0
- data/lib/pg_graph/timer.rb +119 -0
- data/lib/pg_graph/version.rb +3 -0
- data/lib/pg_graph.rb +124 -0
- data/lib/type/dump_type.rb +69 -0
- data/lib/type/read.rb +269 -0
- data/lib/type/type.rb +617 -0
- data/pg_graph.gemspec +40 -0
- data/snippets/1-1.sql +19 -0
- data/snippets/N-M.sql +24 -0
- data/snippets/dag.sql +19 -0
- data/snippets/db.sql +52 -0
- data/snippets/kind.sql +19 -0
- data/snippets/recur.sql +14 -0
- metadata +205 -0
data/lib/data/render.rb
ADDED
@@ -0,0 +1,237 @@
|
|
1
|
+
|
2
|
+
module PgGraph::Data
|
3
|
+
|
4
|
+
class SqlRender
|
5
|
+
attr_reader :database
|
6
|
+
|
7
|
+
attr_reader :format
|
8
|
+
def format=(format)
|
9
|
+
constrain format, lambda { |v| [:sql, :exec, :psql] }, "Illegal value"
|
10
|
+
@format = format
|
11
|
+
end
|
12
|
+
|
13
|
+
# Which data to delete:
|
14
|
+
# none - don't delete any data
|
15
|
+
# touched - delete data for tables in the fox file
|
16
|
+
# recursive - delete data for table in the fox file including recursively depending tables
|
17
|
+
# all - delete data from the whole database
|
18
|
+
attr_reader :delete
|
19
|
+
|
20
|
+
# +ids+ is a map from table UID to ID. Records with larger IDs will
|
21
|
+
# be emitted as insert statements, records with IDs less or equal to the
|
22
|
+
# given ID is emitted as update statements
|
23
|
+
#
|
24
|
+
# +delete+ control which tables are deleted. It can be :none, :touched,
|
25
|
+
# :recursive, :all Only records with an ID greater than the corresponding
|
26
|
+
# ID from +ids+ will be deleted
|
27
|
+
#
|
28
|
+
# +files+ is a list of source file names to be included in the psql SQL
|
29
|
+
# header as documentation. It can be set explicitly when #to_a or #to_h is
|
30
|
+
# called (FIXME: is this used?)
|
31
|
+
def initialize(database, format, ids: {}, delete: :all, files: [])
|
32
|
+
# puts "SqlRender#initialize"
|
33
|
+
# puts " format: #{format.inspect}"
|
34
|
+
# puts " ids: #{ids.inspect}"
|
35
|
+
# puts " delete: #{delete.inspect}"
|
36
|
+
# puts " files: #{files.inspect}"
|
37
|
+
constrain database, Database
|
38
|
+
constrain ids, String => Integer
|
39
|
+
@database = database
|
40
|
+
self.format = format
|
41
|
+
(@ids = ids.dup).default = 0
|
42
|
+
@delete = delete
|
43
|
+
@files = files
|
44
|
+
|
45
|
+
@tables = database.schemas.map(&:tables).flatten.sort
|
46
|
+
@insert_tables = []
|
47
|
+
@update_tables = []
|
48
|
+
@insert_records = {}
|
49
|
+
@update_records = []
|
50
|
+
@tables.each { |table|
|
51
|
+
next if table.empty?
|
52
|
+
@insert_tables << table if table.max_id > @ids[table.uid]
|
53
|
+
@update_tables << table if table.ids.min || 0 <= @ids[table.uid]
|
54
|
+
inserts, updates = table.records.partition { |record| record.id > @ids[table.uid] }
|
55
|
+
@insert_records[table] = inserts if !inserts.empty?
|
56
|
+
@update_records += updates
|
57
|
+
}
|
58
|
+
@table_uids = @tables.select { |table| !table.empty? }.map(&:uid)
|
59
|
+
@materialized_views = @tables.map(&:type).map(&:depending_materialized_views).flatten.uniq
|
60
|
+
end
|
61
|
+
|
62
|
+
def to_a(files = @files)
|
63
|
+
case format
|
64
|
+
when :sql; to_sql.flatten
|
65
|
+
when :exec; to_exec.flatten
|
66
|
+
when :psql; to_psql(files).flatten.compact
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def to_s(files = @files)
|
71
|
+
case format
|
72
|
+
when :sql; to_a.join("\n")
|
73
|
+
when :exec; to_a.join("\n")
|
74
|
+
when :psql; to_psql(files).map { |group| group.join("\n") }.join("\n\n")
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def to_h
|
79
|
+
@to_h ||= {
|
80
|
+
disable: render_triggers(:disable),
|
81
|
+
delete: render_deletes(delete),
|
82
|
+
update: render_updates,
|
83
|
+
insert: render_inserts,
|
84
|
+
restart: render_restart_sequences,
|
85
|
+
enable: render_triggers(:enable),
|
86
|
+
refresh: render_refresh_materialized_views
|
87
|
+
}
|
88
|
+
end
|
89
|
+
|
90
|
+
protected
|
91
|
+
# Returns a single-element array of array of SQL statements
|
92
|
+
def to_sql
|
93
|
+
[to_h[:delete] + to_h[:update] + to_h[:insert]]
|
94
|
+
end
|
95
|
+
|
96
|
+
# Returns an array of non-empty arrays of SQL statements
|
97
|
+
def to_exec() to_h.values.reject(&:empty?) end
|
98
|
+
|
99
|
+
# Returns an array of arrays of SQL statements
|
100
|
+
def to_psql(files = [])
|
101
|
+
[render_psql_header(files), render_begin] + to_exec.select { |a| !a.empty? } + [render_commit]
|
102
|
+
end
|
103
|
+
|
104
|
+
def render_psql_header(files = [])
|
105
|
+
if files.empty?
|
106
|
+
files_text = ""
|
107
|
+
else
|
108
|
+
files_text = " from " + files.join(", ")
|
109
|
+
end
|
110
|
+
[
|
111
|
+
"-- Auto-generated by fox(1)" + files_text,
|
112
|
+
"",
|
113
|
+
"\\set QUIET",
|
114
|
+
"\\set ON_ERROR_STOP"
|
115
|
+
]
|
116
|
+
end
|
117
|
+
|
118
|
+
def render_begin()
|
119
|
+
[ "begin;" ]
|
120
|
+
end
|
121
|
+
|
122
|
+
def render_truncate()
|
123
|
+
@tables.empty? ? [] : [ "truncate #{@tables.map(&:uid).join(", ")} restart identity cascade;" ]
|
124
|
+
end
|
125
|
+
|
126
|
+
# :call-seq:
|
127
|
+
# render_triggers(arg)
|
128
|
+
#
|
129
|
+
# +arg+ can be :disable or :enable
|
130
|
+
#
|
131
|
+
def render_triggers(arg)
|
132
|
+
[:disable, :enable].include?(arg) or raise Error, "Illegal value"
|
133
|
+
tables = @with_deletes ? @tables : @tables.reject(&:empty?)
|
134
|
+
tables.map { |table| "alter table #{table.uid} #{arg} trigger all;" }
|
135
|
+
end
|
136
|
+
|
137
|
+
def render_deletes(kind)
|
138
|
+
table_uids =
|
139
|
+
case kind
|
140
|
+
when :none; []
|
141
|
+
when :touched; @tables.reject(&:empty?).map(&:uid)
|
142
|
+
when :recursive
|
143
|
+
tables = @tables.reject(&:empty?)
|
144
|
+
(tables.map(&:uid) + tables.map { |table| table.type.depending_tables.map(&:uid) }.flatten).uniq
|
145
|
+
when :all; @tables.map(&:uid)
|
146
|
+
else
|
147
|
+
raise ArgumentError
|
148
|
+
end
|
149
|
+
table_uids.map { |uid|
|
150
|
+
if !@ids.key?(uid)
|
151
|
+
"delete from #{uid};"
|
152
|
+
else
|
153
|
+
"delete from #{uid} where id > #{@ids[uid]};"
|
154
|
+
end
|
155
|
+
}
|
156
|
+
end
|
157
|
+
|
158
|
+
def render_updates
|
159
|
+
@update_records.map { |record|
|
160
|
+
"update #{record.table.uid} set " \
|
161
|
+
+ record.value_columns
|
162
|
+
.select { |column| !column.type.primary_key? }
|
163
|
+
.map { |column| "#{column.name} = #{render_value(column)}" }
|
164
|
+
.join(", ") + " " \
|
165
|
+
+ "where id = #{record.id};"
|
166
|
+
}
|
167
|
+
end
|
168
|
+
|
169
|
+
def render_inserts
|
170
|
+
@insert_records.map { |table, records|
|
171
|
+
"insert into #{table.uid} (#{table.type.value_columns.map(&:name).join(', ')}) values " + \
|
172
|
+
records.sort_by(&:id).map { |record|
|
173
|
+
"(" +
|
174
|
+
record.type.value_columns.map { |column_type|
|
175
|
+
record.field?(column_type.name) ? render_value(record[column_type.name]) : 'DEFAULT'
|
176
|
+
}.join(", ") +
|
177
|
+
")"
|
178
|
+
}.join(", ") + ";"
|
179
|
+
}
|
180
|
+
end
|
181
|
+
|
182
|
+
def render_literal(value, element_type = nil)
|
183
|
+
case value
|
184
|
+
when TrueClass, FalseClass; value.to_s
|
185
|
+
when Integer; value.to_s
|
186
|
+
when String; "'#{PG::Connection.escape_string(value.to_s)}'"
|
187
|
+
when NilClass; "NULL"
|
188
|
+
when Array
|
189
|
+
if value.empty?
|
190
|
+
"ARRAY[]::#{element_type}[]" # FIXME: Doesn't handle multidimensional arrays
|
191
|
+
else
|
192
|
+
"ARRAY[" + value.map { |v| render_literal(v) }.join(",") + "]"
|
193
|
+
end
|
194
|
+
when Hash
|
195
|
+
"'" + value.to_json + "'"
|
196
|
+
when Time
|
197
|
+
"'" + value.to_s + "'"
|
198
|
+
else
|
199
|
+
raise "Oops: got #{value.inspect} (#{value.class})"
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def render_value(column)
|
204
|
+
type = column.value_type.type
|
205
|
+
if type.array?
|
206
|
+
render_literal(column.value, type.element_type)
|
207
|
+
else
|
208
|
+
render_literal(column.value)
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
def render_restart_sequences()
|
213
|
+
@tables.map { |table|
|
214
|
+
if table.type.subtable?
|
215
|
+
nil
|
216
|
+
elsif table.empty? && @with_deletes || !table.empty? && table.max_id > @ids[table.uid]
|
217
|
+
"alter table #{table.uid} alter column id restart with #{table.max_id+1};"
|
218
|
+
else
|
219
|
+
nil
|
220
|
+
end
|
221
|
+
}.compact
|
222
|
+
end
|
223
|
+
|
224
|
+
def render_refresh_materialized_views()
|
225
|
+
if @materialized_views.empty?
|
226
|
+
[]
|
227
|
+
else
|
228
|
+
@materialized_views.map { |view| "refresh materialized view #{view.uid};" }
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def render_commit()
|
233
|
+
[ "commit;" ]
|
234
|
+
end
|
235
|
+
end
|
236
|
+
end
|
237
|
+
|
data/lib/data/value.rb
ADDED
@@ -0,0 +1,96 @@
|
|
1
|
+
module PgGraph::Data
|
2
|
+
class Value < Node
|
3
|
+
attr_reader :referenced_object
|
4
|
+
def initialize(type, referenced_object, **opts)
|
5
|
+
super(type, **opts)
|
6
|
+
@referenced_object = referenced_object
|
7
|
+
end
|
8
|
+
end
|
9
|
+
|
10
|
+
# Fake class: RecordValue.new(table, records) simply returns the Record object
|
11
|
+
class RecordValue < Value
|
12
|
+
def self.new(table, records)
|
13
|
+
constrain table, Table
|
14
|
+
constrain records, [Record]
|
15
|
+
records.first
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
class TableValue < Value
|
20
|
+
# Referenced table
|
21
|
+
alias_method :table, :referenced_object
|
22
|
+
|
23
|
+
# Forward to table
|
24
|
+
forward_to :table, :schema, :associations
|
25
|
+
|
26
|
+
# Forward to table implementation
|
27
|
+
forward_to :@impl, :records, :size, :empty?, :[], :id?, :ids, :data, :to_h
|
28
|
+
|
29
|
+
def initialize(table, records = [])
|
30
|
+
constrain table, Table
|
31
|
+
constrain records, [Record]
|
32
|
+
super(table.type, table, dimension: 2 )
|
33
|
+
@impl = Table.new(table.schema, table.type)
|
34
|
+
records.each { |r| @impl.send(:add_record, r) }
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# MmTableValue is implemented as a hash from integer ID to non-empty array of
|
39
|
+
# identical Record objects
|
40
|
+
class MmTableValue < Value
|
41
|
+
# Referenced table
|
42
|
+
alias_method :table, :referenced_object
|
43
|
+
|
44
|
+
# Forward to table
|
45
|
+
forward_to :table, :schema, :associations
|
46
|
+
|
47
|
+
def initialize(table, records = [])
|
48
|
+
constrain records, [Record]
|
49
|
+
super(table.type, table, dimension: 3)
|
50
|
+
@impl = {}
|
51
|
+
records.each { |record| (@impl[record.id] ||= []) << record }
|
52
|
+
end
|
53
|
+
|
54
|
+
# Number of records including duplicates
|
55
|
+
def size() records.size end
|
56
|
+
|
57
|
+
# True if table is empty
|
58
|
+
def empty?() @impl.empty? end
|
59
|
+
|
60
|
+
# #[] returns the unique record for the given key
|
61
|
+
def [](id) @impl[id]&.first end
|
62
|
+
|
63
|
+
# True if the table contains a record with the given ID
|
64
|
+
def id?(id) @impl.key?(id) end
|
65
|
+
|
66
|
+
# List of unique record IDs
|
67
|
+
def ids() @impl.keys end
|
68
|
+
|
69
|
+
# List of Record objects including duplicates
|
70
|
+
def records() @records ||= @impl.values.flatten end
|
71
|
+
|
72
|
+
# List of unique record objects
|
73
|
+
def unique_records() @unique_records ||= @impl.values.map(&:first) end
|
74
|
+
|
75
|
+
# Redefine #data to return a map from ID to array of (identical) records
|
76
|
+
def data() @impl.map { |k,records| [k, records.map(&:value)] }.to_h end
|
77
|
+
|
78
|
+
# Define #value to return a map from ID to the record with that ID
|
79
|
+
def value() @impl.map { |k,records| [k, records.first.value] }.to_h end
|
80
|
+
|
81
|
+
# Return the number of (duplicate) records for the given ID
|
82
|
+
def count(id) @imp[id]&.size || 0 end
|
83
|
+
|
84
|
+
def to_h()
|
85
|
+
@impl.map { |id, records| [id, records.map { |record| record.to_h }] }.to_h
|
86
|
+
end
|
87
|
+
|
88
|
+
protected
|
89
|
+
# Define #add_record to handle duplicate records
|
90
|
+
def add_record(record)
|
91
|
+
constrain record, Record
|
92
|
+
(@impl[record.id] ||= []) << record
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
data/lib/ext/meta.rb
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
|
2
|
+
require "pg_meta"
|
3
|
+
|
4
|
+
# Extend MetaDb with a link table detection method
|
5
|
+
module PgMeta
|
6
|
+
class Table
|
7
|
+
# True if table is a N:M link table. A N:M relation allows for multiple
|
8
|
+
# relations between two records
|
9
|
+
#
|
10
|
+
# A table is a N:M table if
|
11
|
+
# o it has a primary key named 'id' as the first column
|
12
|
+
# o has two reference fields using the link field naming convention
|
13
|
+
# o and nothing else
|
14
|
+
#
|
15
|
+
def mm_table?
|
16
|
+
@mm_table ||=
|
17
|
+
if columns.size != 3
|
18
|
+
false
|
19
|
+
elsif columns.values.first.name != "id" || columns.values.first != primary_key_column
|
20
|
+
false
|
21
|
+
else
|
22
|
+
referential_constraints.size == 2 &&
|
23
|
+
referential_constraints.values.map { |constraint|
|
24
|
+
constraint.referencing_columns
|
25
|
+
}.sort == [[columns.values[1]], [columns.values[2]]].sort
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# True if table is a N:N link table. A N:N relation have at most one
|
30
|
+
# relation between two records. A N:N link table is also a M:M table
|
31
|
+
#
|
32
|
+
# A table is a N:N link table if
|
33
|
+
# o it has a primary key named 'id' as the first column
|
34
|
+
# o has two reference fields using the link field naming convention
|
35
|
+
# o has a unique index on the two reference fields
|
36
|
+
# o and nothing else
|
37
|
+
#
|
38
|
+
def nm_table?
|
39
|
+
@nm_table ||= @mm_table && begin
|
40
|
+
expected_columns = referential_constraints.values.map(&:columns).flatten.sort
|
41
|
+
unique_constraints.values.any? { |constraint|
|
42
|
+
expected_columns == constraint.columns.sort
|
43
|
+
}
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def path
|
48
|
+
schema.name + "." + name
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
class Column
|
53
|
+
def path() table.path + "." + name end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
data/lib/ext/module.rb
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
class Module
|
2
|
+
# Forward list of methods to object. The arguments should be strings or symbols
|
3
|
+
def forward_method(object, *methods)
|
4
|
+
forward_to(object, *methods)
|
5
|
+
end
|
6
|
+
|
7
|
+
# Same but with a better name
|
8
|
+
def forward_to(object, *methods)
|
9
|
+
for method in Array(methods).flatten
|
10
|
+
if method =~ /=$/
|
11
|
+
class_eval("def #{method}(arg) #{object}&.#{method}(arg) end")
|
12
|
+
else
|
13
|
+
class_eval("def #{method}(*args, &block) #{object}&.#{method}(*args, &block) end")
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
@@ -0,0 +1,105 @@
|
|
1
|
+
require "dry-inflector"
|
2
|
+
|
3
|
+
module PgGraph
|
4
|
+
class Inflector #< Dry::Inflector
|
5
|
+
def initialize()
|
6
|
+
@inflector = Dry::Inflector.new
|
7
|
+
end
|
8
|
+
|
9
|
+
def pluralize(word)
|
10
|
+
return word if plural? word
|
11
|
+
result = @inflector.pluralize(word)
|
12
|
+
if result == word
|
13
|
+
word =~ /s$/ ? "#{word}es" : "#{word}s"
|
14
|
+
else
|
15
|
+
result
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
# Note that DryInflector::singularize handles the special PgGraph
|
20
|
+
# pluralization rules by default
|
21
|
+
def singularize(word)
|
22
|
+
@inflector.singularize(word)
|
23
|
+
end
|
24
|
+
|
25
|
+
# #plural? is defined using #singularize because #pluralize would cause
|
26
|
+
# endless recursion
|
27
|
+
def plural?(word)
|
28
|
+
singularize(word) != word
|
29
|
+
end
|
30
|
+
|
31
|
+
def singular?(word)
|
32
|
+
singularize(word) == word
|
33
|
+
end
|
34
|
+
|
35
|
+
def table2table_type(name)
|
36
|
+
record_type2table_type(table2record_type(name))
|
37
|
+
end
|
38
|
+
|
39
|
+
def table2record_type(name)
|
40
|
+
singularize(name)
|
41
|
+
end
|
42
|
+
|
43
|
+
def table_type2table(name)
|
44
|
+
record_type2table(table_type2record_type(name))
|
45
|
+
end
|
46
|
+
|
47
|
+
def table_type2record_type(name)
|
48
|
+
name[1..-2]
|
49
|
+
end
|
50
|
+
|
51
|
+
def record_type2table(name)
|
52
|
+
if name =~ /s$/
|
53
|
+
name + "es"
|
54
|
+
else
|
55
|
+
pluralize(name)
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def record_type2table_type(name)
|
60
|
+
type2array(name)
|
61
|
+
end
|
62
|
+
|
63
|
+
def type2array(name)
|
64
|
+
"[#{name}]"
|
65
|
+
end
|
66
|
+
|
67
|
+
# Convert a column name to a field name by removing a '_id' at the end
|
68
|
+
def column2field_name(column_name)
|
69
|
+
# column_name.sub(/_id$|_kind$/, "")
|
70
|
+
column_name.sub(/_id$/, "")
|
71
|
+
end
|
72
|
+
|
73
|
+
# Camelize string
|
74
|
+
def camelize(s)
|
75
|
+
s = s.sub(/^[a-z\d]*/) { |match| match.capitalize }
|
76
|
+
s.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{$1}#{$2.capitalize}" }
|
77
|
+
end
|
78
|
+
|
79
|
+
# Remove module prefix from class names. Used in debugging
|
80
|
+
def klass_name(qualified_klass_name)
|
81
|
+
demodulize(qualified_klass_name)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Types are both native postgres type and types from the information_schema
|
85
|
+
def postgres_type2ruby_class(name)
|
86
|
+
case name
|
87
|
+
when "character varying", "varchar", "text", "uuid"; String
|
88
|
+
when "smallint", "integer", "int4", "int2"; Integer
|
89
|
+
when "double precision", "float8", "numeric"; Float
|
90
|
+
when "bool", "boolean"; Boolean
|
91
|
+
when "json"; Hash
|
92
|
+
when "bytea"; String
|
93
|
+
when "timestamp", "timestamp without time zone"; Time
|
94
|
+
when /^_/; Array
|
95
|
+
else
|
96
|
+
raise "Unsupported postgres type: #{name.inspect} (FIXIT!)"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
SUPPORTED_RUBY_CLASSES = [String, Integer, Float, Boolean, Hash, Time, NilClass, Array]
|
101
|
+
end
|
102
|
+
|
103
|
+
def self.inflector() @inflector ||= Inflector.new end
|
104
|
+
end
|
105
|
+
|
@@ -0,0 +1,187 @@
|
|
1
|
+
|
2
|
+
require 'constrain'
|
3
|
+
require 'indented_io'
|
4
|
+
|
5
|
+
module PgGraph
|
6
|
+
class Reflection
|
7
|
+
# Textual representation of match RE (String)
|
8
|
+
attr_reader :match
|
9
|
+
|
10
|
+
# Template for 'this' field name. Can be nil
|
11
|
+
attr_reader :this
|
12
|
+
|
13
|
+
# Template for 'that' field name or false if the field shouldn't be
|
14
|
+
# included in the model. Can be nil
|
15
|
+
attr_reader :that
|
16
|
+
|
17
|
+
# Don't pluralize the result of #that if false. Default false
|
18
|
+
attr_reader :pluralize
|
19
|
+
|
20
|
+
# RE corresponding to #match. #re always match a full UID
|
21
|
+
attr_reader :re
|
22
|
+
|
23
|
+
# Number of name components (database, schema, table, column). It can be
|
24
|
+
# between one and four. By ordering reflections from highest to lowest
|
25
|
+
# number of components, specific matches will be tested before more general
|
26
|
+
# matches
|
27
|
+
attr_reader :components
|
28
|
+
|
29
|
+
# +this+ and +that+ are template strings, nil, or false
|
30
|
+
def initialize(match, this, that, pluralize = false)
|
31
|
+
constrain match, Regexp, String
|
32
|
+
constrain this, String, NilClass
|
33
|
+
constrain that, String, FalseClass, NilClass
|
34
|
+
constrain pluralize, TrueClass, FalseClass, NilClass
|
35
|
+
@match = match.is_a?(Regexp) ? match.source : match
|
36
|
+
if @match =~ /^\/(.*)\/$/
|
37
|
+
re = $1
|
38
|
+
@re = Regexp.new("^(?:\\w+\\.)*#{re}$")
|
39
|
+
@components = re.scan(/(?<!\\)\\(?:\\\\)*\./).count + 1
|
40
|
+
else
|
41
|
+
@re = Regexp.new("^(?:\\w+\\.)*#{@match}$")
|
42
|
+
@components = @match.count(".") + 1
|
43
|
+
end
|
44
|
+
(1..4).include?(@components) or raise "Illegal number of name components: #{@match}"
|
45
|
+
@this = this
|
46
|
+
@that = that
|
47
|
+
@pluralize = pluralize || pluralize.nil?
|
48
|
+
end
|
49
|
+
|
50
|
+
def to_yaml
|
51
|
+
{ match: match, this: this, that: that, pluralize: pluralize }
|
52
|
+
end
|
53
|
+
|
54
|
+
def ==(other)
|
55
|
+
METHODS.all? { |method| self.send(method) == other.send(method) }
|
56
|
+
end
|
57
|
+
|
58
|
+
def inspect() "#<Reflection #{match}>" end
|
59
|
+
|
60
|
+
# +hash+ has the keys :match, :this, :that, and :pluralize. The keys can
|
61
|
+
# also be strings
|
62
|
+
def self.load_yaml(hash)
|
63
|
+
Reflection.new *METHODS.map { |key|
|
64
|
+
value = hash[key].nil? ? hash[key.to_s] : hash[key]
|
65
|
+
value == "nil" ? nil : value
|
66
|
+
}
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
METHODS = %w(match this that pluralize).map(&:to_sym)
|
71
|
+
end
|
72
|
+
|
73
|
+
class Reflector
|
74
|
+
# Reflections ordered from most-specific to least-specific and newest to oldest
|
75
|
+
def reflections()
|
76
|
+
# assumes @reflection keys are created in descending order
|
77
|
+
@reflection_list ||= @reflections.values.flatten
|
78
|
+
end
|
79
|
+
|
80
|
+
def initialize(reflections = [], default_reflections: Reflector.default_reflections)
|
81
|
+
constrain reflections, [Reflection]
|
82
|
+
# @reflection maps from number of reflection components to list of reflections. The
|
83
|
+
# keys are initially created in descending to ensure that #values return
|
84
|
+
# lists of components sorted from most to least specific. New reflections
|
85
|
+
# are inserted at the beginning of the lists to make it possible to
|
86
|
+
# override ealier reflections
|
87
|
+
@reflections = {}
|
88
|
+
(1..4).to_a.reverse.each { |components| @reflections[components] = [] }
|
89
|
+
add(default_reflections || [])
|
90
|
+
add(reflections)
|
91
|
+
end
|
92
|
+
|
93
|
+
def dup() Reflector.new(reflections.dup) end
|
94
|
+
|
95
|
+
# Return true if the Reflector has no reflections
|
96
|
+
def empty?()
|
97
|
+
@reflection_list ? reflections.empty? : !@reflections.values.all?(:empty?)
|
98
|
+
end
|
99
|
+
|
100
|
+
# New reflections are inserted as a block at the head of the list of
|
101
|
+
# reflections
|
102
|
+
def add(*reflections)
|
103
|
+
@reflection_list = nil
|
104
|
+
# reverse makes it possible to use #insert but keep order:
|
105
|
+
Array(reflections).flatten.reverse.each { |reflection|
|
106
|
+
@reflections[reflection.components].insert(0, reflection)
|
107
|
+
}
|
108
|
+
end
|
109
|
+
|
110
|
+
# Find 'that' field name for the given UID by searching through reflections
|
111
|
+
# for a match. Returns nil if no match was found or if a matching
|
112
|
+
# reflection has #continue equal to false
|
113
|
+
def this(uid)
|
114
|
+
constrain uid, String
|
115
|
+
do_match(uid, :this)&.first
|
116
|
+
end
|
117
|
+
|
118
|
+
# Find 'that' field name for the given UID by searching through reflections
|
119
|
+
# for a match. The field name is pluralized unless +unique+ is true. The
|
120
|
+
# :table option can be used to override the table name in '$$' rules. This
|
121
|
+
# is used in N:M and M:M relations. Returns nil if no match was found or if
|
122
|
+
# a matching reflection has #continue equal to false
|
123
|
+
def that(uid, unique, table: nil)
|
124
|
+
constrain uid, String
|
125
|
+
if (name, pluralize = do_match(uid, :that, table: table))
|
126
|
+
pluralize != false && unique ? name : PgGraph.inflector.pluralize(name)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def to_yaml
|
131
|
+
reflections.map { |reflection| reflection.to_yaml }
|
132
|
+
end
|
133
|
+
|
134
|
+
def self.load_yaml(yaml_array, default_reflections: Reflector.default_reflections)
|
135
|
+
reflections = yaml_array.map { |hash| Reflection.load_yaml(hash) }
|
136
|
+
Reflector.new(reflections, default_reflections: default_reflections)
|
137
|
+
end
|
138
|
+
|
139
|
+
def self.load_file(file, default_reflections: Reflector.default_reflections)
|
140
|
+
load_yaml YAML.load(IO.read(file)), default_reflections: default_reflections
|
141
|
+
end
|
142
|
+
|
143
|
+
def self.default_reflections
|
144
|
+
@default_reflections ||= begin
|
145
|
+
initializers = [
|
146
|
+
{"match"=>"/parent_id/", "that"=>"child"},
|
147
|
+
{"match"=>"/child_id/", "that"=>"parent"},
|
148
|
+
{"match"=>"/parent_(\\w+)_id/", "that"=>"child_$1"},
|
149
|
+
{"match"=>"/child_(\\w+)_id/", "that"=>"parent_$1"},
|
150
|
+
{"match"=>"kind", "this"=>"kind", "that"=>"$$"},
|
151
|
+
{"match"=>"/(\\w+)_kind/", "this"=>"$1", "that"=>"$$"},
|
152
|
+
{"match"=>"/(\\w+)_by_id/", "this"=>"$1_by", "that"=>"$1_$$", pluralize: true},
|
153
|
+
{"match"=>"/(\\w+)_id/", "this"=>"$1", "that"=>"$$"},
|
154
|
+
{"match"=>"/(\\w+)/", "this"=>"$1", "that"=>"$$"}, # Kind fields w/o explicit 'kind'
|
155
|
+
]
|
156
|
+
initializers.map { |initializer| Reflection.load_yaml(initializer) }
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
def self.default_reflections=(reflections)
|
161
|
+
@default_reflections = reflections
|
162
|
+
end
|
163
|
+
|
164
|
+
private
|
165
|
+
# +kind+ can be :this or :that
|
166
|
+
def do_match(uid, kind, table: nil)
|
167
|
+
reflections.find { |reflection|
|
168
|
+
match_data = reflection.re.match(uid) or next
|
169
|
+
template = reflection.send(kind).dup
|
170
|
+
if template == false
|
171
|
+
return nil
|
172
|
+
elsif template
|
173
|
+
table ||= uid.split(".")[-2]
|
174
|
+
template.gsub!(/\$\$/, table)
|
175
|
+
match_data.captures.each.with_index { |replacement, i| template.gsub!(/\$#{i+1}/, replacement) }
|
176
|
+
return [template, reflection.pluralize]
|
177
|
+
else
|
178
|
+
next
|
179
|
+
end
|
180
|
+
}
|
181
|
+
end
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
|
186
|
+
|
187
|
+
|