simple-sql 0.4.9 → 0.4.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,72 @@
1
+ # xrubocop:disable Metrics/ParameterLists
2
+
3
+ require_relative "association_loader"
4
+
5
+ class ::Simple::SQL::Result::Records < ::Simple::SQL::Result::Rows
6
+ def initialize(records, target_type:, pg_source_oid:) # :nodoc:
7
+ expect! records.first => Hash unless records.empty?
8
+
9
+ super(records)
10
+
11
+ @hash_records = records
12
+ @target_type = target_type
13
+ @pg_source_oid = pg_source_oid
14
+
15
+ materialize
16
+ end
17
+
18
+ # -- preload associations -------------------------------------------------
19
+
20
+ AssociationLoader = ::Simple::SQL::Result::AssociationLoader
21
+
22
+
23
+ # Preloads an association.
24
+ #
25
+ # This can now be used as follows:
26
+ #
27
+ # scope = SQL::Scope.new("SELECT * FROM users")
28
+ # results = SQL.all scope, into: :struct
29
+ # results.preload(:organization)
30
+ #
31
+ # The preload method uses foreign key definitions in the database to figure out
32
+ # which table to load from.
33
+ #
34
+ # This method is only available if <tt>into:</tt> was set in the call to <tt>SQL.all</tt>.
35
+ # It raises an error otherwise.
36
+ #
37
+ # Parameters:
38
+ #
39
+ # - association: the name of the association.
40
+ # - as: the target name of the association.
41
+ # - order_by: if set describes ordering; see Scope#order_by.
42
+ # - limit: if set describes limits; see Scope#order_by.
43
+ def preload(association, as: nil, order_by: nil, limit: nil)
44
+ expect! association => Symbol
45
+ expect! as => [nil, Symbol]
46
+
47
+ # resolve oid into table and schema name.
48
+ schema, host_table = SQL.ask <<~SQL, @pg_source_oid
49
+ SELECT nspname AS schema, relname AS host_table
50
+ FROM pg_class
51
+ JOIN pg_namespace ON pg_namespace.oid=pg_class.relnamespace
52
+ WHERE pg_class.oid=$1
53
+ SQL
54
+
55
+ AssociationLoader.preload @hash_records, association,
56
+ host_table: host_table, schema: schema, as: as,
57
+ order_by: order_by, limit: limit
58
+ materialize
59
+ end
60
+
61
+ private
62
+
63
+ # convert the records into the target type.
64
+ RowConverter = ::Simple::SQL::Helpers::RowConverter
65
+
66
+ def materialize
67
+ records = @hash_records
68
+ records = RowConverter.convert(records, into: @target_type) if @target_type != Hash
69
+
70
+ replace(records)
71
+ end
72
+ end
@@ -0,0 +1,30 @@
1
+ # rubocop:disable Metrics/AbcSize
2
+ # rubocop:disable Naming/AccessorMethodName
3
+
4
+ class ::Simple::SQL::Result::Rows < ::Simple::SQL::Result
5
+ def initialize(records)
6
+ replace(records)
7
+ end
8
+
9
+ # -- pagination info ------------------------------------------------------
10
+
11
+ private
12
+
13
+ def set_pagination_info(scope)
14
+ raise ArgumentError, "per must be > 0" unless scope.per > 0
15
+
16
+ if scope.page <= 1 && empty?
17
+ # This branch is an optimization: the call to the database to count is
18
+ # not necessary if we know that there are not even any results on the
19
+ # first page.
20
+ @total_count = 0
21
+ @current_page = 1
22
+ else
23
+ sql = "SELECT COUNT(*) FROM (#{scope.order_by(nil).to_sql(pagination: false)}) simple_sql_count"
24
+ @total_count = ::Simple::SQL.ask(sql, *scope.args)
25
+ @current_page = scope.page
26
+ end
27
+
28
+ @total_pages = (@total_count * 1.0 / scope.per).ceil
29
+ end
30
+ end
@@ -14,15 +14,51 @@ class Simple::SQL::Scope
14
14
  attr_reader :per, :page
