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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 272c5b7847b93e3eba9c7705e48270e5c1ccde3182d230506693e36d6f35ba9d
4
- data.tar.gz: ec175e9dce36cc4869c0dbe9848285c7f279174232324e746dcace81d4ddf34d
3
+ metadata.gz: f24d06442bae77c5c1a51362e9ef1ac83e50034e6ce1b47b480ed8290f40e2b3
4
+ data.tar.gz: b2b0a88578e3c977ecf88739512d94a39bcbc8ce9eafac7225e02a925300ac9a
5
5
  SHA512:
6
- metadata.gz: ec84f765746d3ffaf73b1eaf108b50f712be07e66429cbd702c19385fc008aad5eef1c7a84244790a41b710c74996ec790b17b19158a43d721c0d0ac0e3c16f6
7
- data.tar.gz: 8c5c6fb5cf4c3e2e6d4d4f5400714f056c9be11727d4ddc9ef97ce8fa1da79540c9fbd11dfe90fe299da8fd87acb73416938e3d766b8ba8d02106d0b127bccda
6
+ metadata.gz: 2ebd2eda811287cf9dbc8ec9deefb512aa7e5b02fbeb3d8c789d254958ff2d7148c16a65fb529ef672a7626f2e8871822493bb8444e602c112070b5be66f6aa5
7
+ data.tar.gz: bdfff2c6a865217aad9507ba6550b8122bc9f449a7cb8a09cc393ce97d75a60eec79a21f0edef183addc73e4378656e099b2ce84871f5a7e2b9721bbff670926
data/.rubocop.yml CHANGED
@@ -12,6 +12,9 @@ AllCops:
12
12
  Metrics/LineLength:
13
13
  Max: 120
14
14
 
15
+ Metrics/MethodLength:
16
+ Max: 20
17
+
15
18
  Style/SpecialGlobalVars:
16
19
  Enabled: false
17
20
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- simple-sql (0.4.9)
4
+ simple-sql (0.4.10)
5
+ expectation (~> 1)
5
6
  pg (~> 0.20)
6
7
  pg_array_parser (~> 0)
7
8
 
@@ -29,6 +30,7 @@ GEM
29
30
  database_cleaner (1.6.2)
30
31
  diff-lcs (1.3)
31
32
  docile (1.1.5)
33
+ expectation (1.1.1)
32
34
  i18n (0.9.1)
33
35
  concurrent-ruby (~> 1.0)
34
36
  json (2.1.0)
data/lib/simple/sql.rb CHANGED
@@ -1,18 +1,20 @@
1
1
  require "forwardable"
2
2
  require "logger"
3
-
4
- require_relative "sql/version.rb"
5
- require_relative "sql/decoder.rb"
6
- require_relative "sql/encoder.rb"
7
- require_relative "sql/config.rb"
8
- require_relative "sql/logging.rb"
9
- require_relative "sql/simple_transactions.rb"
10
- require_relative "sql/scope.rb"
11
- require_relative "sql/connection_adapter.rb"
12
- require_relative "sql/connection.rb"
13
- require_relative "sql/reflection.rb"
14
- require_relative "sql/insert.rb"
15
- require_relative "sql/duplicate.rb"
3
+ require "expectation"
4
+
5
+ require_relative "sql/version"
6
+ require_relative "sql/helpers"
7
+
8
+ require_relative "sql/result"
9
+ require_relative "sql/config"
10
+ require_relative "sql/logging"
11
+ require_relative "sql/simple_transactions"
12
+ require_relative "sql/scope"
13
+ require_relative "sql/connection_adapter"
14
+ require_relative "sql/connection"
15
+ require_relative "sql/reflection"
16
+ require_relative "sql/insert"
17
+ require_relative "sql/duplicate"
16
18
 
17
19
  module Simple
18
20
  # The Simple::SQL module
@@ -1,6 +1,5 @@
1
1
  # rubocop:disable Metrics/AbcSize
2
2
  # rubocop:disable Metrics/CyclomaticComplexity
3
- # rubocop:disable Metrics/MethodLength
4
3
  # rubocop:disable Metrics/PerceivedComplexity
