pgdexter 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 12871a558deb7cd0cb5067476415a34164d5d906
4
- data.tar.gz: 6a293f4992bacb524a37bd7f7882851d364f6080
3
+ metadata.gz: a9dd13cbe8dc39e26b90bdc13abd16201ccec455
4
+ data.tar.gz: bc4693ab0595c9dc1e38b720c294b8e600d597c5
5
5
  SHA512:
6
- metadata.gz: 846f1efba33eba3144e0918348068fc34fefe1ef8dfb62671231ccb01a836b2274b6ac6b43737484d43e0057556898b544aa12142df27860a881b4b9fa43b973
7
- data.tar.gz: 676ad00ea2529681fbfe09e9982a30e49960a03f3dd25733ba206e2fed6bb0708789ec543acb193343b9ec0b31c0ecd12fd4f9d1adbbd9393324acd33322864c
6
+ metadata.gz: 5f96e60ac1660786dd0f059ec0e88a4f017c056f73eb0be07bb6d0d9ad55332a3c36c72cd9fb7f8b3a5ed370254b361f29979baa031850d93800ce8ad74775d2
7
+ data.tar.gz: f6b5a5a8487ea44eb38a4aca3ec59aaaea6c8cdd2c3e40267203fa29b4999d03a507fbe8bbe7e4207a5110c7bcc1e649dba624b4eb11e859be1f432e705095a8
data/README.md CHANGED
@@ -33,28 +33,28 @@ gem install pgdexter
33
33
  Dexter needs a connection to your database and a log file to process.
34
34
 
35
35
  ```sh
36
- dexter <database-url> <log-file>
36
+ tail -F -n +1 <log-file> | dexter <database-url>
37
37
  ```
38
38
 
39
39
  This finds slow queries and generates output like:
40
40
 
41
+ ```log
42
+ 2017-06-25T17:52:19+00:00 Started
43
+ 2017-06-25T17:52:22+00:00 Processing 189 new query fingerprints
44
+ 2017-06-25T17:52:22+00:00 Index found: genres_movies (genre_id)
45
+ 2017-06-25T17:52:22+00:00 Index found: genres_movies (movie_id)
46
+ 2017-06-25T17:52:22+00:00 Index found: movies (title)
47
+ 2017-06-25T17:52:22+00:00 Index found: ratings (movie_id)
48
+ 2017-06-25T17:52:22+00:00 Index found: ratings (rating)
49
+ 2017-06-25T17:52:22+00:00 Index found: ratings (user_id)
50
+ 2017-06-25T17:53:22+00:00 Processing 12 new query fingerprints
41
51
  ```