15
15
 
16
16
  # Build a scope object
17
+ #
18
+ # This call supports a few variants:
19
+ #
20
+ # Simple::SQL::Scope.new("SELECT * FROM mytable")
21
+ # Simple::SQL::Scope.new(table: "mytable", select: "*")
22
+ #
23
+ # The second option also allows one to pass in more options, like the following:
24
+ #
25
+ # Simple::SQL::Scope.new(table: "mytable", select: "*", where: { id: 1, foo: "bar" }, order_by: "id desc")
26
+ #
17
27
  def initialize(sql)
18
- @sql = sql
19
- @args = []
28
+ expect! sql => [String, Hash]
29
+
30
+ @sql = nil
31
+ @args = []
20
32
  @filters = []
21
- @order_by = nil
33
+
34
+ case sql
35
+ when String then @sql = sql
36
+ when Hash then initialize_from_hash(sql)
37
+ end
22
38
  end
23
39
 
24
40
  private
25
41
 
42
+ # rubocop:disable Metrics/AbcSize
43
+ def initialize_from_hash(hsh)
44
+ actual_keys = hsh.keys
45
+ valid_keys = [:table, :select, :where, :order_by]
46
+ extra_keys = actual_keys - valid_keys
47
+ raise ArgumentError, "Extra keys #{extra_keys.inspect}; allowed are #{valid_keys.inspect}" unless extra_keys.empty?
48
+
49
+ # -- set table and select -------------------------------------------------
50
+
51
+ table = hsh[:table] || raise(ArgumentError, "Missing :table option")
52
+ select = hsh[:select] || "*"
53
+
54
+ @sql = "SELECT #{Array(select).join(', ')} FROM #{table}"
55
+
56
+ # -- apply conditions, if any ---------------------------------------------
57
+
58
+ where!(hsh[:where]) unless hsh[:where].nil?
59
+ order_by!(hsh[:order_by]) unless hsh[:order_by].nil?
60
+ end
61
+
26
62
  def duplicate
27
63
  dupe = SELF.new(@sql)
28
64
  dupe.instance_variable_set :@args, @args.dup
@@ -30,6 +66,7 @@ class Simple::SQL::Scope
30
66
  dupe.instance_variable_set :@per, @per
31
67
  dupe.instance_variable_set :@page, @page
32
68
  dupe.instance_variable_set :@order_by_fragment, @order_by_fragment
69
+ dupe.instance_variable_set :@limit, @limit
33
70
  dupe
34
71
  end
35
72
 
@@ -41,23 +78,9 @@ class Simple::SQL::Scope
41
78
 
42
79
  sql = @sql
43
80
  sql = apply_filters(sql)
44
- sql = apply_order(sql)
81
+ sql = apply_order_and_limit(sql)
45
82
  sql = apply_pagination(sql, pagination: pagination)
46
83
 
47
84
  sql
48
85
  end
49
-
50
- # The Scope::PageInfo module can be mixed into other objects to
51
- # hold total_count, total_pages, and current_page.
52
- module PageInfo
53
- attr_reader :total_count, :total_pages, :current_page
54
-
55
- def self.attach(results, total_count:, per:, page:)
56
- results.extend(self)
57
- results.instance_variable_set :@total_count, total_count
58
- results.instance_variable_set :@total_pages, (total_count + (per - 1)) / per
59
- results.instance_variable_set :@current_page, page
60
- results
61
- end
62
- end
63
86
  end
@@ -5,6 +5,10 @@ class Simple::SQL::Scope
5
5
  duplicate.send(:order_by!, sql_fragment)
6
6
  end
7
7
 
8
+ def limit(count)
9
+ duplicate.send(:limit!, count)
10
+ end
11
+
8
12
  private
9
13
 
10
14
  # Adjust sort order