5
4
 
6
5
  # private
@@ -9,11 +8,9 @@ module Simple::SQL::Config
9
8
 
10
9
  # parse a DATABASE_URL, return PG::Connection settings.
11
10
  def parse_url(url)
12
- require "uri"
13
-
14
- raise ArgumentError, "Invalid URL #{url.inspect}" unless url.is_a?(String)
15
- raise ArgumentError, "Invalid URL #{url.inspect}" unless url =~ /^postgres(ql)?s?:\/\//
11
+ expect! url => /^postgres(ql)?s?:\/\//
16
12
 
13
+ require "uri"
17
14
  uri = URI.parse(url)
18
15
  raise ArgumentError, "Invalid URL #{url}" unless uri.hostname && uri.path
19
16
 
@@ -1,16 +1,13 @@
1
1
  # rubocop:disable Style/IfUnlessModifier
2
- # rubocop:disable Metrics/AbcSize
3
- # rubocop:disable Metrics/MethodLength
4
2
 
5
3
  # This module implements an adapter between the Simple::SQL interface
6
4
  # (i.e. ask, all, first, transaction) and a raw connection.
7
5
  #
8
6
  # This module can be mixed onto objects that implement a raw_connection
9
7
  # method, which must return a Pg::Connection.
8
+
10
9
  module Simple::SQL::ConnectionAdapter
11
10
  Logging = ::Simple::SQL::Logging
12
- Encoder = ::Simple::SQL::Encoder
13
- Decoder = ::Simple::SQL::Decoder
14
11
  Scope = ::Simple::SQL::Scope
15
12
 
16
13
  # execute one or more sql statements. This method does not allow to pass in
@@ -37,12 +34,29 @@ module Simple::SQL::ConnectionAdapter
37
34
  # end
38
35
 
39
36
  def all(sql, *args, into: nil, &block)
40
- result = exec_logged(sql, *args)
41
- result = enumerate(result, into: into, &block)
37
+ pg_result = exec_logged(sql, *args)
38
+
39
+ # enumerate the rows in pg_result. This returns either an Array of Hashes
40
+ # (if into is set), or an array of row arrays or of singular values.
41
+ #
42
+ # Even if into is set to something different than a Hash, we'll convert
43
+ # each row into a Hash initially, and only later convert it to the final
44
+ # target type (via RowConverter.convert). This is to allow to fill in
45
+ # more entries later on.
46
+ records = enumerate(pg_result, into: into)
47
+
48
+ # optimization: If we wouldn't clear here the GC would do this later.
49
+ pg_result.clear unless pg_result.autoclear?
50
+
51
+ # [TODO] - resolve associations. Note that this is only possible if the type
52
+ # is not an Array (i.e. into is nil)
53
+
42
54
  if sql.is_a?(Scope) && sql.paginated?
43
- add_page_info(sql, result)
55
+ records.send(:set_pagination_info, sql)
44
56
  end
45
- result
57
+
58
+ records.each(&block) if block
59
+ records
46
60
  end
47
61
 
48
62
  # Runs a query and returns the first result row of a query.
@@ -62,19 +76,7 @@ module Simple::SQL::ConnectionAdapter
62
76
 
63
77
  private
64
78
 
65
- def add_page_info(scope, results)
66
- raise ArgumentError, "expect Array but get a #{results.class.name}" unless results.is_a?(Array)
67
- raise ArgumentError, "per must be > 0" unless scope.per > 0
68
-
69
- # optimization: add empty case (page <= 1 && results.empty?)
70
- if scope.page <= 1 && results.empty?
71
- Scope::PageInfo.attach(results, total_count: 0, per: scope.per, page: 1)
72
- else
73
- sql = "SELECT COUNT(*) FROM (#{scope.order_by(nil).to_sql(pagination: false)}) simple_sql_count"
74
- total_count = ask(sql, *scope.args)
75
- Scope::PageInfo.attach(results, total_count: total_count, per: scope.per, page: scope.page)
76
- end
77
- end
79
+ Encoder = ::Simple::SQL::Helpers::Encoder
78
80
 
