pg_graph 0.1.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.
- 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
|
+
|