timescaledb 0.1.0 → 0.1.5

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.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Timescale
4
+ module ActsAsHypertable
5
+ module Core
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ def time_column
12
+ @time_column ||= hypertable_options[:time_column] || :created_at
13
+ end
14
+
15
+ protected
16
+
17
+ def define_association_scopes
18
+ scope :chunks, -> do
19
+ Chunk.where(hypertable_name: table_name)
20
+ end
21
+
22
+ scope :hypertable, -> do
23
+ Hypertable.find_by(hypertable_name: table_name)
24
+ end
25
+
26
+ scope :jobs, -> do
27
+ Job.where(hypertable_name: table_name)
28
+ end
29
+
30
+ scope :job_stats, -> do
31
+ JobStats.where(hypertable_name: table_name)
32
+ end
33
+
34
+ scope :compression_settings, -> do
35
+ CompressionSettings.where(hypertable_name: table_name)
36
+ end
37
+
38
+ scope :continuous_aggregates, -> do
39
+ ContinuousAggregates.where(hypertable_name: table_name)
40
+ end
41
+ end
42
+
43
+ def define_default_scopes
44
+ scope :previous_month, -> do
45
+ where(
46
+ "DATE(#{time_column}) >= :start_time AND DATE(#{time_column}) <= :end_time",
47
+ start_time: Date.today.last_month.in_time_zone.beginning_of_month.to_date,
48
+ end_time: Date.today.last_month.in_time_zone.end_of_month.to_date
49
+ )
50
+ end
51
+
52
+ scope :previous_week, -> do
53
+ where(
54
+ "DATE(#{time_column}) >= :start_time AND DATE(#{time_column}) <= :end_time",
55
+ start_time: Date.today.last_week.in_time_zone.beginning_of_week.to_date,
56
+ end_time: Date.today.last_week.in_time_zone.end_of_week.to_date
57
+ )
58
+ end
59
+
60
+ scope :this_month, -> do
61
+ where(
62
+ "DATE(#{time_column}) >= :start_time AND DATE(#{time_column}) <= :end_time",
63
+ start_time: Date.today.in_time_zone.beginning_of_month.to_date,
64
+ end_time: Date.today.in_time_zone.end_of_month.to_date
65
+ )
66
+ end
67
+
68
+ scope :this_week, -> do
69
+ where(
70
+ "DATE(#{time_column}) >= :start_time AND DATE(#{time_column}) <= :end_time",
71
+ start_time: Date.today.in_time_zone.beginning_of_week.to_date,
72
+ end_time: Date.today.in_time_zone.end_of_week.to_date
73
+ )
74
+ end
75
+
76
+ scope :yesterday, -> { where("DATE(#{time_column}) = ?", Date.yesterday.in_time_zone.to_date) }
77
+ scope :today, -> { where("DATE(#{time_column}) = ?", Date.today.in_time_zone.to_date) }
78
+ scope :last_hour, -> { where("#{time_column} > ?", 1.hour.ago.in_time_zone) }
79
+ end
80
+
81
+ def normalize_hypertable_options
82
+ hypertable_options[:time_column] = hypertable_options[:time_column].to_sym
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Timescale
4
+ # If you want your model to hook into its underlying hypertable
5
+ # as well as have access to TimescaleDB specific data, methods, and more,
6
+ # specify this macro in your model.
7
+ #
8
+ # @note Your model's table needs to have already been converted to a hypertable
9
+ # via the TimescaleDB `create_hypertable` function for this to work.
10
+ #
11
+ # @see https://docs.timescale.com/api/latest/hypertable/create_hypertable/ for
12
+ # how to use the SQL `create_hypertable` function.
13
+ # @see Timescale::MigrationHelpers#create_table for how to create a new hypertable
14
+ # via a Rails migration utilizing the standard `create_table` method.
15
+ #
16
+ # @example Enabling the macro on your model
17
+ # class Event < ActiveRecord::Base
18
+ # acts_as_hypertable
19
+ # end
20
+ #
21
+ # @see Timescale::ActsAsHypertable#acts_as_hypertable
22
+ # for configuration options
23
+ module ActsAsHypertable
24
+ DEFAULT_OPTIONS = {
25
+ time_column: :created_at
26
+ }.freeze
27
+
28
+ def acts_as_hypertable?
29
+ included_modules.include?(Timescale::ActsAsHypertable::Core)
30
+ end
31
+
32
+ # == Configuration options
33
+ #
34
+ # @param [Hash] options The options to initialize your macro with.
35
+ # @option options [Symbol] :time_column The name of the column in your
36
+ # model's table containing time values. The name provided should be
37
+ # the same name as the `time_column_name` you passed to the
38
+ # TimescaleDB `create_hypertable` function.
39
+ #
40
+ # @example Enabling the macro on your model with options
41
+ # class Event < ActiveRecord::Base
42
+ # acts_as_hypertable time_column: :timestamp
43
+ # end
44
+ #
45
+ def acts_as_hypertable(options = {})
46
+ return if acts_as_hypertable?
47
+
48
+ include Timescale::ActsAsHypertable::Core
49
+
50
+ class_attribute :hypertable_options, instance_writer: false
51
+
52
+ self.hypertable_options = DEFAULT_OPTIONS.dup
53
+ hypertable_options.merge!(options)
54
+ normalize_hypertable_options
55
+
56
+ define_association_scopes
57
+ define_default_scopes
58
+ end
59
+ end
60
+ end
61
+
62
+ ActiveRecord::Base.extend Timescale::ActsAsHypertable
@@ -8,6 +8,14 @@ module Timescale
8
8
  scope :compressed, -> { where(is_compressed: true) }
