rom-sql 1.0.0.beta2 → 1.0.0.beta3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.travis.yml +1 -1
  3. data/CHANGELOG.md +4 -0
  4. data/circle.yml +1 -1
  5. data/lib/rom/plugins/relation/sql/auto_combine.rb +1 -1
  6. data/lib/rom/sql/association.rb +5 -1
  7. data/lib/rom/sql/association/many_to_many.rb +8 -4
  8. data/lib/rom/sql/association/many_to_one.rb +2 -3
  9. data/lib/rom/sql/association/one_to_many.rb +2 -3
  10. data/lib/rom/sql/commands/create.rb +8 -1
  11. data/lib/rom/sql/commands/update.rb +7 -0
  12. data/lib/rom/sql/extensions.rb +8 -0
  13. data/lib/rom/sql/extensions/active_support_notifications.rb +7 -18
  14. data/lib/rom/sql/extensions/mysql.rb +1 -0
  15. data/lib/rom/sql/extensions/mysql/inferrer.rb +10 -0
  16. data/lib/rom/sql/extensions/postgres/commands.rb +1 -1
  17. data/lib/rom/sql/extensions/postgres/inferrer.rb +4 -0
  18. data/lib/rom/sql/extensions/postgres/types.rb +20 -22
  19. data/lib/rom/sql/extensions/sqlite.rb +1 -0
  20. data/lib/rom/sql/extensions/sqlite/inferrer.rb +10 -0
  21. data/lib/rom/sql/function.rb +6 -2
  22. data/lib/rom/sql/order_dsl.rb +1 -1
  23. data/lib/rom/sql/plugin/associates.rb +28 -5
  24. data/lib/rom/sql/relation.rb +18 -0
  25. data/lib/rom/sql/relation/reading.rb +2 -2
  26. data/lib/rom/sql/relation/sequel_api.rb +119 -0
  27. data/lib/rom/sql/schema/inferrer.rb +26 -2
  28. data/lib/rom/sql/tasks/migration_tasks.rake +1 -1
  29. data/lib/rom/sql/type.rb +13 -2
  30. data/lib/rom/sql/types.rb +5 -3
  31. data/lib/rom/sql/version.rb +1 -1
  32. data/spec/extensions/postgres/types_spec.rb +29 -0
  33. data/spec/integration/association/many_to_many_spec.rb +8 -0
  34. data/spec/integration/association/many_to_one_spec.rb +11 -0
  35. data/spec/integration/association/one_to_many_spec.rb +9 -0
  36. data/spec/integration/schema/inferrer/mysql_spec.rb +36 -0
  37. data/spec/integration/schema/inferrer/postgres_spec.rb +118 -0
  38. data/spec/integration/schema/inferrer/sqlite_spec.rb +36 -0
  39. data/spec/integration/{schema_inference_spec.rb → schema/inferrer_spec.rb} +44 -15
  40. data/spec/integration/sequel_api_spec.rb +31 -0
  41. data/spec/support/helpers.rb +4 -0
  42. data/spec/unit/function_spec.rb +35 -0
  43. data/spec/unit/order_dsl_spec.rb +35 -0
  44. data/spec/unit/relation/assoc_spec.rb +38 -0
  45. data/spec/unit/relation/inner_join_spec.rb +15 -0
  46. data/spec/unit/types_spec.rb +53 -1
  47. metadata +23 -6
  48. data/spec/extensions/postgres/inferrer_spec.rb +0 -59
@@ -3,6 +3,7 @@ require 'rom/sql/schema'
3
3
 
4
4
  require 'rom/sql/relation/reading'
5
5
  require 'rom/sql/relation/writing'
6
+ require 'rom/sql/relation/sequel_api'
6
7
 
7
8
  require 'rom/plugins/relation/key_inference'
8
9
  require 'rom/plugins/relation/sql/auto_combine'
@@ -88,6 +89,23 @@ module ROM
88
89
 
89
90
  option :primary_key, reader: true, default: -> rel { rel.schema.primary_key_name }