42
- SELECT * FROM ratings ORDER BY user_id LIMIT 10
43
- Starting cost: 3797.99
44
- Final cost: 0.5
45
- CREATE INDEX CONCURRENTLY ON ratings (user_id);
46
- ```
47
-
48
- To be safe, Dexter does not create indexes unless you pass the `--create` flag.
49
-
50
- You can also pass a single statement with:
51
52
 
52
- ```sh
53
- dexter <database-url> -s "SELECT * FROM ..."
54
- ```
53
+ To be safe, Dexter will not create indexes unless you pass the `--create` flag.
55
54
 
56
55
  ## Options
57
56
 
57
+ - `--interval` - time to wait between processing queries
58
58
  - `--min-time` - only consider queries that have consumed a certain amount of DB time (in minutes)
59
59
 
60
60
  ## Contributing
@@ -0,0 +1,249 @@
1
+ module Dexter
2
+ class Indexer
3
+ attr_reader :client
4
+
5
+ def initialize(client)
6
+ @client = client
7
+
8
+ select_all("SET client_min_messages = warning")
9
+ select_all("CREATE EXTENSION IF NOT EXISTS hypopg")
10
+ end
11
+
12
+ def process_queries(queries)
13
+ # narrow down queries and tables
14
+ tables, queries = narrow_queries(queries)
15
+ return [] if tables.empty?
16
+
17
+ # get ready for hypothetical indexes
18
+ select_all("SELECT hypopg_reset()")
19
+
20
+ # ensure tables have recently been analyzed
21
+ analyze_tables(tables)
22
+
23
+ # get initial plans
24
+ initial_plans = {}
25
+ queries.each do |query|
26
+ begin
27
+ initial_plans[query] = plan(query)
28
+ rescue PG::Error
29
+ # do nothing
30
+ end
31
+ end
32
+ queries.select! { |q| initial_plans[q] }
33
+
34
+ # get existing indexes
35
+ index_set = Set.new
36
+ indexes(tables).each do |index|
37
+ # TODO make sure btree
38
+ index_set << [index["table"], index["columns"]]
39
+ end
40
+
41
+ # create hypothetical indexes
42
+ candidates = {}
43
+ columns(tables).each do |col|
44
+ unless index_set.include?([col[:table], [col[:column]]])
45
+ candidates[col] = select_all("SELECT * FROM hypopg_create_index('CREATE INDEX ON #{col[:table]} (#{[col[:column]].join(", ")})');").first["indexname"]
46
+ end
47
+ end
48
+
49
+ queries_by_index = {}
50
+
51
+ new_indexes = []
52
+ queries.each do |query|
53
+ starting_cost = initial_plans[query]["Total Cost"]
54
+ plan2 = plan(query)
55
+ cost2 = plan2["Total Cost"]
56
+ best_indexes = []
57
+
58
+ candidates.each do |col, index_name|
59
+ if plan2.inspect.include?(index_name) && cost2 < starting_cost * 0.5
60
+ best_indexes << {
61
+ table: col[:table],
62
+ columns: [col[:column]]
63
+ }
64
+ (queries_by_index[best_indexes.last] ||= []) << {
65
+ starting_cost: starting_cost,
66
+ final_cost: cost2,
67
+ query: query
68
+ }
69
+ end
70
+ end
71
+
72
+ new_indexes.concat(best_indexes)
73
+ end
74
+
75
+ new_indexes = new_indexes.uniq.sort_by(&:to_a)
76
+
77
+ # create indexes
78
+ if new_indexes.any?
79
+ new_indexes.each do |index|
80
+ index[:queries] = queries_by_index[index]
81
+
82
+ log "Index found: #{index[:table]} (#{index[:columns].join(", ")})"
83
+ # log "CREATE INDEX CONCURRENTLY ON #{index[:table]} (#{index[:columns].join(", ")});"
84
+ # index[:queries].sort_by { |q| fingerprints[q[:query]] }.each do |query|
85
+ # log "Query #{fingerprints[query[:query]]} (Cost: #{query[:starting_cost]} -> #{query[:final_cost]})"
86
+ # puts
87
+ # puts query[:query]
88
+ # puts
89
+ # end
90
+ end
91
+
92
+ new_indexes.each do |index|
93
+ statement = "CREATE INDEX CONCURRENTLY ON #{index[:table]} (#{index[:columns].join(", ")})"
94
+ # puts "#{statement};"
95
+ if client.options[:create]
96
+ log "Creating index: #{statement}"
97
+ started_at = Time.now
98
+ select_all(statement)
99
+ log "Index created: #{((Time.now - started_at) * 1000).to_i} ms"
100
+ end
101
+ end
102
+ end
103
+
104
+ new_indexes
105
+ end
106
+
107
+ def conn
108
+ @conn ||= begin
109
+ uri = URI.parse(client.arguments[0])
110
+ config = {
111
+ host: uri.host,
112
+ port: uri.port,
113
+ dbname: uri.path.sub(/\A\//, ""),
114
+ user: uri.user,
115
+ password: uri.password,
116
+ connect_timeout: 3
117
+ }.reject { |_, value| value.to_s.empty? }
118
+ PG::Connection.new(config)
119
+ end
120
+ rescue PG::ConnectionBad
121
+ abort "Bad database url"
122
+ end
123
+
124
+ def select_all(query)
125
+ conn.exec(query).to_a
126
+ end
127
+
128
+ def plan(query)
129
+ JSON.parse(select_all("EXPLAIN (FORMAT JSON) #{query}").first["QUERY PLAN"]).first["Plan"]
130
+ end
131
+
132
+ def narrow_queries(queries)
133
+ result = select_all <<-SQL
134
+ SELECT
135
+ table_name
136
+ FROM
137
+ information_schema.tables
138
+ WHERE
139
+ table_catalog = current_database() AND
140
+ table_schema NOT IN ('pg_catalog', 'information_schema')
141
+ SQL
142
+ possible_tables = Set.new(result.map { |r| r["table_name"] })
143
+
144
+ tables = queries.flat_map { |q| PgQuery.parse(q).tables }.uniq.select { |t| possible_tables.include?(t) }
145
+
146
+ [tables, queries.select { |q| PgQuery.parse(q).tables.all? { |t| possible_tables.include?(t) } }]
147
+ end
148
+
149
+ def columns(tables)
150
+ columns = select_all <<-SQL
151
+ SELECT
152
+ table_name,
153
+ column_name
154
+ FROM
155
+ information_schema.columns
156
+ WHERE
157
+ table_schema = 'public' AND
158
+ table_name IN (#{tables.map { |t| quote(t) }.join(", ")})
159
+ SQL
160
+
161
+ columns.map { |v| {table: v["table_name"], column: v["column_name"]} }
162
+ end
163
+
164
+ def indexes(tables)
165
+ select_all(<<-SQL
166
+ SELECT
167
+ schemaname AS schema,
168
+ t.relname AS table,
169
+ ix.relname AS name,
170
+ regexp_replace(pg_get_indexdef(i.indexrelid), '^[^\\(]*\\((.*)\\)$', '\\1') AS columns,
171
+ regexp_replace(pg_get_indexdef(i.indexrelid), '.* USING ([^ ]*) \\(.*', '\\1') AS using,
172
+ indisunique AS unique,
173
+ indisprimary AS primary,
174
+ indisvalid AS valid,
175
+ indexprs::text,
176
+ indpred::text,
177
+ pg_get_indexdef(i.indexrelid) AS definition
178
+ FROM
179
+ pg_index i
180
+ INNER JOIN
181
+ pg_class t ON t.oid = i.indrelid
182
+ INNER JOIN
183
+ pg_class ix ON ix.oid = i.indexrelid
184
+ LEFT JOIN
185
+ pg_stat_user_indexes ui ON ui.indexrelid = i.indexrelid
186
+ WHERE
187
+ t.relname IN (#{tables.map { |t| quote(t) }.join(", ")}) AND
188
+ schemaname IS NOT NULL AND
189
+ indisvalid = 't' AND
190
+ indexprs IS NULL AND
191
+ indpred IS NULL
192
+ ORDER BY
193
+ 1, 2
194
+ SQL
195
+ ).map { |v| v["columns"] = v["columns"].sub(") WHERE (", " WHERE ").split(", ").map { |c| unquote(c) }; v }
196
+ end
197
+
198
+ def unquote(part)
199
+ if part && part.start_with?('"')
200
+ part[1..-2]
201
+ else
202
+ part
203
+ end
204
+ end
205
+
206
+ def analyze_tables(tables)
207
+ analyze_stats = select_all <<-SQL
208
+ SELECT
209
+ schemaname AS schema,
210
+ relname AS table,
211
+ last_analyze,
212
+ last_autoanalyze
213
+ FROM
214
+ pg_stat_user_tables
215
+ WHERE
216
+ relname IN (#{tables.map { |t| quote(t) }.join(", ")})
217
+ SQL
218
+
219
+ last_analyzed = {}
220
+ analyze_stats.each do |stats|
221
+ last_analyzed[stats["table"]] = Time.parse(stats["last_analyze"]) if stats["last_analyze"]
222
+ end
223
+
224
+ tables.each do |table|
225
+ if !last_analyzed[table] || last_analyzed[table] < Time.now - 3600
226
+ log "Analyzing #{table}"
227
+ select_all("ANALYZE #{table}")
228
+ end
229
+ end
230
+ end
231
+
232
+ def quote(value)
233
+ if value.is_a?(String)
234
+ "'#{quote_string(value)}'"
235
+ else
236
+ value
237
+ end
238
+ end
239
+
240
+ # activerecord
241
+ def quote_string(s)
242
+ s.gsub(/\\/, '\&\&').gsub(/'/, "''")
243
+ end
244
+
245
+ def log(message)
246
+ puts "#{Time.now.iso8601} #{message}"
247
+ end
248
+ end
249
+ end
@@ -2,18 +2,38 @@ module Dexter
2
2
  class LogParser
3
3
  REGEX = /duration: (\d+\.\d+) ms (statement|execute <unnamed>): (.+)/
4
4
 
5
- def initialize(logfile, options = {})
5
+ def initialize(logfile, client)
6
6
  @logfile = logfile
7
- @min_time = options[:min_time] * 60000 # convert minutes to ms
8
- end
9
-
10
- def queries
7
+ @min_time = client.options[:min_time] * 60000 # convert minutes to ms
11
8
  @top_queries = {}
9
+ @indexer = Indexer.new(client)
10
+ @new_queries = Set.new
11
+ @new_queries_mutex = Mutex.new
12
+ @process_queries_mutex = Mutex.new
13
+ @last_checked_at = {}
14
+
15
+ log "Started"
16
+
17
+ if @logfile == STDIN
18
+ Thread.abort_on_exception = true
12
19
 
20
+ @timer_thread = Thread.new do
21
+ sleep(3) # starting sleep
22
+ loop do
23
+ @process_queries_mutex.synchronize do
24
+ process_queries
25
+ end
26
+ sleep(client.options[:interval])
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ def perform
13
33
  active_line = nil
14
34
  duration = nil
15
35
 
16
- File.foreach(@logfile) do |line|
36
+ each_line do |line|
17
37
  if active_line
18
38
  if line.include?(": ")
19
39
  process_entry(active_line, duration)
@@ -33,17 +53,72 @@ module Dexter
33
53
  end
34
54
  process_entry(active_line, duration) if active_line
35
55
 
36
- @top_queries.select { |_, v| v[:total_time] > @min_time }.map { |_, v| v[:query] }
56
+ @process_queries_mutex.synchronize do
57
+ process_queries
58
+ end
37
59
  end
38
60
 
39
61
  private
40
62
 
63
+ def each_line
64
+ if @logfile == STDIN
65
+ STDIN.each_line do |line|
66
+ yield line
67
+ end
68
+ else
69
+ File.foreach(@logfile) do |line|
70
+ yield line
71
+ end
72
+ end
73
+ end
74
+
41
75
  def process_entry(query, duration)
42
76
  return unless query =~ /SELECT/i
43
- fingerprint = PgQuery.fingerprint(query)
44
- @top_queries[fingerprint] ||= {calls: 0, total_time: 0, query: query}
77
+ fingerprint =
78
+ begin
79
+ PgQuery.fingerprint(query)
80
+ rescue PgQuery::ParseError
81
+ # do nothing
82
+ end
83
+ return unless fingerprint
84
+
85
+ @top_queries[fingerprint] ||= {calls: 0, total_time: 0}
45
86
  @top_queries[fingerprint][:calls] += 1
46
87
  @top_queries[fingerprint][:total_time] += duration
88
+ @top_queries[fingerprint][:query] = query
89
+ @new_queries_mutex.synchronize do
90
+ @new_queries << fingerprint
91
+ end
92
+ end
93
+
94
+ def process_queries
95
+ new_queries = nil
96
+
97
+ @new_queries_mutex.synchronize do
98
+ new_queries = @new_queries.dup
99
+ @new_queries.clear
100
+ end
101
+
102
+ now = Time.now
103
+ min_checked_at = now - 3600 # don't recheck for an hour
104
+ queries = []
105
+ fingerprints = {}
106
+ @top_queries.each do |k, v|
107
+ if new_queries.include?(k) && v[:total_time] > @min_time && (!@last_checked_at[k] || @last_checked_at[k] < min_checked_at)
108
+ fingerprints[v[:query]] = k
109
+ queries << v[:query]
110
+ @last_checked_at[k] = now
111
+ end
112
+ end
113
+
114
+ log "Processing #{queries.size} new query fingerprints"
115
+ if queries.any?
116
+ @indexer.process_queries(queries)
117
+ end
118
+ end
119
+
120
+ def log(message)
121
+ puts "#{Time.now.iso8601} #{message}"
47
122
  end
48
123
  end
49
124
  end
@@ -1,3 +1,3 @@
1
1
  module Dexter
2
- VERSION = "0.1.0"
2
+ VERSION = "0.1.1"
3
3
  end
data/lib/dexter.rb CHANGED
@@ -4,6 +4,8 @@ require "pg"
4
4
  require "pg_query"
5
5
  require "time"
6
6
  require "set"
7
+ require "thread"
8
+ require "dexter/indexer"
7
9
  require "dexter/log_parser"
8
10
 
9
11
  module Dexter
@@ -22,240 +24,26 @@ module Dexter
22
24
  queries = []
23
25
  if options[:s]
24
26
  queries << options[:s]
27
+ Indexer.new(self).process_queries(queries)
25
28
  end
26
29
  if arguments[1]
27
30
  begin
28
- parser = LogParser.new(arguments[1], min_time: options[:min_time])
29
- queries.concat(parser.queries)
31
+ LogParser.new(arguments[1], self).perform
30
32
  rescue Errno::ENOENT
31
33
  abort "Log file not found"
32
34
  end
33
35
  end
34
-
35
- # narrow down queries and tables
36
- tables, queries = narrow_queries(queries)
37
- return if tables.empty?
38
-
39
- # get ready for hypothetical indexes
40
- select_all("SET client_min_messages = warning")
41
- select_all("CREATE EXTENSION IF NOT EXISTS hypopg")
42
- select_all("SELECT hypopg_reset()")
43
-
44
- # ensure tables have recently been analyzed
45
- analyze_tables(tables)
46
-
47
- # get initial plans
48
- initial_plans = {}
49
- queries.each do |query|
50
- begin
51
- initial_plans[query] = plan(query)
52
- rescue PG::Error
53
- # do nothing
54
- end
55
- end
56
- queries.select! { |q| initial_plans[q] }
57
-
58
- # get existing indexes
59
- index_set = Set.new
60
- indexes(tables).each do |index|
61
- # TODO make sure btree
62
- index_set << [index["table"], index["columns"]]
63
- end
64
-
65
- # create hypothetical indexes
66
- candidates = {}
67
- columns(tables).each do |col|
68
- unless index_set.include?([col[:table], [col[:column]]])
69
- candidates[col] = select_all("SELECT * FROM hypopg_create_index('CREATE INDEX ON #{col[:table]} (#{[col[:column]].join(", ")})');").first["indexname"]
70
- end
71
- end
72
-
73
- new_indexes = []
74
- queries.each do |query|
75
- starting_cost = initial_plans[query]["Total Cost"]
76
- plan2 = plan(query)
77
- cost2 = plan2["Total Cost"]
78
- best_indexes = []
79
-
80
- candidates.each do |col, index_name|
81
- if plan2.inspect.include?(index_name)
82
- best_indexes << {
83
- table: col[:table],
84
- columns: [col[:column]]
85
- }
86
- end
87
- end
88
-
89
- puts query
90
- puts "Starting cost: #{starting_cost}"
91
- puts "Final cost: #{cost2}"
92
-
93
- # must make it 20% faster
94
- if cost2 < starting_cost * 0.8
95
- new_indexes.concat(best_indexes)
96
- best_indexes.each do |index|
97
- puts "CREATE INDEX CONCURRENTLY ON #{index[:table]} (#{index[:columns].join(", ")});"
98
- end
99
- else
100
- puts "Nope!"
101
- end
102
- puts
103
- end
104
-
105
- # create indexes
106
- if new_indexes.any?
107
- puts "Indexes to be created:"
108
- new_indexes.uniq.sort_by(&:to_a).each do |index|
109
- statement = "CREATE INDEX CONCURRENTLY ON #{index[:table]} (#{index[:columns].join(", ")})"
110
- puts "#{statement};"
111
- select_all(statement) if options[:create]
112
- end
113
- end
114
- end
115
-
116
- def conn
117
- @conn ||= begin
118
- uri = URI.parse(arguments[0])
119
- config = {
120
- host: uri.host,
121
- port: uri.port,
122
- dbname: uri.path.sub(/\A\//, ""),
123
- user: uri.user,
124
- password: uri.password,
125
- connect_timeout: 3
126
- }.reject { |_, value| value.to_s.empty? }
127
- PG::Connection.new(config)
128
- end
129
- rescue PG::ConnectionBad
130
- abort "Bad database url"
131
- end
132
-
133
- def select_all(query)
134
- conn.exec(query).to_a
135
- end
136
-
137
- def plan(query)
138
- JSON.parse(select_all("EXPLAIN (FORMAT JSON) #{query}").first["QUERY PLAN"]).first["Plan"]
139
- end
140
-
141
- def narrow_queries(queries)
142
- result = select_all <<-SQL
143
- SELECT
144
- table_name
145
- FROM
146
- information_schema.tables
147
- WHERE
148
- table_catalog = current_database() AND
149
- table_schema NOT IN ('pg_catalog', 'information_schema')
150
- SQL
151
- possible_tables = Set.new(result.map { |r| r["table_name"] })
152
-
153
- tables = queries.flat_map { |q| PgQuery.parse(q).tables }.uniq.select { |t| possible_tables.include?(t) }
154
-
155
- [tables, queries.select { |q| PgQuery.parse(q).tables.all? { |t| possible_tables.include?(t) } }]
156
- end
157
-
158
- def columns(tables)
159
- columns = select_all <<-SQL
160
- SELECT
161
- table_name,
162
- column_name
163
- FROM
164
- information_schema.columns
165
- WHERE
166
- table_schema = 'public' AND
167
- table_name IN (#{tables.map { |t| quote(t) }.join(", ")})
168
- SQL
169
-
170
- columns.map { |v| {table: v["table_name"], column: v["column_name"]} }
171
- end
172
-
173
- def indexes(tables)
174
- select_all(<<-SQL
175
- SELECT
176
- schemaname AS schema,
177
- t.relname AS table,
178
- ix.relname AS name,
179
- regexp_replace(pg_get_indexdef(i.indexrelid), '^[^\\(]*\\((.*)\\)$', '\\1') AS columns,
180
- regexp_replace(pg_get_indexdef(i.indexrelid), '.* USING ([^ ]*) \\(.*', '\\1') AS using,
181
- indisunique AS unique,
182
- indisprimary AS primary,
183
- indisvalid AS valid,
184
- indexprs::text,
185
- indpred::text,
186
- pg_get_indexdef(i.indexrelid) AS definition
187
- FROM
188
- pg_index i
189
- INNER JOIN
190
- pg_class t ON t.oid = i.indrelid
191
- INNER JOIN
192
- pg_class ix ON ix.oid = i.indexrelid
193
- LEFT JOIN
194
- pg_stat_user_indexes ui ON ui.indexrelid = i.indexrelid
195
- WHERE
196
- t.relname IN (#{tables.map { |t| quote(t) }.join(", ")}) AND
197
- schemaname IS NOT NULL AND
198
- indisvalid = 't' AND
199
- indexprs IS NULL AND
200
- indpred IS NULL
201
- ORDER BY
202
- 1, 2
203
- SQL
204
- ).map { |v| v["columns"] = v["columns"].sub(") WHERE (", " WHERE ").split(", ").map { |c| unquote(c) }; v }
205
- end
206
-
207
- def unquote(part)
208
- if part && part.start_with?('"')
209
- part[1..-2]
210
- else
211
- part
36
+ if !options[:s] && !arguments[1]
37
+ LogParser.new(STDIN, self).perform
212
38
  end
213
39
  end
214
40
 
215
- def analyze_tables(tables)
216
- analyze_stats = select_all <<-SQL
217
- SELECT
218
- schemaname AS schema,
219
- relname AS table,
220
- last_analyze,
221
- last_autoanalyze
222
- FROM
223
- pg_stat_user_tables
224
- WHERE
225
- relname IN (#{tables.map { |t| quote(t) }.join(", ")})
226
- SQL
227
-
228
- last_analyzed = {}
229
- analyze_stats.each do |stats|
230
- last_analyzed[stats["table"]] = Time.parse(stats["last_analyze"]) if stats["last_analyze"]
231
- end
232
-
233
- tables.each do |table|
234
- if !last_analyzed[table] || last_analyzed[table] < Time.now - 3600
235
- puts "Analyzing #{table}"
236
- select_all("ANALYZE #{table}")
237
- end
238
- end
239
- end
240
-
241
- def quote(value)
242
- if value.is_a?(String)
243
- "'#{quote_string(value)}'"
244
- else
245
- value
246
- end
247
- end
248
-
249
- # activerecord
250
- def quote_string(s)
251
- s.gsub(/\\/, '\&\&').gsub(/'/, "''")
252
- end
253
-
254
41
  def parse_args(args)
255
42
  opts = Slop.parse(args) do |o|
256
43
  o.boolean "--create", default: false
257
44
  o.string "-s"
258
45
  o.float "--min-time", default: 0
46
+ o.integer "--interval", default: 60
259
47
  end
260
48
  [opts.arguments, opts.to_hash]
261
49
  rescue Slop::Error => e
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pgdexter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2017-06-24 00:00:00.000000000 Z
11
+ date: 2017-06-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: slop
@@ -95,6 +95,7 @@ files:
95
95
  - Rakefile
96
96
  - exe/dexter
97
97
  - lib/dexter.rb
98
+ - lib/dexter/indexer.rb
98
99
  - lib/dexter/log_parser.rb
99
100
  - lib/dexter/version.rb
100
101
  - pgdexter.gemspec