79
81
  def exec_logged(sql_or_scope, *args)
80
82
  if sql_or_scope.is_a?(Scope)
@@ -91,19 +93,20 @@ module Simple::SQL::ConnectionAdapter
91
93
  end
92
94
  end
93
95
 
94
- def enumerate(result, into:, &block)
95
- decoder = Decoder.new(self, result, into: into)
96
+ Result = ::Simple::SQL::Result
97
+ Decoder = ::Simple::SQL::Helpers::Decoder
96
98
 
97
- if block
98
- result.each_row do |row|
99
- yield decoder.decode(row)
100
- end
101
- self
102
- else
103
- ary = []
104
- result.each_row { |row| ary << decoder.decode(row) }
105
- ary
99
+ def enumerate(pg_result, into:)
100
+ records = []
101
+ pg_source_oid = nil
102
+
103
+ if pg_result.ntuples > 0 && pg_result.nfields > 0
104
+ decoder = Decoder.new(self, pg_result, into: (into ? Hash : nil))
105
+ pg_result.each_row { |row| records << decoder.decode(row) }
106
+ pg_source_oid = pg_result.ftable(0)
106
107
  end
108
+
109
+ Result.build(records, target_type: into, pg_source_oid: pg_source_oid)
107
110
  end
108
111
 
109
112
  public
@@ -0,0 +1,32 @@
1
+ module ::Simple::SQL::Helpers
2
+ end
3
+
4
+ require_relative "helpers/decoder.rb"
5
+ require_relative "helpers/encoder.rb"
6
+ require_relative "helpers/row_converter.rb"
7
+
8
+ module ::Simple::SQL::Helpers
9
+ extend self
10
+
11
+ def stable_group_by_key(ary, key)
12
+ hsh = Hash.new { |h, k| h[k] = [] }
13
+ ary.each do |entity|
14
+ group = entity.fetch(key)
15
+ hsh[group] << entity
16
+ end
17
+ hsh
18
+ end
19
+
20
+ def pluck(ary, key)
21
+ ary.map { |rec| rec.fetch(key) }
22
+ end
23
+
24
+ def by_key(ary, key)
25
+ hsh = {}
26
+ ary.each do |entity|
27
+ group = entity.fetch(key)
28
+ hsh[group] = entity
29
+ end
30
+ hsh
31
+ end
32
+ end
@@ -1,18 +1,9 @@
1
1
  require "time"
2
2
 
3
3
  # private
4
- module Simple::SQL::Decoder
4
+ module Simple::SQL::Helpers::Decoder
5
5
  extend self
6
6
 
7
- def new(connection, result, into:)
8
- if into == Hash then HashRecord.new(connection, result)
9
- elsif into == :struct then StructRecord.new(connection, result)
10
- elsif into then Record.new(connection, result, into: into)
11
- elsif result.nfields == 1 then SingleColumn.new(connection, result)
12
- else MultiColumns.new(connection, result)
13
- end
14
- end
15
-
16
7
  def parse_timestamp(s)
17
8
  r = ::Time.parse(s)
18
9
  return r if r.utc_offset == 0
@@ -21,7 +12,6 @@ module Simple::SQL::Decoder
21
12
 
22
13
  # rubocop:disable Metrics/AbcSize
23
14
  # rubocop:disable Metrics/CyclomaticComplexity
24
- # rubocop:disable Metrics/MethodLength
25
15
  def decode_value(type, s)
26
16
  case type
27
17
  when :unknown then s
@@ -81,68 +71,50 @@ module Simple::SQL::Decoder
81
71
  end
82
72
  end
83
73
 
84
- class Simple::SQL::Decoder::SingleColumn
85
- def initialize(connection, result)
86
- typename = connection.resolve_type(result.ftype(0), result.fmod(0))
87
- @field_type = typename.to_sym
88
- end
89
-
90
- def decode(row)
91
- value = row.first
92
- value && Simple::SQL::Decoder.decode_value(@field_type, value)
93
- end
94
- end
95
-
96
- class Simple::SQL::Decoder::MultiColumns
97
- def initialize(connection, result)
98
- @field_types = 0.upto(result.fields.length - 1).map do |idx|
99
- typename = connection.resolve_type(result.ftype(idx), result.fmod(idx))
100
- typename.to_sym
74
+ module Simple::SQL::Helpers::Decoder
75
+ def self.new(connection, result, into:)
76
+ if into == Hash then HashRecord.new(connection, result)
77
+ elsif result.nfields == 1 then SingleColumn.new(connection, result)
78
+ else MultiColumns.new(connection, result)
101
79
  end
