reso_api 1.8.13 → 2.0.1
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 +4 -4
- data/Gemfile +8 -0
- data/Gemfile.lock +17 -3
- data/README.md +184 -4
- data/lib/reso_api/app/models/reso/api/client.rb +15 -5
- data/lib/reso_api/app/models/reso/api/query_builder.rb +261 -0
- data/lib/reso_api/app/models/reso/api/query_conditions.rb +141 -0
- data/lib/reso_api/app/models/reso/api/query_formatter.rb +33 -0
- data/lib/reso_api/app/models/reso/api/where_chain.rb +15 -0
- data/lib/reso_api/version.rb +1 -1
- data/lib/reso_api.rb +4 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d07a8c664cda837a170e4faa1f7e038d50bac47d7f7a8364f46d29255d06c1a8
|
|
4
|
+
data.tar.gz: a5ec6e603fe65f5c66e59032de39a4f44edfef1ea05d0ae4e92036d626e00e6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f52bcb068fc30aa51687e087a66171f6d52c79a8e5c786650b5dfed17c9b8a28ef8f8dd68ca03a2be503b1fab61db655a02b039bf840c28c1771bfcfa8920ae6
|
|
7
|
+
data.tar.gz: 820f3d1a47c2c9fbc2336d170a50203806a671ac883bd2d4ed3d5968b73e31359e96a339facaf792cd2ffc88fa185cafaa928a8f6ed00b1129246b163c45c352
|
data/Gemfile
CHANGED
|
@@ -4,3 +4,11 @@ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
|
|
|
4
4
|
|
|
5
5
|
# Specify your gem's dependencies in reso_api.gemspec
|
|
6
6
|
gemspec
|
|
7
|
+
|
|
8
|
+
# Required for Ruby 3.4+ compatibility with older activesupport
|
|
9
|
+
gem "bigdecimal"
|
|
10
|
+
gem "mutex_m"
|
|
11
|
+
gem "drb"
|
|
12
|
+
gem "benchmark"
|
|
13
|
+
gem "logger"
|
|
14
|
+
gem "base64"
|
data/Gemfile.lock
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
reso_api (0.
|
|
4
|
+
reso_api (2.0.1)
|
|
5
5
|
activesupport
|
|
6
|
+
jwt
|
|
6
7
|
oauth2
|
|
7
8
|
|
|
8
9
|
GEM
|
|
@@ -13,8 +14,12 @@ GEM
|
|
|
13
14
|
i18n (>= 1.6, < 2)
|
|
14
15
|
minitest (>= 5.1)
|
|
15
16
|
tzinfo (~> 2.0)
|
|
17
|
+
base64 (0.3.0)
|
|
18
|
+
benchmark (0.5.0)
|
|
19
|
+
bigdecimal (4.0.1)
|
|
16
20
|
concurrent-ruby (1.1.10)
|
|
17
21
|
diff-lcs (1.5.0)
|
|
22
|
+
drb (2.2.3)
|
|
18
23
|
faraday (2.3.0)
|
|
19
24
|
faraday-net_http (~> 2.0)
|
|
20
25
|
ruby2_keywords (>= 0.0.4)
|
|
@@ -22,9 +27,11 @@ GEM
|
|
|
22
27
|
i18n (1.10.0)
|
|
23
28
|
concurrent-ruby (~> 1.0)
|
|
24
29
|
jwt (2.3.0)
|
|
30
|
+
logger (1.7.0)
|
|
25
31
|
minitest (5.15.0)
|
|
26
32
|
multi_json (1.15.0)
|
|
27
33
|
multi_xml (0.6.0)
|
|
34
|
+
mutex_m (0.3.0)
|
|
28
35
|
oauth2 (1.4.9)
|
|
29
36
|
faraday (>= 0.17.3, < 3.0)
|
|
30
37
|
jwt (>= 1.0, < 3.0)
|
|
@@ -52,13 +59,20 @@ GEM
|
|
|
52
59
|
|
|
53
60
|
PLATFORMS
|
|
54
61
|
arm64-darwin-21
|
|
62
|
+
arm64-darwin-25
|
|
55
63
|
x86_64-linux
|
|
56
64
|
|
|
57
65
|
DEPENDENCIES
|
|
58
|
-
|
|
66
|
+
base64
|
|
67
|
+
benchmark
|
|
68
|
+
bigdecimal
|
|
69
|
+
bundler (>= 2.3.15)
|
|
70
|
+
drb
|
|
71
|
+
logger
|
|
72
|
+
mutex_m
|
|
59
73
|
rake (>= 12.3.3)
|
|
60
74
|
reso_api!
|
|
61
75
|
rspec (~> 3.0)
|
|
62
76
|
|
|
63
77
|
BUNDLED WITH
|
|
64
|
-
|
|
78
|
+
4.0.6
|
data/README.md
CHANGED
|
@@ -72,6 +72,14 @@ You pass these two pieces of information to create an instance of an API client:
|
|
|
72
72
|
client = RESO::API::Client.new(access_token: access_token, base_url: base_url)
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
+
#### Base URL for Replication
|
|
76
|
+
|
|
77
|
+
Some systems, like Bridge Interactive, provide replication endpoints that differ from the ones for normal search requests by including an additional path segment *following* the resource segment. For example:
|
|
78
|
+
|
|
79
|
+
https://api.bridgedataoutput.com/api/v2/OData/{dataset_id}/Property/replication
|
|
80
|
+
|
|
81
|
+
To accommodate this, the `base_url` parameter may contain a `/$resource$` segment which will be replaced for each API call with the appropriate resource segment.
|
|
82
|
+
|
|
75
83
|
|
|
76
84
|
### Resources
|
|
77
85
|
|
|
@@ -94,19 +102,191 @@ The response will be an EDMX xml schema document that matches the [RESO Data Dic
|
|
|
94
102
|
|
|
95
103
|
[RESO Data Dictionary standard]: https://www.reso.org/data-dictionary/
|
|
96
104
|
|
|
97
|
-
###
|
|
105
|
+
### Query Builder (ActiveRecord-style)
|
|
106
|
+
|
|
107
|
+
Calling a resource method with no arguments returns a query builder that supports chainable, ActiveRecord-style queries. Results are returned as unwrapped arrays (the contents of `value` from the OData response).
|
|
108
|
+
|
|
109
|
+
For properties, a default scope of `StandardStatus in ('Active','Pending')` is automatically applied unless you specify `StandardStatus` yourself or call `.unscoped`.
|
|
110
|
+
|
|
111
|
+
#### Basic queries
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
# Returns array of listing hashes (default scope: Active + Pending)
|
|
115
|
+
listings = client.properties.where(City: "Seattle")
|
|
116
|
+
|
|
117
|
+
# Chained conditions (joined with 'and')
|
|
118
|
+
listings = client.properties
|
|
119
|
+
.where(City: "Seattle")
|
|
120
|
+
.where(["ListPrice >= ?", 500_000])
|
|
121
|
+
|
|
122
|
+
# Single record lookup
|
|
123
|
+
listing = client.properties.find_by(ListingId: "123456")
|
|
124
|
+
|
|
125
|
+
# Lookup by primary key (uses detail endpoint)
|
|
126
|
+
listing = client.properties.find("3yd-BINDER-5508272")
|
|
127
|
+
|
|
128
|
+
# First record
|
|
129
|
+
listing = client.properties.where(City: "Seattle").first
|
|
130
|
+
|
|
131
|
+
# Count
|
|
132
|
+
total = client.properties.where(City: "Seattle").count
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
#### Where conditions
|
|
136
|
+
|
|
137
|
+
**Hash conditions** — equality, IN, ranges, and string matching:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
.where(City: "Seattle") # City eq 'Seattle'
|
|
141
|
+
.where(StandardStatus: ['Active', 'Pending']) # StandardStatus in ('Active','Pending')
|
|
142
|
+
.where(ListPrice: 300_000..500_000) # ListPrice ge 300000 and ListPrice le 500000
|
|
143
|
+
.where(ListPrice: 300_000..) # ListPrice ge 300000
|
|
144
|
+
.where(ListPrice: ..500_000) # ListPrice le 500000
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**String matching** — use `%` wildcards like SQL `LIKE`:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
.where(ListOfficeName: "Slifer%") # startswith(ListOfficeName,'Slifer')
|
|
151
|
+
.where(ListOfficeName: "%Frampton") # endswith(ListOfficeName,'Frampton')
|
|
152
|
+
.where(ListOfficeName: "%Smith%") # contains(ListOfficeName,'Smith')
|
|
153
|
+
.where(ListOfficeName: "Slifer Smith & Frampton") # exact match (no %)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Only `%` at the first and/or last position is treated as a wildcard. A `%` in the middle of the string (e.g. `"100% Pure"`) is treated literally. To match a literal `%` at the start or end, escape it with `\%`.
|
|
157
|
+
|
|
158
|
+
**Note:** Not all MLS servers support all string functions. `startswith` has the widest support. Some servers silently return empty results for `endswith` and `contains` rather than an error.
|
|
159
|
+
|
|
160
|
+
**Array conditions** — comparison operators with `?` placeholders:
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
.where(["CloseDate > ?", 1.week.ago]) # CloseDate gt 2026-02-04T00:00:00Z
|
|
164
|
+
.where(["ListPrice >= ?", 500_000]) # ListPrice ge 500000
|
|
165
|
+
.where(["ListPrice >= ? and ListPrice <= ?", 300_000, 500_000])
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Supported operators: `>`, `>=`, `<`, `<=`, `=`, `!=`
|
|
169
|
+
|
|
170
|
+
**Named placeholders:**
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
.where(["ListPrice >= :min and ListPrice <= :max", min: 300_000, max: 500_000])
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**Negation** — call `.where` with no arguments, then `.not`:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
.where.not(City: "Seattle") # City ne 'Seattle'
|
|
180
|
+
.where.not(StandardStatus: ['Closed', 'Expired']) # StandardStatus ne 'Closed' and ...
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
#### Value formatting
|
|
184
|
+
|
|
185
|
+
Ruby values are automatically formatted for OData:
|
|
98
186
|
|
|
99
|
-
|
|
187
|
+
| Ruby Type | OData Output |
|
|
188
|
+
|--------------------|------------------------------------|
|
|
189
|
+
| `String` | `'Seattle'` (single-quoted) |
|
|
190
|
+
| `Integer`, `Float` | `500000` (bare) |
|
|
191
|
+
| `Time`, `DateTime` | `2026-02-04T12:00:00Z` (ISO 8601) |
|
|
192
|
+
| `Date` | `2026-02-04` |
|
|
193
|
+
| `true` / `false` | `true` / `false` |
|
|
194
|
+
| `nil` | `null` |
|
|
195
|
+
|
|
196
|
+
#### Select, order, limit, offset
|
|
100
197
|
|
|
101
198
|
```ruby
|
|
102
199
|
client.properties
|
|
200
|
+
.where(City: "Seattle")
|
|
201
|
+
.select(:ListingKey, :City, :ListPrice) # $select
|
|
202
|
+
.order(ListPrice: :desc) # $orderby
|
|
203
|
+
.limit(25) # $top
|
|
204
|
+
.offset(50) # $skip
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
#### Includes (eager loading)
|
|
208
|
+
|
|
209
|
+
`.includes` maps to OData `$expand` for joining related resources:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
client.properties
|
|
213
|
+
.where(City: "Seattle")
|
|
214
|
+
.includes(:Media, :OpenHouses)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
#### Default scope and unscoped
|
|
218
|
+
|
|
219
|
+
Properties automatically filter to `StandardStatus in ('Active','Pending')`:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
# Default applied
|
|
223
|
+
client.properties.where(City: "Seattle")
|
|
224
|
+
# → StandardStatus in ('Active','Pending') and City eq 'Seattle'
|
|
225
|
+
|
|
226
|
+
# Overridden when you specify StandardStatus
|
|
227
|
+
client.properties.where(StandardStatus: 'Closed')
|
|
228
|
+
# → StandardStatus eq 'Closed'
|
|
229
|
+
|
|
230
|
+
# Explicitly removed
|
|
231
|
+
client.properties.unscoped.where(City: "Seattle")
|
|
232
|
+
# → City eq 'Seattle'
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
No default scope is applied for members, offices, open_houses, or media.
|
|
236
|
+
|
|
237
|
+
#### Iteration and execution
|
|
238
|
+
|
|
239
|
+
Queries execute lazily — the API call fires when you access the results:
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
listings = client.properties.where(City: "Seattle")
|
|
243
|
+
listings.each { |l| puts l["ListPrice"] } # triggers API call
|
|
244
|
+
listings.length # uses cached results
|
|
245
|
+
listings[0] # uses cached results
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
For large datasets, `.each` and `.find_each` auto-paginate through `@odata.nextLink`:
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
client.properties.where(["ModificationTimestamp > ?", 1.day.ago]).each do |listing|
|
|
252
|
+
process(listing)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
client.properties.find_each(batch_size: 200) do |listing|
|
|
256
|
+
process(listing)
|
|
257
|
+
end
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
#### Immutability
|
|
261
|
+
|
|
262
|
+
Each chainable method returns a new builder, so you can safely branch queries:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
base = client.properties.where(City: "Seattle")
|
|
266
|
+
active = base.where(StandardStatus: "Active")
|
|
267
|
+
closed = base.where(StandardStatus: "Closed")
|
|
268
|
+
# base, active, and closed are independent queries
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
### OData Queries (Direct)
|
|
274
|
+
|
|
275
|
+
The query builder above is the recommended way to query resources. You can also query resources directly using OData parameters. When called with arguments, the raw OData response hash is returned (you unwrap `value` yourself).
|
|
276
|
+
|
|
277
|
+
### Listing Requests
|
|
278
|
+
|
|
279
|
+
The simplest query is simply to call `properties` on the initialized client:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
client.properties(filter: "StandardStatus eq 'Active'")
|
|
103
283
|
```
|
|
104
284
|
|
|
105
285
|
The API will return a JSON response with an array of listing JSON objects.
|
|
106
286
|
|
|
107
287
|
### Getting a single listing
|
|
108
288
|
|
|
109
|
-
You can look up a single listing by sending a query with the listing's unique id (ListingKey).
|
|
289
|
+
You can look up a single listing by sending a query with the listing's unique id (ListingKey).
|
|
110
290
|
|
|
111
291
|
```ruby
|
|
112
292
|
client.property('3yd-BINDER-5508272')
|
|
@@ -195,7 +375,7 @@ client.properties(ignorenulls: true)
|
|
|
195
375
|
|
|
196
376
|
#### Automatically iterate over all results
|
|
197
377
|
|
|
198
|
-
By passing a block to Media, Member, Office, and Property resource calls, subsequent paginated calls will automatically be made until the whole result set has been traversed. The block provided is executed for each returned object hash. The `batch` option can be used to return the full array of results.
|
|
378
|
+
By passing a block to Media, Member, Office, and Property resource calls, subsequent paginated calls will automatically be made until the whole result set has been traversed. The block provided is executed for each returned object hash. The `batch` option can be used to return the full array of results.
|
|
199
379
|
|
|
200
380
|
Here are a couple of examples of how that can be used:
|
|
201
381
|
|
|
@@ -57,10 +57,15 @@ module RESO
|
|
|
57
57
|
|
|
58
58
|
FILTERABLE_ENDPOINTS.keys.each do |method_name|
|
|
59
59
|
define_method method_name do |*args, &block|
|
|
60
|
+
# No-arg call returns a QueryBuilder for ActiveRecord-style chaining
|
|
61
|
+
if args.empty? && block.nil?
|
|
62
|
+
return QueryBuilder.new(client: self, resource: method_name)
|
|
63
|
+
end
|
|
64
|
+
|
|
60
65
|
hash = args.first.is_a?(Hash) ? args.first : {}
|
|
61
66
|
|
|
62
|
-
filter = hash[:filter]
|
|
63
|
-
if !filter.include?('OriginatingSystemName') && osn.present?
|
|
67
|
+
filter = hash[:filter]
|
|
68
|
+
if !filter.to_s.include?('OriginatingSystemName') && osn.present?
|
|
64
69
|
osn_filter = format("OriginatingSystemName eq '%s'", osn.to_s)
|
|
65
70
|
filter = [filter.presence, osn_filter].compact.join(' and ')
|
|
66
71
|
end
|
|
@@ -227,7 +232,12 @@ module RESO
|
|
|
227
232
|
end
|
|
228
233
|
|
|
229
234
|
def uri_for_endpoint endpoint
|
|
230
|
-
|
|
235
|
+
uri = URI(endpoint)
|
|
236
|
+
return uri if uri.host
|
|
237
|
+
uri = URI(base_url)
|
|
238
|
+
path = uri.path
|
|
239
|
+
uri.path = path.include?("/$resource$") ? path.sub("/$resource$", endpoint) : [path, endpoint].join
|
|
240
|
+
return uri
|
|
231
241
|
end
|
|
232
242
|
|
|
233
243
|
def perform_call(endpoint, params, max_retries = 5, debug = false)
|
|
@@ -237,7 +247,7 @@ module RESO
|
|
|
237
247
|
if params.present?
|
|
238
248
|
query = params.present? ? URI.encode_www_form(params).gsub("+", " ") : ""
|
|
239
249
|
uri.query && uri.query.length > 0 ? uri.query += '&' + query : uri.query = query
|
|
240
|
-
return URI::
|
|
250
|
+
return URI::DEFAULT_PARSER.unescape(uri.request_uri) if params.dig(:$debug).present?
|
|
241
251
|
end
|
|
242
252
|
|
|
243
253
|
# Store the full request URL for debugging
|
|
@@ -270,7 +280,7 @@ module RESO
|
|
|
270
280
|
fresh_oauth2_payload
|
|
271
281
|
raise StandardError
|
|
272
282
|
elsif response.is_a?(Hash) && response.has_key?("error")
|
|
273
|
-
error_msg = response.inspect
|
|
283
|
+
error_msg = "#{@last_request_url} => #{response.inspect}"
|
|
274
284
|
puts "Error: #{error_msg}" if debug
|
|
275
285
|
raise StandardError, error_msg
|
|
276
286
|
elsif response.is_a?(Hash) && response.has_key?("retry-after")
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
module RESO
|
|
2
|
+
module API
|
|
3
|
+
class QueryBuilder
|
|
4
|
+
include Enumerable
|
|
5
|
+
|
|
6
|
+
DEFAULT_PROPERTIES_SCOPE = "StandardStatus in ('Active','Pending')"
|
|
7
|
+
|
|
8
|
+
def initialize(client:, resource:)
|
|
9
|
+
@client = client
|
|
10
|
+
@resource = resource
|
|
11
|
+
@conditions = []
|
|
12
|
+
@select_fields = nil
|
|
13
|
+
@order_clauses = nil
|
|
14
|
+
@limit_value = nil
|
|
15
|
+
@offset_value = nil
|
|
16
|
+
@includes_values = nil
|
|
17
|
+
@count_flag = false
|
|
18
|
+
@unscoped_flag = false
|
|
19
|
+
@loaded = false
|
|
20
|
+
@records = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# --- Chainable methods ---
|
|
24
|
+
|
|
25
|
+
def where(conditions = nil)
|
|
26
|
+
if conditions.nil?
|
|
27
|
+
return WhereChain.new(clone_builder)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
builder = clone_builder
|
|
31
|
+
fragments = QueryConditions.parse(conditions)
|
|
32
|
+
fragments.each { |f| builder.add_condition(f) }
|
|
33
|
+
builder
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def select(*fields)
|
|
37
|
+
if fields.first.is_a?(Proc) || (fields.empty? && block_given?)
|
|
38
|
+
return super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
builder = clone_builder
|
|
42
|
+
builder.instance_variable_set(:@select_fields, fields.flatten.map(&:to_s))
|
|
43
|
+
builder
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def order(*args)
|
|
47
|
+
builder = clone_builder
|
|
48
|
+
clauses = args.flat_map do |arg|
|
|
49
|
+
case arg
|
|
50
|
+
when Hash
|
|
51
|
+
arg.map { |field, dir| "#{field} #{dir}" }
|
|
52
|
+
when String
|
|
53
|
+
[arg]
|
|
54
|
+
else
|
|
55
|
+
[arg.to_s]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
builder.instance_variable_set(:@order_clauses, clauses)
|
|
59
|
+
builder
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def limit(value)
|
|
63
|
+
builder = clone_builder
|
|
64
|
+
builder.instance_variable_set(:@limit_value, value)
|
|
65
|
+
builder
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def offset(value)
|
|
69
|
+
builder = clone_builder
|
|
70
|
+
builder.instance_variable_set(:@offset_value, value)
|
|
71
|
+
builder
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def includes(*names)
|
|
75
|
+
builder = clone_builder
|
|
76
|
+
builder.instance_variable_set(:@includes_values, names.flatten.map(&:to_s))
|
|
77
|
+
builder
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def unscoped
|
|
81
|
+
builder = clone_builder
|
|
82
|
+
builder.instance_variable_set(:@unscoped_flag, true)
|
|
83
|
+
builder
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# --- Terminal methods ---
|
|
87
|
+
|
|
88
|
+
def find(key)
|
|
89
|
+
endpoint = Client::DETAIL_ENDPOINTS[@resource.to_s.singularize.to_sym]
|
|
90
|
+
raise ArgumentError, "Unknown resource: #{@resource}" unless endpoint
|
|
91
|
+
@client.send(:perform_call, "#{endpoint}('#{key}')", nil)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def find_by(conditions)
|
|
95
|
+
builder = where(conditions).limit(1)
|
|
96
|
+
builder.load
|
|
97
|
+
builder.records.first
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def first(n = nil)
|
|
101
|
+
if n
|
|
102
|
+
builder = limit(n)
|
|
103
|
+
builder.load
|
|
104
|
+
builder.records
|
|
105
|
+
else
|
|
106
|
+
builder = limit(1)
|
|
107
|
+
builder.load
|
|
108
|
+
builder.records.first
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def count
|
|
113
|
+
builder = clone_builder
|
|
114
|
+
builder.instance_variable_set(:@count_flag, true)
|
|
115
|
+
builder.instance_variable_set(:@limit_value, 1)
|
|
116
|
+
response = builder.execute_raw
|
|
117
|
+
response['@odata.totalCount'].to_i
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def each(&block)
|
|
121
|
+
if block_given?
|
|
122
|
+
execute_with_pagination(&block)
|
|
123
|
+
else
|
|
124
|
+
to_a.each
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def find_each(batch_size: 200, &block)
|
|
129
|
+
builder = clone_builder
|
|
130
|
+
builder.instance_variable_set(:@limit_value, batch_size)
|
|
131
|
+
builder.execute_with_pagination(&block)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def to_a
|
|
135
|
+
load
|
|
136
|
+
@records
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def to_ary
|
|
140
|
+
to_a
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def reload
|
|
144
|
+
@loaded = false
|
|
145
|
+
@records = nil
|
|
146
|
+
self
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def inspect
|
|
150
|
+
load
|
|
151
|
+
@records.inspect
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def size
|
|
155
|
+
to_a.size
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def length
|
|
159
|
+
to_a.length
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def empty?
|
|
163
|
+
to_a.empty?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def [](index)
|
|
167
|
+
to_a[index]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# --- Internal ---
|
|
171
|
+
|
|
172
|
+
def add_condition(fragment)
|
|
173
|
+
@conditions << fragment
|
|
174
|
+
@loaded = false
|
|
175
|
+
@records = nil
|
|
176
|
+
self
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
protected
|
|
180
|
+
|
|
181
|
+
attr_reader :records
|
|
182
|
+
|
|
183
|
+
def load
|
|
184
|
+
return if @loaded
|
|
185
|
+
response = execute_raw
|
|
186
|
+
@records = response['value'].is_a?(Array) ? response['value'] : []
|
|
187
|
+
@loaded = true
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def execute_raw
|
|
191
|
+
endpoint = Client::FILTERABLE_ENDPOINTS[@resource]
|
|
192
|
+
raise ArgumentError, "Unknown resource: #{@resource}" unless endpoint
|
|
193
|
+
|
|
194
|
+
params = build_params
|
|
195
|
+
@client.send(:perform_call, endpoint, params)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def execute_with_pagination(&block)
|
|
199
|
+
endpoint = Client::FILTERABLE_ENDPOINTS[@resource]
|
|
200
|
+
raise ArgumentError, "Unknown resource: #{@resource}" unless endpoint
|
|
201
|
+
|
|
202
|
+
params = build_params
|
|
203
|
+
response = @client.send(:perform_call, endpoint, params)
|
|
204
|
+
if response['value'].is_a?(Array)
|
|
205
|
+
response['value'].each(&block)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
while (next_link = response['@odata.nextLink']).present?
|
|
209
|
+
response = @client.send(:perform_call, next_link, nil)
|
|
210
|
+
if response['value'].is_a?(Array)
|
|
211
|
+
response['value'].each(&block)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def build_params
|
|
217
|
+
params = {}
|
|
218
|
+
params[:"$filter"] = build_filter
|
|
219
|
+
params[:"$select"] = @select_fields.join(',') if @select_fields
|
|
220
|
+
params[:"$orderby"] = @order_clauses.join(',') if @order_clauses
|
|
221
|
+
params[:"$top"] = @limit_value if @limit_value
|
|
222
|
+
params[:"$skip"] = @offset_value if @offset_value
|
|
223
|
+
params[:"$expand"] = @includes_values.join(',') if @includes_values
|
|
224
|
+
params[:"$count"] = 'true' if @count_flag
|
|
225
|
+
params.compact
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def build_filter
|
|
229
|
+
filters = @conditions.dup
|
|
230
|
+
|
|
231
|
+
# Apply OSN filter if configured and not already present
|
|
232
|
+
if @client.osn.present? && filters.none? { |f| f.include?('OriginatingSystemName') }
|
|
233
|
+
filters.unshift("OriginatingSystemName eq '#{@client.osn}'")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Apply default scope for properties unless overridden
|
|
237
|
+
if @resource == :properties && !@unscoped_flag
|
|
238
|
+
unless filters.any? { |f| f.include?('StandardStatus') }
|
|
239
|
+
filters << DEFAULT_PROPERTIES_SCOPE
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
filter_string = filters.join(' and ')
|
|
244
|
+
filter_string.presence
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def clone_builder
|
|
248
|
+
builder = QueryBuilder.new(client: @client, resource: @resource)
|
|
249
|
+
builder.instance_variable_set(:@conditions, @conditions.dup)
|
|
250
|
+
builder.instance_variable_set(:@select_fields, @select_fields&.dup)
|
|
251
|
+
builder.instance_variable_set(:@order_clauses, @order_clauses&.dup)
|
|
252
|
+
builder.instance_variable_set(:@limit_value, @limit_value)
|
|
253
|
+
builder.instance_variable_set(:@offset_value, @offset_value)
|
|
254
|
+
builder.instance_variable_set(:@includes_values, @includes_values&.dup)
|
|
255
|
+
builder.instance_variable_set(:@count_flag, @count_flag)
|
|
256
|
+
builder.instance_variable_set(:@unscoped_flag, @unscoped_flag)
|
|
257
|
+
builder
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
module RESO
|
|
2
|
+
module API
|
|
3
|
+
class QueryConditions
|
|
4
|
+
class << self
|
|
5
|
+
def parse(args, negate: false)
|
|
6
|
+
case args
|
|
7
|
+
when Hash
|
|
8
|
+
from_hash(args, negate: negate)
|
|
9
|
+
when Array
|
|
10
|
+
from_array(args, negate: negate)
|
|
11
|
+
else
|
|
12
|
+
raise ArgumentError, "Unsupported where argument type: #{args.class}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def from_hash(hash, negate: false)
|
|
19
|
+
hash.map do |field, value|
|
|
20
|
+
fragment = case value
|
|
21
|
+
when Array
|
|
22
|
+
format_in(field, value)
|
|
23
|
+
when Range
|
|
24
|
+
format_range(field, value)
|
|
25
|
+
else
|
|
26
|
+
format_eq(field, value)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
negate ? negate_fragment(field, value, fragment) : fragment
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def from_array(array, negate: false)
|
|
34
|
+
template = array.first
|
|
35
|
+
bindings = array[1..]
|
|
36
|
+
|
|
37
|
+
if bindings.first.is_a?(Hash)
|
|
38
|
+
result = substitute_named_placeholders(template, bindings.first)
|
|
39
|
+
else
|
|
40
|
+
result = substitute_positional_placeholders(template, bindings)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
negate ? ["not (#{result})"] : [result]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def format_eq(field, value)
|
|
47
|
+
if value.is_a?(String)
|
|
48
|
+
leading = value.start_with?('%') && !value.start_with?('\%')
|
|
49
|
+
trailing = value.end_with?('%') && !value.end_with?('\%')
|
|
50
|
+
|
|
51
|
+
if leading || trailing
|
|
52
|
+
return format_string_match(field, value, leading, trailing)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Unescape literal \% at boundaries for exact match
|
|
56
|
+
if value.start_with?('\%') || value.end_with?('\%')
|
|
57
|
+
value = value.sub(/\A\\%/, '%').sub(/\\%\z/, '%')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
"#{field} eq #{QueryFormatter.format_value(value)}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_string_match(field, value, leading, trailing)
|
|
65
|
+
inner = value.dup
|
|
66
|
+
inner = inner[1..] if leading
|
|
67
|
+
inner = inner[0..-2] if trailing
|
|
68
|
+
|
|
69
|
+
# Unescape literal \% at boundaries
|
|
70
|
+
inner = inner.sub(/\A\\%/, '%')
|
|
71
|
+
inner = inner.sub(/\\%\z/, '%')
|
|
72
|
+
|
|
73
|
+
formatted = QueryFormatter.format_value(inner)
|
|
74
|
+
|
|
75
|
+
if leading && trailing
|
|
76
|
+
"contains(#{field},#{formatted})"
|
|
77
|
+
elsif leading
|
|
78
|
+
"endswith(#{field},#{formatted})"
|
|
79
|
+
else
|
|
80
|
+
"startswith(#{field},#{formatted})"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def format_in(field, values)
|
|
85
|
+
formatted = values.map { |v| QueryFormatter.format_value(v) }.join(',')
|
|
86
|
+
"#{field} in (#{formatted})"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def format_range(field, range)
|
|
90
|
+
parts = []
|
|
91
|
+
parts << "#{field} ge #{QueryFormatter.format_value(range.begin)}" if range.begin
|
|
92
|
+
if range.end
|
|
93
|
+
op = range.exclude_end? ? 'lt' : 'le'
|
|
94
|
+
parts << "#{field} #{op} #{QueryFormatter.format_value(range.end)}"
|
|
95
|
+
end
|
|
96
|
+
parts.join(' and ')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def negate_fragment(field, value, fragment)
|
|
100
|
+
case value
|
|
101
|
+
when Array
|
|
102
|
+
# Expand NOT IN to individual ne conditions
|
|
103
|
+
value.map { |v| "#{field} ne #{QueryFormatter.format_value(v)}" }.join(' and ')
|
|
104
|
+
when String
|
|
105
|
+
if fragment.start_with?('contains(', 'startswith(', 'endswith(')
|
|
106
|
+
"not #{fragment}"
|
|
107
|
+
else
|
|
108
|
+
"#{field} ne #{QueryFormatter.format_value(value)}"
|
|
109
|
+
end
|
|
110
|
+
else
|
|
111
|
+
"#{field} ne #{QueryFormatter.format_value(value)}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def substitute_positional_placeholders(template, values)
|
|
116
|
+
result = template.dup
|
|
117
|
+
values.each do |value|
|
|
118
|
+
result.sub!(QueryFormatter::OPERATOR_PATTERN) do |match|
|
|
119
|
+
op = match.strip
|
|
120
|
+
" #{QueryFormatter.translate_operator(op)} "
|
|
121
|
+
end
|
|
122
|
+
result.sub!('?', QueryFormatter.format_value(value))
|
|
123
|
+
end
|
|
124
|
+
result
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def substitute_named_placeholders(template, bindings)
|
|
128
|
+
result = template.dup
|
|
129
|
+
result.gsub!(QueryFormatter::OPERATOR_PATTERN) do |match|
|
|
130
|
+
op = match.strip
|
|
131
|
+
" #{QueryFormatter.translate_operator(op)} "
|
|
132
|
+
end
|
|
133
|
+
bindings.each do |name, value|
|
|
134
|
+
result.gsub!(":#{name}", QueryFormatter.format_value(value))
|
|
135
|
+
end
|
|
136
|
+
result
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module RESO
|
|
2
|
+
module API
|
|
3
|
+
class QueryFormatter
|
|
4
|
+
OPERATOR_MAP = {
|
|
5
|
+
'>' => 'gt',
|
|
6
|
+
'>=' => 'ge',
|
|
7
|
+
'<' => 'lt',
|
|
8
|
+
'<=' => 'le',
|
|
9
|
+
'=' => 'eq',
|
|
10
|
+
'!=' => 'ne'
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
OPERATOR_PATTERN = /\s*(>=|<=|!=|>|<|=)\s*/
|
|
14
|
+
|
|
15
|
+
def self.format_value(value)
|
|
16
|
+
case value
|
|
17
|
+
when String then "'#{value.gsub("'", "''")}'"
|
|
18
|
+
when Integer then value.to_s
|
|
19
|
+
when Float then value.to_s
|
|
20
|
+
when Time, DateTime then value.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
21
|
+
when Date then value.strftime('%Y-%m-%d')
|
|
22
|
+
when TrueClass, FalseClass then value.to_s
|
|
23
|
+
when NilClass then 'null'
|
|
24
|
+
else value.to_s
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.translate_operator(op)
|
|
29
|
+
OPERATOR_MAP[op.strip] || raise(ArgumentError, "Unknown operator: #{op}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module RESO
|
|
2
|
+
module API
|
|
3
|
+
class WhereChain
|
|
4
|
+
def initialize(query_builder)
|
|
5
|
+
@query_builder = query_builder
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def not(conditions)
|
|
9
|
+
fragments = QueryConditions.parse(conditions, negate: true)
|
|
10
|
+
fragments.each { |f| @query_builder.add_condition(f) }
|
|
11
|
+
@query_builder
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/reso_api/version.rb
CHANGED
data/lib/reso_api.rb
CHANGED
|
@@ -4,6 +4,10 @@ require "reso_api/version"
|
|
|
4
4
|
require "reso_api/config/inflections.rb"
|
|
5
5
|
require "reso_api/app/models/reso.rb"
|
|
6
6
|
require "reso_api/app/models/reso/api.rb"
|
|
7
|
+
require "reso_api/app/models/reso/api/query_formatter.rb"
|
|
8
|
+
require "reso_api/app/models/reso/api/query_conditions.rb"
|
|
9
|
+
require "reso_api/app/models/reso/api/where_chain.rb"
|
|
10
|
+
require "reso_api/app/models/reso/api/query_builder.rb"
|
|
7
11
|
require "reso_api/app/models/reso/api/client.rb"
|
|
8
12
|
|
|
9
13
|
module ResoApi
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: reso_api
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 2.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michael Edlund
|
|
@@ -114,6 +114,10 @@ files:
|
|
|
114
114
|
- lib/reso_api/app/models/reso.rb
|
|
115
115
|
- lib/reso_api/app/models/reso/api.rb
|
|
116
116
|
- lib/reso_api/app/models/reso/api/client.rb
|
|
117
|
+
- lib/reso_api/app/models/reso/api/query_builder.rb
|
|
118
|
+
- lib/reso_api/app/models/reso/api/query_conditions.rb
|
|
119
|
+
- lib/reso_api/app/models/reso/api/query_formatter.rb
|
|
120
|
+
- lib/reso_api/app/models/reso/api/where_chain.rb
|
|
117
121
|
- lib/reso_api/config/inflections.rb
|
|
118
122
|
- lib/reso_api/version.rb
|
|
119
123
|
- license.md
|