90
91
 
92
+ # Return relation that will load associated tuples of this relation
93
+ #
94
+ # This method is useful for defining custom relation views for relation
95
+ # composition when you want to enhance default association query
96
+ #
97
+ # @example
98
+ # assoc(:tasks).where(tasks[:title] => "Task One")
99
+ #
100
+ # @param [Symbol] name The association name
101
+ #
102
+ # @return [Relation]
103
+ #
104
+ # @api public
105
+ def assoc(name)
106
+ associations[name].(__registry__)
107
+ end
108
+
91
109
  # Return raw column names
92
110
  #
93
111
  # @return [Array<Symbol>]
@@ -558,10 +558,10 @@ module ROM
558
558
  private
559
559
 
560
560
  # @api private
561
- def __join__(type, other, opts = EMPTY_HASH, &block)
561
+ def __join__(type, other, join_cond = EMPTY_HASH, opts = EMPTY_HASH, &block)
562
562
  case other
563
563
  when Symbol, Association::Name
564
- new(dataset.__send__(type, other.to_sym, opts, &block))
564
+ new(dataset.__send__(type, other.to_sym, join_cond, opts, &block))
565
565
  when Relation
566
566
  __send__(type, other.name.dataset, join_keys(other))
567
567
  else
@@ -0,0 +1,119 @@
1
+ module ROM
2
+ module SQL
3
+ # Query API for SQL::Relation
4
+ #
5
+ # @api public
6
+ module SequelAPI
7
+ # Select specific columns for select clause
8
+ #
9
+ # @example
10
+ # users.select(:id, :name).first
11
+ # # {:id => 1, :name => "Jane" }
12
+ #
13
+ # @return [Relation]
14
+ #
15
+ # @api public
16
+ def select(*args, &block)
17
+ new(dataset.__send__(__method__, *args, &block))
18
+ end
19
+
20
+ # Append specific columns to select clause
21
+ #
22
+ # @example
23
+ # users.select(:id, :name).select_append(:email)
24
+ # # {:id => 1, :name => "Jane", :email => "jane@doe.org"}
25
+ #
26
+ # @param [Array<Symbol>] *args A list with column names
27
+ #
28
+ # @return [Relation]
29
+ #
30
+ # @api public
31
+ def select_append(*args, &block)
32
+ new(dataset.__send__(__method__, *args, &block))
33
+ end
34
+
35
+ # Restrict a relation to match criteria
36
+ #
37
+ # If block is passed it'll be executed in the context of a condition
38
+ # builder object.
39
+ #
40
+ # @example
41
+ # users.where(name: 'Jane')
42
+ #
43
+ # users.where { age >= 18 }
44
+ #
45
+ # @param [Hash] *args An optional hash with conditions for WHERE clause
46
+ #
47
+ # @return [Relation]
48
+ #
49
+ # @see http://sequel.jeremyevans.net/rdoc/files/doc/dataset_filtering_rdoc.html
50
+ #
51
+ # @api public
52
+ def where(*args, &block)
53
+ new(dataset.__send__(__method__, *args, &block))
54
+ end
55
+
56
+ # Restrict a relation to match grouping criteria
57
+ #
58
+ # @example
59
+ # users.with_task_count.having( task_count: 2 )
60
+ #
61
+ # users.with_task_count.having { task_count > 3 }
62
+ #
63
+ # @param [Hash] *args An optional hash with conditions for HAVING clause
64
+ #
65
+ # @return [Relation]
66
+ #
67
+ # @see http://sequel.jeremyevans.net/rdoc/files/doc/dataset_filtering_rdoc.html
68
+ #
69
+ # @api public
70
+ def having(*args, &block)
71
+ new(dataset.__send__(__method__, *args, &block))
72
+ end
73
+
74
+ # Set order for the relation
75
+ #
76
+ # @example
77
+ # users.order(:name)
78
+ #
79
+ # @param [Array<Symbol>] *args A list with column names
80
+ #
81
+ # @return [Relation]
82
+ #
83
+ # @api public
84
+ def order(*args, &block)
85
+ new(dataset.__send__(__method__, *args, &block))
86
+ end
87
+
88
+ # Join with another relation using INNER JOIN
89
+ #
90
+ # @example
91
+ # users.inner_join(:tasks, id: :user_id)
92
+ #
93
+ # @param [Symbol] relation name
94
+ # @param [Hash] join keys
95
+ #
96
+ # @return [Relation]
97
+ #
98
+ # @api public
99
+ def inner_join(*args, &block)
100
+ new(dataset.__send__(__method__, *args, &block))
101
+ end
102
+
103
+ # Join other relation using LEFT OUTER JOIN
104
+ #
105
+ # @example
106
+ # users.left_join(:tasks, id: :user_id)
107
+ #
108
+ # @param [Symbol] relation name
109
+ # @param [Hash] join keys
110
+ #
111
+ # @return [Relation]
112
+ #
113
+ # @api public
114
+ def left_join(*args, &block)
115
+ new(dataset.__send__(__method__, *args, &block))
116
+ end
117
+ end
118
+ end
119
+ end
@@ -26,6 +26,7 @@ module ROM
26
26
  db_registry Hash.new(self)