102
80
  end
103
81
 
104
- def decode(row)
105
- @field_types.zip(row).map do |field_type, value|
106
- value && Simple::SQL::Decoder.decode_value(field_type, value)
82
+ class SingleColumn
83
+ def initialize(connection, result)
84
+ typename = connection.resolve_type(result.ftype(0), result.fmod(0))
85
+ @field_type = typename.to_sym
107
86
  end
108
- end
109
- end
110
87
 
111
- class Simple::SQL::Decoder::HashRecord < Simple::SQL::Decoder::MultiColumns
112
- def initialize(connection, result)
113
- super(connection, result)
114
- @field_names = result.fields.map(&:to_sym)
115
- end
116
-
117
- def decode(row)
118
- decoded_row = super(row)
119
- Hash[@field_names.zip(decoded_row)]
88
+ def decode(row)
89
+ value = row.first
90
+ value && Simple::SQL::Helpers::Decoder.decode_value(@field_type, value)
91
+ end
120
92
  end
121
- end
122
93
 
123
- class Simple::SQL::Decoder::Record < Simple::SQL::Decoder::HashRecord
124
- def initialize(connection, result, into:)
125
- super(connection, result)
126
- @into = into
127
- end
94
+ class MultiColumns
95
+ def initialize(connection, result)
96
+ @field_types = 0.upto(result.fields.length - 1).map do |idx|
97
+ typename = connection.resolve_type(result.ftype(idx), result.fmod(idx))
98
+ typename.to_sym
99
+ end
100
+ end
128
101
 
129
- def decode(row)
130
- @into.new(super)
102
+ def decode(row)
103
+ @field_types.zip(row).map do |field_type, value|
104
+ value && Simple::SQL::Helpers::Decoder.decode_value(field_type, value)
105
+ end
106
+ end
131
107
  end
132
- end
133
-
134
- class Simple::SQL::Decoder::StructRecord < Simple::SQL::Decoder::MultiColumns
135
- @@struct_cache = {}
136
-
137
- def initialize(connection, result)
138
- super(connection, result)
139
108
 
140
- field_names = result.fields.map(&:to_sym)
141
- @into = @@struct_cache[field_names] ||= Struct.new(*field_names)
142
- end
109
+ class HashRecord < MultiColumns
110
+ def initialize(connection, result)
111
+ super(connection, result)
112
+ @field_names = result.fields.map(&:to_sym)
113
+ end
143
114
 
144
- def decode(row)
145
- decoded_row = super(row)
146
- @into.new(*decoded_row)
115
+ def decode(row)
116
+ decoded_row = super(row)
117
+ Hash[@field_names.zip(decoded_row)]
118
+ end
147
119
  end
148
120
  end
@@ -1,5 +1,5 @@
1
1
  # private
2
- module Simple::SQL::Encoder
2
+ module Simple::SQL::Helpers::Encoder
3
3
  extend self
4
4
 
5
5
  def encode_args(connection, args)
@@ -0,0 +1,34 @@
1
+ module Simple::SQL::Helpers::RowConverter
2
+ # returns an array of converted records
3
+ def self.convert(records, into:)
4
+ hsh = records.first
5
+ return records unless hsh
6
+
7
+ if into == :struct
8
+ converter = StructConverter.for(attributes: hsh.keys)
9
+ records.map { |record| converter.convert(record) }
10
+ else
11
+ records.map { |record| into.new(record) }
12
+ end
13
+ end
14
+
15
+ class StructConverter # :nodoc:
16
+ def self.for(attributes:)
17
+ @cache ||= {}
18
+ @cache[attributes] ||= new(attributes)
19
+ end
20
+
21
+ private
22
+
23
+ def initialize(attributes)
24
+ @klass = Struct.new(*attributes)
25
+ end
26
+
27
+ public
28
+
29
+ def convert(hsh)
30
+ values = hsh.values_at(*@klass.members)
31
+ @klass.new(*values)
32
+ end
33
+ end
34
+ end
@@ -1,5 +1,3 @@
1
- # rubocop:disable Metrics/MethodLength
2
-
3
1
  module Simple
