odata_duty 0.30.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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +312 -0
  3. data/lib/generators/odata_duty/entity_set/entity_set_generator.rb +56 -0
  4. data/lib/generators/odata_duty/entity_set/templates/entity_set.rb.erb +20 -0
  5. data/lib/generators/odata_duty/entity_set/templates/entity_set_spec.rb.erb +38 -0
  6. data/lib/generators/odata_duty/entity_set/templates/entity_type.rb.erb +6 -0
  7. data/lib/generators/odata_duty/entity_set/templates/entity_type_spec.rb.erb +20 -0
  8. data/lib/generators/odata_duty/entity_set/templates/odata_active_record_concern.rb.erb +109 -0
  9. data/lib/generators/odata_duty/entity_set/templates/resolver.rb.erb +39 -0
  10. data/lib/generators/odata_duty/entity_set/templates/resolver_spec.rb.erb +50 -0
  11. data/lib/generators/odata_duty/install/install_generator.rb +60 -0
  12. data/lib/generators/odata_duty/install/templates/controller.rb.tt +43 -0
  13. data/lib/generators/odata_duty/install/templates/schema.rb.tt +16 -0
  14. data/lib/metadata.xml.erb +116 -0
  15. data/lib/odata_duty/complex_type.rb +70 -0
  16. data/lib/odata_duty/context_wrapper.rb +26 -0
  17. data/lib/odata_duty/create_complex_type_hash_wrapper.rb +49 -0
  18. data/lib/odata_duty/dynamic_object_wrapper.rb.erb +79 -0
  19. data/lib/odata_duty/edms.rb +122 -0
  20. data/lib/odata_duty/edmx_schema.rb +19 -0
  21. data/lib/odata_duty/entity_type.rb +72 -0
  22. data/lib/odata_duty/enum_type.rb +71 -0
  23. data/lib/odata_duty/errors.rb +52 -0
  24. data/lib/odata_duty/executor.rb +277 -0
  25. data/lib/odata_duty/filter.rb +76 -0
  26. data/lib/odata_duty/filter_predicate.rb +12 -0
  27. data/lib/odata_duty/mapper_builder.rb +86 -0
  28. data/lib/odata_duty/mcp_input_schemas.rb +59 -0
  29. data/lib/odata_duty/mcp_server_builder.rb +107 -0
  30. data/lib/odata_duty/oas2/collection_get_path.rb +97 -0
  31. data/lib/odata_duty/oas2/collection_post_path.rb +63 -0
  32. data/lib/odata_duty/oas2/individual_delete_path.rb +42 -0
  33. data/lib/odata_duty/oas2/individual_get_path.rb +37 -0
  34. data/lib/odata_duty/oas2/individual_patch_path.rb +63 -0
  35. data/lib/odata_duty/oas2.rb +113 -0
  36. data/lib/odata_duty/parslet_search_expression.rb +163 -0
  37. data/lib/odata_duty/property/collection_prop.rb +25 -0
  38. data/lib/odata_duty/property/single_prop.rb +131 -0
  39. data/lib/odata_duty/property.rb +49 -0
  40. data/lib/odata_duty/railtie.rb +11 -0
  41. data/lib/odata_duty/schema_builder/complex_type.rb +34 -0
  42. data/lib/odata_duty/schema_builder/container.rb +16 -0
  43. data/lib/odata_duty/schema_builder/data_type.rb +18 -0
  44. data/lib/odata_duty/schema_builder/endpoint.rb +117 -0
  45. data/lib/odata_duty/schema_builder/entity_set.rb +67 -0
  46. data/lib/odata_duty/schema_builder/entity_type.rb +49 -0
  47. data/lib/odata_duty/schema_builder/enum_type.rb +32 -0
  48. data/lib/odata_duty/schema_builder.rb +130 -0
  49. data/lib/odata_duty/set_resolver.rb +51 -0
  50. data/lib/odata_duty.rb +311 -0
  51. metadata +168 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 40bb7b15362438457b9d5c0db888c0b0d7bf59e4ce663dcc137d89bbedd4d415
4
+ data.tar.gz: d9fe2a78f192516121b742c6460294ad3325c13724e6ae415d713e75841d02e2
5
+ SHA512:
6
+ metadata.gz: 5a417712f769ca96f93d8c5b15758011f798d07cc10e4f8900bc9ff1df8e98eb74d2c6a0a38e8ecd2d5f4bf2463e3910fe11e291d1f1b35fe1712a3988794b3e
7
+ data.tar.gz: 99925b2b64b8832fa00b713ccb1867a5260a070b3437362df1ca2c4f17421c3fbb2fa9d2dc1b5b603b88fcdc57461a6672f817b87249d96227db4d0619de10d1
data/README.md ADDED
@@ -0,0 +1,312 @@
1
+ # OdataDuty
2
+
3
+ **OdataDuty** is a Ruby gem that lets you define structured data and operations once using a simple DSL — and expose them seamlessly to analytics tools (like PowerBI), no-code platforms (like PowerAutomate), and AI systems (via JSON-RPC or the Model Context Protocol).
4
+
5
+ It’s designed around the principle of _"define once, serve everywhere"_: you model your entities, properties, filters, and behaviors in Ruby, and OdataDuty takes care of transforming that into formats and protocols your tools and agents understand.
6
+
7
+ ---
8
+
9
+ ## ✨ Why use OdataDuty?
10
+
11
+ - ✅ Define your data model and logic in plain Ruby
12
+ - ✅ Support schema-based APIs (OpenAPI/Swagger)
13
+ - ✅ Avoid repeating business logic in multiple layers or formats
14
+ - ✅ Build for humans and works with reporting tools, automation tools, and LLMs (WIP) simultaneously
15
+
16
+ ---
17
+
18
+ ## Installation
19
+
20
+ Add this line to your application's Gemfile:
21
+
22
+ ```ruby
23
+ gem 'odata_duty'
24
+ ```
25
+
26
+ And then execute:
27
+
28
+ ```bash
29
+ bundle install
30
+ ```
31
+
32
+ Or install it manually:
33
+
34
+ ```bash
35
+ gem install odata_duty
36
+ ```
37
+
38
+ ### Rails Integration
39
+
40
+ If you're using Rails, you can use the included generators to quickly set up OdataDuty:
41
+
42
+ 1. Set up the basic OData API structure:
43
+ ```bash
44
+ bin/rails generate odata_duty:install
45
+ ```
46
+
47
+ 2. Generate entity types and sets:
48
+ ```bash
49
+ bin/rails generate odata_duty:entity_set Product name:string price:decimal category:string
50
+ ```
51
+
52
+ See the [Entity Set Generator documentation](doc/entity_set_generator.md) for more details.
53
+
54
+ ---
55
+
56
+ ## Getting Started
57
+
58
+ > The gem assumes basic familiarity with OData concepts.
59
+ > If you’re new, check out the [OData Crash Course](doc/odata_crash_course.md).
60
+
61
+ ### 🔧 Key Features
62
+
63
+ - **Entity and property definition** using a simple DSL
64
+ - **Filtering, paging, and count support** (`$filter` supports implicit `and` and flat single-operator `or`, e.g. `color eq 'red' or color eq 'blue'`, via the `od_filter_or` hook; mixing `and`/`or` or using parentheses is not supported)
65
+ - **Complex types and enums**
66
+ - **Individual item retrieval and creation**
67
+ - **Schema introspection and OpenAPI generation**
68
+
69
+ ---
70
+
71
+ ## DSL Quick Example
72
+
73
+ ```ruby
74
+ require 'odata_duty'
75
+
76
+ class PersonEntity < OdataDuty::EntityType
77
+ property_ref 'id', String
78
+ property 'user_name', String, nullable: false
79
+ property 'name', String
80
+ property 'emails', [String], nullable: false
81
+ end
82
+
83
+ class PeopleSet < OdataDuty::EntitySet
84
+ entity_type PersonEntity
85
+
86
+ def od_after_init
87
+ @records = Person.active
88
+ end
89
+
90
+ def collection
91
+ @records
92
+ end
93
+
94
+ def individual(id)
95
+ @records.find(id)
96
+ end
97
+
98
+ def create(data)
99
+ Person.create!(username: data.user_name, name: data.name, emails: data.emails)
100
+ end
101
+ end
102
+
103
+ class SampleSchema < OdataDuty::Schema
104
+ namespace 'SampleSpace'
105
+ entity_sets [PeopleSet]
106
+ base_url Rails.application.routes.url_helpers.api_root_url
107
+ end
108
+ ```
109
+
110
+ Implementing `create` makes a set insertable, `update` makes it updatable, and `delete` makes it deletable; omitting them keeps the set read-only. Each choice is reflected automatically across `$oas2`, `$metadata`, and MCP — see [Using `create`, `update`, and `delete`](doc/using_create_update_and_delete.md).
111
+
112
+ By default the entity-set name comes from the class name: a class named `<X>EntitySet` or `<X>Set` exposes entity set `<X>` (the suffix is stripped), so `PeopleSet` above serves `People`. Pass an explicit `name`/`url` to override.
113
+
114
+ ---
115
+
116
+ ## Rails Integration Example
117
+
118
+ You can quickly generate the boilerplate controller, routes and schema with:
119
+
120
+ ```bash
121
+ bin/rails generate odata_duty:install
122
+ ```
123
+
124
+ ```ruby
125
+ # config/routes.rb
126
+ scope '/api' do
127
+ root 'api#index'
128
+ get '$metadata' => 'api#metadata'
129
+ get '$oas2' => 'api#oas2'
130
+ get '*url' => 'api#show'
131
+ post '*url' => 'api#create'
132
+ end
133
+ ```
134
+
135
+ ```ruby
136
+ # app/controllers/api_controller.rb
137
+
138
+ def index
139
+ render json: OdataDuty::EdmxSchema.index_hash(schema)
140
+ end
141
+
142
+ def metadata
143
+ render xml: OdataDuty::EdmxSchema.metadata_xml(schema)
144
+ end
145
+
146
+ # `OAS2.build_json` requires a builder-DSL schema (`SchemaBuilder.build`), not a class-based `Schema`.
147
+ def oas2
148
+ # CORS header so Power Automate can import `$oas2` from URL — see doc/using_oas2.md.
149
+ response.set_header('Access-Control-Allow-Origin', '*')
150
+ render json: OdataDuty::OAS2.build_json(schema)
151
+ end
152
+
153
+ def show
154
+ render json: schema.execute(params[:url], context: self, query_options: query_options)
155
+ end
156
+
157
+ def create
158
+ render json: schema.create(params[:url], context: self, query_options: query_options)
159
+ end
160
+
161
+ private
162
+
163
+ def query_options
164
+ params.to_unsafe_hash.except('url', 'action', 'controller', 'format')
165
+ end
166
+
167
+ def schema
168
+ @schema ||= OdataDuty::SchemaBuilder.build(namespace: 'MySpace', host: request.host_with_port,
169
+ scheme: request.scheme, base_path: api_index_path) do |s|
170
+ s.title = "My Dynamic API"
171
+ s.version = '0.0.1'
172
+ person_entity = s.add_entity_type(name: 'Person') do |et|
173
+ et.property_ref 'id', String
174
+ et.property 'user_name', String, nullable: false
175
+ end
176
+ s.add_entity_set(url: 'People', entity_type: person_entity,
177
+ resolver: 'PeopleResolver')
178
+ end
179
+ end
180
+ ```
181
+
182
+ In the builder DSL the entity-set name defaults from the resolver: `resolver: '<Namespace>::<X>Resolver'` with no `name:` yields entity set `<X>` (the namespace and the `Resolver` suffix are stripped). Above, `resolver: 'PeopleResolver'` would default to `People`; the explicit `url: 'People'` sets it directly.
183
+
184
+ When a builder resolver's `collection` / `individual` raises, the error's backtrace is prefixed with the resolver's own method, its `od_after_init`, and the `add_entity_set` call site so failures point at your code first.
185
+
186
+ ```ruby
187
+ # app/models/people_resolver.rb
188
+ class PeopleResolver < OdataDuty::SetResolver
189
+ def od_after_init
190
+ @records = Person.all
191
+ end
192
+
193
+ def od_filter_eq(property_name, value)
194
+ @records = @records.where(property_name.to_sym => value)
195
+ end
196
+
197
+ def od_filter_ne(property_name, value)
198
+ @records = @records.where.not(property_name.to_sym => value)
199
+ end
200
+
201
+ def od_filter_gt(property_name, value)
202
+ @records = @records.where("#{property_name} > ?", value)
203
+ end
204
+
205
+ def od_filter_lt(property_name, value)
206
+ @records = @records.where("#{property_name} < ?", value)
207
+ end
208
+
209
+ # Flat single-operator `or`, e.g. `color eq 'red' or color eq 'blue'`.
210
+ # Called once with every or'd predicate. Mixing `and`/`or` and parentheses are not supported.
211
+ def od_filter_or(predicates)
212
+ clauses = predicates.map do |p|
213
+ case p.operation
214
+ when :eq then @records.where(p.property_name => p.value)
215
+ when :ne then @records.where.not(p.property_name => p.value)
216
+ when :gt then @records.where("#{p.property_name} > ?", p.value)
217
+ when :ge then @records.where("#{p.property_name} >= ?", p.value)
218
+ when :lt then @records.where("#{p.property_name} < ?", p.value)
219
+ when :le then @records.where("#{p.property_name} <= ?", p.value)
220
+ else
221
+ raise ArgumentError, "Unsupported filter operation: #{p.operation.inspect}"
222
+ end
223
+ end
224
+ @records = clauses.reduce(:or)
225
+ end
226
+
227
+ def count
228
+ @records.count
229
+ end
230
+
231
+ def collection
232
+ @records
233
+ end
234
+
235
+ def individual(id)
236
+ @records.find { |record| record.id == id }
237
+ end
238
+ end
239
+ ```
240
+
241
+ ---
242
+
243
+ ## 📚 Further Documentation
244
+
245
+ - [OData Crash Course](doc/odata_crash_course.md)
246
+ - [MCP Crash Course](doc/mcp_crash_course.md)
247
+ - [Using MCP](doc/using_mcp.md)
248
+ - [Using `$select`](doc/using_select.md)
249
+ - [Using `$search`](doc/using_search.md)
250
+ - [Using `create`, `update`, and `delete`](doc/using_create_update_and_delete.md)
251
+ - [Using `computed` (read-only) properties](doc/using_computed.md)
252
+ - [Using `$oas2` (OpenAPI/Swagger) with Power Automate](doc/using_oas2.md)
253
+
254
+ > **Documentation convention:** Every externally-facing feature (a new DSL option,
255
+ > query option, or protocol surface) ships with a `doc/using_*.md` guide and a link
256
+ > from the Further Documentation list above. Lead integration examples with the
257
+ > Rails-controller pattern (the primary audience); show raw Rack only as a secondary,
258
+ > runnable reference.
259
+
260
+ ---
261
+
262
+ ## TODO
263
+
264
+ - Add support for composite keys
265
+ - Add support for schema descriptions
266
+ - Extend protocol adapters (MCP tools, resource reading)
267
+
268
+ ---
269
+
270
+ ## Development
271
+
272
+ ```bash
273
+ bin/setup # Install dependencies
274
+ rake spec # Run the test suite
275
+ bin/console # Open interactive console
276
+ ```
277
+
278
+ ### Test Server
279
+
280
+ To run the test server with auto-restart:
281
+
282
+ ```bash
283
+ bundle exec rerun -- bundle exec rackup spec/config.ru
284
+ ```
285
+
286
+ For MCP debugging with the inspector:
287
+
288
+ ```bash
289
+ npx @modelcontextprotocol/inspector@0.15.0 -e PORT=9292 bundle exec rackup spec/config.ru
290
+ ```
291
+
292
+ To install this gem locally:
293
+
294
+ ```bash
295
+ bundle exec rake install
296
+ ```
297
+
298
+ To release a new version:
299
+
300
+ ```bash
301
+ bundle exec rake release
302
+ ```
303
+
304
+ ---
305
+
306
+ ## Contributing
307
+
308
+ Bug reports and pull requests are welcome on GitHub at [https://github.com/NEXL-LTS/odata_duty-ruby](https://github.com/NEXL-LTS/odata_duty-ruby).
309
+
310
+ If you're interested in extending the DSL to support new protocols or tool integrations, open an issue or start a discussion — the architecture is designed for extensibility.
311
+
312
+ ---
@@ -0,0 +1,56 @@
1
+ require 'rails/generators'
2
+
3
+ module OdataDuty
4
+ module Generators
5
+ # EntitySetGenerator creates OData entity types and sets
6
+ class EntitySetGenerator < Rails::Generators::NamedBase
7
+ source_root File.expand_path('templates', __dir__)
8
+
9
+ argument :attributes, type: :array, default: [], banner: 'field:type field:type'
10
+
11
+ class_option :skip_tests, type: :boolean, default: false, desc: 'Skip test file generation'
12
+
13
+ def create_entity_type
14
+ template 'entity_type.rb.erb', File.join('app/odata', "#{file_name}_entity.rb")
15
+ end
16
+
17
+ def create_active_record_concern
18
+ template 'odata_active_record_concern.rb.erb',
19
+ File.join('app/odata', 'odata_active_record_concern.rb')
20
+ end
21
+
22
+ def create_entity_set
23
+ template 'entity_set.rb.erb', File.join('app/odata', "#{file_name}_set.rb")
24
+ end
25
+
26
+ def create_tests
27
+ return if options[:skip_tests]
28
+
29
+ template 'entity_type_spec.rb.erb', File.join('spec/odata', "#{file_name}_entity_spec.rb")
30
+ template 'entity_set_spec.rb.erb', File.join('spec/odata', "#{file_name}_set_spec.rb")
31
+ end
32
+
33
+ private
34
+
35
+ def attribute_type_map
36
+ {
37
+ 'string' => 'String',
38
+ 'text' => 'String',
39
+ 'integer' => 'Integer',
40
+ 'int' => 'Integer',
41
+ 'datetime' => 'Time',
42
+ 'timestamp' => 'Time',
43
+ 'time' => 'Time',
44
+ 'date' => 'Date',
45
+ 'boolean' => 'TrueClass',
46
+ 'bool' => 'TrueClass'
47
+ }
48
+ end
49
+
50
+ def odata_type(type)
51
+ type_key = type.to_s.downcase
52
+ attribute_type_map.fetch(type_key, 'String')
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,20 @@
1
+ class <%= class_name %>Set < OdataDuty::EntitySet
2
+ include OdataActiveRecordConcern
3
+ entity_type <%= class_name %>Entity
4
+
5
+ def od_after_init
6
+ @records = <%= class_name %>.active
7
+ end
8
+
9
+ def create(data)
10
+ <%= class_name %>.create!(<% attributes.each do |attr| %><%= attr.name %>: data.<%= attr.name %>,<% end %>)
11
+ end
12
+
13
+ def delete(id)
14
+ record = @records.find_by(<%= attributes.first.name %>: id)
15
+ return nil unless record
16
+
17
+ record.destroy!
18
+ record
19
+ end
20
+ end
@@ -0,0 +1,38 @@
1
+ require 'rails_helper'
2
+
3
+ RSpec.describe <%= class_name %>Set do
4
+ let(:context) { { } }
5
+ let(:entity_set) { <%= class_name %>Set.new(context) }
6
+
7
+ it 'returns all records' do
8
+ expect(entity_set.collection).to eq(<%= class_name %>.active)
9
+ end
10
+
11
+ it 'finds individual record' do
12
+ record = <%= class_name %>.create!(<%= attributes.map { |attr| "#{attr.name}: '#{attr.name}_value'" }.join(', ') %>)
13
+ expect(entity_set.individual(record.id)).to eq(record)
14
+ end
15
+
16
+ it 'creates a new record' do
17
+ data = OpenStruct.new(<%= attributes.map { |attr| "#{attr.name}: '#{attr.name}_new'" }.join(', ') %>)
18
+ expect { entity_set.create(data) }.to change { <%= class_name %>.count }.by(1)
19
+ end
20
+
21
+ describe '#delete' do
22
+ it 'removes an existing record' do
23
+ record = <%= class_name %>.create!(<%= attributes.map { |attr| "#{attr.name}: '#{attr.name}_value'" }.join(', ') %>)
24
+ expect { entity_set.delete(record.<%= attributes.first.name %>) }.to change { <%= class_name %>.count }.by(-1)
25
+ end
26
+
27
+ it 'returns a falsey value and does not change the count when not found' do
28
+ expect(entity_set.delete('non_existent_id')).to be_falsey
29
+ expect { entity_set.delete('non_existent_id') }.not_to change { <%= class_name %>.count }
30
+ end
31
+ end
32
+
33
+ describe 'entity_type' do
34
+ it 'uses the correct entity type' do
35
+ expect(described_class.entity_type).to eq(<%= class_name %>Entity)
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,6 @@
1
+ class <%= class_name %>Entity < OdataDuty::EntityType
2
+ property_ref '<%= attributes.first.name %>', <%= odata_type(attributes.first.type) %>, nullable: false
3
+ <% attributes.slice(1..-1).each do |attr| -%>
4
+ property '<%= attr.name %>', <%= odata_type(attr.type) %>
5
+ <% end -%>
6
+ end
@@ -0,0 +1,20 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe <%= class_name %>Entity do
4
+ let(:attributes) do
5
+ {
6
+ <% attributes.slice(1..-1).each do |attr| %>
7
+ <%= attr.name %>: '',
8
+ <% end %>
9
+ }
10
+ end
11
+ let(:record) { <%= class_name %>.create!(attributes) }
12
+ let(:context) { {} }
13
+ subject(:entity) { described_class.new(record, context) }
14
+
15
+ <% attributes.each do |attr| %>
16
+ describe '<%= attr.name %> property' do
17
+ it { expect(entity.<%= attr.name %>).to eq(record.<%= attr.name %>) }
18
+ end
19
+ <% end %>
20
+ end
@@ -0,0 +1,109 @@
1
+ module OdataActiveRecordConcern
2
+ def max_results
3
+ 1000
4
+ end
5
+
6
+ def od_top(value)
7
+ if value.to_i > max_results
8
+ raise OdataDuty::ClientError, "Invalid $top cannot be more than \#{max_results}"
9
+ end
10
+
11
+ @records = @records.limit(value)
12
+ end
13
+
14
+ def od_skip(value)
15
+ @records = @records.offset(value)
16
+ end
17
+
18
+ def od_skiptoken(value)
19
+ @skiptoken = value
20
+ @records = @records.reorder(:id).where("\#{@records.table_name}.id > ?", value)
21
+ end
22
+
23
+ def count
24
+ @records.count
25
+ end
26
+
27
+ def collection
28
+ if @records.count > max_results
29
+ @records.safe_loading.reorder(:id).limit(max_results).to_a.tap do |r|
30
+ od_next_link_skiptoken(r.last.id)
31
+ end
32
+ else
33
+ @records.safe_loading
34
+ end
35
+ end
36
+
37
+ def individual(id)
38
+ @records.find_by(id: id)
39
+ end
40
+ # ODATA operators:
41
+ # eq, ne, gt, lt, ge, and le comes from OData naming conventions
42
+
43
+ # eq: Test whether a field is equal to a constant value
44
+ def od_filter_eq(property_name, value)
45
+ assert_filterable_property(property_name)
46
+ @records = @records.where(property_name.to_sym => value)
47
+ end
48
+
49
+ # ne: Test whether a field is not equal to a constant value
50
+ def od_filter_ne(property_name, value)
51
+ assert_filterable_property(property_name)
52
+ @records = @records.where.not(property_name.to_sym => value)
53
+ end
54
+
55
+ # gt: Test whether a field is greater than a constant value
56
+ def od_filter_gt(property_name, value)
57
+ assert_filterable_property(property_name)
58
+ @records = @records.order(property_name => :asc)
59
+ .where("\#{@records.table_name}.\#{property_name} > ?", value)
60
+ end
61
+
62
+ # ge: Test whether a field is greater than or equal to a constant value
63
+ def od_filter_ge(property_name, value)
64
+ assert_filterable_property(property_name)
65
+ @records = @records.order(property_name => :asc)
66
+ .where(property_name.to_sym => value..)
67
+ end
68
+
69
+ # lt: Test whether a field is less than a constant value
70
+ def od_filter_lt(property_name, value)
71
+ assert_filterable_property(property_name)
72
+ @records = @records.order(property_name => :desc)
73
+ .where(property_name.to_sym => ...value)
74
+ end
75
+
76
+ # le: Test whether a field is less than or equal to a constant value
77
+ def od_filter_le(property_name, value)
78
+ assert_filterable_property(property_name)
79
+ @records = @records.order(property_name => :desc)
80
+ .where(property_name.to_sym => ..value)
81
+ end
82
+
83
+ # or: Union of records satisfying any of the or'd predicates
84
+ def od_filter_or(predicates)
85
+ clauses = predicates.map do |p|
86
+ assert_filterable_property(p.property_name)
87
+ case p.operation
88
+ when :eq then @records.where(p.property_name => p.value)
89
+ when :ne then @records.where.not(p.property_name => p.value)
90
+ when :gt then @records.where("\#{@records.table_name}.\#{p.property_name} > ?", p.value)
91
+ when :ge then @records.where("\#{@records.table_name}.\#{p.property_name} >= ?", p.value)
92
+ when :lt then @records.where("\#{@records.table_name}.\#{p.property_name} < ?", p.value)
93
+ when :le then @records.where("\#{@records.table_name}.\#{p.property_name} <= ?", p.value)
94
+ else
95
+ raise ArgumentError, "Unsupported filter operation: \#{p.operation.inspect}"
96
+ end
97
+ end
98
+ @records = clauses.reduce(:or)
99
+ end
100
+
101
+ protected
102
+
103
+ def assert_filterable_property(property_name)
104
+ return if @records.column_names.include?(property_name.to_s)
105
+ return if @records.attribute_aliases.keys.include?(property_name.to_s)
106
+
107
+ raise NotImplementedError, "Filtering on \#{property_name} not supported"
108
+ end
109
+ end
@@ -0,0 +1,39 @@
1
+ class <%= class_name %>Resolver < OdataDuty::SetResolver
2
+ def od_after_init
3
+ # Initialize any resources needed for this entity set
4
+ @<%= file_name.pluralize %> = []
5
+ end
6
+
7
+ def collection
8
+ # Return all instances of this entity
9
+ @<%= file_name.pluralize %>
10
+ end
11
+
12
+ def individual(id)
13
+ # Find and return a specific entity by id
14
+ @<%= file_name.pluralize %>.find { |item| item.<%= attributes.first.name %> == id }
15
+ end
16
+
17
+ # Optional: Implement create method to support POST operations
18
+ def create(data)
19
+ # Create a new entity from the submitted data
20
+ new_<%= file_name %> = OpenStruct.new(
21
+ <% attributes.each do |attr| %>
22
+ <%= attr.name %>: data['<%= attr.name %>'],
23
+ <% end %>
24
+ )
25
+
26
+ @<%= file_name.pluralize %> << new_<%= file_name %>
27
+ new_<%= file_name %>
28
+ end
29
+
30
+ # Optional: Implement delete method to support DELETE operations
31
+ def delete(id)
32
+ # Find and remove the entity by id; return truthy on success, falsey if not found
33
+ <%= file_name %> = @<%= file_name.pluralize %>.find { |item| item.<%= attributes.first.name %> == id }
34
+ return nil unless <%= file_name %>
35
+
36
+ @<%= file_name.pluralize %>.delete(<%= file_name %>)
37
+ <%= file_name %>
38
+ end
39
+ end
@@ -0,0 +1,50 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe <%= class_name %>Resolver do
4
+ let(:context) { instance_double(OdataDuty::Context) }
5
+
6
+ subject { described_class.new(context: context, init_args: nil) }
7
+
8
+ before do
9
+ allow(subject).to receive(:collection).and_return([])
10
+ end
11
+
12
+ describe '#collection' do
13
+ it 'returns a collection of entities' do
14
+ expect(subject.collection).to be_an(Array)
15
+ end
16
+ end
17
+
18
+ describe '#individual' do
19
+ let(:test_entity) { OpenStruct.new(<%= attributes.first.name %>: 'test_id') }
20
+
21
+ before do
22
+ allow(subject).to receive(:collection).and_return([test_entity])
23
+ end
24
+
25
+ it 'returns a specific entity by id' do
26
+ expect(subject.individual('test_id')).to eq(test_entity)
27
+ end
28
+
29
+ it 'returns nil when the entity is not found' do
30
+ expect(subject.individual('non_existent_id')).to be_nil
31
+ end
32
+ end
33
+
34
+ describe '#delete' do
35
+ let(:test_entity) { OpenStruct.new(<%= attributes.first.name %>: 'test_id') }
36
+
37
+ before do
38
+ subject.instance_variable_set(:@<%= file_name.pluralize %>, [test_entity])
39
+ end
40
+
41
+ it 'removes an existing entity by id' do
42
+ expect(subject.delete('test_id')).to eq(test_entity)
43
+ expect(subject.instance_variable_get(:@<%= file_name.pluralize %>)).not_to include(test_entity)
44
+ end
45
+
46
+ it 'returns a falsey value when the entity is not found' do
47
+ expect(subject.delete('non_existent_id')).to be_falsey
48
+ end
49
+ end
50
+ end