flounder 0.3.0 → 0.4.0

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
  SHA1:
3
- metadata.gz: 740168d7c1b6af4512d1fe0241aa2311ff290cc6
4
- data.tar.gz: de30b4d4e04bb31f2082c9ef95fac9147ec3713f
3
+ metadata.gz: 9860bdc14eff074ea0e340f278593cc4e489599b
4
+ data.tar.gz: 830bccfe9f8204a93e9eacb9c72b3b53191f82fb
5
5
  SHA512:
6
- metadata.gz: 5a11bd2ebf80f5af077325f58ea96045bc2e61a3f4c8db4ba82020a143eb29f669b614df8b4155d54df20ce602e37c264b7547af75bece93181a43ef67db4f70
7
- data.tar.gz: f4591506a011b6857a5b84c628cac7e6ef7220e1f996a72b40d4f1c08ae01d36bb8230c40903e87a3590f0819b5b83905dc3e8cad6ce2c777cdbd6d40858dcf6
6
+ metadata.gz: 2c93426c7de1ac2a10368dcf8f85cdd269a20d00f71229706bbd2a42ff928d2415bf157217d52167c7d2e308a6dd59cb50ad505cacf04f4425eb7210e5380d94
7
+ data.tar.gz: 4799ae63203ad83d6059c87efcb5c86619a054628591594e5c5cae93a10d3d1156136481b4387b4ddffb6defb0715d66d9ef31c3d01146435ecd6829973e16d0
data/Gemfile.lock ADDED
@@ -0,0 +1,33 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ flounder (0.2.0)
5
+ arel (~> 5, > 5.0.1)
6
+ connection_pool (~> 2)
7
+ hashie (~> 3, >= 3.2)
8
+ pg (> 0.17)
9
+
10
+ GEM
11
+ remote: https://rubygems.org/
12
+ specs:
13
+ ae (1.8.2)
14
+ ansi
15
+ ansi (1.4.3)
16
+ arel (5.0.1.20140414130214)
17
+ brass (1.2.1)
18
+ connection_pool (2.0.0)
19
+ facets (2.9.3)
20
+ hashie (3.2.0)
21
+ pg (0.17.1)
22
+ qed (2.9.1)
23
+ ansi
24
+ brass
25
+ facets (>= 2.8)
26
+
27
+ PLATFORMS
28
+ ruby
29
+
30
+ DEPENDENCIES
31
+ ae
32
+ flounder!
33
+ qed
data/flounder.gemspec CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = "flounder"
5
- s.version = '0.3.0'
5
+ s.version = '0.4.0'
6
6
  s.summary = "Flounder is a way to write SQL simply in Ruby. It deals with everything BUT object relational mapping. "
7
7
  s.email = "kaspar.schiess@technologyastronauts.ch"
8
8
  s.homepage = "https://bitbucket.org/technologyastronauts/laboratory_flounder"
9
- s.authors = ['Kaspar Schiess']
9
+ s.authors = ['Kaspar Schiess', 'Florian Hanke']
10
10
 
11
11
  # s.description = <<-EOF
12
12
  # EOF
@@ -16,10 +16,6 @@ module Flounder
16
16
  end
17
17
 
18
18
  # ------------------------------------------------ official Connection iface
19
- def schema_cache
20
- self
21
- end
22
-
23
19
  Column = Struct.new(:name, :type)
24
20
  def columns_hash table_name
25
21
  hash = {}
@@ -40,12 +36,12 @@ module Flounder
40
36
  hash[name] = Column.new(name, typesym)
41
37
  end
42
38
  end
43
-
39
+
44
40
  hash
45
41
  end
46
42
 
47
43
  def primary_key name
48
- fail
44
+ fail NotImplementedError
49
45
  @primary_keys[name.to_s]
50
46
  end
51
47
 
@@ -55,7 +51,7 @@ module Flounder
55
51
  end
56
52
 
57
53
  def columns name, message = nil
58
- fail
54
+ fail NotImplementedError
59
55
  @columns[name.to_s]
60
56
  end
61
57
 
@@ -75,6 +71,39 @@ module Flounder
75
71
  # p [:quote, thing, column]
76
72
  pg.escape_literal(thing.to_s)
77
73
  end
74
+
75
+ def objectify_result_row ent, result, row_idx
76
+ obj = Hashie::Mash.new
77
+
78
+ each_field(ent, result, row_idx) do
79
+ |entity, name, value, type_oid, binary, idx|
80
+ # TODO remove entity resolution from each_field?
81
+
82
+ entity, name = yield name if block_given?
83
+
84
+ typecast_value = typecast(type_oid, value)
85
+
86
+ # JOIN tables are available from the result using their singular
87
+ # names.
88
+ if entity
89
+ obj[entity.singular] ||= {}
90
+
91
+ sub_obj = obj[entity.singular]
92
+ sub_obj[name] = typecast_value
93
+ end
94
+
95
+ # The main entity and custom fields (AS something) are available on the
96
+ # top-level of the result.
97
+ if !entity || entity == ent
98
+ warn "#{name.inspect} already defined in result set, aliasing occurs." \
99
+ if obj.has_key? name
100
+ obj[name] = typecast_value
101
+ end
102
+ end
103
+
104
+ return obj
105
+ end
106
+
78
107
  private
79
108
  include PostgresUtils
80
109
  end
@@ -7,9 +7,6 @@ module Flounder
7
7
  end
8
8
 
9
9
  class Domain
10
- attr_reader :connection_pool
11
- attr_accessor :logger
12
-
13
10
  # A device that discards all logging made to it. This is used to be silent
14
11
  # by default while still allowing the logging of all queries.
15
12
  #
@@ -27,6 +24,10 @@ module Flounder
27
24
  @oids_entity_map = {}
28
25
  @logger = Logger.new(NilDevice.new)
29
26
  end
27
+
28
+ attr_reader :connection_pool
29
+ attr_accessor :logger
30
+
30
31
  def [] name
31
32
  raise NoSuchEntity, "No such entity #{name.inspect} in this domain." \
32
33
  unless @plural.has_key?(name)
@@ -34,6 +35,12 @@ module Flounder
34
35
  @plural.fetch(name)
35
36
  end
36
37
 