4
2
  module SQL
5
3
  module Reflection
@@ -0,0 +1,27 @@
1
+ require_relative "helpers"
2
+
3
+ class ::Simple::SQL::Result < Array
4
+ end
5
+
6
+ require_relative "result/rows"
7
+ require_relative "result/records"
8
+
9
+ # The result of SQL.all
10
+ #
11
+ # This class implements the interface of a Result.
12
+ class ::Simple::SQL::Result < Array
13
+ # A Result object is requested via ::Simple::SQL::Result.build, which then
14
+ # chooses the correct implementation, based on the <tt>target_type:</tt>
15
+ # parameter.
16
+ def self.build(records, target_type:, pg_source_oid:) # :nodoc:
17
+ if target_type.nil?
18
+ Rows.new(records)
19
+ else
20
+ Records.new(records, target_type: target_type, pg_source_oid: pg_source_oid)
21
+ end
22
+ end
23
+
24
+ attr_reader :total_count
25
+ attr_reader :total_pages
26
+ attr_reader :current_page
27
+ end
@@ -0,0 +1,161 @@
1
+ # rubocop:disable Metrics/AbcSize
2
+ # rubocop:disable Metrics/MethodLength
3
+ # rubocop:disable Metrics/ParameterLists
4
+
5
+ #
6
+ # This module implements a pretty generic AssociationLoader.
7
+ #
8
+ module ::Simple::SQL::Result::AssociationLoader # :nodoc:
9
+ extend self
10
+
11
+ SQL = ::Simple::SQL
12
+ H = ::Simple::SQL::Helpers
13
+
14
+ private
15
+
16
+ # Assuming association refers to a table, what would the table name be?
17
+ #
18
+ # For example, find_associated_table(:user, schema: "foo") could return
19
+ # "foo.users", if such a table exists.
20
+ #
21
+ # Raises an ArgumentError if no matching table can be found.
22
+ def find_associated_table(association, schema:)
23
+ association = association.to_s
24
+
25
+ tables_in_schema = ::Simple::SQL::Reflection.table_info(schema: schema).keys
26
+
27
+ return "#{schema}.#{association}" if tables_in_schema.include?(association)
28
+ return "#{schema}.#{association.singularize}" if tables_in_schema.include?(association.singularize)
29
+ return "#{schema}.#{association.pluralize}" if tables_in_schema.include?(association.pluralize)
30
+
31
+ raise ArgumentError, "Don't know how to find foreign table for association #{association.inspect}"
32
+ end
33
+
34
+ # Given two tables returns a structure which describes a potential association
35
+ # between these tables, based on foreign key descriptions found in the database.
36
+ #
37
+ # The returned struct looks something like this:
38
+ #
39
+ # #<struct
40
+ # belonging_table="public.users",
41
+ # belonging_column="organization_id",
42
+ # having_table="public.organizations",
43
+ # having_column="id"
44
+ # >
45
+ #
46
+ # Raises an ArgumentError if no association can be found between these tables.
47
+ #
48
+ def find_matching_relation(host_table, associated_table)
49
+ sql = <<~SQL
50
+ WITH foreign_keys AS(
51
+ SELECT
52
+ tc.table_schema || '.' || tc.table_name AS belonging_table,
53
+ kcu.column_name AS belonging_column,
54
+ ccu.table_schema || '.' || ccu.table_name AS having_table,
55
+ ccu.column_name AS having_column
56
+ FROM
57
+ information_schema.table_constraints AS tc
58
+ JOIN information_schema.key_column_usage AS kcu
59
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
60
+ JOIN information_schema.constraint_column_usage AS ccu
61
+ ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
62
+ WHERE constraint_type = 'FOREIGN KEY'
63
+ )
64
+ SELECT * FROM foreign_keys
65
+ WHERE (belonging_table=$1 AND having_table=$2)
66
+ OR (belonging_table=$2 AND having_table=$1)
67
+ SQL
68
+
69
+ relations = SQL.all(sql, host_table, associated_table, into: :struct)
70
+ raise ArgumentError, "Found two potential matches for the #{association.inspect} relation" if relations.length > 1
71
+ raise ArgumentError, "Found no potential match for the #{association.inspect} relation" if relations.empty?
72
+
73
+ relations.first
74
+ end
75
+
76
+ # preloads a belongs_to association.
77
+ def preload_belongs_to(records, relation, as:)
78
+ belonging_column = relation.belonging_column.to_sym
79
+ having_column = relation.having_column.to_sym
80
+
81
+ foreign_ids = H.pluck(records, belonging_column).uniq.compact
82
+
83
+ scope = SQL::Scope.new(table: relation.having_table)
84
+ scope = scope.where(having_column => foreign_ids)
85
+
86
+ recs = SQL.all(scope, into: Hash)
87
+ recs_by_id = H.by_key(recs, having_column)
88
+
89
+ records.each do |model|
90
+ model[as] = recs_by_id[model.fetch(belonging_column)]
91
+ end
92
+ end
93
+
94
+ # preloads a has_one or has_many association.
95
+ def preload_has_one_or_many(records, relation, as:, order_by:, limit:)
96
+ if limit
97
+ # To really make sense limit must be implemented using window
98
+ # functions, because one (or, at lieast, I) would expect this code
99
+ #
100
+ # organizations = SQL.all "SELECT * FROM organizations", into: Hash
101
+ # organizations.preload :users, limit: 2, order_by: "id DESC"
102
+ #
103
+ # to return up to two users **per organization**.
104
+ #
105
+ raise "Support for limit: is not implemented yet!"
106
+ end
107
+
108
+ belonging_column = relation.belonging_column.to_sym
109
+ having_column = relation.having_column.to_sym
110
+
111
+ host_ids = H.pluck(records, having_column).uniq.compact
112
+
113
+ scope = SQL::Scope.new(table: relation.belonging_table)
114
+ scope = scope.where(belonging_column => host_ids)
115
+ scope = scope.order_by(order_by) if order_by
116
+
117
+ recs = SQL.all(scope, into: Hash)
118
+
119
+ if as.to_s.singularize == as.to_s
120
+ recs_by_id = H.by_key(recs, belonging_column) # has_one
121
+ else
122
+ recs_by_id = H.stable_group_by_key(recs, belonging_column) # has_many
123
+ end
124
+
125
+ records.each do |model|
126
+ model[as] = recs_by_id[model.fetch(having_column)]
127
+ end
128
+ end
129
+
130
+ public
131
+
132
+ # Preloads a association into the records array.
133
+ #
134
+ # Parameters:
135
+ #
136
+ # - records: an Array of hashes.
137
+ # - association: the name of the association
138
+ # - host_table: the name of the table \a records has been loaded from.
139
+ # - schema: the schema name in the database.
140
+ # - as: the name to sue for the association. Defaults to +association+
141
+ def preload(records, association, host_table:, schema:, as:, order_by:, limit:)
142
+ return records if records.empty?
143
+
144
+ expect! records.first => Hash
145
+
146
+ as = association if as.nil?
147
+ fq_host_table = "#{schema}.#{host_table}"
148
+
149
+ associated_table = find_associated_table(association, schema: schema)
150
+ relation = find_matching_relation(fq_host_table, associated_table)
151
+
152
+ if fq_host_table == relation.belonging_table
153
+ if order_by || limit
154
+ raise ArgumentError, "#{association.inspect} is a singular association, w/o support for order_by: and limit:"
155
+ end
156
+ preload_belongs_to records, relation, as: as
157
+ else
158
+ preload_has_one_or_many records, relation, as: as, order_by: order_by, limit: limit
159
+ end
160
+ end
161
+ end