simple-sql 0.5.0 → 0.5.2

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
  SHA256:
3
- metadata.gz: f7a208873c084434a4267d43cfd76ccae14aa249e7ce2cd40de78e76fe4d0df8
4
- data.tar.gz: c6c961737c2ebd4b57b5ab7946b872f229e63a8c22c275177825a8bcb5301cef
3
+ metadata.gz: 76825a68d4370b6f5e870a09f2013502706b0f35a83a911e5451fc147a9a1940
4
+ data.tar.gz: 4093415861c077fbdf633dd1d347c6a6e2b2ed3da7df0c95dfaa078919d9b5d4
5
5
  SHA512:
6
- metadata.gz: 2c5c053640e50ba398acf014f855fb28ea369bdd7806ae7a564f69ce87f1d7323dd58ced4ca9a77bd6e7a595266f16754a5de5d0ce6efc0f24fd29b2ea2acb39
7
- data.tar.gz: af48f5add33e6330a526ec2d6486579f84f2e1b2fb7ee0d03aff0a0d65723da07ea8c5932978c26db6cf005aee2627c02ebb7c2b4ba273ad909c95fd0fa8b4bb
6
+ metadata.gz: 835f1c43a6d63d7647ba0d81c4dffabe39ef599f3623e47c3d62f2073e8f53dc88d54d387514efdb996aabdfebd5b0a599025eb3c0e4c3e2abe54685000323d9
7
+ data.tar.gz: eda18e175ed172c11b3a8132fb956e66fbd06da57036ce65cb9361da9a1d66ca41edac4085fa4c048db69827025b5c49b8b7bde43d132f217e5defd0665f92e4
@@ -0,0 +1,86 @@
1
+ class Simple::SQL::Connection
2
+ #
3
+ # Creates duplicates of record in a table.
4
+ #
5
+ # This method handles timestamp columns (these will be set to the current
6
+ # time) and primary keys (will be set to NULL.) You can pass in overrides
7
+ # as a third argument for specific columns.
8
+ #
9
+ # Parameters:
10
+ #
11
+ # - ids: (Integer, Array<Integer>) primary key ids
12
+ # - overrides: Hash[column_names => SQL::Fragment]
13
+ #
14
+ def duplicate(table, ids, overrides = {})
15
+ ids = Array(ids)
16
+ return [] if ids.empty?
17
+
18
+ Duplicator.new(self, table, overrides).call(ids)
19
+ end
20
+
21
+ class Duplicator
22
+ attr_reader :connection
23
+ attr_reader :table_name
24
+ attr_reader :custom_overrides
25
+
26
+ def initialize(connection, table_name, overrides)
27
+ @connection = connection
28
+ @table_name = table_name
29
+ @custom_overrides = validated_overrides(overrides)
30
+ end
31
+
32
+ def call(ids)
33
+ connection.all query, ids
34
+ rescue PG::UndefinedColumn => e
35
+ raise ArgumentError, e.message
36
+ end
37
+
38
+ private
39
+
40
+ # This stringify all keys of the overrides hash, and verifies that
41
+ # all values in there are SQL::Fragments
42
+ def validated_overrides(overrides)
43
+ overrides.inject({}) do |hsh, (key, value)|
44
+ unless value.is_a?(::Simple::SQL::Fragment)
45
+ raise ArgumentError, "Pass in override values via SQL.fragment (for #{value.inspect})"
46
+ end
47
+
48
+ hsh.update key.to_s => value.to_sql
49
+ end
50
+ end
51
+
52
+ def timestamp_overrides
53
+ reflection = connection.reflection
54
+ reflection.timestamp_columns(table_name).inject({}) do |hsh, column|
55
+ hsh.update column => "now() AS #{column}"
56
+ end
57
+ end
58
+
59
+ def copy_columns
60
+ reflection = connection.reflection
61
+ (reflection.columns(table_name) - reflection.primary_key_columns(table_name)).inject({}) do |hsh, column|
62
+ hsh.update column => column
63
+ end
64
+ end
65
+
66
+ # build SQL query
67
+ def query
68
+ sources = {}
69
+
70
+ sources.update copy_columns
71
+ sources.update timestamp_overrides
72
+ sources.update custom_overrides
73
+
74
+ # convert into an Array, to make sure that keys and values aka firsts
75
+ # and lasts are always in the correct order.
76
+ sources = sources.to_a
77
+
78
+ <<~SQL
79
+ INSERT INTO #{table_name}(#{sources.map(&:first).join(', ')})
80
+ SELECT #{sources.map(&:last).join(', ')}
81
+ FROM #{table_name}
82
+ WHERE id = ANY($1) RETURNING id
83
+ SQL
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,73 @@
1
+ # rubocop:disable Metrics/AbcSize
2
+ # rubocop:disable Metrics/LineLength
3
+ # rubocop:disable Layout/AlignHash
4
+
5
+ class Simple::SQL::Connection
6
+ #
7
+ # - table_name - the name of the table
8
+ # - records - a single hash of attributes or an array of hashes of attributes
9
+ # - on_conflict - uses a postgres ON CONFLICT clause to ignore insert conflicts if true
10
+ #
11
+ def insert(table, records, on_conflict: nil, into: nil)
12
+ if records.is_a?(Hash)
13
+ inserted_records = insert(table, [records], on_conflict: on_conflict, into: into)
14
+ return inserted_records.first
15
+ end
16
+
17
+ return [] if records.empty?
18
+
19
+ inserter = inserter(table_name: table.to_s, columns: records.first.keys, on_conflict: on_conflict, into: into)
20
+ inserter.insert(records: records)
21
+ end
22
+
23
+ private
24
+
25
+ def inserter(table_name:, columns:, on_conflict:, into:)
26
+ @inserters ||= {}
27
+ @inserters[[table_name, columns, on_conflict, into]] ||= Inserter.new(self, table_name, columns, on_conflict, into)
28
+ end
29
+
30
+ class Inserter
31
+ CONFICT_HANDLING = {
32
+ nil => "",
33
+ :nothing => "ON CONFLICT DO NOTHING",
34
+ :ignore => "ON CONFLICT DO NOTHING"
35
+ }
36
+
37
+ #
38
+ # - table_name - the name of the table
39
+ # - columns - name of columns, as Array[String] or Array[Symbol]
40
+ #
41
+ def initialize(connection, table_name, columns, on_conflict, into)
42
+ expect! on_conflict => CONFICT_HANDLING.keys
43
+ raise ArgumentError, "Cannot insert a record without attributes" if columns.empty?
44
+
45
+ @connection = connection
46
+ @columns = columns
47
+ @into = into
48
+
49
+ cols = []
50
+ vals = []
51
+
52
+ cols += columns
53
+ vals += columns.each_with_index.map { |_, idx| "$#{idx + 1}" }
54
+
55
+ timestamp_columns = @connection.reflection.timestamp_columns(table_name) - columns.map(&:to_s)
56
+
57
+ cols += timestamp_columns
58
+ vals += timestamp_columns.map { "now()" }
59
+
60
+ returning = into ? "*" : "id"
61
+
62
+ @sql = "INSERT INTO #{table_name} (#{cols.join(',')}) VALUES(#{vals.join(',')}) #{CONFICT_HANDLING[on_conflict]} RETURNING #{returning}"
63
+ end
64
+
65
+ def insert(records:)
66
+ SQL.transaction do
67
+ records.map do |record|
68
+ SQL.ask @sql, *record.values_at(*@columns), into: @into
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,107 @@
1
+ class Simple::SQL::Connection
2
+ def reflection
3
+ @reflection ||= Reflection.new(self)
4
+ end
5
+
6
+ class Reflection
7
+ def initialize(connection)
8
+ @connection = connection
9
+ end
10
+
11
+ def tables(schema: "public")
12
+ table_info(schema: schema).keys
13
+ end
14
+
15
+ def primary_key_columns(table_name)
16
+ @connection.all <<~SQL, table_name
17
+ SELECT pg_attribute.attname
18
+ FROM pg_index
19
+ JOIN pg_attribute ON pg_attribute.attrelid = pg_index.indrelid AND pg_attribute.attnum = ANY(pg_index.indkey)
20
+ WHERE pg_index.indrelid = $1::regclass
21
+ AND pg_index.indisprimary;
22
+ SQL
23
+ end
24
+
25
+ TIMESTAMP_COLUMN_NAMES = %w(inserted_at created_at updated_at)
26
+
27
+ # timestamp_columns are columns that will be set automatically after
28
+ # inserting or updating a record. This includes:
29
+ #
30
+ # - inserted_at (for Ecto)
31
+ # - created_at (for ActiveRecord)
32
+ # - updated_at (for Ecto and ActiveRecord)
33
+ def timestamp_columns(table_name)
34
+ columns(table_name) & TIMESTAMP_COLUMN_NAMES
35
+ end
36
+
37
+ def columns(table_name)
38
+ column_info(table_name).keys
39
+ end
40
+
41
+ def table_info(schema: "public")
42
+ recs = @connection.all <<~SQL, schema, into: Hash
43
+ SELECT table_schema || '.' || table_name AS name, *
44
+ FROM information_schema.tables
45
+ WHERE table_schema=$1
46
+ SQL
47
+ records_by_attr(recs, :name)
48
+ end
49
+
50
+ def column_info(table_name)
51
+ @column_info ||= {}
52
+ @column_info[table_name] ||= _column_info(table_name)
53
+ end
54
+
55
+ private
56
+
57
+ def _column_info(table_name)
58
+ schema, table_name = parse_table_name(table_name)
59
+ recs = @connection.all <<~SQL, schema, table_name, into: Hash
60
+ SELECT
61
+ column_name AS name,
62
+ *
63
+ FROM information_schema.columns
64
+ WHERE table_schema=$1 AND table_name=$2
65
+ SQL
66
+
67
+ records_by_attr(recs, :column_name)
68
+ end
69
+
70
+ def parse_table_name(table_name)
71
+ p1, p2 = table_name.split(".", 2)
72
+ if p2
73
+ [p1, p2]
74
+ else
75
+ ["public", p1]
76
+ end
77
+ end
78
+
79
+ def records_by_attr(records, attr)
80
+ records.inject({}) do |hsh, record|
81
+ record.reject! { |_k, v| v.nil? }
82
+ hsh.update record[attr] => OpenStruct.new(record)
83
+ end
84
+ end
85
+
86
+ public
87
+
88
+ def looked_up_pg_classes
89
+ @looked_up_pg_classes ||= Hash.new { |hsh, key| hsh[key] = _lookup_pg_class(key) }
90
+ end
91
+
92
+ def lookup_pg_class(oid)
93
+ looked_up_pg_classes[oid]
94
+ end
95
+
96
+ private
97
+
98
+ def _lookup_pg_class(oid)
99
+ ::Simple::SQL.ask <<~SQL, oid
100
+ SELECT nspname AS schema, relname AS host_table
101
+ FROM pg_class
102
+ JOIN pg_namespace ON pg_namespace.oid=pg_class.relnamespace
103
+ WHERE pg_class.oid=$1
104
+ SQL
105
+ end
106
+ end
107
+ end
@@ -1,3 +1,13 @@
1
+ class Simple::SQL::Connection
2
+ end
3
+
4
+ require_relative "connection/raw_connection"
5
+ require_relative "connection/active_record_connection"
6
+
7
+ require_relative "connection/reflection"
8
+ require_relative "connection/insert"
9
+ require_relative "connection/duplicate"
10
+
1
11
  # A Connection object.