27
27
 
28
28
  CONSTRAINT_DB_TYPE = 'add_constraint'.freeze
29
+ DECIMAL_REGEX = /(?:decimal|numeric)\((\d+)(?:,\s*(\d+))?\)/.freeze
29
30
 
30
31
  def self.inherited(klass)
31
32
  super
@@ -72,9 +73,16 @@ module ROM
72
73
  mapped_type = map_type(type, db_type, rest)
73
74
 
74
75
  if mapped_type
76
+ read_type = mapped_type.meta[:read]
75
77
  mapped_type = mapped_type.optional if allow_null
76
78
  mapped_type = mapped_type.meta(foreign_key: true, target: foreign_key) if foreign_key
77
- mapped_type
79
+ if read_type && allow_null
80
+ mapped_type.meta(read: read_type.optional)
81
+ elsif read_type
82
+ mapped_type.meta(read: read_type)
83
+ else
84
+ mapped_type
85
+ end
78
86
  end
79
87
  end
80
88
  end
@@ -87,7 +95,7 @@ module ROM
87
95
  type = self.class.ruby_type_mapping[ruby_type]
88
96
 
89
97
  if db_type.is_a?(String) && db_type.include?('numeric') || db_type.include?('decimal')
90
- self.class.ruby_type_mapping[:decimal]
98
+ map_decimal_type(db_type)
91
99
  else
92
100
  type
93
101
  end
@@ -110,6 +118,22 @@ module ROM
110
118
  columns[0]
111
119
  end
112
120
  end
121
+
122
+ # @api private
123
+ def map_decimal_type(type)
124
+ precision = DECIMAL_REGEX.match(type)
125
+
126
+ if precision
127
+ prcsn, scale = precision[1..2].map(&:to_i)
128
+
129
+ self.class.ruby_type_mapping[:decimal].meta(
130
+ precision: prcsn,
131
+ scale: scale
132
+ )
133
+ else
134
+ self.class.ruby_type_mapping[:decimal]
135
+ end
136
+ end
113
137
  end
114
138
  end
115
139
  end
@@ -24,11 +24,11 @@ module ROM
24
24
  end
25
25
 
26
26
  namespace :db do
27
- desc "Perform migration reset (full erase and migration up)"
28
27
  task :rom_configuration do
29
28
  Rake::Task["db:setup"].invoke
30
29
  end
31
30
 
31
+ desc "Perform migration reset (full erase and migration up)"
32
32
  task reset: :rom_configuration do
33
33
  ROM::SQL::RakeSupport.run_migrations(target: 0)
34
34
  ROM::SQL::RakeSupport.run_migrations
data/lib/rom/sql/type.rb CHANGED
@@ -3,6 +3,8 @@ require 'rom/schema/type'
3
3
  module ROM
