gladwords 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/.circleci/config.yml +34 -0
  3. data/.gitignore +4 -0
  4. data/.projections.json +5 -0
  5. data/.rspec +1 -0
  6. data/.rubocop.yml +57 -0
  7. data/.rubocop_todo.yml +32 -0
  8. data/.vim/coc-settings.json +12 -0
  9. data/.vim/install.sh +38 -0
  10. data/.vscode/launch.json +13 -0
  11. data/.vscode/settings.json +9 -0
  12. data/.vscode/tasks.json +21 -0
  13. data/Gemfile +20 -0
  14. data/Gemfile.lock +200 -0
  15. data/LICENSE.txt +21 -0
  16. data/README.md +71 -0
  17. data/Rakefile +15 -0
  18. data/bin/rake +31 -0
  19. data/bin/rspec +31 -0
  20. data/bin/solargraph +29 -0
  21. data/config/environment.rb +3 -0
  22. data/gladwords.code-workspace +11 -0
  23. data/gladwords.gemspec +27 -0
  24. data/lib/ext/rom/inflector.rb +8 -0
  25. data/lib/gladwords.rb +22 -0
  26. data/lib/gladwords/associations.rb +7 -0
  27. data/lib/gladwords/associations/many_to_many.rb +18 -0
  28. data/lib/gladwords/associations/many_to_one.rb +22 -0
  29. data/lib/gladwords/associations/one_to_many.rb +19 -0
  30. data/lib/gladwords/associations/one_to_one.rb +10 -0
  31. data/lib/gladwords/associations/one_to_one_through.rb +8 -0
  32. data/lib/gladwords/commands.rb +7 -0
  33. data/lib/gladwords/commands/core.rb +76 -0
  34. data/lib/gladwords/commands/create.rb +18 -0
  35. data/lib/gladwords/commands/delete.rb +22 -0
  36. data/lib/gladwords/commands/error_wrapper.rb +25 -0
  37. data/lib/gladwords/commands/update.rb +17 -0
  38. data/lib/gladwords/errors.rb +7 -0
  39. data/lib/gladwords/gateway.rb +48 -0
  40. data/lib/gladwords/inflector.rb +20 -0
  41. data/lib/gladwords/relation.rb +197 -0
  42. data/lib/gladwords/relation/association_methods.rb +29 -0
  43. data/lib/gladwords/relation/joined_relation.rb +52 -0
  44. data/lib/gladwords/schema.rb +26 -0
  45. data/lib/gladwords/schema/attributes_inferrer.rb +171 -0
  46. data/lib/gladwords/schema/dsl.rb +28 -0
  47. data/lib/gladwords/schema/inferrer.rb +19 -0
  48. data/lib/gladwords/selector_fields_db.rb +30 -0
  49. data/lib/gladwords/selector_fields_db/v201806.json +3882 -0
  50. data/lib/gladwords/selector_fields_db/v201809.json +4026 -0
  51. data/lib/gladwords/struct.rb +24 -0
  52. data/lib/gladwords/types.rb +27 -0
  53. data/lib/gladwords/version.rb +5 -0
  54. data/rakelib/generate_selector_fields_db.rake +72 -0
  55. data/spec/integration/commands/create_spec.rb +24 -0
  56. data/spec/integration/commands/delete_spec.rb +47 -0
  57. data/spec/integration/commands/update_spec.rb +24 -0
  58. data/spec/shared/campaigns.rb +56 -0
  59. data/spec/shared/labels.rb +17 -0
  60. data/spec/spec_helper.rb +33 -0
  61. data/spec/support/adwords_helpers.rb +41 -0
  62. data/spec/unit/commands/create_spec.rb +85 -0
  63. data/spec/unit/commands/delete_spec.rb +32 -0
  64. data/spec/unit/commands/update_spec.rb +96 -0
  65. data/spec/unit/inflector_spec.rb +11 -0
  66. data/spec/unit/relation/association_methods_spec.rb +91 -0
  67. data/spec/unit/relation_spec.rb +187 -0
  68. data/spec/unit/schema/attributes_inferrer_spec.rb +83 -0
  69. data/spec/unit/selector_fields_db_spec.rb +29 -0
  70. data/spec/unit/types_spec.rb +49 -0
  71. metadata +190 -0
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Gladwords::Commands::Delete do
4
+ include_context 'labels'
5
+
6
+ subject(:command) do
7
+ relation.command(:delete)
8
+ end
9
+
10
+ let(:service) { label_service }
11
+ let(:relation) { labels }
12
+
13
+ before do
14
+ allow(service).to receive(:get).and_return(entries: [{ id: '1' }])
15
+ allow(service).to receive(:mutate).and_return(value: [{ id: '1' }])
16
+ end
17
+
18
+ it 'mutates the service with the correct operations' do
19
+ expect(service).to receive(:mutate).with(
20
+ [
21
+ {
22
+ operator: 'REMOVE',
23
+ operand: { id: '1' }
24
+ }
25
+ ]
26
+ )
27
+
28
+ rel = relation.where(id: 1)
29
+ delete_command = rel.command(:delete)
30
+ delete_command.call
31
+ end
32
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ RSpec.describe Gladwords::Commands::Update do
6
+ include_context 'campaigns'
7
+
8
+ subject(:command) do
9
+ relation.command(:update)
10
+ end
11
+
12
+ let(:service) { campaign_service }
13
+ let(:relation) { campaigns }
14
+
15
+ context 'when provided a single tuple' do
16
+ before do
17
+ allow(service).to receive(:mutate).and_return(
18
+ value: [{ name: 'updated name', id: '1', end_date: '01012017' }]
19
+ )
20
+ end
21
+
22
+ it 'mutates the service with the correct operations' do
23
+ expect(service).to receive(:mutate).with(
24
+ [
25
+ {
26
+ operator: 'SET',
27
+ operand: {
28
+ name: 'updated name',
29
+ id: '1'
30
+ }
31
+ }
32
+ ]
33
+ )
34
+
35
+ subject.call(name: 'updated name', id: '1')
36
+ end
37
+
38
+ it 'returns a struct' do
39
+ result = subject.call(name: 'updated name', id: '1')
40
+
41
+ expect(result).to be_a(Gladwords::Struct::Campaign)
42
+ expect(result.id).to eq '1'
43
+ expect(result.name).to eq 'updated name'
44
+ end
45
+ end
46
+
47
+ context 'when provided multiple tuples' do
48
+ subject(:command) do
49
+ relation.command(:update, result: :many)
50
+ end
51
+
52
+ before do
53
+ allow(service).to(
54
+ receive(:mutate).and_return(
55
+ value: [
56
+ { name: 'updated name', id: '1', end_date: '01012017' },
57
+ { name: 'updated name 2', id: '2', end_date: '01012018' }
58
+ ]
59
+ )
60
+ )
61
+ end
62
+
63
+ it 'mutates the service with the correct operations' do
64
+ expect(service).to receive(:mutate).with(
65
+ [
66
+ {
67
+ operator: 'SET',
68
+ operand: {
69
+ name: 'updated name',
70
+ id: '1'
71
+ }
72
+ },
73
+ {
74
+ operator: 'SET',
75
+ operand: {
76
+ name: 'updated name 2',
77
+ id: '2'
78
+ }
79
+ }
80
+ ]
81
+ )
82
+
83
+ subject.call([{ name: 'updated name', id: '1' }, { name: 'updated name 2', id: '2' }])
84
+ end
85
+
86
+ it 'returns an array of structs' do
87
+ result = subject.call(
88
+ [{ name: 'updated name', id: '1' }, { name: 'updated name 2', id: '2' }]
89
+ )
90
+
91
+ expect(result.count).to eq 2
92
+
93
+ expect(result).to all be_a(Gladwords::Struct::Campaign)
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Gladwords::Inflector do
4
+ it 'correctly pluralizes criterion' do
5
+ expect(described_class.pluralize('criterion')).to eq 'criteria'
6
+ end
7
+
8
+ it 'correctly singularizes criteria' do
9
+ expect(described_class.singularize('criteria')).to eq 'criterion'
10
+ end
11
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gladwords
4
+ RSpec.describe Relation::AssociationMethods do
5
+ include_context 'campaigns'
6
+
7
+ context 'when the relation is one-to-many' do
8
+ subject { campaigns }
9
+
10
+ describe '#join' do
11
+ let(:campaign_id) { 1_338_164_832 }
12
+ let(:filtered_relation) { subject.select(:id).where(id: campaign_id).join(:ad_groups) }
13
+
14
+ it 'filters the target relation' do
15
+ returned_ids = filtered_relation.call.flat_map(&:ad_groups).map(&:campaign_id)
16
+
17
+ expect(returned_ids).to all eq(campaign_id)
18
+ end
19
+
20
+ it 'returns the filtered combined relationship' do
21
+ ad_groups = filtered_relation.ad_groups.call
22
+
23
+ aggregate_failures do
24
+ expect(ad_groups).to all be_a(Gladwords::Struct::AdGroup)
25
+ expect(ad_groups.pluck(:campaign_id)).to all eq(campaign_id)
26
+ end
27
+ end
28
+
29
+ it 'can query on the combined node' do
30
+ scope = filtered_relation.node(:ad_groups) { |n| n.select(:campaign_id) }
31
+ ad_group = scope.one.ad_groups.first
32
+ expect(ad_group.to_h).to eql(campaign_id: campaign_id)
33
+ end
34
+ end
35
+ end
36
+
37
+ context 'when the relation is many-to-one' do
38
+ subject { rom.relations[:ad_groups] }
39
+
40
+ describe '#combine' do
41
+ let(:campaign_id) { 1_338_164_832 }
42
+ let(:filtered_relation) { subject.combine(:campaign) }
43
+
44
+ it 'filters the target relation' do
45
+ scope = filtered_relation.where(campaign_id: campaign_id)
46
+ ad_group = scope.limit(1).call.one
47
+ expect(ad_group.campaign.id).to eql(campaign_id)
48
+ end
49
+
50
+ it 'can query on the combined node' do
51
+ scope = filtered_relation.where(campaign_id: campaign_id)
52
+ scope = scope.node(:campaign) { |n| n.select(:id) }
53
+ campaign = scope.call.one.campaign
54
+ expect(campaign.to_h).to eql(id: campaign_id)
55
+ end
56
+ end
57
+ end
58
+
59
+ context 'when the relation is has-many through' do
60
+ subject { campaigns }
61
+
62
+ describe '#combine' do
63
+ let(:campaign_id) { 1_338_164_832 }
64
+
65
+ context 'when the combine is nested' do
66
+ let(:filtered_relation) { subject.combine(ad_groups: [:ad_group_ads]) }
67
+
68
+ it 'includes the nested has_many through' do
69
+ scope = filtered_relation.where(campaign_id: campaign_id).one
70
+ ads = scope.ad_groups.flat_map(&:ad_group_ads)
71
+ expect(ads).not_to be_empty
72
+ expect(ads.map(&:base_campaign_id)).to all eq(campaign_id)
73
+ end
74
+ end
75
+
76
+ context 'when the combine is direct' do
77
+ let(:filtered_relation) { subject.combine(:ad_group_ads) }
78
+
79
+ it 'includes the nested has_many through' do
80
+ pending 'Combine is not working for direct has many through'
81
+ scope = filtered_relation.where(campaign_id: campaign_id).one
82
+
83
+ ads = scope.ad_group_ads
84
+ expect(ads).not_to be_empty
85
+ expect(ads.map(&:base_campaign_id)).to all eq(campaign_id)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Gladwords::Relation do
4
+ include_context 'campaigns'
5
+
6
+ subject { campaigns }
7
+
8
+ describe '#by_pk' do
9
+ it 'loads the resource by id and limits the result' do
10
+ new_scope = subject.by_pk('123')
11
+ predicates = new_scope.options.dig(:selector, :predicates)
12
+ paging = new_scope.options.dig(:selector, :paging)
13
+
14
+ expect(predicates).to eql([{ field: 'Id', operator: 'IN', values: ['123'] }])
15
+ expect(paging).to eql(number_results: 1)
16
+ end
17
+ end
18
+
19
+ describe '#where' do
20
+ it 'chains where clauses' do
21
+ old_scope = subject.select(:id, :name).where(id: '123')
22
+ new_scope = old_scope.where(name: 'TestName')
23
+ expected = [{ field: 'Id', operator: 'IN', values: ['123'] },
24
+ { field: 'Name', operator: 'IN', values: ['TestName'] }]
25
+ predicates = new_scope.options.dig(:selector, :predicates)
26
+
27
+ expect(predicates).to match_array(expected)
28
+ end
29
+
30
+ it 'de-duplicates where clauses' do
31
+ old_scope = subject.select(:id, :name).where(id: '123')
32
+ new_scope = old_scope.where(id: '123')
33
+ expected = [{ field: 'Id', operator: 'IN', values: ['123'] }]
34
+ predicates = new_scope.options.dig(:selector, :predicates)
35
+
36
+ expect(predicates).to match_array(expected)
37
+ end
38
+ end
39
+
40
+ describe '#total_count' do
41
+ it 'shows the total_count' do
42
+ scope = subject.select(:id, :name).where(name: 'Test Campaign 2')
43
+
44
+ expect(scope.total_count).to eq 1
45
+ end
46
+ end
47
+
48
+ describe '#offset' do
49
+ it 'offsets the start_index' do
50
+ scope = subject.offset(10)
51
+
52
+ expect(scope.options.dig(:selector, :paging, :start_index)).to eq 10
53
+ end
54
+ end
55
+
56
+ describe '#limit' do
57
+ it 'sets the paging[:number_results]' do
58
+ scope = subject.limit(3)
59
+
60
+ expect(scope.options.dig(:selector, :paging, :number_results)).to eq 3
61
+ end
62
+ end
63
+
64
+ describe '#select' do
65
+ it 'camelcases the fields' do
66
+ scope = subject.select(:base_campaign_id, :name)
67
+ fields = scope.options.dig(:selector, :fields)
68
+
69
+ expect(fields).to contain_exactly 'BaseCampaignId', 'Name'
70
+ end
71
+
72
+ it 'only selects unique fields' do
73
+ scope = subject.select(:name).select(:name)
74
+ fields = scope.options.dig(:selector, :fields)
75
+
76
+ expect(fields).to contain_exactly 'Name'
77
+ end
78
+
79
+ it 'limits requested fields when using #select' do
80
+ repo = Class.new(ROM::Repository[:campaigns]).new(rom)
81
+ result = repo.campaigns.select(:name).limit(1).one
82
+
83
+ expect(result.to_h.keys).to contain_exactly(:name)
84
+ end
85
+ end
86
+
87
+ describe '#pluck' do
88
+ it 'returns an array of the ids' do
89
+ ids = subject.pluck(:id).call
90
+
91
+ expect(ids).not_to be_empty
92
+ expect(ids).to all be_a(Integer)
93
+ end
94
+
95
+ it 'only requests the plucked field' do
96
+ expect(campaign_service)
97
+ .to receive(:get)
98
+ .with(a_hash_including(fields: ['Id']))
99
+ .and_return({})
100
+
101
+ scope = subject.pluck(:id)
102
+
103
+ expect(scope.options.dig(:selector, :fields)).to eql(['Id'])
104
+ scope.call
105
+ end
106
+ end
107
+
108
+ it 'by default selects all selectable fields' do
109
+ repo = Class.new(ROM::Repository[:campaigns]).new(rom)
110
+ scope = repo.campaigns.where(name: 'Test Campaign 2')
111
+ result = scope.call
112
+
113
+ expected = {
114
+ id: 1_338_164_832,
115
+ status: 'ENABLED',
116
+ serving_status: 'SERVING',
117
+ ad_serving_optimization_status: 'OPTIMIZE',
118
+ advertising_channel_type: 'SEARCH',
119
+ advertising_channel_sub_type: nil,
120
+ campaign_trial_type: 'BASE',
121
+ campaign_group_id: nil,
122
+ name: 'Test Campaign 2',
123
+ start_date: '20180329',
124
+ end_date: '20371230',
125
+ universal_app_campaign_info: nil,
126
+ final_url_suffix: nil,
127
+ budget: {
128
+ budget_id: 1_391_080_779,
129
+ name: 'Test Campaign 2',
130
+ amount: a_hash_including(micro_amount: 999_000_000),
131
+ delivery_method: 'STANDARD',
132
+ reference_count: 1,
133
+ is_explicitly_shared: false,
134
+ status: 'ENABLED'
135
+ },
136
+ conversion_optimizer_eligibility: {
137
+ eligible: false,
138
+ rejection_reasons: ['NOT_ENOUGH_CONVERSIONS']
139
+ },
140
+ frequency_cap: nil,
141
+ settings: [{
142
+ setting_type: 'GeoTargetTypeSetting',
143
+ positive_geo_target_type: 'LOCATION_OF_PRESENCE',
144
+ negative_geo_target_type: 'DONT_CARE',
145
+ xsi_type: 'GeoTargetTypeSetting'
146
+ }],
147
+ network_setting: {
148
+ target_google_search: true,
149
+ target_search_network: true,
150
+ target_content_network: false,
151
+ target_partner_search_network: false
152
+ },
153
+ labels: [],
154
+ bidding_strategy_configuration: {
155
+ bidding_strategy_id: nil,
156
+ bidding_strategy_name: nil,
157
+ bidding_strategy_type: 'MANUAL_CPC',
158
+ bidding_strategy_source: nil,
159
+ bidding_scheme: {
160
+ bidding_scheme_type: 'ManualCpcBiddingScheme',
161
+ enhanced_cpc_enabled: false,
162
+ xsi_type: 'ManualCpcBiddingScheme'
163
+ },
164
+ bids: [],
165
+ target_roas_override: nil
166
+ },
167
+ base_campaign_id: 1_338_164_832,
168
+ forward_compatibility_map: [],
169
+ tracking_url_template: nil,
170
+ url_custom_parameters: nil,
171
+ vanity_pharma: nil,
172
+ selective_optimization: nil
173
+ }
174
+
175
+ entries = result.map(&:to_h)
176
+
177
+ expect(entries.first).to match(expected)
178
+ expect(entries).to contain_exactly(expected)
179
+ end
180
+
181
+ describe '#request' do
182
+ it 'raises when the method does not exist' do
183
+ expect { subject.request(:bad_method) }
184
+ .to raise_error described_class::InvalidRequestMethodError
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'adwords_api/v201809/campaign_service_registry'
4
+
5
+ module Gladwords
6
+ RSpec.describe Schema::AttributesInferrer do
7
+ let(:schema) { double(name: :campaigns, options: { shitlist: [] }) }
8
+ let(:registry) { AdwordsApi::V201809::CampaignService::CampaignServiceRegistry }
9
+ let(:gateway) { double(service_registry: registry) }
10
+
11
+ describe '#call' do
12
+ declarations = {
13
+ String => %i[
14
+ name
15
+ status
16
+ serving_status
17
+ start_date
18
+ end_date
19
+ ad_serving_optimization_status
20
+ advertising_channel_type
21
+ advertising_channel_sub_type
22
+ campaign_trial_type
23
+ tracking_url_template
24
+ final_url_suffix
25
+ ],
26
+ Integer => %i[id base_campaign_id campaign_group_id]
27
+ }
28
+
29
+ declarations.each do |primitive, fields|
30
+ it "maps to #{primitive.name}", skip: fields.delete(skip: true) do
31
+ types, _missing = subject.call(schema, gateway)
32
+ attrs = types.select { |a| a.type.primitive == primitive }
33
+
34
+ expect(attrs.map(&:name)).to match_array(fields)
35
+ end
36
+ end
37
+
38
+ it 'maps nested types to hashes' do
39
+ types, _missing = subject.call(schema, gateway)
40
+ hash_field = types.find { |t| t.name == :budget }
41
+ member_types = hash_field.type.options[:member_types]
42
+
43
+ aggregate_failures do
44
+ expect(member_types[:budget_id].primitive).to eq(Integer)
45
+ expect(member_types[:name].primitive).to eq(String)
46
+ end
47
+ end
48
+
49
+ it 'includes read types for known nested hashes' do
50
+ types, _missing = subject.call(schema, gateway)
51
+ hash_field = types.find { |t| t.name == :budget }
52
+
53
+ read_type = hash_field.meta[:read]
54
+
55
+ expect(read_type).to be_a(Dry::Types::Constructor)
56
+ end
57
+
58
+ it 'removes shitlist attributes from schema' do
59
+ allow(schema).to receive(:options).and_return(shitlist: [:end_date])
60
+ types, _missing = subject.call(schema, gateway)
61
+
62
+ expect(types.find { |t| t.name == :end_date }).to be_nil
63
+ end
64
+
65
+ it 'created enumerated types' do
66
+ types, _missing = subject.call(schema, gateway)
67
+ status = types.find { |t| t.name == :status }
68
+ type = status.type
69
+
70
+ expect(type).to be_a(Dry::Types::Enum)
71
+ expect(type.options).to eql(values: %w[UNKNOWN ENABLED PAUSED REMOVED])
72
+ end
73
+
74
+ it 'creates enumerated type arrays' do
75
+ types, _missing = subject.call(schema, gateway)
76
+ status = types.find { |t| t.name == :conversion_optimizer_eligibility }
77
+ type = status.type.member_types[:rejection_reasons].type
78
+
79
+ expect(type).to be_a(Dry::Types::Array)
80
+ end
81
+ end
82
+ end
83
+ end