2
12
  #
3
13
  # A Connection object is built around a raw connection (as created from the pg
@@ -26,6 +36,3 @@ class Simple::SQL::Connection
26
36
  extend Forwardable
27
37
  delegate [:wait_for_notify] => :raw_connection
28
38
  end
29
-
30
- require_relative "connection/raw_connection"
31
- require_relative "connection/active_record_connection"
@@ -90,6 +90,16 @@ module Simple::SQL::ConnectionAdapter
90
90
  end
91
91
  end
92
92
 
93
+ # returns an Array [min_cost, max_cost] based on the database's estimation
94
+ def costs(sql, *args)
95
+ explanation_first = Simple::SQL.ask "EXPLAIN #{sql}", *args
96
+ unless explanation_first =~ /cost=(\d+(\.\d+))\.+(\d+(\.\d+))/
97
+ raise "Cannot determine cost"
98
+ end
99
+
100
+ [Float($1), Float($3)]
101
+ end
102
+
93
103
  # Executes a block, usually of db insert code, while holding an
94
104
  # advisory lock.
95
105
  #
@@ -145,7 +155,7 @@ module Simple::SQL::ConnectionAdapter
145
155
  end
146
156
 
147
157
  def convert_rows_to_result(rows, into:, pg_source_oid:)
148
- Result.build(rows, target_type: into, pg_source_oid: pg_source_oid)
158
+ Result.build(self, rows, target_type: into, pg_source_oid: pg_source_oid)
149
159
  end
150
160
 
151
161
  public
