simple-sql 0.5.0 → 0.5.2

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