pg_graph 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+