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 +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
|