4
4
  module SQL
5
5
  class Type < ROM::Schema::Type
6
+ QualifyError = Class.new(StandardError)
7
+
6
8
  # Return a new type marked as a FK
7
9
  #
8
10
  # @return [SQL::Type]
@@ -13,9 +15,10 @@ module ROM
13
15
  end
14
16
 
15
17
  # @api public
16
- def as(name)
18
+ def aliased(name)
17
19
  super.meta(sql_expr: sql_expr.as(name))
18
20
  end
21
+ alias_method :as, :aliased
19
22
 
20
23
  # Return a new type marked as qualified
21
24
  #
@@ -23,7 +26,15 @@ module ROM
23
26
  #
24
27
  # @api public
25
28
  def qualified
26
- meta(qualified: true)
29
+ return self if qualified?
30
+
31
+ case sql_expr
32
+ when Sequel::SQL::AliasedExpression, Sequel::SQL::Identifier
33
+ type = meta(qualified: true)
34
+ type.meta(qualified: true, sql_expr: Sequel[type.to_sym])
35
+ else
36
+ raise QualifyError, "can't qualify #{name.inspect} (#{sql_expr.inspect})"
37
+ end
27
38
  end
28
39
 
29
40
  # Return a new type marked as joined
data/lib/rom/sql/types.rb CHANGED
@@ -5,11 +5,13 @@ module ROM
5
5
  module Types
6
6
  include ROM::Types
7
7
 
8
+ def self.Constructor(*args, &block)
9
+ ROM::Types.Constructor(*args, &block)
10
+ end
11
+
8
12
  Serial = Int.constrained(gt: 0).meta(primary_key: true)
9
13
 
10
- Blob = Dry::Types::Definition
11
- .new(Sequel::SQL::Blob)
12
- .constructor(Sequel::SQL::Blob.method(:new))
14
+ Blob = Constructor(Sequel::SQL::Blob, &Sequel::SQL::Blob.method(:new))
13
15
  end
14
16
  end
15
17
  end
@@ -1,5 +1,5 @@
1
1
  module ROM
2
2
  module SQL
3
- VERSION = '1.0.0.beta2'.freeze
3
+ VERSION = '1.0.0.beta3'.freeze
4
4
  end
5
5
  end
@@ -112,4 +112,33 @@ RSpec.describe 'ROM::SQL::Types' do
112
112
  expect(output).to be_instance_of(BigDecimal)
113
113
  end
114
114
  end
115
+
116
+ describe ROM::SQL::Types::PG::IPAddress do
117
+ it 'converts IPAddr to a string' do
118
+ expect(described_class[IPAddr.new('127.0.0.1')]).to eql('127.0.0.1')
119
+ end
120
+
121
+ it 'coerces to builtin IPAddr type on read' do
122
+ expect(described_class.meta[:read]['127.0.0.1']).to eql(IPAddr.new('127.0.0.1'))
123
+ end
124
+
125
+ it 'supports networks' do
126
+ class_a = described_class.meta[:read]['10.0.0.0/8']
127
+
128
+ expect(class_a).to eql(IPAddr.new('10.0.0.0/8'))
129
+ expect(class_a).to include(IPAddr.new('10.8.8.8'))
130
+ end
131
+ end
132
+
133
+ describe ROM::SQL::Types::PG::PointT do
134
+ let(:point) { ROM::SQL::Types::PG::Point.new(7.5, 30.5) }
135
+
136
+ it 'serializes a point down to a string' do
137
+ expect(described_class[point]).to eql('(7.5,30.5)')
138
+ end
139
+
140
+ it 'reads serialized format' do
141
+ expect(described_class.meta[:read]['(7.5,30.5)']).to eql(point)
142
+ end
143
+ end
115
144
  end
@@ -133,6 +133,14 @@ RSpec.describe ROM::SQL::Association::ManyToMany do
133
133
 
134
134
  expect(relation.to_a).to eql([id: 1, name: 'important', task_id: 1])