@@ -0,0 +1,11 @@
1
+ # rubocop:disable Style/StructInheritance
2
+ module Simple
3
+ module SQL
4
+ class Fragment < Struct.new(:to_sql)
5
+ end
6
+
7
+ def fragment(str)
8
+ Fragment.new(str)
9
+ end
10
+ end
11
+ end
@@ -50,7 +50,7 @@ module Simple
50
50
  end
51
51
 
52
52
  def slow_query_treshold=(slow_query_treshold)
53
- expect! slow_query_treshold > 0
53
+ expect! slow_query_treshold.nil? || slow_query_treshold > 0
54
54
  @slow_query_treshold = slow_query_treshold
55
55
  end
56
56
 
@@ -12,7 +12,6 @@ require "active_support/core_ext/string/inflections"
12
12
  module ::Simple::SQL::Result::AssociationLoader # :nodoc:
13
13
  extend self
14
14
 
15
- SQL = ::Simple::SQL
16
15
  H = ::Simple::SQL::Helpers
17
16
 
18
17
  private
@@ -23,10 +22,10 @@ module ::Simple::SQL::Result::AssociationLoader # :nodoc:
23
22
  # "foo.users", if such a table exists.
24
23
  #
25
24
  # Raises an ArgumentError if no matching table can be found.
26
- def find_associated_table(association, schema:)
25
+ def find_associated_table(connection, association, schema:)
27
26
  fq_association = "#{schema}.#{association}"
28
27
 
29
- tables_in_schema = ::Simple::SQL::Reflection.table_info(schema: schema).keys
28
+ tables_in_schema = connection.reflection.table_info(schema: schema).keys
30
29
 
31
30
  return fq_association if tables_in_schema.include?(fq_association)
32
31
  return fq_association.singularize if tables_in_schema.include?(fq_association.singularize)
@@ -49,7 +48,7 @@ module ::Simple::SQL::Result::AssociationLoader # :nodoc:
49
48
  #
50
49
  # Raises an ArgumentError if no association can be found between these tables.
51
50
  #
52
- def find_matching_relation(host_table, associated_table)
51
+ def find_matching_relation(connection, host_table, associated_table)
53
52
  expect! host_table => /^[^.]+.[^.]+$/
54
53
  expect! associated_table => /^[^.]+.[^.]+$/
55
54
 
@@ -73,7 +72,7 @@ module ::Simple::SQL::Result::AssociationLoader # :nodoc:
73
72
  OR (belonging_table=$2 AND having_table=$1)
74
73
  SQL
75
74
 
76
- relations = SQL.all(sql, host_table, associated_table, into: :struct)
75
+ relations = connection.all(sql, host_table, associated_table, into: :struct)
77
76
 
78
77
  return relations.first if relations.length == 1
79
78
 
@@ -150,7 +149,7 @@ module ::Simple::SQL::Result::AssociationLoader # :nodoc:
150
149
  # - host_table: the name of the table \a records has been loaded from.
151
150
  # - schema: the schema name in the database.
152
151
  # - as: the name to sue for the association. Defaults to +association+
153
- def preload(records, association, host_table:, schema:, as:, order_by:, limit:)
152
+ def preload(connection, records, association, host_table:, schema:, as:, order_by:, limit:)
154
153
  return records if records.empty?
155
154
 
156
155
  expect! records.first => Hash
@@ -158,8 +157,8 @@ module ::Simple::SQL::Result::AssociationLoader # :nodoc:
158
157
  as = association if as.nil?
159
158
  fq_host_table = "#{schema}.#{host_table}"
160
159
 
161
- associated_table = find_associated_table(association, schema: schema)
162
- relation = find_matching_relation(fq_host_table, associated_table)
160
+ associated_table = find_associated_table(connection, association, schema: schema)
161
+ relation = find_matching_relation(connection, fq_host_table, associated_table)
163
162
 
164
163
  if fq_host_table == relation.belonging_table
165
164
  if order_by || limit
@@ -1,15 +1,12 @@
1
1
  # rubocop:disable Naming/UncommunicativeMethodParamName
2
2
 
3
3
  require_relative "association_loader"
4
- require "simple/sql/reflection"
5
4
 
6
5
  class ::Simple::SQL::Result::Records < ::Simple::SQL::Result
7
- Reflection = ::Simple::SQL::Reflection
8
-
9
- def initialize(records, target_type:, pg_source_oid:) # :nodoc:
6
+ def initialize(connection, records, target_type:, pg_source_oid:) # :nodoc:
10
7
  # expect! records.first => Hash unless records.empty?
11
8
 
12
- super(records)
9
+ super(connection, records)
13
10
 
14
11
  @hash_records = records
15
12
  @target_type = target_type
@@ -50,9 +47,10 @@ class ::Simple::SQL::Result::Records < ::Simple::SQL::Result
50
47
  # resolve oid into table and schema name.
51
48
  #
52
49
  # [TODO] is this still correct?
53
- schema, host_table = Reflection.lookup_pg_class @pg_source_oid
50
+ schema, host_table = connection.reflection.lookup_pg_class @pg_source_oid
54
51
 
55
- AssociationLoader.preload @hash_records, association,
52
+ AssociationLoader.preload connection,
53
+ @hash_records, association,
56
54
  host_table: host_table, schema: schema, as: as,
57
55
  order_by: order_by, limit: limit
58
56
 
@@ -69,7 +67,7 @@ class ::Simple::SQL::Result::Records < ::Simple::SQL::Result
69
67
  def materialize
70
68
  records = @hash_records
71
69
  if @target_type != Hash
72
- schema, host_table = Reflection.lookup_pg_class(@pg_source_oid)
70
+ schema, host_table = connection.reflection.lookup_pg_class(@pg_source_oid)
73
71
  records = RowConverter.convert_row(records, associations: @associations,
74
72
  into: @target_type,
75
73
  fq_table_name: "#{schema}.#{host_table}")
@@ -1,5 +1,6 @@
1
- # rubocop:disable Metrics/AbcSize
2
1
  # rubocop:disable Naming/AccessorMethodName
2
+ # rubocop:disable Style/DoubleNegation
3
+ # rubocop:disable Style/GuardClause
3
4
 
4
5
  require_relative "helpers"
5
6
 
@@ -19,51 +20,82 @@ class ::Simple::SQL::Result < Array
19
20
  # A Result object is requested via ::Simple::SQL::Result.build, which then
20
21
  # chooses the correct implementation, based on the <tt>target_type:</tt>
21
22
  # parameter.
22
- def self.build(records, target_type:, pg_source_oid:) # :nodoc:
23
+ def self.build(connection, records, target_type:, pg_source_oid:) # :nodoc:
23
24
  if target_type.nil?
24
- new(records)
25
+ new(connection, records)
25
26
  else