38
+ # Returns all entities as an array.
39
+ #
40
+ def entities
41
+ @plural.values
42
+ end
43
+
37
44
  # Logs sql statements that are prepared for execution.
38
45
  #
39
46
  def log_sql sql
@@ -5,6 +5,10 @@ module Flounder
5
5
 
6
6
  # Name of the entity in plural form.
7
7
  attr_reader :name
8
+
9
+ # Also, the name is the plural, so we'll support that as well.
10
+ alias plural name
11
+
8
12
  # Name of the entity in singular form.
9
13
  attr_reader :singular
10
14
 
@@ -18,6 +22,8 @@ module Flounder
18
22
  @name = plural
19
23
  @singular = singular
20
24
  @table_name = table_name
25
+ @columns_hash = nil
26
+
21
27
  @table = Arel::Table.new(table_name)
22
28
  end
23
29
 
@@ -27,24 +33,60 @@ module Flounder
27
33
  Field.new(self, name, table[name])
28
34
  end
29
35
 
30
- def belongs_to entity, fk
31
- end
32
-
33
36
  def to_s
34
37
  "entity(#{name}/#{table_name})"
35
38
  end
36
39
 
40
+ def query
41
+ Query.new(domain, self).tap { |q|
42
+ yield q if block_given?
43
+ }
44
+ end
45
+
46
+ def insert hash
47
+ Insert.new(domain, self).tap { |i|
48
+ yield i if block_given?
49
+ i.insert(hash)
50
+ }
51
+ end
52
+
53
+ def update hash
54
+ Update.new(domain, self).tap { |u|
55
+ yield u if block_given?
56
+ u.update(hash)
57
+ }
58
+ end
59
+
60
+ # Temporarily creates a new entity that is available as if it was declared
61
+ # with the given plural and singular, but referencing to the same
62
+ # underlying relation.
63
+ #
64
+ def as plural, singular
65
+ EntityAlias.new(self, plural, singular)
66
+ end
67
+
68
+ def columns_hash
69
+ @columns_hash ||= begin
70
+ domain.connection_pool.with_connection do |conn|
71
+ conn.columns_hash(table_name)
72
+ end
73
+ end
74
+ end
75
+ def column_names
76
+ columns_hash.keys
77
+ end
78
+
37
79
  # Query initiators
38
- [:where, :join, :outer_join, :project].each do |name|
80
+ [:where, :join, :outer_join, :project, :order_by].each do |name|
39
81
  define_method name do |*args|
40
- Query.new(domain, self).tap { |q| q.send(name, *args) }
82
+ query { |q| q.send(name, *args) }
41
83
  end
42
84
  end
43
85
 
44
86
  # Kickers
45
87
  [:first, :all, :size].each do |name|
46
88
  define_method name do |*args|
47
- q = Query.new(domain, self)
89
+ q = query
48
90
  q.send(name, *args)
49
91
  end
50
92
  end
@@ -0,0 +1,39 @@
1
+
2
+ module Flounder
3
+ # Alias for an Entity, implementing roughly the same interface.
4
+ #
5
+ # @see Entity
6
+ #
7
+ class EntityAlias
8
+ def initialize entity, plural, singular
9
+ @entity = entity
10
+ @plural = plural
11
+ @singular = singular
12
+ end
13
+
14
+ # Entity this alias refers to
15
+ attr_reader :entity
16
+
17
+ # Plural name of the alias
18
+ attr_reader :plural
19
+
20
+ # Singular name of the alias
21
+ attr_reader :singular
22
+
23
+ # Plural is also available as #name
24
+ alias name plural
25
+
26
+ def table
27
+ table = entity.table
28
+ table.alias(plural)
29
+ end
30
+
31
+ def column_names
32
+ entity.column_names
33
+ end
34
+
35
+ def [] name
36
+ Field.new(self, name, table[name])
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,8 @@
1
+
2
+
3
+ module Flounder
4
+ # Indicates a field reference in a chain method that cannot be resolved
5
+ # to a real field on the underlying relation.
6
+ #
7
+ class InvalidFieldReference < StandardError; end
8
+ end
@@ -1,15 +1,15 @@
1
1
  module Flounder
2
2
  class Field
3
- attr_reader :entity
4
- attr_reader :arel_field
5
- attr_reader :name
6
-
7
3
  def initialize entity, name, arel_field
8
4
  @entity = entity
9
5
  @name = name
10
6
  @arel_field = arel_field
11
7
  end
12
8
 
9
+ attr_reader :entity
10
+ attr_reader :arel_field
11
+ attr_reader :name
12
+
13
13
  def fully_qualified_name
14
14
  # TBD quoting? Demeter?
15
15
  entity.domain.connection_pool.with_connection do |conn|
@@ -19,6 +19,10 @@ module Flounder
19
19
  end
20
20
  end
21
21
 
22
+ def to_arel_field
23
+ arel_field
24
+ end
25
+
22
26
  include SymbolExtensions
23
27
  end
24
28
  end