9
9
  scope :uncompressed, -> { where(is_compressed: false) }
10
10
 
11
+ scope :resume, -> do
12
+ {
13
+ total: count,
14
+ compressed: compressed.count,
15
+ uncompressed: uncompressed.count
16
+ }
17
+ end
18
+
11
19
  def compress!
12
20
  execute("SELECT compress_chunk(#{chunk_relation})")
13
21
  end
@@ -1,6 +1,7 @@
1
1
  module Timescale
2
- class CompressionSettings < ActiveRecord::Base
2
+ class CompressionSetting < ActiveRecord::Base
3
3
  self.table_name = "timescaledb_information.compression_settings"
4
4
  belongs_to :hypertable, foreign_key: :hypertable_name
5
5
  end
6
+ CompressionSettings = CompressionSetting
6
7
  end
@@ -1,9 +1,19 @@
1
1
  module Timescale
2
- class ContinuousAggregates < ActiveRecord::Base
2
+ class ContinuousAggregate < ActiveRecord::Base
3
3
  self.table_name = "timescaledb_information.continuous_aggregates"
4
4
  self.primary_key = 'materialization_hypertable_name'
5
5
 
6
6
  has_many :jobs, foreign_key: "hypertable_name",
7
7
  class_name: "Timescale::Job"
8
+
9
+ has_many :chunks, foreign_key: "hypertable_name",
10
+ class_name: "Timescale::Chunk"
11
+
12
+ scope :resume, -> do
13
+ {
14
+ total: count
15
+ }
16
+ end
8
17
  end
18
+ ContinuousAggregates = ContinuousAggregate
9
19
  end
@@ -0,0 +1,6 @@
1
+ module Timescale
2
+ class Dimension < ActiveRecord::Base
3
+ self.table_name = "timescaledb_information.dimensions"
4
+ end
5
+ Dimensions = Dimension
6
+ end
@@ -1,7 +1,6 @@
1
1
  module Timescale
2
2
  class Hypertable < ActiveRecord::Base
3
3
  self.table_name = "timescaledb_information.hypertables"
4
-
5
4
  self.primary_key = "hypertable_name"
6
5
 
7
6
  has_many :jobs, foreign_key: "hypertable_name"
@@ -9,18 +8,39 @@ module Timescale
9
8
 
10
9
  has_many :compression_settings,
11
10
  foreign_key: "hypertable_name",
12
- class_name: "Timescale::CompressionSettings"
11
+ class_name: "Timescale::CompressionSetting"
12
+
13
+ has_one :dimensions,
14
+ foreign_key: "hypertable_name",
15
+ class_name: "Timescale::Dimension"
13
16
 
14
17
  has_many :continuous_aggregates,
15
18
  foreign_key: "hypertable_name",
16
- class_name: "Timescale::ContinuousAggregates"
19
+ class_name: "Timescale::ContinuousAggregate"
17
20
 