135
135
  end
136
+
137
+ it 'maintains original relation' do
138
+ relation = tags.
139
+ select_append(tags[:name].as(:tag)).
140
+ for_combine(assoc).call(tasks.call)
141
+
142
+ expect(relation.to_a).to eql([id: 1, tag: 'important', name: 'important', task_id: 1])
143
+ end
136
144
  end
137
145
  end
138
146
  end
@@ -66,6 +66,17 @@ RSpec.describe ROM::SQL::Association::ManyToOne, helpers: true do
66
66
  { id: 1, task_id: 2, name: 'Jane' }
67
67
  ])
68
68
  end
69
+
70
+ it 'maintains original relation' do
71
+ users.accounts.insert(user_id: 2, number: 'a1', balance: 0)
72
+
73
+ relation = users.
74
+ join(:accounts, user_id: :id).
75
+ select_append(users.accounts[:number].as(:account_num)).
76
+ for_combine(assoc).call(tasks.call)
77
+
78
+ expect(relation.to_a).to eql([{ id: 2, task_id: 1, name: 'Joe', account_num: 'a1' }])
79
+ end
69
80
  end
70
81
 
71
82
  context 'arbitrary name conventions' do
@@ -53,6 +53,15 @@ RSpec.describe ROM::SQL::Association::OneToMany do
53
53
  { id: 2, user_id: 1, title: "Jane's task" }
54
54
  ])
55
55
  end
56
+
57
+ it 'maintains original relation' do
58
+ relation = tasks.
59
+ join(:task_tags, tag_id: :id).
60
+ select_append(tasks.task_tags[:tag_id].qualified).
61
+ for_combine(assoc).call(users.call)
62
+
63
+ expect(relation.to_a).to eql([{ id: 1, user_id: 2, title: "Joe's task", tag_id: 1 }])
64
+ end
56
65
  end
57
66
  end
58
67
  end
