simple-sql 0.4.9 → 0.4.10

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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