18
- def detailed_size
21
+ def chunks_detailed_size
19
22
  struct_from "SELECT * from chunks_detailed_size('#{self.hypertable_name}')"
20
23
  end
21
24
 
25
+ def approximate_row_count
26
+ struct_from("SELECT * FROM approximate_row_count('#{self.hypertable_name}')").first.approximate_row_count
27
+ end
28
+
22
29
  def compression_stats
23
- struct_from "SELECT * from hypertable_compression_stats('#{self.hypertable_name}')"
30
+ @compression_stats ||=
31
+ struct_from("SELECT * from hypertable_compression_stats('#{self.hypertable_name}')").first || {}
32
+ end
33
+
34
+ def detailed_size
35
+ struct_from("SELECT * FROM hypertable_detailed_size('#{self.hypertable_name}')").first
36
+ end
37
+
38
+ def before_total_bytes
39
+ compression_stats["before_compression_total_bytes"] || detailed_size.total_bytes
40
+ end
41
+
42
+ def after_total_bytes
43
+ compression_stats["after_compression_total_bytes"] || 0
24
44
  end
25
45
 
26
46
  private
data/lib/timescale/job.rb CHANGED
@@ -3,11 +3,8 @@ module Timescale
3
3
  self.table_name = "timescaledb_information.jobs"
4
4
  self.primary_key = "job_id"
5
5
 
6
- attribute :schedule_interval, :interval
7
- attribute :max_runtime, :interval
8
- attribute :retry_period, :interval
9
-
10
- scope :compression, -> { where(proc_name: "tsbs_compress_chunks") }
6
+ scope :compression, -> { where(proc_name: [:tsbs_compress_chunks, :policy_compression]) }
7
+ scope :refresh_continuous_aggregate, -> { where(proc_name: :policy_refresh_continuous_aggregate) }
11
8
  scope :scheduled, -> { where(scheduled: true) }
12
9
  end
13
10
  end
@@ -1,12 +1,17 @@
1
1
  module Timescale
2
- class JobStats < ActiveRecord::Base
2
+ class JobStat < ActiveRecord::Base
3
3
  self.table_name = "timescaledb_information.job_stats"
4
4
 
5
5
  belongs_to :job
6
6
 
7
- attribute :last_run_duration, :interval
8
-
9
7
  scope :success, -> { where(last_run_status: "Success") }
10
8
  scope :scheduled, -> { where(job_status: "Scheduled") }