@@ -0,0 +1,36 @@
1
+ RSpec.describe 'ROM::SQL::Schema::MysqlInferrer', :mysql do
2
+ include_context 'database setup'
3
+
4
+ before do
5
+ conn.drop_table?(:test_inferrence)
6
+
7
+ conn.create_table :test_inferrence do
8
+ tinyint :tiny
9
+ mediumint :medium
10
+ end
11
+ end
12
+
13
+ after do
14
+ conn.drop_table?(:test_inferrence)
15
+ end
16
+
17
+ let(:dataset) { :test_inferrence }
18
+
19
+ let(:schema) { container.relations[dataset].schema }
20
+
21
+ before do
22
+ dataset = self.dataset
23
+ conf.relation(dataset) do
24
+ schema(dataset, infer: true)
25
+ end
26
+ end
27
+
28
+ it 'can infer attributes for dataset' do
29
+ source = container.relations[:test_inferrence].name
30
+
31
+ expect(schema.to_h).to eql(
32
+ tiny: ROM::SQL::Types::Int.optional.meta(name: :tiny, source: source),
33
+ medium: ROM::SQL::Types::Int.optional.meta(name: :medium, source: source),
34
+ )
35
+ end
36
+ end
@@ -0,0 +1,118 @@
1
+
2
+ RSpec.describe 'ROM::SQL::Schema::PostgresInferrer', :postgres do
3
+ include_context 'database setup'
4
+
5
+ colors = %w(red orange yellow green blue purple)
6
+
7
+ before do
8
+ conn.extension :pg_enum
9
+
10
+ conn.drop_table?(:test_inferrence)
11
+ conn.drop_enum(:rainbow, if_exists: true)
12
+
13
+ conn.create_enum(:rainbow, colors)
14
+
15
+ conn.create_table :test_inferrence do
16
+ primary_key :id, :uuid
17
+ Json :json_data
18
+ Jsonb :jsonb_data
19
+ Decimal :money, null: false
20
+ column :tags, "text[]"
21
+ column :tag_ids, "bigint[]"
22
+ column :ip, "inet"
23
+ column :subnet, "cidr"
24
+ column :hw_address, "macaddr"
25
+ rainbow :color
26
+ point :center
27
+ end
28
+ end
29
+
30
+ after do
31
+ conn.drop_table?(:test_inferrence)
32
+ end
33
+
34
+ let(:dataset) { :test_inferrence }
35
+
36
+ let(:schema) { container.relations[dataset].schema }
37
+
38
+ context 'inferring db-specific attributes' do
39
+ before do
40
+ dataset = self.dataset
41
+ conf.relation(dataset) do
42
+ schema(dataset, infer: true)
43
+ end
44
+ end
45
+
46
+ it 'can infer attributes for dataset' do
47
+ source = container.relations[:test_inferrence].name
48
+
49
+ expect(schema.to_h).to eql(
50
+ id: ROM::SQL::Types::PG::UUID.meta(name: :id, source: source, primary_key: true),
51
+ json_data: ROM::SQL::Types::PG::JSON.optional.meta(name: :json_data, source: source),
52
+ jsonb_data: ROM::SQL::Types::PG::JSONB.optional.meta(name: :jsonb_data, source: source),
53
+ money: ROM::SQL::Types::Decimal.meta(name: :money, source: source),
54
+ tags: ROM::SQL::Types::PG::Array('text').optional.meta(name: :tags, source: source),
55
+ tag_ids: ROM::SQL::Types::PG::Array('biging').optional.meta(name: :tag_ids, source: source),
56
+ color: ROM::SQL::Types::String.enum(*colors).optional.meta(name: :color, source: source),
57
+ ip: ROM::SQL::Types::PG::IPAddress.optional.meta(
58
+ name: :ip,
59
+ source: source,
60
+ read: ROM::SQL::Types::PG::IPAddressR.optional
61
+ ),
62
+ subnet: ROM::SQL::Types::PG::IPAddress.optional.meta(
63
+ name: :subnet,
64
+ source: source,
65
+ read: ROM::SQL::Types::PG::IPAddressR.optional
66
+ ),
67
+ hw_address: ROM::SQL::Types::String.optional.meta(name: :hw_address, source: source),
68
+ center: ROM::SQL::Types::PG::PointT.optional.meta(
69
+ name: :center,
70
+ source: source,
71
+ read: ROM::SQL::Types::PG::PointTR.optional
72
+ )
73
+ )
74
+ end
75
+ end
76
+
77
+ context 'with a table without columns' do
78
+ before do
79
+ conn.create_table(:dummy) unless conn.table_exists?(:dummy)
80
+ conf.relation(:dummy) { schema(infer: true) }
81
+ end
82
+
83
+ it 'does not fail with a weird error when a relation does not have attributes' do
84
+ expect(container.relations[:dummy].schema).to be_empty
85
+ end
86
+ end
87
+
88
+ context 'with a column with bi-directional mapping' do
89
+ before do
90
+ conn.drop_table?(:test_bidirectional)
91
+ conn.create_table(:test_bidirectional) do
92
+ primary_key :id
93
+ inet :ip
94
+ point :center
95
+ end
96
+
97
+ conf.relation(:test_bidirectional) { schema(infer: true) }
98
+
99
+ conf.commands(:test_bidirectional) do
100
+ define(:create) do
101
+ result :one
102
+ end
103
+ end
104
+ end
105
+
106
+ let(:point) { ROM::SQL::Types::PG::Point.new(7.5, 30.5) }
107
+ let(:dns) { IPAddr.new('8.8.8.8') }
108
+
109
+ let(:relation) { container.relations[:test_bidirectional] }
110
+ let(:create) { commands[:test_bidirectional].create }
111
+
112
+ it 'writes and reads data' do
113
+ inserted = create.call(id: 1, center: point, ip: dns)
114
+ expect(inserted).to eql(id: 1, center: point, ip: dns)
115
+ expect(relation.to_a).to eql([inserted])
116
+ end
117
+ end
118
+ end