26
- Records.new(records, target_type: target_type, pg_source_oid: pg_source_oid)
27
+ Records.new(connection, records, target_type: target_type, pg_source_oid: pg_source_oid)
27
28
  end
28
29
  end
29
30
 
30
- def initialize(records) # :nodoc:
31
+ attr_reader :connection
32
+
33
+ def initialize(connection, records) # :nodoc:
34
+ @connection = connection
31
35
  replace(records)
32
36
  end
33
37
 
34
- # returns the total_count of search hits
38
+ # returns the (potentialy estimated) total count of results
39
+ #
40
+ # This is only available for paginated scopes
41
+ def total_fast_count
42
+ @total_fast_count ||= pagination_scope.fast_count
43
+ end
44
+
45
+ # returns the (potentialy estimated) total number of pages
46
+ #
47
+ # This is only available for paginated scopes
48
+ def total_fast_pages
49
+ @total_fast_pages ||= (total_fast_count * 1.0 / pagination_scope.per).ceil
50
+ end
51
+
52
+ # returns the (potentialy slow) exact total count of results
35
53
  #
36
- # This is filled in when resolving a paginated scope.
37
- attr_reader :total_count
54
+ # This is only available for paginated scopes
55
+ def total_count
56
+ @total_count ||= pagination_scope.count
57
+ end
38
58
 
39
- # returns the total number of pages of search hits
59
+ # returns the (potentialy estimated) total number of pages
40
60
  #
41
- # This is filled in when resolving a paginated scope. It takes
42
- # into account the scope's "per" option.
43
- attr_reader :total_pages
61
+ # This is only available for paginated scopes
62
+ def total_pages
63
+ @total_pages ||= (total_count * 1.0 / pagination_scope.per).ceil
64
+ end
44
65
 
45
66
  # returns the current page number in a paginated search
46
67
  #
47
- # This is filled in when resolving a paginated scope.
48
- attr_reader :current_page
68
+ # This is only available for paginated scopes
69
+ def current_page
70
+ @current_page ||= pagination_scope.page
71
+ end
72
+
73
+ def paginated?
74
+ !!@pagination_scope
75
+ end
49
76
 
50
77
  private
51
78
 
79
+ def pagination_scope
80
+ raise "Only available only for paginated scopes" unless paginated?
81
+
82
+ @pagination_scope
83
+ end
84
+
52
85
  def set_pagination_info(scope)
53
86
  raise ArgumentError, "per must be > 0" unless scope.per > 0
54
87
 
88
+ @pagination_scope = scope
89
+
90
+ # This branch is an optimization: the call to the database to count is
91
+ # not necessary if we know that there are not even any results on the
92
+ # first page.
55
93
  if scope.page <= 1 && empty?
56
- # This branch is an optimization: the call to the database to count is
57
- # not necessary if we know that there are not even any results on the
58
- # first page.
59
- @total_count = 0
60
94
  @current_page = 1
61
- else
62
- sql = "SELECT COUNT(*) FROM (#{scope.order_by(nil).to_sql(pagination: false)}) simple_sql_count"
63
- @total_count = ::Simple::SQL.ask(sql, *scope.args)
64
- @current_page = scope.page
95
+ @total_count = 0
96
+ @total_pages = 1
97
+ @total_fast_count = 0
98
+ @total_fast_pages = 1
65
99
  end
66
-
67
- @total_pages = (@total_count * 1.0 / scope.per).ceil
68
100
  end
69
101
  end