@@ -13,9 +17,17 @@ class Simple::SQL::Scope
13
17
  self
14
18
  end
15
19
 
20
+ # Adjust sort order
21
+ def limit!(count)
22
+ @limit = count
23
+ self
24
+ end
25
+
16
26
  # called from to_sql
17
- def apply_order(sql)
18
- return sql unless @order_by_fragment
19
- "#{sql} ORDER BY #{@order_by_fragment}"
27
+ def apply_order_and_limit(sql)
28
+ sql = "#{sql} ORDER BY #{@order_by_fragment}" if @order_by_fragment
29
+ sql = "#{sql} LIMIT #{@limit}" if @limit
30
+
31
+ sql
20
32
  end
21
33
  end
@@ -1,4 +1,3 @@
1
- # rubocop:disable Metrics/MethodLength
2
1
  # rubocop:disable Style/IfUnlessModifier
3
2
 
4
3
  # private
@@ -1,5 +1,5 @@
1
1
  module Simple
2
2
  module SQL
3
- VERSION = "0.4.9"
3
+ VERSION = "0.4.10"
4
4
  end
5
5
  end
data/simple-sql.gemspec CHANGED
@@ -29,6 +29,7 @@ Gem::Specification.new do |gem|
29
29
 
30
30
  gem.add_dependency 'pg_array_parser', '~> 0'
31
31
  gem.add_dependency 'pg', '~> 0.20'
32
+ gem.add_dependency 'expectation', '~> 1'
32
33
 
33
34
  # optional gems (required by some of the parts)
34
35
 
@@ -1,17 +1,76 @@
1
1
  require "spec_helper"
2
2
 
3
- describe "Simple::SQL.all into: argument" do
3
+ describe "Simple::SQL.ask into: :struct" do
4
4
  let!(:users) { 1.upto(USER_COUNT).map { create(:user) } }
5
5
 
6
- it "calls the database" do
7
- r = SQL.all("SELECT * FROM users", into: Hash)
8
- expect(r).to be_a(Array)
9
- expect(r.length).to eq(USER_COUNT)
10
- expect(r.map(&:class).uniq).to eq([Hash])
6
+ describe "all into: X" do
7
+ it "calls the database" do
8
+ r = SQL.all("SELECT id FROM users", into: Hash)
9
+ expect(r).to be_a(Array)
10
+ expect(r.length).to eq(USER_COUNT)
11
+ expect(r.map(&:class).uniq).to eq([Hash])
12
+ end
13
+
14
+ it "returns an empty array when there is no match" do
15
+ r = SQL.all("SELECT * FROM users WHERE FALSE", into: Hash)
16
+ expect(r).to eq([])
17
+ end
18
+
19
+ it "yields the results into a block" do
20
+ received = []
21
+ SQL.all("SELECT id FROM users", into: Hash) do |hsh|
22
+ received << hsh
23
+ end
24
+ expect(received.length).to eq(USER_COUNT)
25
+ expect(received.map(&:class).uniq).to eq([Hash])
26
+ end
27
+
28
+ it "does not yield if there is no match" do
29
+ received = []
30
+ SQL.all("SELECT id FROM users WHERE FALSE", into: Hash) do |hsh|
31
+ received << hsh
32
+ end
33
+ expect(received.length).to eq(0)
34
+ end
35
+ end
36
+
37
+ describe "into: :struct" do
38
+ it "calls the database" do
39
+ r = SQL.ask("SELECT COUNT(*) AS count FROM users", into: :struct)
40
+ expect(r.count).to eq(2)
41
+ expect(r.class.members).to eq([:count])
42
+ end
43
+
44
+ it "reuses the struct Class" do
45
+ r1 = SQL.ask("SELECT COUNT(*) AS count FROM users", into: :struct)
46
+ r2 = SQL.ask("SELECT COUNT(*) AS count FROM users", into: :struct)
47
+ expect(r1.class.object_id).to eq(r2.class.object_id)
48
+ end
49
+ end
50
+
51
+ describe "into: Hash" do
52
+ it "calls the database" do
53
+ r = SQL.ask("SELECT COUNT(*) AS count FROM users", into: Hash)
54
+ expect(r).to eq({count: 2})
55
+ end
56
+
57
+ it "returns nil when there is no match" do
58
+ r = SQL.ask("SELECT * FROM users WHERE FALSE", into: Hash)
59
+ expect(r).to be_nil
60
+ end
11
61
  end
