flounder 0.8.1 → 0.9.0

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
  SHA1:
3
- metadata.gz: d747ac7404138eb5d0dd5adf3e1a936f70381c84
4
- data.tar.gz: 6186aeaf643c3f6a2e7f42d18bc7d3b903d3038c
3
+ metadata.gz: 97e8380edd41a2232f500fc61ceb3f49e2dd3211
4
+ data.tar.gz: e0330758a5aac5eba5d509cdffa778f0dca41735
5
5
  SHA512:
6
- metadata.gz: 54e0c5140cbef2595cd5b32333166220f233098c8e5f158f8499538009f2f8a306f5fd6fee026297f509b401e2662998aac17e02b6de4a3ffc6b87b1f21f2a75
7
- data.tar.gz: 5bf99fed4005c03b21df75d39adffe4099b83ab032bb207689cdbf249434237db92f577a2927668a15b4a997b0e5061faf8407959d0fe3ed5b9a1deecb491240
6
+ metadata.gz: c17571110ffbe588b794f026e5eddfc244bd479c37d1a09e4f01639caa4e6031bbb82a1ff5512a616fc4c0069cab4c0165eeddcd5a39535275376c8e8dcf5509
7
+ data.tar.gz: 76e6e016a4ac6bcaa9f2c8679909a63cd1564efeed5b5ce970d6a41e845cdc77d43c09cc188e0fbd3e367bff6725c382fae2fa223b07b15ce25c15947766417e
data/HISTORY ADDED
@@ -0,0 +1,7 @@
1
+
2
+ # 0.9
3
+
4
+ + Allows expressions of the form `where('id=$1', 1)`.
5
+ + You now need to call `#kick` when performing an insert or an update
6
+ to get it to really happen. Please see our documentation in qed/*.
7
+ + `domain.transaction do |conn| ... end`
Binary file
data/flounder.gemspec CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  Gem::Specification.new do |s|
4
4
  s.name = "flounder"
5
- s.version = '0.8.1'
5
+ s.version = '0.9.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
- s.homepage = "https://bitbucket.org/technologyastronauts/laboratory_flounder"
8
+ s.homepage = "https://bitbucket.org/technologyastronauts/oss_flounder"
9
9
  s.authors = ['Kaspar Schiess', 'Florian Hanke']
10
10
 
11
11
  # s.description = <<-EOF
@@ -6,11 +6,14 @@ module Flounder
6
6
 
7
7
  def initialize pg_conn_args
8
8
  @pg = PG.connect(*pg_conn_args)
9
- @visitor = Arel::Visitors::ToSql.new(self)
9
+ @visitor = Arel::Visitors::PostgreSQL.new(self)
10
10
  end
11
11
 
12
12
  attr_reader :visitor
13
13
 
14
+ def transaction &block
15
+ pg.transaction(&block)
16
+ end
14
17
  def exec *args, &block
15
18
  pg.exec *args, &block
16
19
  end
@@ -42,7 +45,6 @@ module Flounder
42
45
 
43
46
  def primary_key name
44
47
  fail NotImplementedError
45
- @primary_keys[name.to_s]
46
48
  end
47
49
 
48
50
  def table_exists? table_name
@@ -52,7 +54,6 @@ module Flounder
52
54
 
53
55
  def columns name, message = nil
54
56
  fail NotImplementedError
55
- @columns[name.to_s]
56
57
  end
57
58
 
58
59
  def quote_table_name name
@@ -63,25 +64,51 @@ module Flounder
63
64
  pg.quote_ident name.to_s
64
65
  end
65
66
 
66
- def schema_cache
67
+ def schema_cache
67
68
  self
68
69
  end
69
70
 
70
71
  def quote thing, column = nil
72
+ # require 'pp '
71
73
  # p [:quote, thing, column]
74
+ # pp caller.first(10)
72
75
  pg.escape_literal(thing.to_s)
73
76
  end
74
-
77
+
78
+ # ------------------------------------------------------------------------
79
+
80
+ # Turns a PG result row into a hash-like object. There are some transformation
81
+ # rules that govern this conversion:
82
+ #
83
+ # * All data types are converted to their closest Ruby equivalent
84
+ # (type conversion)
85
+ # * Fields from the main entity (the entity that started the select)
86
+ # are returned on the top level of the hash.
87
+ # * Fields from joined entities are returned in a subhash stored under the
88
+ # singular name of the joined entity.
89
+ #
90
+ # Example:
91
+ # row = users.join(posts).on(:id => :user_id).first
92
+ # row[:id] # refers to users.id, also as row.id
93
+ # row[:post][:id] # refers to posts.id, also as row.post.id
94
+ #
95
+ # row.keys # hash keys of the row, not equal to row[:keys]!
96
+ #
97
+ # @param ent [Entity] entity that the query originated from
98
+ # @param result [PG::Result]
99
+ # @param row_idx [Fixnum] row we're interested in
100
+ # @return [Hashie::Mash] result row as hash-like object
101
+ #
75
102
  def objectify_result_row ent, result, row_idx
76
103
  obj = Hashie::Mash.new
77
104
 
78
105
  each_field(ent, result, row_idx) do
79
106
  |entity, name, value, type_oid, binary, idx|
80
- # TODO remove entity resolution from each_field?
81
107
 
82
- # TODO This is currently here to cover the special case of a query.
83
- # Certainly needs refactoring.
84
- #
108
+ # NOTE entity resolution is done both through aliasing and through
109
+ # postgres column reporting. The above entity variable carries what
110
+ # postgres reports to us; the block below resolves aliased entity
111
+ # names:
85
112
  processed_entity, processed_name = yield name if block_given?
86
113
  entity = processed_entity if processed_entity
87
114
  name = processed_name if processed_name
@@ -100,8 +127,9 @@ module Flounder
100
127
  # The main entity and custom fields (AS something) are available on the
101
128
  # top-level of the result.
102
129
  if !entity || entity == ent
103
- warn "#{name.inspect} already defined in result set, aliasing occurs." \
130
+ raise DuplicateField, "#{name.inspect} already defined in result set, aliasing occurs." \
104
131
  if obj.has_key? name
132
+
105
133
  obj[name] = typecast_value
106
134
  end
107
135
  end
@@ -11,18 +11,27 @@ module Flounder
11
11
  }
12
12
  end
13
13
 
14
- Spec = Struct.new(:config)
15
-
14
+ # Checks out a connection from the pool and yields it to the block. The
15
+ # connection is returned to the pool at the end of the block; don't hold
16
+ # on to it.
17
+ #
16
18
  def with_connection
17
19
  @pool.with do |conn|
18
20
  yield conn
19
21
  end
20
22
  end
21
23
 
24
+ # Checks out a connection from the pool. You have to return this
25
+ # connection manually.
26
+ #
22
27
  def checkout
23
28
  @pool.checkout
24
29
  end
25
30
 
31
+ Spec = Struct.new(:config)
32
+
33
+ # This is needed to conform to arels interface.
34
+ #
26
35
  def spec
27
36
  Spec.new(adapter: 'pg')
28
37
  end
@@ -1,4 +1,5 @@
1
1
 
2
+ require 'forwardable'
2
3
  require 'logger'
3
4
 
4
5
  module Flounder
@@ -19,15 +20,33 @@ module Flounder
19
20
 
20
21
  def initialize connection_pool
21
22
  @connection_pool = connection_pool
23
+
24
+ # maps from plural/singular names to entities in this domain
22
25
  @plural = {}
23
26
  @singular = {}
27
+
28
+ # maps OIDs of entities to entities
24
29
  @oids_entity_map = {}
30
+
25
31
  @logger = Logger.new(NilDevice.new)
26
32
  end
27
33
 
28
34
  attr_reader :connection_pool
29
35
  attr_accessor :logger
30
36
 
37
+ extend Forwardable
38
+ def_delegators :connection_pool, :with_connection
39
+
40
+ def transaction &block
41
+ with_connection do |conn|
42
+ conn.transaction do
43
+ block.call(conn)
44
+ end
45
+ end
46
+ end
47
+
48
+ # Returns the entity with the given plural name.
49
+ #
31
50
  def [] name
32
51
  raise NoSuchEntity, "No such entity #{name.inspect} in this domain." \
33
52
  unless @plural.has_key?(name)
@@ -1,4 +1,8 @@
1
1
  module Flounder
2
+
3
+ # Intermediary class that arel wants us to create. Mostly supports the
4
+ # #connection_pool message returning our connection pool.
5
+ #
2
6
  class Engine
3
7
  attr_reader :connection_pool
4
8
  attr_reader :connection
@@ -18,6 +22,10 @@ module Flounder
18
22
 
19
23
  # ---------------------------------------------------- official Engine iface
20
24
 
25
+ # Returns the connection pool.
26
+ #
27
+ # @return [ConnectionPool]
28
+ #
21
29
  def connection_pool
22
30
  @connection_pool
23
31
  end
@@ -1,33 +1,58 @@
1
+ require 'forwardable'
2
+
1
3
  module Flounder
4
+
5
+ # An entity corresponds to a table in the database. On top of its table
6
+ # name, an entity will know its code name (plural) and its singular name,
7
+ # what to call a single row returned from this entity.
8
+ #
9
+ # An entity is not a model. In fact, it is what you need to completely
10
+ # hand-roll your models, without being the model itself.
11
+ #
12
+ # Entities are mainly used to start a query via one of the query initiators.
13
+ # Almost all of the chain methods on the Query object can be used here as
14
+ # well - they will return a query object. Entities, like queries, can be
15
+ # used as enumerables.
16
+ #
17
+ # Example:
18
+ #
19
+ # foo = domain.entity(:plural, :singular, 'table_name')
20
+ #
21
+ # foo.all # SELECT * FROM table_name
22
+ # foo.where(id: 1).first # SELECT * FROM table_name WHERE "table_name"."id" = 1
23
+ #
2
24
  class Entity
3
- # Domain this entity is defined in.
25
+ def initialize domain, plural, singular, table_name
26
+ @domain = domain
27
+ @name = plural
28
+ @singular = singular
29
+ @table_name = table_name
30
+ @columns_hash = nil
31
+
32
+ @table = Arel::Table.new(table_name)
33
+ end
34
+
35
+ # @return [Domain] Domain this entity is defined in.
4
36
  attr_reader :domain
5
37
 
6
- # Name of the entity in plural form.
38
+ # @return [Symbol] Name of the entity in plural form.
7
39
  attr_reader :name
8
40
 
9
41
  # Also, the name is the plural, so we'll support that as well.
10
42
  alias plural name
11
43
 
12
- # Name of the entity in singular form.
44
+ # @return [Symbol] Name of the entity in singular form.
13
45
  attr_reader :singular
14
46
 
15
- # Arel table that underlies this entity.
47
+ # @return [Arel::Table] Arel table that underlies this entity.
16
48
  attr_reader :table
17
- # Name of the table that underlies this entity.
49
+ # @return [String] Name of the table that underlies this entity.
18
50
  attr_reader :table_name
19
51
 
20
- def initialize domain, plural, singular, table_name
21
- @domain = domain
22
- @name = plural
23
- @singular = singular
24
- @table_name = table_name
25
- @columns_hash = nil
26
-
27
- @table = Arel::Table.new(table_name)
28
- end
52
+ extend Forwardable
53
+ def_delegators :domain, :transaction, :with_connection
29
54
 
30
- # Returns a field of the entity.
55
+ # @return [Field] Field with name of the entity.
31
56
  #
32
57
  def [] name
33
58
  Field.new(self, name, table[name])
@@ -37,51 +62,28 @@ module Flounder
37
62
  "entity(#{name}/#{table_name})"
38
63
  end
39
64
 
40
- def query
41
- Query.new(domain, self).tap { |q|
65
+ # Starts a new select query and yields it to the block. Note that you don't
66
+ # need to call this method to obtain a select query - any of the methods
67
+ # on Query::Select should work on the entity and return a new query object.
68
+ #
69
+ # @return [Query::Select]
70
+ #
71
+ def select
72
+ Query::Select.new(domain, self).tap { |q|
42
73
  yield q if block_given?
43
74
  }
44
75
  end
45
76
 
46
77
  def insert hash
47
- Insert.new(domain, self).tap { |i|
48
- yield i if block_given?
49
- i.insert(hash)
50
- }
78
+ Query::Insert.new(domain, self).tap { |q|
79
+ q.row(hash) }
51
80
  end
52
81
 
53
82
  def update hash
54
- Update.new(domain, self).tap { |u|
55
- yield u if block_given?
56
- u.update(hash)
57
- }
83
+ Query::Update.new(domain, self).tap { |u|
84
+ u.set(hash) }
58
85
  end
59
-
60
- # Insert or update.
61
- #
62
- def insdate fields, hash
63
- with_transaction do
64
- found = where(hash.select { |k, _| fields.include?(k) }).first
65
- if found
66
- update(hash).where(:id => found.id).returning
67
- else
68
- result = insert(hash).returning
69
- yield result if block_given?
70
- result
71
- end
72
- end
73
- end
74
-
75
- def with_transaction &block
76
- result = nil
77
- domain.connection_pool.with_connection do |conn|
78
- conn.exec 'START TRANSACTION'
79
- result = block.call
80
- conn.exec 'COMMIT TRANSACTION'
81
- end
82
- result
83
- end
84
-
86
+
85
87
  # Temporarily creates a new entity that is available as if it was declared
86
88
  # with the given plural and singular, but referencing to the same
87
89
  # underlying relation.
@@ -104,14 +106,14 @@ module Flounder
104
106
  # Query initiators
105
107
  [:where, :join, :outer_join, :project, :order_by].each do |name|
106
108
  define_method name do |*args|
107
- query { |q| q.send(name, *args) }
109
+ select { |q| q.send(name, *args) }
108
110
  end
109
111
  end
110
112
 
111
113
  # Kickers
112
114
  [:first, :all, :size, :delete].each do |name|
113
115
  define_method name do |*args|
114
- q = query
116
+ q = select
115
117
  q.send(name, *args)
116
118
  end
117
119
  end
@@ -5,4 +5,12 @@ module Flounder
5
5
  # to a real field on the underlying relation.
6
6
  #
7
7
  class InvalidFieldReference < StandardError; end
8
+
9
+ # Indicates that a where clause with bind variables went out of bounds.
10
+ class BindIndexOutOfBounds < StandardError; end
11
+
12
+ # Exception raised whenever a result set contains two columns with the
13
+ # same name.
14
+ #
15
+ class DuplicateField < StandardError; end
8
16
  end
@@ -6,13 +6,21 @@ module Flounder
6
6
  @arel_field = arel_field
7
7
  end
8
8
 
9
+ # @return [Entity] entity this field belongs to
9
10
  attr_reader :entity
11
+
12
+ # @return [Arel::Attribute] arel attribute that corresponds to this field
10
13
  attr_reader :arel_field
14
+
15
+ # @return [String] name of this field
11
16
  attr_reader :name
12
17
 
18
+ # Returns a fully qualified name (table.field).
19
+ #
20
+ # @return [String] fully qualified field name
21
+ #
13
22
  def fully_qualified_name
14
- # TBD quoting? Demeter?
15
- entity.domain.connection_pool.with_connection do |conn|
23
+ entity.with_connection do |conn|
16
24
  table = conn.quote_table_name(entity.table_name)
17
25
  column = conn.quote_column_name(name)
18
26
  "#{table}.#{column}"
@@ -0,0 +1,157 @@
1
+
2
+ module Flounder::Query
3
+ class Base
4
+ def initialize domain, manager_klass, entity
5
+ @domain = domain
6
+ @engine = Flounder::Engine.new(domain.connection_pool)
7
+ @manager = manager_klass.new(engine)
8
+ @entity = entity
9
+
10
+ @bind_values = []
11
+ end
12
+
13
+ # Domain that this query was issued from.
14
+ attr_reader :domain
15
+ # Database engine that links Arel to Postgres.
16
+ attr_reader :engine
17
+ # Bound values in this query.
18
+ attr_reader :bind_values
19
+ # Arel *Manager that accumulates this query.
20
+ attr_reader :manager
21
+ # Entity this query operates on.
22
+ attr_reader :entity
23
+
24
+ # Restricts the result returned to only those records that match the
25
+ # conditions.
26
+ #
27
+ # Example:
28
+ #
29
+ # query.where(id: 1) # ... WHERE id = 1 ...
30
+ # query.where(:id => :user_id) # ... WHERE id = user_id
31
+ # query.where(:id.noteq => 1) # ... WHERE id != 1
32
+ #
33
+ def where *conditions
34
+ # is this a hash? extract the first element
35
+ if conditions.size == 1 && conditions.first.kind_of?(Hash)
36
+ conditions = conditions.first
37
+
38
+ conditions.each do |k, v|
39
+ manager.where(transform_tuple(k, v))
40
+ end
41
+ return self
42
+ end
43
+
44
+ # maybe conditions is of the second form?
45
+ conditions.each do |cond_str, *values|
46
+ manager.where(
47
+ Arel::Nodes::SqlLiteral.new(
48
+ rewrite_bind_variables(cond_str, bind_values.size, values.size)))
49
+ bind_values.concat values
50
+ end
51
+ end
52
+
53
+ def with name, query
54
+ # Nodes::TableAlias.new(relation, name)
55
+ manager.with(query.manager)
56
+ end
57
+
58
+ # Kickers
59
+ def to_sql
60
+ prepare_kick
61
+
62
+ manager.to_sql.tap { |sql|
63
+ domain.log_sql(sql) }
64
+ end
65
+
66
+ # Returns all rows of the query result as an array. Individual rows are
67
+ # mapped to objects using the row mapper.
68
+ #
69
+ def kick connection=nil
70
+ all = nil
71
+
72
+ (connection || engine).exec(self.to_sql, bind_values) do |result|
73
+ all = Array.new(result.ntuples, nil)
74
+ result.ntuples.times do |row_idx|
75
+ all[row_idx] = engine.connection.
76
+ objectify_result_row(entity, result, row_idx) do |name|
77
+ column_name_to_entity(name)
78
+ end
79
+ end
80
+ end
81
+
82
+ all
83
+ end
84
+ def column_name_to_entity name
85
+ # Implement this if your column names in the query allow inferring
86
+ # the entity and the column name. Return them as a tuple <entity, name>.
87
+ end
88
+
89
+ private
90
+ # Prepares a kick - meaning an execution on the database or a transform
91
+ # to an sql string. Ready Set...
92
+ #
93
+ def prepare_kick
94
+ # should be overridden
95
+ end
96
+
97
+ # Rewrites a statement that contains bind placeholders like '$1' to
98
+ # contain placeholders starting at offset+1. Also checks that no
99
+ # placeholder exceeds the limit.
100
+ #
101
+ def rewrite_bind_variables str, offset, limit
102
+ str.gsub(%r(\$(?<idx>\d+))) do |match|
103
+ idx = Integer($~[:idx])
104
+
105
+ raise Flounder::BindIndexOutOfBounds,
106
+ "Binding to $#{idx} in #{str.inspect}, but only #{limit} variables provided" \
107
+ if idx-1 >= limit
108
+
109
+ "$#{idx + offset}"
110
+ end
111
+ end
112
+
113
+ # Called on each key/value pair of a
114
+ # * condition
115
+ # * join
116
+ # clause, this returns a field that can be passed to Arel
117
+ # * #where
118
+ # * #on
119
+ #
120
+ def transform_tuple field, value
121
+ if value.kind_of? Flounder::Field
122
+ value = value.arel_field
123
+ end
124
+
125
+ case field
126
+ # covers: :field_a => ...
127
+ when Symbol
128
+ join_and_condition_part(entity[field].arel_field, value)
129
+ # covers: entity[:field] => ...
130
+ when Flounder::Field
131
+ join_and_condition_part(field.arel_field, value)
132
+ # covers: :field_a.noteq => ...
133
+ when Flounder::SymbolExtensions::Modifier
134
+ join_and_condition_part(
135
+ field.to_arel_field(entity),
136
+ value,
137
+ field.kind)
138
+ else
139
+ fail "Could not transform condition part. (#{field.inspect}, #{value.inspect})"
140
+ end
141
+ end
142
+ def join_and_condition_part arel_field, value, kind=:eq
143
+ case value
144
+ # covers :field_a => :field_b
145
+ when Symbol
146
+ value_field = entity[value].arel_field
147
+ arel_field.send(kind, value_field)
148
+ # covers: :field => (1..100)
149
+ when Range
150
+ arel_field.in(value)
151
+ else
152
+ arel_field.send(kind, value)
153
+ end
154
+ end
155
+
156
+ end
157
+ end
@@ -1,5 +1,5 @@
1
1
 
2
- module Flounder
2
+ module Flounder::Query
3
3
  # An immmediate string that needs to be passed around and _not_ quoted or
4
4
  # escaped when going to the database.
5
5
  #
@@ -0,0 +1,41 @@
1
+
2
+ require_relative 'base'
3
+ require_relative 'returning'
4
+
5
+ module Flounder::Query
6
+
7
+ # An insert.
8
+ #
9
+ class Insert < Base
10
+ def initialize domain, into_entity
11
+ super domain, Arel::InsertManager, into_entity
12
+
13
+ manager.into entity.table
14
+ end
15
+
16
+ # Add one row to the inserts.
17
+ #
18
+ def row fields
19
+ manager.insert(
20
+ fields.map { |k, v|
21
+ transform_values(k, v) })
22
+ end
23
+
24
+ include Returning
25
+ private
26
+
27
+ # Called on each key/value pair of an insert clause, this returns a
28
+ # hash that can be passed to Arel #insert.
29
+ #
30
+ def transform_values field, value
31
+ case field
32
+ when Symbol, String
33
+ [entity[field.to_sym].arel_field, value]
34
+ when Flounder::Field
35
+ [field.arel_field, value]
36
+ else
37
+ fail "Could not transform value. (#{field.inspect}, #{value.inspect})"
38
+ end
39
+ end
40
+ end # class
41
+ end # module Flounder
@@ -0,0 +1,19 @@
1
+
2
+
3
+ module Flounder::Query
4
+ module Returning
5
+ def returning_fields
6
+ @returning_fields || '*'
7
+ end
8
+
9
+ def returning fields
10
+ @returning_fields = fields
11
+
12
+ self
13
+ end
14
+
15
+ def to_sql
16
+ super << " RETURNING #{returning_fields}"
17
+ end
18
+ end
19
+ end