activerecord 5.0.0.beta1.1 → 5.0.0.beta2
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of activerecord might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/CHANGELOG.md +123 -15
- data/MIT-LICENSE +2 -2
- data/README.rdoc +1 -1
- data/lib/active_record.rb +2 -1
- data/lib/active_record/aggregations.rb +1 -1
- data/lib/active_record/associations.rb +3 -0
- data/lib/active_record/associations/builder/belongs_to.rb +1 -1
- data/lib/active_record/associations/builder/has_one.rb +1 -1
- data/lib/active_record/associations/builder/singular_association.rb +1 -1
- data/lib/active_record/associations/has_many_through_association.rb +5 -0
- data/lib/active_record/associations/join_dependency/join_association.rb +1 -2
- data/lib/active_record/associations/preloader/through_association.rb +7 -2
- data/lib/active_record/attribute_methods/time_zone_conversion.rb +11 -7
- data/lib/active_record/autosave_association.rb +18 -3
- data/lib/active_record/base.rb +0 -3
- data/lib/active_record/collection_cache_key.rb +12 -3
- data/lib/active_record/connection_adapters/abstract/database_statements.rb +21 -34
- data/lib/active_record/connection_adapters/abstract/quoting.rb +8 -4
- data/lib/active_record/connection_adapters/abstract/schema_definitions.rb +1 -1
- data/lib/active_record/connection_adapters/abstract/schema_dumper.rb +7 -1
- data/lib/active_record/connection_adapters/abstract/schema_statements.rb +36 -20
- data/lib/active_record/connection_adapters/abstract/transaction.rb +8 -2
- data/lib/active_record/connection_adapters/abstract_adapter.rb +8 -15
- data/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +57 -198
- data/lib/active_record/connection_adapters/column.rb +1 -1
- data/lib/active_record/connection_adapters/mysql/column.rb +50 -0
- data/lib/active_record/connection_adapters/mysql/explain_pretty_printer.rb +70 -0
- data/lib/active_record/connection_adapters/mysql/schema_definitions.rb +24 -0
- data/lib/active_record/connection_adapters/mysql/type_metadata.rb +32 -0
- data/lib/active_record/connection_adapters/mysql2_adapter.rb +15 -34
- data/lib/active_record/connection_adapters/postgresql/database_statements.rb +7 -69
- data/lib/active_record/connection_adapters/postgresql/explain_pretty_printer.rb +42 -0
- data/lib/active_record/connection_adapters/postgresql/oid/array.rb +4 -0
- data/lib/active_record/connection_adapters/postgresql/oid/money.rb +0 -2
- data/lib/active_record/connection_adapters/postgresql/oid/range.rb +8 -1
- data/lib/active_record/connection_adapters/postgresql/quoting.rb +5 -4
- data/lib/active_record/connection_adapters/postgresql/schema_dumper.rb +3 -7
- data/lib/active_record/connection_adapters/postgresql_adapter.rb +15 -23
- data/lib/active_record/connection_adapters/sqlite3/explain_pretty_printer.rb +19 -0
- data/lib/active_record/connection_adapters/sqlite3_adapter.rb +2 -31
- data/lib/active_record/connection_handling.rb +1 -1
- data/lib/active_record/core.rb +1 -1
- data/lib/active_record/counter_cache.rb +4 -4
- data/lib/active_record/enum.rb +8 -5
- data/lib/active_record/errors.rb +6 -1
- data/lib/active_record/gem_version.rb +1 -1
- data/lib/active_record/inheritance.rb +6 -1
- data/lib/active_record/internal_metadata.rb +56 -0
- data/lib/active_record/migration.rb +85 -20
- data/lib/active_record/migration/compatibility.rb +28 -2
- data/lib/active_record/model_schema.rb +25 -1
- data/lib/active_record/persistence.rb +11 -10
- data/lib/active_record/railtie.rb +6 -3
- data/lib/active_record/railties/databases.rake +20 -6
- data/lib/active_record/reflection.rb +39 -31
- data/lib/active_record/relation.rb +4 -4
- data/lib/active_record/relation/batches.rb +26 -41
- data/lib/active_record/relation/batches/batch_enumerator.rb +6 -6
- data/lib/active_record/relation/finder_methods.rb +35 -13
- data/lib/active_record/relation/from_clause.rb +1 -1
- data/lib/active_record/relation/merger.rb +3 -0
- data/lib/active_record/relation/predicate_builder.rb +19 -1
- data/lib/active_record/relation/predicate_builder/range_handler.rb +17 -1
- data/lib/active_record/relation/query_methods.rb +37 -19
- data/lib/active_record/relation/record_fetch_warning.rb +4 -6
- data/lib/active_record/relation/where_clause.rb +1 -1
- data/lib/active_record/relation/where_clause_factory.rb +1 -0
- data/lib/active_record/sanitization.rb +1 -1
- data/lib/active_record/schema.rb +3 -0
- data/lib/active_record/schema_dumper.rb +1 -1
- data/lib/active_record/schema_migration.rb +5 -14
- data/lib/active_record/scoping.rb +17 -11
- data/lib/active_record/scoping/default.rb +2 -2
- data/lib/active_record/tasks/database_tasks.rb +18 -0
- data/lib/active_record/timestamp.rb +5 -1
- data/lib/active_record/transactions.rb +3 -3
- data/lib/active_record/validations/uniqueness.rb +6 -3
- data/lib/rails/generators/active_record/migration/templates/migration.rb +4 -0
- data/lib/rails/generators/active_record/model/model_generator.rb +7 -1
- metadata +14 -7
@@ -0,0 +1,50 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module ConnectionAdapters
|
3
|
+
module MySQL
|
4
|
+
class Column < ConnectionAdapters::Column # :nodoc:
|
5
|
+
delegate :strict, :extra, to: :sql_type_metadata, allow_nil: true
|
6
|
+
|
7
|
+
def initialize(*)
|
8
|
+
super
|
9
|
+
assert_valid_default
|
10
|
+
extract_default
|
11
|
+
end
|
12
|
+
|
13
|
+
def has_default?
|
14
|
+
return false if blob_or_text_column? # MySQL forbids defaults on blob and text columns
|
15
|
+
super
|
16
|
+
end
|
17
|
+
|
18
|
+
def blob_or_text_column?
|
19
|
+
/\A(?:tiny|medium|long)?blob\b/ === sql_type || type == :text
|
20
|
+
end
|
21
|
+
|
22
|
+
def unsigned?
|
23
|
+
/\bunsigned\z/ === sql_type
|
24
|
+
end
|
25
|
+
|
26
|
+
def case_sensitive?
|
27
|
+
collation && collation !~ /_ci\z/
|
28
|
+
end
|
29
|
+
|
30
|
+
def auto_increment?
|
31
|
+
extra == 'auto_increment'
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def extract_default
|
37
|
+
if blob_or_text_column?
|
38
|
+
@default = null || strict ? nil : ''
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def assert_valid_default
|
43
|
+
if blob_or_text_column? && default.present?
|
44
|
+
raise ArgumentError, "#{type} columns cannot have a default value: #{default.inspect}"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module ConnectionAdapters
|
3
|
+
module MySQL
|
4
|
+
class ExplainPrettyPrinter # :nodoc:
|
5
|
+
# Pretty prints the result of an EXPLAIN in a way that resembles the output of the
|
6
|
+
# MySQL shell:
|
7
|
+
#
|
8
|
+
# +----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+
|
9
|
+
# | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
|
10
|
+
# +----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+
|
11
|
+
# | 1 | SIMPLE | users | const | PRIMARY | PRIMARY | 4 | const | 1 | |
|
12
|
+
# | 1 | SIMPLE | posts | ALL | NULL | NULL | NULL | NULL | 1 | Using where |
|
13
|
+
# +----+-------------+-------+-------+---------------+---------+---------+-------+------+-------------+
|
14
|
+
# 2 rows in set (0.00 sec)
|
15
|
+
#
|
16
|
+
# This is an exercise in Ruby hyperrealism :).
|
17
|
+
def pp(result, elapsed)
|
18
|
+
widths = compute_column_widths(result)
|
19
|
+
separator = build_separator(widths)
|
20
|
+
|
21
|
+
pp = []
|
22
|
+
|
23
|
+
pp << separator
|
24
|
+
pp << build_cells(result.columns, widths)
|
25
|
+
pp << separator
|
26
|
+
|
27
|
+
result.rows.each do |row|
|
28
|
+
pp << build_cells(row, widths)
|
29
|
+
end
|
30
|
+
|
31
|
+
pp << separator
|
32
|
+
pp << build_footer(result.rows.length, elapsed)
|
33
|
+
|
34
|
+
pp.join("\n") + "\n"
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def compute_column_widths(result)
|
40
|
+
[].tap do |widths|
|
41
|
+
result.columns.each_with_index do |column, i|
|
42
|
+
cells_in_column = [column] + result.rows.map {|r| r[i].nil? ? 'NULL' : r[i].to_s}
|
43
|
+
widths << cells_in_column.map(&:length).max
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def build_separator(widths)
|
49
|
+
padding = 1
|
50
|
+
'+' + widths.map {|w| '-' * (w + (padding*2))}.join('+') + '+'
|
51
|
+
end
|
52
|
+
|
53
|
+
def build_cells(items, widths)
|
54
|
+
cells = []
|
55
|
+
items.each_with_index do |item, i|
|
56
|
+
item = 'NULL' if item.nil?
|
57
|
+
justifier = item.is_a?(Numeric) ? 'rjust' : 'ljust'
|
58
|
+
cells << item.to_s.send(justifier, widths[i])
|
59
|
+
end
|
60
|
+
'| ' + cells.join(' | ') + ' |'
|
61
|
+
end
|
62
|
+
|
63
|
+
def build_footer(nrows, elapsed)
|
64
|
+
rows_label = nrows == 1 ? 'row' : 'rows'
|
65
|
+
"#{nrows} #{rows_label} in set (%.2f sec)" % elapsed
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -11,6 +11,30 @@ module ActiveRecord
|
|
11
11
|
args.each { |name| column(name, :blob, options) }
|
12
12
|
end
|
13
13
|
|
14
|
+
def tinyblob(*args, **options)
|
15
|
+
args.each { |name| column(name, :tinyblob, options) }
|
16
|
+
end
|
17
|
+
|
18
|
+
def mediumblob(*args, **options)
|
19
|
+
args.each { |name| column(name, :mediumblob, options) }
|
20
|
+
end
|
21
|
+
|
22
|
+
def longblob(*args, **options)
|
23
|
+
args.each { |name| column(name, :longblob, options) }
|
24
|
+
end
|
25
|
+
|
26
|
+
def tinytext(*args, **options)
|
27
|
+
args.each { |name| column(name, :tinytext, options) }
|
28
|
+
end
|
29
|
+
|
30
|
+
def mediumtext(*args, **options)
|
31
|
+
args.each { |name| column(name, :mediumtext, options) }
|
32
|
+
end
|
33
|
+
|
34
|
+
def longtext(*args, **options)
|
35
|
+
args.each { |name| column(name, :longtext, options) }
|
36
|
+
end
|
37
|
+
|
14
38
|
def json(*args, **options)
|
15
39
|
args.each { |name| column(name, :json, options) }
|
16
40
|
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module ConnectionAdapters
|
3
|
+
module MySQL
|
4
|
+
class TypeMetadata < DelegateClass(SqlTypeMetadata) # :nodoc:
|
5
|
+
attr_reader :extra, :strict
|
6
|
+
|
7
|
+
def initialize(type_metadata, extra: "", strict: false)
|
8
|
+
super(type_metadata)
|
9
|
+
@type_metadata = type_metadata
|
10
|
+
@extra = extra
|
11
|
+
@strict = strict
|
12
|
+
end
|
13
|
+
|
14
|
+
def ==(other)
|
15
|
+
other.is_a?(MySQL::TypeMetadata) &&
|
16
|
+
attributes_for_hash == other.attributes_for_hash
|
17
|
+
end
|
18
|
+
alias eql? ==
|
19
|
+
|
20
|
+
def hash
|
21
|
+
attributes_for_hash.hash
|
22
|
+
end
|
23
|
+
|
24
|
+
protected
|
25
|
+
|
26
|
+
def attributes_for_hash
|
27
|
+
[self.class, @type_metadata, extra, strict]
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -11,8 +11,13 @@ module ActiveRecord
|
|
11
11
|
|
12
12
|
config[:username] = 'root' if config[:username].nil?
|
13
13
|
config[:flags] ||= 0
|
14
|
+
|
14
15
|
if Mysql2::Client.const_defined? :FOUND_ROWS
|
15
|
-
config[:flags]
|
16
|
+
if config[:flags].kind_of? Array
|
17
|
+
config[:flags].push "FOUND_ROWS".freeze
|
18
|
+
else
|
19
|
+
config[:flags] |= Mysql2::Client::FOUND_ROWS
|
20
|
+
end
|
16
21
|
end
|
17
22
|
|
18
23
|
client = Mysql2::Client.new(config)
|
@@ -94,33 +99,15 @@ module ActiveRecord
|
|
94
99
|
# DATABASE STATEMENTS ======================================
|
95
100
|
#++
|
96
101
|
|
97
|
-
#
|
98
|
-
#
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
# result.each(as: :hash) do |r|
|
107
|
-
# return r
|
108
|
-
# end
|
109
|
-
# end
|
110
|
-
#
|
111
|
-
# # Returns a single value from a record
|
112
|
-
# def select_value(sql, name = nil)
|
113
|
-
# result = execute(sql, name)
|
114
|
-
# if first = result.first
|
115
|
-
# first.first
|
116
|
-
# end
|
117
|
-
# end
|
118
|
-
#
|
119
|
-
# # Returns an array of the values of the first column in a select:
|
120
|
-
# # select_values("SELECT id FROM companies LIMIT 3") => [1,2,3]
|
121
|
-
# def select_values(sql, name = nil)
|
122
|
-
# execute(sql, name).map { |row| row.first }
|
123
|
-
# end
|
102
|
+
# Returns a record hash with the column names as keys and column values
|
103
|
+
# as values.
|
104
|
+
def select_one(arel, name = nil, binds = [])
|
105
|
+
arel, binds = binds_from_relation(arel, binds)
|
106
|
+
execute(to_sql(arel, binds), name).each(as: :hash) do |row|
|
107
|
+
@connection.next_result while @connection.more_results?
|
108
|
+
return row
|
109
|
+
end
|
110
|
+
end
|
124
111
|
|
125
112
|
# Returns an array of arrays containing the field values.
|
126
113
|
# Order is the same as that returned by +columns+.
|
@@ -149,12 +136,6 @@ module ActiveRecord
|
|
149
136
|
|
150
137
|
alias exec_without_stmt exec_query
|
151
138
|
|
152
|
-
def insert_sql(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil)
|
153
|
-
super
|
154
|
-
id_value || @connection.last_id
|
155
|
-
end
|
156
|
-
alias :create :insert_sql
|
157
|
-
|
158
139
|
def exec_insert(sql, name, binds, pk = nil, sequence_name = nil)
|
159
140
|
execute to_sql(sql, binds), name
|
160
141
|
end
|
@@ -4,44 +4,7 @@ module ActiveRecord
|
|
4
4
|
module DatabaseStatements
|
5
5
|
def explain(arel, binds = [])
|
6
6
|
sql = "EXPLAIN #{to_sql(arel, binds)}"
|
7
|
-
ExplainPrettyPrinter.new.pp(exec_query(sql, 'EXPLAIN', binds))
|
8
|
-
end
|
9
|
-
|
10
|
-
class ExplainPrettyPrinter # :nodoc:
|
11
|
-
# Pretty prints the result of an EXPLAIN in a way that resembles the output of the
|
12
|
-
# PostgreSQL shell:
|
13
|
-
#
|
14
|
-
# QUERY PLAN
|
15
|
-
# ------------------------------------------------------------------------------
|
16
|
-
# Nested Loop Left Join (cost=0.00..37.24 rows=8 width=0)
|
17
|
-
# Join Filter: (posts.user_id = users.id)
|
18
|
-
# -> Index Scan using users_pkey on users (cost=0.00..8.27 rows=1 width=4)
|
19
|
-
# Index Cond: (id = 1)
|
20
|
-
# -> Seq Scan on posts (cost=0.00..28.88 rows=8 width=4)
|
21
|
-
# Filter: (posts.user_id = 1)
|
22
|
-
# (6 rows)
|
23
|
-
#
|
24
|
-
def pp(result)
|
25
|
-
header = result.columns.first
|
26
|
-
lines = result.rows.map(&:first)
|
27
|
-
|
28
|
-
# We add 2 because there's one char of padding at both sides, note
|
29
|
-
# the extra hyphens in the example above.
|
30
|
-
width = [header, *lines].map(&:length).max + 2
|
31
|
-
|
32
|
-
pp = []
|
33
|
-
|
34
|
-
pp << header.center(width).rstrip
|
35
|
-
pp << '-' * width
|
36
|
-
|
37
|
-
pp += lines.map {|line| " #{line}"}
|
38
|
-
|
39
|
-
nrows = result.rows.length
|
40
|
-
rows_label = nrows == 1 ? 'row' : 'rows'
|
41
|
-
pp << "(#{nrows} #{rows_label})"
|
42
|
-
|
43
|
-
pp.join("\n") + "\n"
|
44
|
-
end
|
7
|
+
PostgreSQL::ExplainPrettyPrinter.new.pp(exec_query(sql, 'EXPLAIN', binds))
|
45
8
|
end
|
46
9
|
|
47
10
|
def select_value(arel, name = nil, binds = [])
|
@@ -52,8 +15,8 @@ module ActiveRecord
|
|
52
15
|
end
|
53
16
|
end
|
54
17
|
|
55
|
-
def select_values(arel, name = nil)
|
56
|
-
arel, binds = binds_from_relation arel,
|
18
|
+
def select_values(arel, name = nil, binds = [])
|
19
|
+
arel, binds = binds_from_relation arel, binds
|
57
20
|
sql = to_sql(arel, binds)
|
58
21
|
execute_and_clear(sql, name, binds) do |result|
|
59
22
|
if result.nfields > 0
|
@@ -72,28 +35,6 @@ module ActiveRecord
|
|
72
35
|
end
|
73
36
|
end
|
74
37
|
|
75
|
-
# Executes an INSERT query and returns the new record's ID
|
76
|
-
def insert_sql(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil)
|
77
|
-
unless pk
|
78
|
-
# Extract the table from the insert sql. Yuck.
|
79
|
-
table_ref = extract_table_ref_from_insert_sql(sql)
|
80
|
-
pk = primary_key(table_ref) if table_ref
|
81
|
-
end
|
82
|
-
|
83
|
-
if pk && use_insert_returning?
|
84
|
-
select_value("#{sql} RETURNING #{quote_column_name(pk)}")
|
85
|
-
elsif pk
|
86
|
-
super
|
87
|
-
last_insert_id_value(sequence_name || default_sequence_name(table_ref, pk))
|
88
|
-
else
|
89
|
-
super
|
90
|
-
end
|
91
|
-
end
|
92
|
-
|
93
|
-
def create
|
94
|
-
super.insert
|
95
|
-
end
|
96
|
-
|
97
38
|
# The internal PostgreSQL identifier of the money data type.
|
98
39
|
MONEY_COLUMN_TYPE_OID = 790 #:nodoc:
|
99
40
|
# The internal PostgreSQL identifier of the BYTEA data type.
|
@@ -150,6 +91,8 @@ module ActiveRecord
|
|
150
91
|
|
151
92
|
# Executes an SQL statement, returning a PGresult object on success
|
152
93
|
# or raising a PGError exception otherwise.
|
94
|
+
# Note: the PGresult object is manually memory managed; if you don't
|
95
|
+
# need it specifically, you many want consider the exec_query wrapper.
|
153
96
|
def execute(sql, name = nil)
|
154
97
|
log(sql, name) do
|
155
98
|
@connection.async_exec(sql)
|
@@ -174,7 +117,7 @@ module ActiveRecord
|
|
174
117
|
end
|
175
118
|
alias :exec_update :exec_delete
|
176
119
|
|
177
|
-
def sql_for_insert(sql, pk, id_value, sequence_name, binds)
|
120
|
+
def sql_for_insert(sql, pk, id_value, sequence_name, binds) # :nodoc:
|
178
121
|
unless pk
|
179
122
|
# Extract the table from the insert sql. Yuck.
|
180
123
|
table_ref = extract_table_ref_from_insert_sql(sql)
|
@@ -185,7 +128,7 @@ module ActiveRecord
|
|
185
128
|
sql = "#{sql} RETURNING #{quote_column_name(pk)}"
|
186
129
|
end
|
187
130
|
|
188
|
-
|
131
|
+
super
|
189
132
|
end
|
190
133
|
|
191
134
|
def exec_insert(sql, name, binds, pk = nil, sequence_name = nil)
|
@@ -202,11 +145,6 @@ module ActiveRecord
|
|
202
145
|
end
|
203
146
|
end
|
204
147
|
|
205
|
-
# Executes an UPDATE query and returns the number of affected tuples.
|
206
|
-
def update_sql(sql, name = nil)
|
207
|
-
super.cmd_tuples
|
208
|
-
end
|
209
|
-
|
210
148
|
# Begins a transaction.
|
211
149
|
def begin_db_transaction
|
212
150
|
execute "BEGIN"
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
module ConnectionAdapters
|
3
|
+
module PostgreSQL
|
4
|
+
class ExplainPrettyPrinter # :nodoc:
|
5
|
+
# Pretty prints the result of an EXPLAIN in a way that resembles the output of the
|
6
|
+
# PostgreSQL shell:
|
7
|
+
#
|
8
|
+
# QUERY PLAN
|
9
|
+
# ------------------------------------------------------------------------------
|
10
|
+
# Nested Loop Left Join (cost=0.00..37.24 rows=8 width=0)
|
11
|
+
# Join Filter: (posts.user_id = users.id)
|
12
|
+
# -> Index Scan using users_pkey on users (cost=0.00..8.27 rows=1 width=4)
|
13
|
+
# Index Cond: (id = 1)
|
14
|
+
# -> Seq Scan on posts (cost=0.00..28.88 rows=8 width=4)
|
15
|
+
# Filter: (posts.user_id = 1)
|
16
|
+
# (6 rows)
|
17
|
+
#
|
18
|
+
def pp(result)
|
19
|
+
header = result.columns.first
|
20
|
+
lines = result.rows.map(&:first)
|
21
|
+
|
22
|
+
# We add 2 because there's one char of padding at both sides, note
|
23
|
+
# the extra hyphens in the example above.
|
24
|
+
width = [header, *lines].map(&:length).max + 2
|
25
|
+
|
26
|
+
pp = []
|
27
|
+
|
28
|
+
pp << header.center(width).rstrip
|
29
|
+
pp << '-' * width
|
30
|
+
|
31
|
+
pp += lines.map {|line| " #{line}"}
|
32
|
+
|
33
|
+
nrows = result.rows.length
|
34
|
+
rows_label = nrows == 1 ? 'row' : 'rows'
|
35
|
+
pp << "(#{nrows} #{rows_label})"
|
36
|
+
|
37
|
+
pp.join("\n") + "\n"
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|