62
+
63
+ describe "into: OpenStruct" do
64
+ it "returns a OpenStruct with into: OpenStruct" do
65
+ r = SQL.ask("SELECT COUNT(*) AS count FROM users", into: OpenStruct)
66
+ expect(r).to be_a(OpenStruct)
67
+ expect(r).to eq(OpenStruct.new(count: 2))
68
+ end
12
69
 
13
- it "returns an empty array when there is no match" do
14
- r = SQL.all("SELECT * FROM users WHERE FALSE", into: Hash)
15
- expect(r).to eq([])
70
+ it "supports the into: option even with parameters" do
71
+ r = SQL.ask("SELECT $1::integer AS count FROM users", 2, into: OpenStruct)
72
+ expect(r).to be_a(OpenStruct)
73
+ expect(r).to eq(OpenStruct.new(count: 2))
74
+ end
16
75
  end
17
76
  end
@@ -0,0 +1,142 @@
1
+ require "spec_helper"
2
+
3
+ module ArrayPluck
4
+ refine Array do
5
+ def pluck(key)
6
+ map { |e| e.fetch(key) }
7
+ end
8
+ end
9
+ end
10
+
11
+ using ArrayPluck
12
+
13
+ describe "Simple::SQL::Result#preload" do
14
+ let!(:org1) { create(:organization) }
15
+ let!(:users1) { 1.upto(USER_COUNT).map { create(:user, organization_id: org1.id) } }
16
+ let!(:org2) { create(:organization) }
17
+ let!(:users2) { 1.upto(USER_COUNT).map { create(:user, organization_id: org2.id) } }
18
+ let!(:users) { 1.upto(USER_COUNT).map { create(:user) } }
19
+
20
+ # The block below checks that the factories are set up correctly.
21
+ describe "factories used in spec" do
22
+ it "builds correct objects" do
23
+ expect(org1.id).not_to eq(org2.id)
24
+ expect(users1.map(&:organization_id).uniq).to eq([org1.id])
25
+ expect(users2.map(&:organization_id).uniq).to eq([org2.id])
26
+ expect(users.map(&:organization_id).uniq).to eq([nil])
27
+ end
28
+ end
29
+
30
+ # describe "correctness" do
31
+ # it "resolves a belongs_to association" do
32
+ # users = SQL.all "SELECT * FROM users WHERE organization_id=$1", org1.id, into: Hash
33
+ # users.preload :organization
34
+ # expect(users.first[:organization]).to eq(org1.to_h)
35
+ # end
36
+ #
37
+ # it "resolves a has_many association" do
38
+ # organizations = SQL.all "SELECT * FROM organizations", into: Hash
39
+ # organizations.preload :users
40
+ # expect(organizations.first[:users]).to eq(users1.map(&:to_h))
41
+ # end
42
+ #
43
+ # it "resolves a has_one association" do
44
+ # organizations = SQL.all "SELECT * FROM organizations", into: Hash
45
+ # organizations.preload :user
46
+ #
47
+ # organization = organizations.first
48
+ # users_of_organization = SQL.all "SELECT * FROM users WHERE organization_id=$1", organization[:id], into: Hash
49
+ # expect(users_of_organization).to include(organization[:user])
50
+ # end
51
+ # end
52
+
53
+ describe "automatic detection via foreign keys" do
54
+ it "detects a belongs_to association" do
55
+ users = SQL.all "SELECT * FROM users WHERE organization_id=$1", org1.id, into: Hash
56
+ users.preload :organization
57
+ expect(users.first[:organization]).to eq(org1.to_h)
58
+ end
59
+
60
+ it "detects a has_many association" do
61
+ organizations = SQL.all "SELECT * FROM organizations", into: Hash
62
+ organizations.preload :users
63
+ expect(organizations.first[:users]).to eq(users1.map(&:to_h))
64
+ end
65
+
66
+ it "detects a has_one association" do
67
+ organizations = SQL.all "SELECT * FROM organizations", into: Hash
68
+ organizations.preload :user
69
+
70
+ organization = organizations.first
71
+ users_of_organization = SQL.all "SELECT * FROM users WHERE organization_id=$1", organization[:id], into: Hash
72
+ expect(users_of_organization).to include(organization[:user])
73
+ end
74
+ end
75
+
76
+ describe ":as option" do
77
+ it "renames a belongs_to association" do
78
+ users = SQL.all "SELECT * FROM users WHERE organization_id=$1", org1.id, into: Hash
79
+ users.preload :organization, as: :org
80
+
81
+ expect(users.first.keys).not_to include(:organization)
82
+ expect(users.first[:org]).to eq(org1.to_h)
83
+ end
84
+
85
+ it "renames a has_many association" do
86
+ organizations = SQL.all "SELECT * FROM organizations", into: Hash
87
+ organizations.preload :users, as: :members
88
+
89
+ expect(organizations.first.keys).not_to include(:users)
90
+ expect(organizations.first[:members]).to eq(users1.map(&:to_h))
91
+ end
92
+
93
+ it "detects a has_one association" do
94
+ organizations = SQL.all "SELECT * FROM organizations", into: Hash
95
+ organizations.preload :user, as: :usr
96
+ expect(organizations.first.keys).not_to include(:user)
97
+
98
+ organization = organizations.first
99
+ users_of_organization = SQL.all "SELECT * FROM users WHERE organization_id=$1", organization[:id], into: Hash
100
+ expect(users_of_organization).to include(organization[:usr])
101
+ end
102
+ end
103
+
104
+ describe ":order_by" do
105
+ it "supports order_by" do
106
+ organizations = SQL.all "SELECT * FROM organizations", into: Hash
107
+ organizations.preload :users, order_by: "id"
108
+ users = organizations.first[:users]
109
+
110
+ ordered_user_ids = SQL.all("SELECT id FROM users WHERE organization_id=$1 ORDER BY id", organizations.first[:id])
111
+ expect(users.pluck(:id)).to eq(ordered_user_ids)
112
+ end
113
+
114
+ it "supports order_by DESC" do
115
+ organizations = SQL.all "SELECT * FROM organizations", into: Hash
116
+ organizations.preload :users, order_by: "id DESC"
117
+ users = organizations.first[:users]
118
+
119
+ ordered_user_ids = SQL.all("SELECT id FROM users WHERE organization_id=$1 ORDER BY id", organizations.first[:id])
120
+ expect(users.pluck(:id)).to eq(ordered_user_ids.reverse)
121
+ end
122
+ end
123
+
124
+ describe ":limit" do
125
+ xit "limits the number of returned records" do
126
+ organizations = SQL.all "SELECT * FROM organizations", into: Hash
127
+ organizations.preload :users, limit: 1
128
+
129
+ expect(organizations.first[:users].length).to eq(1)
130
+ end
131
+
132
+ xit "limits the number of returned records" do
133
+ organizations = SQL.all "SELECT * FROM organizations", into: Hash
134
+ organizations.preload :users, limit: 2, order_by: "id"
135
+ users = organizations.first[:users]
136
+ expect(users.length).to eq(2)
137
+
138
+ ordered_user_ids = SQL.all("SELECT id FROM users WHERE organization_id=$1 ORDER BY id", organizations.first[:id])
139
+ expect(users.pluck(:id)).to eq(ordered_user_ids)
140
+ end
141
+ end
142
+ end