@@ -0,0 +1,18 @@
1
+
2
+ module Flounder
3
+ # An immmediate string that needs to be passed around and _not_ quoted or
4
+ # escaped when going to the database.
5
+ #
6
+ # @private
7
+ class Immediate
8
+ def initialize string
9
+ @string = string
10
+ end
11
+
12
+ attr_reader :string
13
+
14
+ def to_arel_field
15
+ string
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,73 @@
1
+ module Flounder
2
+
3
+ # An insert.
4
+ #
5
+ class Insert
6
+ def initialize domain, into_entity
7
+ @domain = domain
8
+ @into_entity = into_entity
9
+ @engine = Engine.new(into_entity.domain.connection_pool)
10
+ @manager = Arel::InsertManager.new(@engine)
11
+
12
+ manager.into into_entity.table
13
+ end
14
+
15
+ # Domain that this insert was issued for.
16
+ attr_reader :domain
17
+ # Entity that this insert acts on.
18
+ attr_reader :into_entity
19
+
20
+ # Arel SqlManager that accumulates this insert.
21
+ attr_reader :manager
22
+ # Database engine that links Arel to Postgres.
23
+ attr_reader :engine
24
+
25
+ # Add one row to the inserts.
26
+ #
27
+ def insert fields
28
+ manager.insert fields.map { |k, v| transform_hash_keys(k, v) }
29
+ end
30
+
31
+ # Kickers
32
+ def to_sql
33
+ manager.to_sql.tap { |sql|
34
+ domain.log_sql(sql) }
35
+ end
36
+ alias sql to_sql
37
+
38
+ # Returns all rows of the insert result as an array. Individual rows are
39
+ # mapped to objects using the row mapper.
40
+ #
41
+ def returning fields = '*'
42
+ inserted = []
43
+ engine.exec(sql + " RETURNING #{fields}") do |result|
44
+ inserted = Array.new(result.ntuples, nil)
45
+ result.ntuples.times do |row_idx|
46
+ inserted[row_idx] = engine.connection.
47
+ objectify_result_row(into_entity, result, row_idx)
48
+ end
49
+ end
50
+ inserted
51
+ end
52
+
53
+ private
54
+
55
+ # Called on each key/value pair of an insert clause, this returns a
56
+ # hash that can be passed to Arel #insert.
57
+ #
58
+ def transform_hash_keys field, value
59
+ if value.kind_of? Field
60
+ value = value.arel_field
61
+ end
62
+
63
+ case field
64
+ when Symbol
65
+ [into_entity[field].arel_field, value]
66
+ when Flounder::Field
67
+ [field.arel_field, value]
68
+ else
69
+ fail "Could not transform condition part. (#{field.inspect}, #{value.inspect})"
70
+ end
71
+ end
72
+ end # class
73
+ end # module Flounder
@@ -4,10 +4,11 @@ require 'date'
4
4
 
5
5
  module Flounder
6
6
  module PostgresUtils
7
- OID_INTEGER = 23
7
+ OID_BOOLEAN = 16
8
8
  OID_SMALLINT = 21
9
+ OID_INTEGER = 23
10
+ OID_TEXT = 25
9
11
  OID_VARCHAR = 1043
10
- OID_BOOLEAN = 16
11
12
  OID_TIMESTAMP = 1114
12
13
  OID_DATE = 1082
13
14
  OID_TIME = 1083
@@ -25,6 +26,8 @@ module Flounder
25
26
  value.to_i
26
27
  when OID_BOOLEAN
27
28
  value == 't'
29
+ when OID_TEXT
30
+ value.to_s
28
31
  else
29
32
  value
30
33
  end
@@ -40,7 +43,7 @@ module Flounder
40
43
  :time
41
44
  when OID_INTEGER, OID_SMALLINT
42
45
  :integer
43
- when OID_VARCHAR
46
+ when OID_VARCHAR, OID_TEXT
44
47
  :string
45
48
  when OID_BOOLEAN
46
49
  :boolean
@@ -49,8 +52,8 @@ module Flounder
49
52
  end
50
53
  end
51
54
 
52
- def each_field from_entity, result, row_idx
53
- domain = from_entity.domain
55
+ def each_field entity, result, row_idx
56
+ domain = entity.domain
54
57
 
55
58
  result.nfields.times do |field_idx|
56
59
  table_oid = result.ftable(field_idx)
@@ -1,5 +1,24 @@
1
1
  module Flounder
2
+
3
+ # A query obtained by calling any of the chain methods on an entity.
4
+ #
2
5
  class Query
6
+ def initialize domain, from_entity
7
+ @domain = domain
8
+ @from_entity = from_entity
9
+ @engine = Engine.new(from_entity.domain.connection_pool)
10
+ @manager = Arel::SelectManager.new(@engine)
11
+
12
+ @has_projection = false
13
+
14
+ @projection_prefixes = Hash.new
15
+ @default_projection = []
16
+
17
+ add_fields_to_default from_entity
18
+
19
+ manager.from from_entity.table
20
+ end
21
+
3
22
  # Domain that this query was issued from.
4
23
  attr_reader :domain
5
24
  # Entity that this query acts on.
@@ -9,17 +28,14 @@ module Flounder
9
28
  attr_reader :manager
10
29
  # Database engine that links Arel to Postgres.
11
30
  attr_reader :engine
12
-
13
31
 
14
- def initialize domain, from_entity
15
- @domain = domain
16
- @from_entity = from_entity
17
- @engine = Engine.new(from_entity.domain.connection_pool)
18
- @manager = Arel::SelectManager.new(@engine)
19
- @has_projection = false
32
+ # All projected fields if no custom projection is made. Fields are encoded
33
+ # so that they can be traced back to the entity that contributed them.
34
+ attr_reader :default_projection
20
35
 
21
- manager.from from_entity.table
22
- end
36
+ # Each projection has a unique prefix mapping to the entity that uses this
37
+ # prefix during this query.
38
+ attr_reader :projection_prefixes
23
39
 
24
40
  def where conditions={}
25
41
  conditions.each do |k, v|
@@ -28,15 +44,38 @@ module Flounder
28
44
  self
29
45
  end
30
46
 
31
- def join entity, join_node=Arel::Nodes::InnerJoin
47
+ def _join join_node, entity
32
48
  @last_join = entity
33
49
 
34
- manager.join(entity.table, join_node)
50
+ table = entity.table
51
+ manager.join(table, join_node)
52
+ add_fields_to_default(entity)
53
+
35
54
  self
36
55
  end
37
- def outer_join entity
38
- join(entity, Arel::Nodes::OuterJoin)
56
+ def add_fields_to_default entity
57
+ prefix = entity.name.to_s
58
+ table = entity.table
59
+
60
+ warn "Table alias #{prefix} already used in select; field aliasing will occur!" \
61
+ if projection_prefixes.has_key? prefix
62
+
63
+ projection_prefixes[prefix] = entity
64
+
65
+ entity.column_names.each do |name|
66
+ default_projection << table[name].as("_#{prefix}_#{name}")
67
+ end
39
68
  end
69
+
70
+ def inner_join *args
71
+ _join(Arel::Nodes::InnerJoin, *args)
72
+ end
73
+ alias join inner_join
74
+
75
+ def outer_join *args
76
+ _join(Arel::Nodes::OuterJoin, *args)
77
+ end
78
+
40
79
  def on join_conditions
41
80
  join_conditions.each do |k, v|
