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 +4 -4
- data/lib/simple/sql/connection/duplicate.rb +86 -0
- data/lib/simple/sql/connection/insert.rb +73 -0
- data/lib/simple/sql/connection/reflection.rb +107 -0
- data/lib/simple/sql/connection.rb +10 -3
- data/lib/simple/sql/connection_adapter.rb +11 -1
- data/lib/simple/sql/fragment.rb +11 -0
- data/lib/simple/sql/logging.rb +1 -1
- data/lib/simple/sql/result/association_loader.rb +7 -8
- data/lib/simple/sql/result/records.rb +6 -8
- data/lib/simple/sql/result.rb +56 -24
- data/lib/simple/sql/scope/count.rb +33 -0
- data/lib/simple/sql/scope/count_by_groups.rb +79 -0
- data/lib/simple/sql/scope.rb +4 -2
- data/lib/simple/sql/version.rb +1 -1
- data/lib/simple/sql.rb +10 -5
- data/spec/simple/sql/count_by_groups_spec.rb +44 -0
- data/spec/simple/sql/count_spec.rb +29 -0
- data/spec/simple/sql/logging_spec.rb +16 -0
- data/spec/simple/sql/reflection_spec.rb +5 -5
- data/spec/simple/sql/result_count_spec.rb +57 -0
- data/spec/spec_helper.rb +3 -1
- metadata +17 -7
- data/lib/simple/sql/duplicate.rb +0 -96
- data/lib/simple/sql/insert.rb +0 -87
- data/lib/simple/sql/reflection.rb +0 -106
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 76825a68d4370b6f5e870a09f2013502706b0f35a83a911e5451fc147a9a1940
|
4
|
+
data.tar.gz: 4093415861c077fbdf633dd1d347c6a6e2b2ed3da7df0c95dfaa078919d9b5d4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/lib/simple/sql/logging.rb
CHANGED
@@ -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 =
|
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 =
|
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
|
-
|
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 =
|
50
|
+
schema, host_table = connection.reflection.lookup_pg_class @pg_source_oid
|
54
51
|
|
55
|
-
AssociationLoader.preload
|
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 =
|
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}")
|
data/lib/simple/sql/result.rb
CHANGED
@@ -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
|
-
|
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
|
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
|
37
|
-
|
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
|
59
|
+
# returns the (potentialy estimated) total number of pages
|
40
60
|
#
|
41
|
-
# This is
|
42
|
-
|
43
|
-
|
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
|
48
|
-
|
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
|
-
|
62
|
-
|
63
|
-
@
|
64
|
-
@
|
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
|
data/lib/simple/sql/scope.rb
CHANGED
@@ -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
|
data/lib/simple/sql/version.rb
CHANGED
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
|
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
|
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
|
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
|
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
|
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.
|
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.
|
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-
|
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
|
-
|
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
|
data/lib/simple/sql/duplicate.rb
DELETED
@@ -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
|
data/lib/simple/sql/insert.rb
DELETED
@@ -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
|