9
+ scope :resume, -> do
10
+ select("sum(total_successes)::int as success,
11
+ sum(total_runs)::int as runs,
12
+ sum(total_failures)::int as failures")
13
+ .to_a.map{|e|e.attributes.transform_keys(&:to_sym) }
14
+ end
11
15
  end
16
+ JobStats = JobStat
12
17
  end
@@ -21,12 +21,12 @@ module Timescale
21
21
  # end
22
22
  def create_table(table_name, id: :primary_key, primary_key: nil, force: nil, **options)
23
23
  super
24
- setup_hypertable_options(table_name, **options[:hypertable]) if options.key?(:hypertable)
24
+ create_hypertable(table_name, **options[:hypertable]) if options.key?(:hypertable)
25
25
  end
26
26
 
27
27
  # Setup hypertable from options
28
28
  # @see create_table with the hypertable options.
29
- def setup_hypertable_options(table_name,
29
+ def create_hypertable(table_name,
30
30
  time_column: 'created_at',
31
31
  chunk_time_interval: '1 week',
32
32
  compress_segmentby: nil,
@@ -51,7 +51,28 @@ module Timescale
51
51
  end
52
52
  end
53
53
 
54
- def create_continuous_aggregates(name, query, **options)
54
+ # Create a new continuous aggregate
55
+ #
56
+ # @param name [String, Symbol] The name of the continuous aggregate.
57
+ # @param query [String] The SQL query for the aggregate view definition.
58
+ # @param with_data [Boolean] Set to true to create the aggregate WITH DATA
59
+ # @param refresh_policies [Hash] Set to create a refresh policy
60
+ # @option refresh_policies [String] start_offset: INTERVAL or integer
61
+ # @option refresh_policies [String] end_offset: INTERVAL or integer
62
+ # @option refresh_policies [String] schedule_interval: INTERVAL
63
+ #
64
+ # @see https://docs.timescale.com/api/latest/continuous-aggregates/add_continuous_aggregate_policy/
65
+ #
66
+ # @example
67
+ # create_continuous_aggregate(:activity_counts, query: <<-SQL, refresh_policies: { schedule_interval: "INTERVAL '1 hour'" })
68
+ # SELECT
69
+ # time_bucket(INTERVAL '1 day', activity.created_at) AS bucket,
70
+ # count(*)
71
+ # FROM activity
72
+ # GROUP BY bucket
73
+ # SQL
74
+ #
75
+ def create_continuous_aggregate(name, query, **options)
55
76
  execute <<~SQL
56
77
  CREATE MATERIALIZED VIEW #{name}
57
78
  WITH (timescaledb.continuous) AS
@@ -69,6 +90,15 @@ module Timescale
69
90
  SQL
70
91
  end
71
92
  end
93
+ alias_method :create_continuous_aggregates, :create_continuous_aggregate
94
+
95
+ def create_retention_policy(table_name, interval:)
96
+ execute "SELECT add_retention_policy('#{table_name}', INTERVAL '#{interval}')"
97
+ end
98
+
99
+ def remove_retention_policy(table_name)
100
+ execute "SELECT remove_retention_policy('#{table_name}')"
101
+ end
72
102
  end
73
103
  end
74
104
 
@@ -0,0 +1,55 @@
1
+ require 'scenic/adapters/postgres'
2
+ require 'scenic/adapters/postgres/views'
3
+
4
+ module Timescale
5
+ module Scenic
6
+ class Views < ::Scenic::Adapters::Postgres::Views
7
+ # All of the views that this connection has defined, excluding any
8
+ # Timescale continuous aggregates. Those should be defined using
9
+ # +create_continuous_aggregate+ rather than +create_view+.
10
+ #
11
+ # @return [Array<Scenic::View>]
12
+ def all
13
+ ts_views = views_from_timescale.map { |v| to_scenic_view(v) }
14
+ pg_views = views_from_postgres.map { |v| to_scenic_view(v) }
15
+ ts_view_names = ts_views.map(&:name)
16
+ # Skip records with matching names (includes the schema name
17
+ # for records not in the public schema)
18
+ pg_views.reject { |v| v.name.in?(ts_view_names) }
19
+ end
20
+
21
+ private
22
+
23
+ def views_from_timescale
24
+ connection.execute(<<-SQL.squish)
25
+ SELECT
26
+ view_name as viewname,
27
+ view_definition AS definition,
28
+ 'm' AS kind,
29
+ view_schema AS namespace
30
+ FROM timescaledb_information.continuous_aggregates
31
+ SQL
32
+ end
33
+ end
34
+
35
+ class Adapter < ::Scenic::Adapters::Postgres
36
+ # Timescale does some funky stuff under the hood with continuous
37
+ # aggregates. A continuous aggregate is made up of:
38
+ #
39
+ # 1. A hypertable to store the materialized data
40
+ # 2. An entry in the jobs table to refresh the data
41
+ # 3. A view definition that union's the hypertable and any recent data
42
+ # not included in the hypertable
43
+ #
44
+ # That doesn't dump well, even to structure.sql (we lose the job
45
+ # definition, since it's not part of the DDL).
46
+ #
47
+ # Our schema dumper implementation will handle dumping the continuous
48
+ # aggregate definintions, but we need to override Scenic's schema dumping
49
+ # to exclude those continuous aggregates.
50
+ def views
51
+ Views.new(connection).all
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,88 @@
1
+ require 'active_record/connection_adapters/postgresql_adapter'
2
+ require 'active_support/core_ext/string/indent'
3
+
4
+ module Timescale
5
+ module SchemaDumper
6
+ def tables(stream)
7
+ super # This will call #table for each table in the database
8
+ views(stream) unless defined?(Scenic) # Don't call this twice if we're using Scenic
9
+ end
10
+
11
+ def table(table_name, stream)
12
+ super(table_name, stream)
13
+ if hypertable = Timescale::Hypertable.find_by(hypertable_name: table_name)
14
+ timescale_hypertable(hypertable, stream)
15
+ timescale_retention_policy(hypertable, stream)
16
+ end
17
+ end
18
+
19
+ def views(stream)
20
+ timescale_continuous_aggregates(stream) # Define these before any Scenic views that might use them
21
+ super if defined?(super)
22
+ end
23
+
24
+ private
25
+
26
+ def timescale_hypertable(hypertable, stream)
27
+ dim = hypertable.dimensions
28
+ extra_settings = {
29
+ time_column: "#{dim.column_name}",
30
+ chunk_time_interval: "#{dim.time_interval.inspect}"
31
+ }.merge(timescale_compression_settings_for(hypertable)).map {|k, v| %Q[#{k}: "#{v}"]}.join(", ")
32
+
33
+ stream.puts %Q[ create_hypertable "#{hypertable.hypertable_name}", #{extra_settings}]
34
+ stream.puts
35
+ end
36
+
37
+ def timescale_retention_policy(hypertable, stream)
38
+ hypertable.jobs.where(proc_name: "policy_retention").each do |job|
39
+ stream.puts %Q[ create_retention_policy "#{job.hypertable_name}", interval: "#{job.config["drop_after"]}"]
40
+ stream.puts
41
+ end
42
+ end
43
+
44
+ def timescale_compression_settings_for(hypertable)
45
+ compression_settings = hypertable.compression_settings.each_with_object({}) do |setting, compression_settings|
46
+ compression_settings[:compress_segmentby] = setting.attname if setting.segmentby_column_index
47
+
48
+ if setting.orderby_column_index
49
+ direction = setting.orderby_asc ? "ASC" : "DESC"
50
+ compression_settings[:compress_orderby] = "#{setting.attname} #{direction}"
51
+ end
52
+ end
53
+
54
+ hypertable.jobs.compression.each do |job|
55
+ compression_settings[:compression_interval] = job.config["compress_after"]
56
+ end
57
+ compression_settings
58
+ end
59
+
60
+ def timescale_continuous_aggregates(stream)
61
+ Timescale::ContinuousAggregates.all.each do |aggregate|
62
+ opts = if (refresh_policy = aggregate.jobs.refresh_continuous_aggregate.first)
63
+ interval = timescale_interval(refresh_policy.schedule_interval)
64
+ end_offset = timescale_interval(refresh_policy.config["end_offset"])
65
+ start_offset = timescale_interval(refresh_policy.config["start_offset"])
66
+ %Q[, refresh_policies: { start_offset: "#{start_offset}", end_offset: "#{end_offset}", schedule_interval: "#{interval}"}]
67
+ else
68
+ ""
69
+ end
70
+
71
+ stream.puts <<~AGG.indent(2)
72
+ create_continuous_aggregate("#{aggregate.view_name}", <<-SQL#{opts})
73
+ #{aggregate.view_definition.strip.gsub(/;$/, "")}
74
+ SQL
75
+ AGG
76
+ stream.puts
77
+ end
78
+ end
79
+
80
+ def timescale_interval(value)
81
+ return "NULL" if value.nil? || value.to_s.downcase == "null"
82
+
83
+ "INTERVAL '#{value}'"
84
+ end
85
+ end
86
+ end
87
+
88
+ ActiveRecord::ConnectionAdapters::PostgreSQL::SchemaDumper.prepend(Timescale::SchemaDumper)
@@ -0,0 +1,35 @@
1
+ require "active_support/core_ext/numeric/conversions"
2
+
3
+ module Timescale
4
+ module StatsReport
5
+ module_function
6
+ def resume(scope=Hypertable.all)
7
+ base_filter = {hypertable_name: scope.pluck(:hypertable_name)}
8
+ {
9
+ hypertables: {
10
+ count: scope.count,
11
+ uncompressed: scope.to_a.count { |h| h.compression_stats.empty? },
12
+ approximate_row_count: approximate_row_count(scope),
13
+ chunks: Chunk.where(base_filter).resume,
14
+ size: compression_resume(scope)
15
+ },
16
+ continuous_aggregates: ContinuousAggregates.where(base_filter).resume,
17
+ jobs_stats: JobStats.where(base_filter).resume
18
+ }
19
+ end
20
+
21
+ def compression_resume(scope)
22
+ sum = -> (method) { (scope.map(&method).inject(:+) || 0).to_s(:human_size)}
23
+ {
24
+ uncompressed: sum[:before_total_bytes],
25
+ compressed: sum[:after_total_bytes]
26
+ }
27
+ end
28
+
29
+ def approximate_row_count(scope)
30
+ scope.to_a.map do |hypertable|
31
+ { hypertable.hypertable_name => hypertable.approximate_row_count }
32
+ end.inject(&:merge!)
33
+ end
34
+ end
35
+ end
@@ -1,3 +1,3 @@
1
1
  module Timescale
2
- VERSION = "0.1.0"
2
+ VERSION = '0.1.5'
3
3
  end
data/lib/timescale.rb CHANGED
@@ -1,16 +1,22 @@
1
- require "timescale/version"
2
1
  require 'active_record'
2
+
3
+ require_relative 'timescale/acts_as_hypertable'
4
+ require_relative 'timescale/acts_as_hypertable/core'
3
5
  require_relative 'timescale/chunk'
6
+ require_relative 'timescale/compression_settings'
7
+ require_relative 'timescale/continuous_aggregates'
8
+ require_relative 'timescale/dimensions'
4
9
  require_relative 'timescale/hypertable'
5
10
  require_relative 'timescale/job'
6
11
  require_relative 'timescale/job_stats'
7
- require_relative 'timescale/continuous_aggregates'
8
- require_relative 'timescale/compression_settings'
9
- require_relative 'timescale/hypertable_helpers'
12
+ require_relative 'timescale/schema_dumper'
13
+ require_relative 'timescale/stats_report'
10
14
  require_relative 'timescale/migration_helpers'
15
+ require_relative 'timescale/version'
11
16
 
12
17
  module Timescale
13
18
  module_function
19
+
14
20
  def chunks
15
21
  Chunk.all
16
22
  end
@@ -34,4 +40,23 @@ module Timescale
34
40
  def job_stats
35
41
  JobStats.all
36
42
  end
43
+
44
+ def stats(scope=Hypertable.all)
45
+ StatsReport.resume(scope)
46
+ end
47
+
48
+ def default_hypertable_options
49
+ Timescale::ActsAsHypertable::DEFAULT_OPTIONS
50
+ end
51
+ end
52
+
53
+ begin
54
+ require 'scenic'
55
+ require_relative 'timescale/scenic/adapter'
56
+
57
+ Scenic.configure do |config|
58
+ config.database = Timescale::Scenic::Adapter.new
59
+ end
60
+ rescue LoadError
61
+ # This is expected when the scenic gem is not being used
37
62
  end
data/timescale.gemspec CHANGED
@@ -6,7 +6,7 @@ Gem::Specification.new do |spec|
6
6
  spec.authors = ["Jônatas Davi Paganini"]
7
7
  spec.email = ["jonatasdp@gmail.com"]
8
8
 
9
- spec.summary = %q{TimesaleDB helpers for Ruby ecosystem.}
9
+ spec.summary = %q{TimescaleDB helpers for Ruby ecosystem.}
10
10
  spec.description = %q{Functions from timescaledb available in the ActiveRecord models.}
11
11
  spec.homepage = "https://github.com/jonatas/timescale"
12
12
  spec.license = "MIT"
@@ -23,10 +23,18 @@ Gem::Specification.new do |spec|
23
23
  spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
24
24
  `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
25
25
  end
26
- spec.bindir = "exe"
27
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
26
+ spec.bindir = "bin"
27
+ spec.executables = spec.files.grep(%r{^bin/tsdb}) { |f| File.basename(f) }
28
28
  spec.require_paths = ["lib"]
29
29
 
30
- spec.add_dependency "pg", "~> 1.2"
31
- spec.add_dependency 'activerecord'
30
+ spec.add_dependency "pg", "~> 1.2"
31
+ spec.add_dependency "activerecord"
32
+ spec.add_dependency "activesupport"
33
+
34
+ spec.add_development_dependency "pry"
35
+ spec.add_development_dependency "rspec-its"
36
+ spec.add_development_dependency "rspec", "~> 3.0"
37
+ spec.add_development_dependency "dotenv"
38
+ spec.add_development_dependency "rake", "~> 12.0"
39
+ spec.add_development_dependency "database_cleaner-active_record"
32
40
  end