@@ -0,0 +1,33 @@
1
+ class Simple::SQL::Scope
2
+ EXACT_COUNT_THRESHOLD = 10_000
3
+
4
+ # Returns the exact count of matching records
5
+ def count
6
+ sql = order_by(nil).to_sql(pagination: false)
7
+ ::Simple::SQL.ask("SELECT COUNT(*) FROM (#{sql}) _total_count", *args)
8
+ end
9
+
10
+ # Returns the fast count of matching records
11
+ #
12
+ # For counts larger than EXACT_COUNT_THRESHOLD this returns an estimate
13
+ def fast_count
14
+ estimate = estimated_count
15
+ return estimate if estimate > EXACT_COUNT_THRESHOLD
16
+
17
+ sql = order_by(nil).to_sql(pagination: false)
18
+ ::Simple::SQL.ask("SELECT COUNT(*) FROM (#{sql}) _total_count", *args)
19
+ end
20
+
21
+ private
22
+
23
+ def estimated_count
24
+ sql = order_by(nil).to_sql(pagination: false)
25
+ ::Simple::SQL.each("EXPLAIN #{sql}", *args) do |line|
26
+ next unless line =~ /\brows=(\d+)/
27
+
28
+ return Integer($1)
29
+ end
30
+
31
+ -1
32
+ end
33
+ end
@@ -0,0 +1,79 @@
1
+ # rubocop:disable Metrics/AbcSize
2
+ # rubocop:disable Metrics/MethodLength
3
+
4
+ class Simple::SQL::Scope
5
+ # Potentially fast implementation of returning all different values for a specific group.
6
+ #
7
+ # For example:
8
+ #
9
+ # Scope.new("SELECT * FROM users").enumerate_groups("gender") -> [ "female", "male" ]
10
+ #
11
+ # It is possible to enumerate over multiple attributes, for example:
12
+ #
13
+ # scope.enumerate_groups fragment: "ARRAY[workflow, queue]"
14
+ #
15
+ # In any case it is important that an index exists that the database can use to group
16
+ # by the +sql_fragment+, for example:
17
+ #
18
+ # CREATE INDEX ix3 ON table((ARRAY[workflow, queue]));
19
+ #
20
+ def enumerate_groups(sql_fragment)
21
+ sql = order_by(nil).to_sql(pagination: false)
22
+
23
+ _, max_cost = ::Simple::SQL.costs "SELECT MIN(#{sql_fragment}) FROM (#{sql}) sq", *args
24
+ raise "enumerate_groups: takes too much time. Make sure to create a suitable index" if max_cost > 10_000
25
+
26
+ groups = []
27
+ var_name = "$#{@args.count + 1}"
28
+ cur = ::Simple::SQL.ask "SELECT MIN(#{sql_fragment}) FROM (#{sql}) sq", *args
29
+
30
+ while cur
31
+ groups << cur
32
+ cur = ::Simple::SQL.ask "SELECT MIN(#{sql_fragment}) FROM (#{sql}) sq"" WHERE #{sql_fragment} > #{var_name}", *args, cur
33
+ end
34
+
35
+ groups
36
+ end
37
+
38
+ def count_by(sql_fragment)
39
+ sql = order_by(nil).to_sql(pagination: false)
40
+
41
+ recs = ::Simple::SQL.all "SELECT #{sql_fragment} AS group, COUNT(*) AS count FROM (#{sql}) sq GROUP BY #{sql_fragment}", *args
42
+ Hash[recs]
43
+ end
44
+
45
+ def fast_count_by(sql_fragment)
46
+ sql = order_by(nil).to_sql(pagination: false)
47
+
48
+ _, max_cost = ::Simple::SQL.costs "SELECT COUNT(*) FROM (#{sql}) sq GROUP BY #{sql_fragment}", *args
49
+
50
+ return count_by(sql_fragment) if max_cost < 10_000
51
+
52
+ # iterate over all groups, estimating the count for each. If the count is
53
+ # less than EXACT_COUNT_THRESHOLD we ask for the exact count in that and
54
+ # similarily sparse groups.
55
+ var_name = "$#{@args.count + 1}"
56
+
57
+ counts = {}
58
+ sparse_groups = []
59
+ enumerate_groups(sql_fragment).each do |group|
60
+ scope = ::Simple::SQL::Scope.new("SELECT * FROM (#{sql}) sq WHERE #{sql_fragment}=#{var_name}", *args, group)
61
+ counts[group] = scope.send(:estimated_count)
62
+ sparse_groups << group if estimated_count < EXACT_COUNT_THRESHOLD
63
+ end
64
+
65
+ # fetch exact counts in all sparse_groups
66
+ unless sparse_groups.empty?
67
+ sparse_counts = ::Simple::SQL.all <<~SQL, *args, sparse_groups
68
+ SELECT #{sql_fragment} AS group, COUNT(*) AS count
69
+ FROM (#{sql}) sq
70
+ WHERE #{sql_fragment} = ANY(#{var_name})
71
+ GROUP BY #{sql_fragment}
72
+ SQL
73
+
74
+ counts.update Hash[sparse_counts]
75
+ end
76
+
77
+ counts
78
+ end
79
+ end
@@ -3,6 +3,8 @@
3
3
  require_relative "scope/filters.rb"
4
4
  require_relative "scope/order.rb"
5
5
  require_relative "scope/pagination.rb"
6
+ require_relative "scope/count.rb"
7
+ require_relative "scope/count_by_groups.rb"
6
8
 
7
9
  # The Simple::SQL::Scope class helps building scopes; i.e. objects
8
10
  # that start as a quite basic SQL query, and allow one to add
@@ -24,11 +26,11 @@ class Simple::SQL::Scope
24
26
  #
25
27
  # Simple::SQL::Scope.new(table: "mytable", select: "*", where: { id: 1, foo: "bar" }, order_by: "id desc")
26
28
  #
27
- def initialize(sql)
29
+ def initialize(sql, args = [])
28
30
  expect! sql => [String, Hash]
29
31
 
30
32
  @sql = nil
31
- @args = []
33
+ @args = args
32
34
  @filters = []
33
35
 
34
36
  case sql
@@ -1,5 +1,5 @@
1
1
  module Simple
2
2
  module SQL
3
- VERSION = "0.5.0"
3
+ VERSION = "0.5.2"
4
4
  end
5
5
  end
data/lib/simple/sql.rb CHANGED
@@ -3,17 +3,14 @@ require "logger"
3
3
  require "expectation"
4
4
 
5
5
  require_relative "sql/version"
6
+ require_relative "sql/fragment"
6
7
  require_relative "sql/helpers"
7
-
8
8
  require_relative "sql/result"
9
9
  require_relative "sql/config"
10
10
  require_relative "sql/logging"
11
11
  require_relative "sql/scope"
12
12
  require_relative "sql/connection_adapter"
13
13
  require_relative "sql/connection"
14
- require_relative "sql/reflection"
15
- require_relative "sql/insert"
16
- require_relative "sql/duplicate"
17
14
 
18
15
  module Simple
19
16
  # The Simple::SQL module
@@ -21,7 +18,10 @@ module Simple
21
18
  extend self
22
19
 
23
20
  extend Forwardable
24
- delegate [:ask, :all, :each, :exec, :locked, :print, :transaction, :wait_for_notify] => :default_connection
21
+ delegate [:ask, :all, :each, :exec, :locked, :print, :transaction, :wait_for_notify, :costs] => :default_connection
22
+ delegate [:reflection] => :default_connection
23
+ delegate [:duplicate] => :default_connection
24
+ delegate [:insert] => :default_connection
25
25
 
26
26
  delegate [:logger, :logger=] => ::Simple::SQL::Logging
27
27
 
@@ -35,6 +35,11 @@ module Simple
35
35
  Connection.create(database_url)
36
36
  end
37
37
 
38
+ # deprecated
39
+ def configuration
40
+ Config.parse_url(Config.determine_url)
41
+ end
42
+
38
43
  # -- default connection ---------------------------------------------------
39
44
 
40
45
  DEFAULT_CONNECTION_KEY = :"Simple::SQL.default_connection"
@@ -0,0 +1,44 @@
1
+ require "spec_helper"
2
+
3
+ describe "Simple::SQL::Scope#count_by" do
4
+ let!(:users) { 1.upto(10).map { |i| create(:user, role_id: i) } }
5
+ let(:all_role_ids) { SQL.all("SELECT DISTINCT role_id FROM users") }
6
+ let(:scope) { SQL::Scope.new("SELECT * FROM users") }
7
+
8
+ describe "enumerate_groups" do
9
+ it "returns all groups" do
10
+ expect(scope.enumerate_groups("role_id")).to contain_exactly(*all_role_ids)
11
+ expect(scope.where("role_id < 4").enumerate_groups("role_id")).to contain_exactly(*(1.upto(3).to_a))
12
+ end
13
+ end
14
+
15
+ describe "count_by" do
16
+ it "counts all groups" do
17
+ create(:user, role_id: 1)
18
+ create(:user, role_id: 1)
19
+ create(:user, role_id: 1)
20
+
21
+ expect(scope.count_by("role_id")).to include(1 => 4)
22
+ expect(scope.count_by("role_id")).to include(2 => 1)
23
+ expect(scope.count_by("role_id").keys).to contain_exactly(*all_role_ids)
24
+ end
25
+ end
26
+
27
+ describe "fast_count_by" do
28
+ before do
29
+ # 10_000 is chosen "magically". It is large enough to switch to the fast algorithm,
30
+ # but
31
+ allow(::Simple::SQL).to receive(:costs).and_return([0, 10_000])
32
+ end
33
+
34
+ it "counts all groups" do
35
+ create(:user, role_id: 1)
36
+ create(:user, role_id: 1)
37
+ create(:user, role_id: 1)
38
+
39
+ expect(scope.fast_count_by("role_id")).to include(1 => 4)
40
+ expect(scope.fast_count_by("role_id")).to include(2 => 1)
41
+ expect(scope.fast_count_by("role_id").keys).to contain_exactly(*all_role_ids)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,29 @@
1
+ require "spec_helper"
2
+
3
+ describe "Simple::SQL::Scope#count" do
4
+ let!(:users) { 1.upto(USER_COUNT).map { create(:user) } }
5
+ let(:min_user_id) { SQL.ask "SELECT min(id) FROM users" }
6
+ let(:scope) { SQL::Scope.new("SELECT * FROM users") }
7
+
8
+ describe "exact count" do
9
+ it "counts" do
10
+ expect(scope.count).to eq(USER_COUNT)
11
+ end
12
+
13
+ it "evaluates conditions" do
14
+ expect(scope.where("id < $1", min_user_id).count).to eq(0)
15
+ expect(scope.where("id <= $1", min_user_id).count).to eq(1)
16
+ end
17
+ end
18
+
19
+ describe "fast count" do
20
+ it "counts" do
21
+ expect(scope.fast_count).to eq(USER_COUNT)
22
+ end
23
+
24
+ it "evaluates conditions" do
25
+ expect(scope.where("id < $1", min_user_id).fast_count).to eq(0)
26
+ expect(scope.where("id <= $1", min_user_id).fast_count).to eq(1)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,16 @@
1
+ require "spec_helper"
2
+
3
+ describe "Simple::SQL logging" do
4
+ context 'when running a slow query' do
5
+ before do
6
+ SQL::Logging.slow_query_treshold = 0.05
7
+ end
8
+ after do
9
+ SQL::Logging.slow_query_treshold = nil
10
+ end
11
+
12
+ it "does not crash" do
13
+ SQL.ask "SELECT pg_sleep(0.1)"
14
+ end
15
+ end
16
+ end
@@ -1,23 +1,23 @@
1
1
  require "spec_helper"
2
2
 
3
- describe "Simple::SQL::Reflection" do
3
+ describe "Simple::SQL.reflection" do
4
4
  describe ".columns" do
5
5
  it "returns the columns of a table in the public schema" do
6
- expect(SQL::Reflection.columns("users")).to include("first_name")
6
+ expect(SQL.reflection.columns("users")).to include("first_name")
7
7
  end
8
8
 
9
9
  it "returns the columns of a table in a non-'public' schema" do
10
- expect(SQL::Reflection.columns("information_schema.tables")).to include("table_name")
10
+ expect(SQL.reflection.columns("information_schema.tables")).to include("table_name")
11
11
  end
12
12
  end
13
13
 
14
14
  describe ".tables" do
15
15
  it "returns the tables in the public schema" do
16
- expect(SQL::Reflection.tables).to include("public.users")
16
+ expect(SQL.reflection.tables).to include("public.users")
17
17
  end
18
18
 
19
19
  it "returns tables in a non-'public' schema" do
20
- expect(SQL::Reflection.tables(schema: "information_schema")).to include("information_schema.tables")
20
+ expect(SQL.reflection.tables(schema: "information_schema")).to include("information_schema.tables")
21
21
  end
22
22
  end
23
23
  end
@@ -0,0 +1,57 @@
1
+ require "spec_helper"
2
+
3
+ describe "Simple::SQL::Result counts" do
4
+ let!(:users) { 1.upto(USER_COUNT).map { create(:user) } }
5
+ let(:min_user_id) { SQL.ask "SELECT min(id) FROM users" }
6
+ let(:scope) { SQL::Scope.new("SELECT * FROM users") }
7
+ let(:paginated_scope) { scope.paginate(per: 1, page: 1) }
8
+
9
+ describe "exact counting" do
10
+ it "counts" do
11
+ result = SQL.all(paginated_scope)
12
+ expect(result.total_count).to eq(USER_COUNT)
13
+ expect(result.total_pages).to eq(USER_COUNT)
14
+ expect(result.current_page).to eq(1)
15
+ end
16
+ end
17
+
18
+ describe "fast counting" do
19
+ it "counts fast" do
20
+ result = SQL.all(paginated_scope)
21
+
22
+ expect(result.total_fast_count).to eq(USER_COUNT)
23
+ expect(result.total_fast_pages).to eq(USER_COUNT)
24
+ expect(result.current_page).to eq(1)
25
+ end
26
+ end
27
+
28
+ context 'when running with a non-paginated paginated_scope' do
29
+ it "raises errors" do
30
+ result = SQL.all(scope)
31
+
32
+ expect { result.total_count }.to raise_error(RuntimeError)
33
+ expect { result.total_pages }.to raise_error(RuntimeError)
34
+ expect { result.current_page }.to raise_error(RuntimeError)
35
+ expect { result.total_fast_count }.to raise_error(RuntimeError)
36
+ expect { result.total_fast_pages }.to raise_error(RuntimeError)
37
+ end
38
+ end
39
+
40
+
41
+ context 'when running with an empty, paginated paginated_scope' do
42
+ let(:scope) { SQL::Scope.new("SELECT * FROM users WHERE FALSE") }
43
+ let(:paginated_scope) { scope.paginate(per: 1, page: 1) }
44
+
45
+ it "returns correct results" do
46
+ result = SQL.all(paginated_scope)
47
+
48
+ expect(result.total_count).to eq(0)
49
+ expect(result.total_pages).to eq(1)
50
+
51
+ expect(result.total_fast_count).to eq(0)
52
+ expect(result.total_fast_pages).to eq(1)
53
+
54
+ expect(result.current_page).to eq(1)
55
+ end
56
+ end
57
+ end
data/spec/spec_helper.rb CHANGED
@@ -14,7 +14,9 @@ Dir.glob("./spec/support/**/*.rb").sort.each { |path| load path }
14
14
  require "simple/sql"
15
15
 
16
16
  unless ENV["USE_ACTIVE_RECORD"]
17
- Simple::SQL.connect!
17
+ database_url = Simple::SQL::Config.determine_url
18
+
19
+ Simple::SQL.connect! database_url
18
20
  Simple::SQL.ask "DELETE FROM users"
19
21
  end
20
22
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: simple-sql
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - radiospiel
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2019-03-04 00:00:00.000000000 Z
12
+ date: 2019-03-29 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: pg_array_parser
@@ -202,22 +202,25 @@ files:
202
202
  - lib/simple/sql/config.rb
203
203
  - lib/simple/sql/connection.rb
204
204
  - lib/simple/sql/connection/active_record_connection.rb
205
+ - lib/simple/sql/connection/duplicate.rb
206
+ - lib/simple/sql/connection/insert.rb
205
207
  - lib/simple/sql/connection/raw_connection.rb
208
+ - lib/simple/sql/connection/reflection.rb
206
209
  - lib/simple/sql/connection_adapter.rb
207
- - lib/simple/sql/duplicate.rb
208
210
  - lib/simple/sql/formatting.rb
211
+ - lib/simple/sql/fragment.rb
209
212
  - lib/simple/sql/helpers.rb
210
213
  - lib/simple/sql/helpers/decoder.rb
211
214
  - lib/simple/sql/helpers/encoder.rb
212
215
  - lib/simple/sql/helpers/immutable.rb
213
216
  - lib/simple/sql/helpers/row_converter.rb
214
- - lib/simple/sql/insert.rb
215
217
  - lib/simple/sql/logging.rb
216
- - lib/simple/sql/reflection.rb
217
218
  - lib/simple/sql/result.rb
218
219
  - lib/simple/sql/result/association_loader.rb
219
220
  - lib/simple/sql/result/records.rb
220
221
  - lib/simple/sql/scope.rb
222
+ - lib/simple/sql/scope/count.rb
223
+ - lib/simple/sql/scope/count_by_groups.rb
221
224
  - lib/simple/sql/scope/filters.rb
222
225
  - lib/simple/sql/scope/order.rb
223
226
  - lib/simple/sql/scope/pagination.rb
@@ -232,11 +235,15 @@ files:
232
235
  - spec/simple/sql/associations_spec.rb
233
236
  - spec/simple/sql/config_spec.rb
234
237
  - spec/simple/sql/conversion_spec.rb
238
+ - spec/simple/sql/count_by_groups_spec.rb
239
+ - spec/simple/sql/count_spec.rb
235
240
  - spec/simple/sql/duplicate_spec.rb
236
241
  - spec/simple/sql/duplicate_unique_spec.rb
237
242
  - spec/simple/sql/each_spec.rb
238
243
  - spec/simple/sql/insert_spec.rb
244
+ - spec/simple/sql/logging_spec.rb
239
245
  - spec/simple/sql/reflection_spec.rb
246
+ - spec/simple/sql/result_count_spec.rb
240
247
  - spec/simple/sql/scope_spec.rb
241
248
  - spec/simple/sql/version_spec.rb
242
249
  - spec/simple/sql_locked_spec.rb
@@ -267,8 +274,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
267
274
  - !ruby/object:Gem::Version
268
275
  version: '0'
269
276
  requirements: []
270
- rubyforge_project:
271
- rubygems_version: 2.7.6
277
+ rubygems_version: 3.0.2
272
278
  signing_key:
273
279
  specification_version: 4
274
280
  summary: SQL with a simple interface
@@ -279,11 +285,15 @@ test_files:
279
285
  - spec/simple/sql/associations_spec.rb
280
286
  - spec/simple/sql/config_spec.rb
281
287
  - spec/simple/sql/conversion_spec.rb
288
+ - spec/simple/sql/count_by_groups_spec.rb
289
+ - spec/simple/sql/count_spec.rb
282
290
  - spec/simple/sql/duplicate_spec.rb
283
291
  - spec/simple/sql/duplicate_unique_spec.rb
284
292
  - spec/simple/sql/each_spec.rb
285
293
  - spec/simple/sql/insert_spec.rb
294
+ - spec/simple/sql/logging_spec.rb
286
295
  - spec/simple/sql/reflection_spec.rb
296
+ - spec/simple/sql/result_count_spec.rb
287
297
  - spec/simple/sql/scope_spec.rb
288
298
  - spec/simple/sql/version_spec.rb
289
299
  - spec/simple/sql_locked_spec.rb
@@ -1,96 +0,0 @@
1
- # rubocop:disable Style/StructInheritance
2
-
3
- module Simple
4
- module SQL
5
- unless defined?(Fragment)
6
-
7
- class Fragment < Struct.new(:to_sql)
8
- end
9
-
10
- end
11
-
12
- def fragment(str)
13
- Fragment.new(str)
14
- end
15
-
16
- #
17
- # Creates duplicates of record in a table.
18
- #
19
- # This method handles timestamp columns (these will be set to the current
20
- # time) and primary keys (will be set to NULL.) You can pass in overrides
21
- # as a third argument for specific columns.
22
- #
23
- # Parameters:
24
- #
25
- # - ids: (Integer, Array<Integer>) primary key ids
26
- # - overrides: Hash[column_names => SQL::Fragment]
27
- #
28
- def duplicate(table, ids, overrides = {})
29
- ids = Array(ids)
30
- return [] if ids.empty?
31
-
32
- Duplicator.new(table, overrides).call(ids)
33
- end
34
-
35
- class Duplicator
36
- attr_reader :table_name, :custom_overrides
37
-
38
- def initialize(table_name, overrides)
39
- @table_name = table_name
40
- @custom_overrides = validated_overrides(overrides)
41
- end
42
-
43
- def call(ids)
44
- Simple::SQL.all query, ids
45
- rescue PG::UndefinedColumn => e
46
- raise ArgumentError, e.message
47
- end
48
-
49
- private
50
-
51
- # This stringify all keys of the overrides hash, and verifies that
52
- # all values in there are SQL::Fragments
53
- def validated_overrides(overrides)
54
- overrides.inject({}) do |hsh, (key, value)|
55
- unless value.is_a?(Fragment)
56
- raise ArgumentError, "Pass in override values via SQL.fragment (for #{value.inspect})"
57
- end
58
-
59
- hsh.update key.to_s => value.to_sql
60
- end
61
- end
62
-
63
- def timestamp_overrides
64
- Reflection.timestamp_columns(table_name).inject({}) do |hsh, column|
65
- hsh.update column => "now() AS #{column}"
66
- end
67
- end
68
-
69
- def copy_columns
70
- (Reflection.columns(table_name) - Reflection.primary_key_columns(table_name)).inject({}) do |hsh, column|
71
- hsh.update column => column
72
- end
73
- end
74
-
75
- # build SQL query
76
- def query
77
- sources = {}
78
-
79
- sources.update copy_columns
80
- sources.update timestamp_overrides
81
- sources.update custom_overrides
82
-
83
- # convert into an Array, to make sure that keys and values aka firsts
84
- # and lasts are always in the correct order.
85
- sources = sources.to_a
86
-
87
- <<~SQL
88
- INSERT INTO #{table_name}(#{sources.map(&:first).join(', ')})
89
- SELECT #{sources.map(&:last).join(', ')}
90
- FROM #{table_name}
91
- WHERE id = ANY($1) RETURNING id
92
- SQL
93
- end
94
- end
95
- end
96
- end
@@ -1,87 +0,0 @@
1
- # rubocop:disable Metrics/AbcSize
2
- # rubocop:disable Metrics/LineLength
3
- # rubocop:disable Layout/AlignHash
4
-
5
- module Simple
6
- module SQL
7
- #
8
- # - table_name - the name of the table
9
- # - records - a single hash of attributes or an array of hashes of attributes
10
- # - on_conflict - uses a postgres ON CONFLICT clause to ignore insert conflicts if true
11
- #
12
- def insert(table, records, on_conflict: nil, into: nil)
13
- if records.is_a?(Hash)
14
- insert_many(table, [records], on_conflict, into).first
15
- else
16
- insert_many(table, records, on_conflict, into)
17
- end
18
- end
19
-
20
- private
21
-
22
- def insert_many(table, records, on_conflict, into)
23
- return [] if records.empty?
24
-
25
- inserter = Inserter.create(table_name: table.to_s, columns: records.first.keys, on_conflict: on_conflict, into: into)
26
- inserter.insert(records: records)
27
- end
28
-
29
- class Inserter
30
- SQL = ::Simple::SQL
31
-
32
- @@inserters = {}
33
-
34
- def self.create(table_name:, columns:, on_conflict:, into:)
35
- expect! on_conflict => CONFICT_HANDLING.keys
36
-
37
- @@inserters[[table_name, columns, on_conflict, into]] ||= new(table_name: table_name, columns: columns, on_conflict: on_conflict, into: into)
38
- end
39
-
40
- #
41
- # - table_name - the name of the table
42
- # - columns - name of columns, as Array[String] or Array[Symbol]
43
- #
44
- def initialize(table_name:, columns:, on_conflict:, into:)
45
- raise ArgumentError, "Cannot insert a record without attributes" if columns.empty?
46
-
47
- @columns = columns
48
- @into = into
49
-
50
- cols = []
51
- vals = []
52
-
53
- cols += columns
54
- vals += columns.each_with_index.map { |_, idx| "$#{idx + 1}" }
55
-
56
- timestamp_columns = SQL::Reflection.timestamp_columns(table_name) - columns.map(&:to_s)
57
-
58
- cols += timestamp_columns
59
- vals += timestamp_columns.map { "now()" }
60
-
61
- returning = into ? "*" : "id"
62
-
63
- @sql = "INSERT INTO #{table_name} (#{cols.join(',')}) VALUES(#{vals.join(',')}) #{confict_handling(on_conflict)} RETURNING #{returning}"
64
- end
65
-
66
- CONFICT_HANDLING = {
67
- nil => "",
68
- :nothing => "ON CONFLICT DO NOTHING",
69
- :ignore => "ON CONFLICT DO NOTHING"
70
- }
71
-
72
- def confict_handling(on_conflict)
73
- CONFICT_HANDLING.fetch(on_conflict) do
74
- raise(ArgumentError, "Invalid on_conflict value #{on_conflict.inspect}")
75
- end
76
- end
77
-
78
- def insert(records:)
79
- SQL.transaction do
80
- records.map do |record|
81
- SQL.ask @sql, *record.values_at(*@columns), into: @into
82
- end
83
- end
84
- end
85
- end
86
- end
87
- end
@@ -1,106 +0,0 @@
1
- module Simple
2
- module SQL
3
- module Reflection
4
- extend self
5
-
6
- extend Forwardable
7
- delegate [:ask, :all, :records, :record] => ::Simple::SQL
8
-
9
- def tables(schema: "public")
10
- table_info(schema: schema).keys
11
- end
12
-
13
- def primary_key_columns(table_name)
14
- all <<~SQL, table_name
15
- SELECT pg_attribute.attname
16
- FROM pg_index
17
- JOIN pg_attribute ON pg_attribute.attrelid = pg_index.indrelid AND pg_attribute.attnum = ANY(pg_index.indkey)
18
- WHERE pg_index.indrelid = $1::regclass
19
- AND pg_index.indisprimary;
20
- SQL
21
- end
22
-
23
- TIMESTAMP_COLUMN_NAMES = %w(inserted_at created_at updated_at)
24
-
25
- # timestamp_columns are columns that will be set automatically after
26
- # inserting or updating a record. This includes:
27
- #
28
- # - inserted_at (for Ecto)
29
- # - created_at (for ActiveRecord)
30
- # - updated_at (for Ecto and ActiveRecord)
31
- def timestamp_columns(table_name)
32
- columns(table_name) & TIMESTAMP_COLUMN_NAMES
33
- end
34
-
35
- def columns(table_name)
36
- column_info(table_name).keys
37
- end
38
-
39
- def table_info(schema: "public")
40
- recs = all <<~SQL, schema, into: Hash
41
- SELECT table_schema || '.' || table_name AS name, *
42
- FROM information_schema.tables
43
- WHERE table_schema=$1
44
- SQL
45
- records_by_attr(recs, :name)
46
- end
47
-
48
- def column_info(table_name)
49
- @column_info ||= {}
50
- @column_info[table_name] ||= _column_info(table_name)
51
- end
52
-
53
- private
54
-
55
- def _column_info(table_name)
56
- schema, table_name = parse_table_name(table_name)
57
- recs = all <<~SQL, schema, table_name, into: Hash
58
- SELECT
59
- column_name AS name,
60
- *
61
- FROM information_schema.columns
62
- WHERE table_schema=$1 AND table_name=$2
63
- SQL
64
-
65
- records_by_attr(recs, :column_name)
66
- end
67
-
68
- def parse_table_name(table_name)
69
- p1, p2 = table_name.split(".", 2)
70
- if p2
71
- [p1, p2]
72
- else
73
- ["public", p1]
74
- end
75
- end
76
-
77
- def records_by_attr(records, attr)
78
- records.inject({}) do |hsh, record|
79
- record.reject! { |_k, v| v.nil? }
80
- hsh.update record[attr] => OpenStruct.new(record)
81
- end
82
- end
83
-
84
- public
85
-
86
- def looked_up_pg_classes
87
- @looked_up_pg_classes ||= Hash.new { |hsh, key| hsh[key] = _lookup_pg_class(key) }
88
- end
89
-
90
- def lookup_pg_class(oid)
91
- looked_up_pg_classes[oid]
92
- end
93
-
94
- private
95
-
96
- def _lookup_pg_class(oid)
97
- ::Simple::SQL.ask <<~SQL, oid
98
- SELECT nspname AS schema, relname AS host_table
99
- FROM pg_class
100
- JOIN pg_namespace ON pg_namespace.oid=pg_class.relnamespace
101
- WHERE pg_class.oid=$1
102
- SQL
103
- end
104
- end
105
- end
106
- end