42
81
  manager.on(
@@ -49,9 +88,14 @@ module Flounder
49
88
  self
50
89
  end
51
90
 
91
+ # Adds a field to the projection clause of the SQL statement (the part
92
+ # between SELECT and FROM). Projection of '*' is the default, so you can
93
+ # omit this call entirely if you want that.
94
+ #
52
95
  def project *field_list
53
- # TBD: Clean up
54
96
  @has_projection = true
97
+ @default_projection = {}
98
+
55
99
  manager.project *map_to_arel(field_list)
56
100
  self
57
101
  end
@@ -61,15 +105,9 @@ module Flounder
61
105
  self
62
106
  end
63
107
 
64
- # Transforms a simple symbol into either a field of the last .join table,
65
- # or respects field values passed in.
108
+ # Orders by a list of field references.
66
109
  #
67
- def join_field name
68
- return name if name.kind_of? Field
69
- @last_join[name]
70
- end
71
-
72
- def order *field_list
110
+ def order_by *field_list
73
111
  field_list.each do |field|
74
112
  field = from_entity[field] unless field.kind_of?(Field)
75
113
  manager.order field.fully_qualified_name
@@ -80,7 +118,7 @@ module Flounder
80
118
  # Kickers
81
119
  def to_sql
82
120
  prepare_kick
83
-
121
+
84
122
  manager.to_sql.tap { |sql|
85
123
  domain.log_sql(sql) }
86
124
  end
@@ -111,7 +149,12 @@ module Flounder
111
149
  engine.exec(sql) do |result|
112
150
  all = Array.new(result.ntuples, nil)
113
151
  result.ntuples.times do |row_idx|
114
- all[row_idx] = objectify_result_row(result, row_idx)
152
+ all[row_idx] = engine.connection.
153
+ objectify_result_row(from_entity, result, row_idx) do |name|
154
+ unless default_projection.empty?
155
+ extract_source_info_from_name(name)
156
+ end
157
+ end
115
158
  end
116
159
  end
117
160
 
@@ -120,23 +163,71 @@ module Flounder
120
163
 
121
164
  private
122
165
 
123
- def map_to_arel field_list
124
- field_list.map { |field|
125
- if field.kind_of? Field
126
- field.arel_field
127
- else
128
- field
129
- end
166
+ # Transforms a simple symbol into either a field of the last .join table,
167
+ # or respects field values passed in.
168
+ #
169
+ def join_field name
170
+ return name if name.kind_of? Field
171
+ @last_join[name]
172
+ end
173
+
174
+ # Maps an array of field references to Flounder::Field objects. A field
175
+ # reference can be:
176
+ #
177
+ # * a symbol, interpreted as a field name of the main enity of the
178
+ # operation
179
+ # * a string, interpreted as something to be passed into the SQL statement
180
+ # as is. Caution: Don't expose this to unsecure channels!
181
+ # * a Flounder::Field, left alone (obtained through calling #[] on any
182
+ # entity)
183
+ #
184
+ def map_to_fields field_list
185
+ field_list.map { |x|
186
+ map_to_field(x)
130
187
  }
131
188
  end
189
+ def map_to_field field_ref
190
+ case field_ref
191
+ when Symbol
192
+ from_entity[field_ref]
193
+ when String
194
+ Immediate.new(field_ref)
195
+ when Field
196
+ field_ref
197
+ else
198
+ fail InvalidFieldReference, "Cannot resolve #{field_ref.inspect} to a field."
199
+ end
200
+ end
132
201
 
202
+ # Maps a field reference (see #fields) to an Arel::Field.
203
+ #
204
+ def map_to_arel field_list
205
+ map_to_fields(field_list).map(&:to_arel_field)
206
+ end
207
+
208
+ # Prepares a kick (aka transformation into sql/result). This should include
209
+ # all actions that need to be performed to validate the query.
210
+ #
133
211
  def prepare_kick
134
212
  unless @has_projection
135
- manager.project Arel.star
213
+ @has_projection = true
214
+
215
+ # Prepare the regular expression that we'll use to extract entities
216
+ # from column names.
217
+ @re_field = %r(
218
+ ^_ # indicates one of our own
219
+ (?<prefix>#{projection_prefixes.keys.join('|')})
220
+ _
221
+ (?<field_name>.*)
222
+ $
223
+ )x
224
+
225
+ manager.project *default_projection
136
226
  end
137
227
  end
138
228
 
139
- # Transforms things like :a => 1 into field('a').eq(1).
229
+ # Called on each key/value pair of a condition clause, this returns a
230
+ # condition that can be passed to Arel #where.
140
231
  #
141
232
  def transform_hash_condition field, value
142
233
  if value.kind_of? Field
@@ -169,27 +260,15 @@ module Flounder
169
260
  end
170
261
  end
171
262
 
172
- # Turns row (a hash) into something that can be treated like an object.
173
- # Also performs type coercion.
174
- #
175
- def objectify_result_row result, row_idx
176
- obj = Hashie::Mash.new
177
-
178
- engine.connection.each_field(from_entity, result, row_idx) do
179
- |entity, name, value, type_oid, binary, idx|
180
-
181
- # p [entity.name, name, type_oid, type_name(result, idx)]
182
- typecast_value = engine.connection.typecast(type_oid, value)
183
-
184
- if entity
185
- obj[entity.singular] ||= {}
186
- obj[entity.singular][name] = typecast_value
187
- else
188
- obj[name] = typecast_value
189
- end
190
- end
263
+ def extract_source_info_from_name name
264
+ md = name.match(@re_field)
265
+ fail "ASSERTION FAILURE Source info extraction failed." unless md
266
+
267
+ entity = projection_prefixes[md[:prefix]]
268
+ fail "ASSERTION FAILURE entity cannot be nil" unless entity
269
+ name = md[:field_name]
191
270
 
192
- return obj
271
+ return entity, name
193
272
  end
194
273
  end # class
195
274
  end # module Flounder
@@ -0,0 +1,114 @@
1
+ module Flounder
2
+
3
+ # An update obtained by calling any of the chain methods on an entity.
4
+ #
5
+ class Update
6
+ def initialize domain, entity
7
+ @domain = domain
8
+ @entity = entity
9
+ @engine = Engine.new(entity.domain.connection_pool)
10
+ @manager = Arel::UpdateManager.new(@engine)
11
+
12
+ manager.table entity.table
13
+ end
14
+
15
+ # Domain that this update was issued from.
16
+ attr_reader :domain
17
+ # Entity that this update acts on.
18
+ attr_reader :entity
19
+
20
+ # Arel SqlManager that accumulates this query.
21
+ attr_reader :manager
22
+ # Database engine that links Arel to Postgres.
23
+ attr_reader :engine
24
+
25
+ # Add one row to the updates.
26
+ #
27
+ def update fields
28
+ manager.set fields.map { |k, v| transform_hash_keys(k, v) }
29
+ end
30
+
31
+ def where conditions={}
32
+ conditions.each do |k, v|
33
+ manager.where(transform_hash_condition(k, v))
34
+ end
35
+ self
36
+ end
37
+
38
+ # Kickers
39
+ def to_sql
40
+ manager.to_sql.tap { |sql|
41
+ domain.log_sql(sql) }
42
+ end
43
+ alias sql to_sql
44
+
45
+ # Returns all rows of the update result as an array. Individual rows are
46
+ # mapped to objects using the row mapper.
47
+ #
48
+ def returning fields = '*'
49
+ updated = nil
50
+ engine.exec(sql + " RETURNING #{fields}") do |result|
51
+ updated = Array.new(result.ntuples, nil)
52
+ result.ntuples.times do |row_idx|
53
+ updated[row_idx] = engine.connection.
54
+ objectify_result_row(entity, result, row_idx)
55
+ end
56
+ end
57
+ updated
58
+ end
59
+
60
+ private
61
+
62
+ # Called on each key/value pair of an insert clause, this returns a
63
+ # hash that can be passed to Arel #insert.
64
+ #
65
+ def transform_hash_keys field, value
66
+ if value.kind_of? Field
67
+ value = value.arel_field
68
+ end
69
+
70
+ case field
71
+ when Symbol
72
+ [entity[field].arel_field, value]
73
+ when Flounder::Field
74
+ [field.arel_field, value]
75
+ else
76
+ fail "Could not transform condition part. (#{field.inspect}, #{value.inspect})"
77
+ end
78
+ end
79
+
80
+ # Called on each key/value pair of a condition clause, this returns a
81
+ # condition that can be passed to Arel #where.
82
+ #
83
+ def transform_hash_condition field, value
84
+ if value.kind_of? Field
85
+ value = value.arel_field
86
+ end
87
+
88
+ case field
89
+ when Symbol
90
+ condition_part(entity[field].arel_field, value)
91
+ when Flounder::Field
92
+ condition_part(field.arel_field, value)
93
+ when Flounder::SymbolExtensions::Modifier
94
+ condition_part(
95
+ field.to_arel_field(entity),
96
+ value,
97
+ field.kind)
98
+ else
99
+ fail "Could not transform condition part. (#{field.inspect}, #{value.inspect})"
100
+ end
101
+ end
102
+ def condition_part arel_field, value, kind=:eq
103
+ case value
104
+ when Symbol
105
+ value_field = entity[value].arel_field
106
+ arel_field.send(kind, value_field)
107
+ when Range
108
+ arel_field.in(value)
109
+ else
110
+ arel_field.send(kind, value)
111
+ end
112
+ end
113
+ end # class
114
+ end # module Flounder
data/lib/flounder.rb CHANGED
@@ -10,8 +10,13 @@ require 'flounder/connection_pool'
10
10
  require 'flounder/domain'
11
11
  require 'flounder/engine'
12
12
  require 'flounder/entity'
13
+ require 'flounder/entity_alias'
13
14
  require 'flounder/field'
15
+ require 'flounder/immediate'
14
16
  require 'flounder/query'
17
+ require 'flounder/insert'
18
+ require 'flounder/update'
19
+ require 'flounder/exceptions'
15
20
 
16
21
  module Flounder
17
22
  module_function
data/qed/applique/ae.rb CHANGED
@@ -1 +1,7 @@
1
- require 'ae'
1
+ require 'ae'
2
+
3
+ def generates_sql(expected)
4
+ -> (given) {
5
+ given.sql.gsub(/^SELECT.*FROM/, 'SELECT [fields] FROM').assert == expected
6
+ }
7
+ end
File without changes
@@ -0,0 +1,27 @@
1
+
2
+
3
+ def domain
4
+ @domain ||= begin
5
+ connection = Flounder.connect(dbname: 'flounder')
6
+ Flounder.domain(connection) do |dom|
7
+ dom.entity(:users, :user, 'users')
8
+ dom.entity(:posts, :post, 'posts')
9
+ dom.entity(:comments, :comment, 'comments')
10
+ end
11
+ end
12
+ end
13
+
14
+ # Allows using entities directly, without indirection via domain. (ie `users`)
15
+ domain.entities.each do |entity|
16
+ define_method entity.plural do
17
+ entity
18
+ end
19
+ end
20
+
21
+ # Load the SQL fixtures.
22
+ #
23
+ # One fine day we will use transactional features.
24
+ #
25
+ File.open File.expand_path '../../qed/flounder.sql' do |file|
26
+ Flounder::Engine.new(domain.connection_pool).exec file.read
27
+ end
data/qed/exceptions.md ADDED
@@ -0,0 +1,12 @@
1
+
2
+ Some things are forbidden. Here's a list of those:
3
+
4
+ # Invalid Field References
5
+
6
+ All of these statements raise an `InvalidFieldReference` exception.
7
+
8
+ ~~~ruby
9
+ expect Flounder::InvalidFieldReference do
10
+ users.project(1, 2, 3)
11
+ end
12
+ ~~~
data/qed/flounder.sql CHANGED
@@ -1,4 +1,4 @@
1
- -- Database fixture for these tests. Please improt into a database that
1
+ -- Database fixture for these tests. Please import into a database that
2
2
  -- should also be called 'flounder'.
3
3
 
4
4
  DROP TABLE IF EXISTS "users" CASCADE;
@@ -9,6 +9,7 @@ CREATE TABLE "users" (
9
9
 
10
10
  BEGIN;
11
11
  INSERT INTO "users" (name) VALUES ('John Snow');
12
+ INSERT INTO "users" (name) VALUES ('John Doe');
12
13
  COMMIT;
13
14
 
14
15
  DROP TABLE IF EXISTS "posts" CASCADE;
@@ -16,12 +17,13 @@ CREATE TABLE "posts" (
16
17
  "id" serial PRIMARY KEY,
17
18
  "title" varchar(100) NOT NULL,
18
19
  "text" text NOT NULL,
19
- "user_id" int NOT NULL REFERENCES users("id")
20
+ "user_id" int NOT NULL REFERENCES users("id"),
21
+ "approver_id" int REFERENCES users("id")
20
22
  );
21
23
 
22
24
  BEGIN;
23
- INSERT INTO "posts" (title, text, user_id) VALUES (
24
- 'First Light', 'This is the first post in our test system.', '1');
25
+ INSERT INTO "posts" (title, text, user_id, approver_id) VALUES (
26
+ 'First Light', 'This is the first post in our test system.', 1, 2);
25
27
  COMMIT;
26
28
 
27
29
  DROP TABLE IF EXISTS "comments" CASCADE;
data/qed/index.md CHANGED
@@ -1,8 +1,8 @@
1
1
 
2
2
  Flounder is a simple layer between you and the database. It abstracts syntax details and removes the need for string manipulation and parsing, but it does not 'map to objects' in the traditional sense. It still returns objects from queries, though - and it certainly allows influencing the mapping, but the core of flounder is about making querying and manipulating the database look nice and be maintainable. Here are our guiding principles:
3
3
 
4
- * DSL should be close to SQL; we're not imitating Enumerables.
5
- * Not the multitude of chain methods, but the composability of these methods makes Flounders power.
4
+ * Given the choice, we'll chose the method name close to SQL. Flounder should make that domain knowledge more useful, not introduce another domain.
5
+ * Composability over complexity.
6
6
 
7
7
  # A simple Domain
8
8
 
@@ -20,8 +20,8 @@ Here's our domain definition. In which - yes - you specify both singular and plu
20
20
  Now very simple selects should work as expected.
21
21
 
22
22
  ~~~ruby
23
- sql = domain[:users].where(id: 1).sql
24
- sql.assert == %Q(SELECT * FROM "users" WHERE "users"."id" = 1)
23
+ domain[:users].where(id: 1).assert generates_sql(
24
+ %Q(SELECT [fields] FROM "users" WHERE "users"."id" = 1))
25
25
  ~~~
26
26
 
27
27
  The `domain[:users]` bit refers to a relation in the database and when you append another square bracket pair, you'll refer to a field in the database - everywhere, in all flounder clauses.
@@ -33,29 +33,29 @@ The `domain[:users]` bit refers to a relation in the database and when you appen
33
33
  Also, several conditions work as one would expect from DataMapper.
34
34
 
35
35
  ~~~ruby
36
- domain[:users].where(id: 1..10).sql.assert ==
37
- %Q(SELECT * FROM "users" WHERE "users"."id" BETWEEN 1 AND 10)
38
-
39
- domain[:users].where(:id.lt => 10).sql.assert ==
40
- "SELECT * FROM \"users\" WHERE \"users\".\"id\" < 10"
41
- domain[:users].where(:id.lteq => 10).sql.assert ==
42
- "SELECT * FROM \"users\" WHERE \"users\".\"id\" <= 10"
43
- domain[:users].where(:id.gt => 10).sql.assert ==
44
- "SELECT * FROM \"users\" WHERE \"users\".\"id\" > 10"
45
- domain[:users].where(:id.gteq => 10).sql.assert ==
46
- "SELECT * FROM \"users\" WHERE \"users\".\"id\" >= 10"
47
- domain[:users].where(:id.not_eq => 10).sql.assert ==
48
- "SELECT * FROM \"users\" WHERE \"users\".\"id\" != 10"
36
+ domain[:users].where(id: 1..10).assert generates_sql(
37
+ %Q(SELECT [fields] FROM "users" WHERE "users"."id" BETWEEN 1 AND 10))
38
+
39
+ domain[:users].where(:id.lt => 10).assert generates_sql(
40
+ "SELECT [fields] FROM \"users\" WHERE \"users\".\"id\" < 10")
41
+ domain[:users].where(:id.lteq => 10).assert generates_sql(
42
+ "SELECT [fields] FROM \"users\" WHERE \"users\".\"id\" <= 10")
43
+ domain[:users].where(:id.gt => 10).assert generates_sql(
44
+ "SELECT [fields] FROM \"users\" WHERE \"users\".\"id\" > 10")
45
+ domain[:users].where(:id.gteq => 10).assert generates_sql(
46
+ "SELECT [fields] FROM \"users\" WHERE \"users\".\"id\" >= 10")
47
+ domain[:users].where(:id.not_eq => 10).assert generates_sql(
48
+ "SELECT [fields] FROM \"users\" WHERE \"users\".\"id\" != 10")
49
49
  ~~~
50
50
 
51
51
  Fields can be used fully qualified by going through the entity.
52
52
 
53
53
  ~~~ruby
54
54
  domain[:users].where(domain[:users][:id] => 10).
55
- sql.assert == %Q(SELECT * FROM "users" WHERE "users"."id" = 10)
55
+ assert generates_sql(%Q(SELECT [fields] FROM "users" WHERE "users"."id" = 10))
56
56
 
57
57
  domain[:users].where(domain[:users][:name].matches => 'a%').
58
- sql.assert == %Q(SELECT * FROM "users" WHERE "users"."name" LIKE 'a%')
58
+ assert generates_sql(%Q(SELECT [fields] FROM "users" WHERE "users"."name" LIKE 'a%'))
59
59
  ~~~
60
60
 
61
61
  # Some JOINs
@@ -63,11 +63,11 @@ Fields can be used fully qualified by going through the entity.
63
63
  Here are some non-crazy joins that also work.
64
64
 
65
65
  ~~~ruby
66
- sql = domain[:users].join(domain[:posts]).on(:id => :user_id).sql
67
- sql.assert == %Q(SELECT * FROM "users" INNER JOIN "posts" ON "users"."id" = "posts"."user_id")
66
+ domain[:users].join(domain[:posts]).on(:id => :user_id).
67
+ assert generates_sql(%Q(SELECT [fields] FROM "users" INNER JOIN "posts" ON "users"."id" = "posts"."user_id"))
68
68
 
69
- sql = domain[:users].outer_join(domain[:posts]).on(:id => :user_id).sql
70
- sql.assert == %Q(SELECT * FROM "users" LEFT OUTER JOIN "posts" ON "users"."id" = "posts"."user_id")
69
+ domain[:users].outer_join(domain[:posts]).on(:id => :user_id).
70
+ assert generates_sql(%Q(SELECT [fields] FROM "users" LEFT OUTER JOIN "posts" ON "users"."id" = "posts"."user_id"))
71
71
  ~~~
72
72
 
73
73
  Joining presents an interesting dilemma. There are two ways of joining things together, given three tables. The sequence A.B.C might mean to join A to B and C; it might also be interpreted to mean to join A to B and B to C. Here's how we solve this.
@@ -76,7 +76,7 @@ Joining presents an interesting dilemma. There are two ways of joining things to
76
76
  domain[:users].
77
77
  join(domain[:posts]).on(:id => :user_id).
78
78
  join(domain[:comments]).on(:id => :post_id).
79
- sql.assert == %Q(SELECT * FROM "users" INNER JOIN "posts" ON "users"."id" = "posts"."user_id" INNER JOIN "comments" ON "users"."id" = "comments"."post_id")
79
+ assert generates_sql(%Q(SELECT [fields] FROM "users" INNER JOIN "posts" ON "users"."id" = "posts"."user_id" INNER JOIN "comments" ON "users"."id" = "comments"."post_id"))
80
80
  ~~~
81
81
 
82
82
  So just doing `A.B.C` will give you the first of the above possibilities. Here's how to achive the second effect.
@@ -85,7 +85,7 @@ So just doing `A.B.C` will give you the first of the above possibilities. Here's
85
85
  domain[:users].
86
86
  join(domain[:posts]).on(:id => :user_id).anchor.
87
87
  join(domain[:comments]).on(:id => :post_id).
88
- sql.assert == %Q(SELECT * FROM "users" INNER JOIN "posts" ON "users"."id" = "posts"."user_id" INNER JOIN "comments" ON "posts"."id" = "comments"."post_id")
88
+ assert generates_sql(%Q(SELECT [fields] FROM "users" INNER JOIN "posts" ON "users"."id" = "posts"."user_id" INNER JOIN "comments" ON "posts"."id" = "comments"."post_id"))
89
89
  ~~~
90
90
 
91
91
  The call to `#anchor` anchors all further joins at that point.
@@ -93,8 +93,11 @@ The call to `#anchor` anchors all further joins at that point.
93
93
  # ORDER BY
94
94
 
95
95
  ~~~ruby
96
- domain[:users].where(id: 2013).order(domain[:users][:id]).
97
- sql.assert == %Q(SELECT * FROM "users" WHERE "users"."id" = 2013 ORDER BY "users"."id")
96
+ domain[:users].where(id: 2013).order_by(domain[:users][:id]).
97
+ assert generates_sql(%Q(SELECT [fields] FROM "users" WHERE "users"."id" = 2013 ORDER BY "users"."id"))
98
+
99
+ domain[:users].order_by('id').
100
+ assert generates_sql(%Q(SELECT [fields] FROM "users" ORDER BY "users"."id"))
98
101
  ~~~
99
102
 
100
103
  # Selective projection
data/qed/inserts.md ADDED
@@ -0,0 +1,43 @@
1
+ An insert creates a state from which SQL can be extracted.
2
+
3
+ ~~~ruby
4
+ sql = users.insert(:name => 'Mr. Insert SQL').to_sql
5
+
6
+ sql.assert == "INSERT INTO \"users\" (\"name\") VALUES ('Mr. Insert SQL')"
7
+ ~~~
8
+
9
+ Using returning will return the inserted object.
10
+
11
+ ~~~ruby
12
+ results = users.insert(:name => 'Mr. Returning Asterisk').returning
13
+
14
+ results.first.name.assert == 'Mr. Returning Asterisk'
15
+ ~~~
16
+
17
+ Returning all fields is the default, but you can provide that explicitly.
18
+
19
+ ~~~ruby
20
+ results = users.insert(:name => 'Mr. Returning Asterisk').returning '*'
21
+
22
+ results.first.name.assert == 'Mr. Returning Asterisk'
23
+ ~~~
24
+
25
+ Using returning with a field name will return those fields of the inserted object.
26
+
27
+ ~~~ruby
28
+ results = users.insert(:name => 'Mr. Returning').returning(:id)
29
+
30
+ id = results.first.id
31
+
32
+ users.where(:id => id).first.name.assert == 'Mr. Returning'
33
+ ~~~
34
+
35
+ Flounder fields can be used as keys.
36
+
37
+ ~~~ruby
38
+ results = users.insert(users[:name] => 'Mr. Flounder Field').returning
39
+
40
+ results.first.name.assert == 'Mr. Flounder Field'
41
+ ~~~
42
+
43
+ TODO It does not yet support multi-row inserts.
data/qed/results.md ADDED
@@ -0,0 +1,21 @@
1
+
2
+
3
+
4
+ ~~~ruby
5
+ post = posts.
6
+ join(users).on(:user_id => :id).
7
+ join(users.as(:approvers, :approver)).on(:approver_id => :id).
8
+ first
9
+
10
+ post.user.name.assert == 'John Snow'
11
+ post.approver.name.assert == 'John Doe'
12
+ ~~~
13
+
14
+ # Custom Projections
15
+
16
+ TBD: Describes how to do custom projections and how these results will be available in the resulting object.
17
+
18
+
19
+ # Aliasing
20
+
21
+ TBD: Describes to join a table multiple times and how to deal with the results.
data/qed/selects.md CHANGED
@@ -3,10 +3,14 @@
3
3
  A simple domain definition.
4
4
 
5
5
  ~~~ruby
6
- connection = Flounder.connect(dbname: 'flounder')
7
- domain = Flounder.domain(connection) do |dom|
8
- dom.entity(:users, :user, 'users')
9
- end
6
+ # See 'setup_domain.rb' for the domain definition. Repeated here, as a
7
+ # comment:
8
+ #
9
+ # Flounder.domain(connection) do |dom|
10
+ # dom.entity(:users, :user, 'users')
11
+ # dom.entity(:posts, :post, 'posts')
12
+ # dom.entity(:comments, :comment, 'comments')
13
+ # end
10
14
 
11
15
  # Enable this line if you want to see all statements executed.
12
16
  # domain.logger = Logger.new(STDOUT)
@@ -22,8 +26,8 @@ And a simple use case.
22
26
  If we want to see all records, we use the `all` kicker, which has some synonyms. You best discover those by treating the domain entity as an array.
23
27
 
24
28
  ~~~ruby
25
- users = domain[:users].all
26
- users.size.assert == 1
29
+ users = domain[:users].all
30
+ users.size.assert == 6
27
31
  users.assert.kind_of? Array
28
32
 
29
33
  domain[:users].map(&:id).assert == users.map(&:id)
data/qed/updates.md ADDED
@@ -0,0 +1,68 @@
1
+ An update creates a state from which SQL can be extracted.
2
+
3
+ ~~~ruby
4
+ post = posts.first
5
+
6
+ sql = posts.update(:title => 'Update SQL').where(:id => post.id).to_sql
7
+
8
+ sql.assert == 'UPDATE "posts" SET "title" = \'Update SQL\' WHERE "posts"."id" = 1'
9
+ ~~~
10
+
11
+ Flounder fields are ok.
12
+
13
+ ~~~ruby
14
+ post = posts.first
15
+
16
+ sql = posts.update(posts[:title] => 'Update Flounder SQL').where(:id => post.id).to_sql
17
+
18
+ sql.assert == 'UPDATE "posts" SET "title" = \'Update Flounder SQL\' WHERE "posts"."id" = 1'
19
+ ~~~
20
+
21
+ It can update a single field.
22
+
23
+ ~~~ruby
24
+ post = posts.first
25
+
26
+ post = posts.update(:title => 'Update Field').where(:id => post.id).returning.first
27
+
28
+ post.title.assert == 'Update Field'
29
+ ~~~
30
+
31
+ Flounder fields are ok here too.
32
+
33
+ ~~~ruby
34
+ post = posts.first
35
+
36
+ post = posts.update(posts[:title] => 'Update Flounder Field').where(:id => post.id).returning.first
37
+
38
+ post.title.assert == 'Update Flounder Field'
39
+ ~~~
40
+
41
+ An update can take multiple fields.
42
+
43
+ ~~~ruby
44
+ post = posts.first
45
+
46
+ sql = posts.update(:title => 'Update SQL', :text => 'Update Multiple Fields Text').where(:id => post.id).to_sql
47
+
48
+ sql.assert == 'UPDATE "posts" SET "title" = \'Update SQL\', "text" = \'Update Multiple Fields Text\' WHERE "posts"."id" = 1'
49
+ ~~~
50
+
51
+ Updating a single row is possible.
52
+
53
+ ~~~ruby
54
+ post = posts.first
55
+
56
+ post = posts.update(:title => 'Updated Title', :text => 'Update Single Row Possible').where(:id => post.id).returning.first
57
+
58
+ post.title.assert == 'Updated Title'
59
+ post.text.assert == 'Update Single Row Possible'
60
+ ~~~
61
+
62
+ Updating multiple rows is possible.
63
+
64
+ ~~~ruby
65
+ updated = users.update(:name => 'Update Multiple Rows').where(:name.not_eq => nil).returning
66
+
67
+ updated.map(&:name).assert == ['Update Multiple Rows']*6
68
+ ~~~
metadata CHANGED
@@ -1,14 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: flounder
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaspar Schiess
8
+ - Florian Hanke
8
9
  autorequire:
9
10
  bindir: bin
10
11
  cert_chain: []
11
- date: 2014-07-23 00:00:00.000000000 Z
12
+ date: 2014-08-01 00:00:00.000000000 Z
12
13
  dependencies:
13
14
  - !ruby/object:Gem::Dependency
14
15
  name: arel
@@ -85,6 +86,7 @@ extensions: []
85
86
  extra_rdoc_files: []
86
87
  files:
87
88
  - Gemfile
89
+ - Gemfile.lock
88
90
  - HACKING
89
91
  - LICENSE
90
92
  - README
@@ -95,15 +97,25 @@ files:
95
97
  - lib/flounder/domain.rb
96
98
  - lib/flounder/engine.rb
97
99
  - lib/flounder/entity.rb
100
+ - lib/flounder/entity_alias.rb
101
+ - lib/flounder/exceptions.rb
98
102
  - lib/flounder/field.rb
103
+ - lib/flounder/immediate.rb
104
+ - lib/flounder/insert.rb
99
105
  - lib/flounder/postgres_utils.rb
100
106
  - lib/flounder/query.rb
101
107
  - lib/flounder/symbol_extensions.rb
108
+ - lib/flounder/update.rb
102
109
  - qed/applique/ae.rb
103
- - qed/applique/ahrel.rb
110
+ - qed/applique/flounder.rb
111
+ - qed/applique/setup_domain.rb
112
+ - qed/exceptions.md
104
113
  - qed/flounder.sql
105
114
  - qed/index.md
115
+ - qed/inserts.md
116
+ - qed/results.md
106
117
  - qed/selects.md
118
+ - qed/updates.md
107
119
  homepage: https://bitbucket.org/technologyastronauts/laboratory_flounder
108
120
  licenses: []
109
121
  metadata: {}
@@ -123,15 +135,20 @@ required_rubygems_version: !ruby/object:Gem::Requirement
123
135
  version: '0'
124
136
  requirements: []
125
137
  rubyforge_project:
126
- rubygems_version: 2.2.2
138
+ rubygems_version: 2.2.0
127
139
  signing_key:
128
140
  specification_version: 4
129
141
  summary: Flounder is a way to write SQL simply in Ruby. It deals with everything BUT
130
142
  object relational mapping.
131
143
  test_files:
132
144
  - qed/applique/ae.rb
133
- - qed/applique/ahrel.rb
145
+ - qed/applique/flounder.rb
146
+ - qed/applique/setup_domain.rb
147
+ - qed/exceptions.md
134
148
  - qed/flounder.sql
135
149
  - qed/index.md
150
+ - qed/inserts.md
151
+ - qed/results.md
136
152
  - qed/selects.md
153
+ - qed/updates.md
137
154
  has_rdoc: