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.
- checksums.yaml +7 -0
- data/README.md +312 -0
- data/lib/generators/odata_duty/entity_set/entity_set_generator.rb +56 -0
- data/lib/generators/odata_duty/entity_set/templates/entity_set.rb.erb +20 -0
- data/lib/generators/odata_duty/entity_set/templates/entity_set_spec.rb.erb +38 -0
- data/lib/generators/odata_duty/entity_set/templates/entity_type.rb.erb +6 -0
- data/lib/generators/odata_duty/entity_set/templates/entity_type_spec.rb.erb +20 -0
- data/lib/generators/odata_duty/entity_set/templates/odata_active_record_concern.rb.erb +109 -0
- data/lib/generators/odata_duty/entity_set/templates/resolver.rb.erb +39 -0
- data/lib/generators/odata_duty/entity_set/templates/resolver_spec.rb.erb +50 -0
- data/lib/generators/odata_duty/install/install_generator.rb +60 -0
- data/lib/generators/odata_duty/install/templates/controller.rb.tt +43 -0
- data/lib/generators/odata_duty/install/templates/schema.rb.tt +16 -0
- data/lib/metadata.xml.erb +116 -0
- data/lib/odata_duty/complex_type.rb +70 -0
- data/lib/odata_duty/context_wrapper.rb +26 -0
- data/lib/odata_duty/create_complex_type_hash_wrapper.rb +49 -0
- data/lib/odata_duty/dynamic_object_wrapper.rb.erb +79 -0
- data/lib/odata_duty/edms.rb +122 -0
- data/lib/odata_duty/edmx_schema.rb +19 -0
- data/lib/odata_duty/entity_type.rb +72 -0
- data/lib/odata_duty/enum_type.rb +71 -0
- data/lib/odata_duty/errors.rb +52 -0
- data/lib/odata_duty/executor.rb +277 -0
- data/lib/odata_duty/filter.rb +76 -0
- data/lib/odata_duty/filter_predicate.rb +12 -0
- data/lib/odata_duty/mapper_builder.rb +86 -0
- data/lib/odata_duty/mcp_input_schemas.rb +59 -0
- data/lib/odata_duty/mcp_server_builder.rb +107 -0
- data/lib/odata_duty/oas2/collection_get_path.rb +97 -0
- data/lib/odata_duty/oas2/collection_post_path.rb +63 -0
- data/lib/odata_duty/oas2/individual_delete_path.rb +42 -0
- data/lib/odata_duty/oas2/individual_get_path.rb +37 -0
- data/lib/odata_duty/oas2/individual_patch_path.rb +63 -0
- data/lib/odata_duty/oas2.rb +113 -0
- data/lib/odata_duty/parslet_search_expression.rb +163 -0
- data/lib/odata_duty/property/collection_prop.rb +25 -0
- data/lib/odata_duty/property/single_prop.rb +131 -0
- data/lib/odata_duty/property.rb +49 -0
- data/lib/odata_duty/railtie.rb +11 -0
- data/lib/odata_duty/schema_builder/complex_type.rb +34 -0
- data/lib/odata_duty/schema_builder/container.rb +16 -0
- data/lib/odata_duty/schema_builder/data_type.rb +18 -0
- data/lib/odata_duty/schema_builder/endpoint.rb +117 -0
- data/lib/odata_duty/schema_builder/entity_set.rb +67 -0
- data/lib/odata_duty/schema_builder/entity_type.rb +49 -0
- data/lib/odata_duty/schema_builder/enum_type.rb +32 -0
- data/lib/odata_duty/schema_builder.rb +130 -0
- data/lib/odata_duty/set_resolver.rb +51 -0
- data/lib/odata_duty.rb +311 -0
- 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
|