reso_api 1.8.12 → 2.0.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 +4 -4
- data/Gemfile +8 -0
- data/Gemfile.lock +17 -3
- data/README.md +175 -3
- data/lib/reso_api/app/models/reso/api/client.rb +17 -1
- 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: 3a24486d919c84b2bd716ce37c8b37ee62fca566624f3434acb475dfd2de462b
|
|
4
|
+
data.tar.gz: 0e0cdc536424944953720bfe4947b99dc2333c672aab55c6d4cb42908785e7a7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56c4522468e00c7891a244ae8efa9de68ea5b7575d72a61c373bd01e616c17a3cac7de8f84912cbd349e1bcb07e397b08c05db81ae34c6136fd07e3156c60385
|
|
7
|
+
data.tar.gz: 32c65364cce709339eaabe46a0e165efdfcbc54d7cca45d2cdd99e858af63df1b69adc5813c63fc4980ceb647821648186fcb41df20f8f0465313fc006a656da
|
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.0)
|
|
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
|
@@ -94,19 +94,191 @@ The response will be an EDMX xml schema document that matches the [RESO Data Dic
|
|
|
94
94
|
|
|
95
95
|
[RESO Data Dictionary standard]: https://www.reso.org/data-dictionary/
|
|
96
96
|
|
|
97
|
-
###
|
|
97
|
+
### Query Builder (ActiveRecord-style)
|
|
98
|
+
|
|
99
|
+
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).
|
|
100
|
+
|
|
101
|
+
For properties, a default scope of `StandardStatus in ('Active','Pending')` is automatically applied unless you specify `StandardStatus` yourself or call `.unscoped`.
|
|
102
|
+
|
|
103
|
+
#### Basic queries
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# Returns array of listing hashes (default scope: Active + Pending)
|
|
107
|
+
listings = client.properties.where(City: "Seattle")
|
|
108
|
+
|
|
109
|
+
# Chained conditions (joined with 'and')
|
|
110
|
+
listings = client.properties
|
|
111
|
+
.where(City: "Seattle")
|
|
112
|
+
.where(["ListPrice >= ?", 500_000])
|
|
113
|
+
|
|
114
|
+
# Single record lookup
|
|
115
|
+
listing = client.properties.find_by(ListingId: "123456")
|
|
116
|
+
|
|
117
|
+
# Lookup by primary key (uses detail endpoint)
|
|
118
|
+
listing = client.properties.find("3yd-BINDER-5508272")
|
|
119
|
+
|
|
120
|
+
# First record
|
|
121
|
+
listing = client.properties.where(City: "Seattle").first
|
|
122
|
+
|
|
123
|
+
# Count
|
|
124
|
+
total = client.properties.where(City: "Seattle").count
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
#### Where conditions
|
|
128
|
+
|
|
129
|
+
**Hash conditions** — equality, IN, ranges, and string matching:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
.where(City: "Seattle") # City eq 'Seattle'
|
|
133
|
+
.where(StandardStatus: ['Active', 'Pending']) # StandardStatus in ('Active','Pending')
|
|
134
|
+
.where(ListPrice: 300_000..500_000) # ListPrice ge 300000 and ListPrice le 500000
|
|
135
|
+
.where(ListPrice: 300_000..) # ListPrice ge 300000
|
|
136
|
+
.where(ListPrice: ..500_000) # ListPrice le 500000
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
**String matching** — use `%` wildcards like SQL `LIKE`:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
.where(ListOfficeName: "Slifer%") # startswith(ListOfficeName,'Slifer')
|
|
143
|
+
.where(ListOfficeName: "%Frampton") # endswith(ListOfficeName,'Frampton')
|
|
144
|
+
.where(ListOfficeName: "%Smith%") # contains(ListOfficeName,'Smith')
|
|
145
|
+
.where(ListOfficeName: "Slifer Smith & Frampton") # exact match (no %)
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
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 `\%`.
|
|
149
|
+
|
|
150
|
+
**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.
|
|
151
|
+
|
|
152
|
+
**Array conditions** — comparison operators with `?` placeholders:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
.where(["CloseDate > ?", 1.week.ago]) # CloseDate gt 2026-02-04T00:00:00Z
|
|
156
|
+
.where(["ListPrice >= ?", 500_000]) # ListPrice ge 500000
|
|
157
|
+
.where(["ListPrice >= ? and ListPrice <= ?", 300_000, 500_000])
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Supported operators: `>`, `>=`, `<`, `<=`, `=`, `!=`
|
|
161
|
+
|
|
162
|
+
**Named placeholders:**
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
.where(["ListPrice >= :min and ListPrice <= :max", min: 300_000, max: 500_000])
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**Negation** — call `.where` with no arguments, then `.not`:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
.where.not(City: "Seattle") # City ne 'Seattle'
|
|
172
|
+
.where.not(StandardStatus: ['Closed', 'Expired']) # StandardStatus ne 'Closed' and ...
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
#### Value formatting
|
|
176
|
+
|
|
177
|
+
Ruby values are automatically formatted for OData:
|
|
178
|
+
|
|
179
|
+
| Ruby Type | OData Output |
|
|
180
|
+
|--------------------|------------------------------------|
|
|
181
|
+
| `String` | `'Seattle'` (single-quoted) |
|
|
182
|
+
| `Integer`, `Float` | `500000` (bare) |
|
|
183
|
+
| `Time`, `DateTime` | `2026-02-04T12:00:00Z` (ISO 8601) |
|
|
184
|
+
| `Date` | `2026-02-04` |
|
|
185
|
+
| `true` / `false` | `true` / `false` |
|
|
186
|
+
| `nil` | `null` |
|
|
187
|
+
|
|
188
|
+
#### Select, order, limit, offset
|
|
98
189
|
|
|
99
|
-
|
|
190
|
+
```ruby
|
|
191
|
+
client.properties
|
|
192
|
+
.where(City: "Seattle")
|
|
193
|
+
.select(:ListingKey, :City, :ListPrice) # $select
|
|
194
|
+
.order(ListPrice: :desc) # $orderby
|
|
195
|
+
.limit(25) # $top
|
|
196
|
+
.offset(50) # $skip
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
#### Includes (eager loading)
|
|
200
|
+
|
|
201
|
+
`.includes` maps to OData `$expand` for joining related resources:
|
|
100
202
|
|
|
101
203
|
```ruby
|
|
102
204
|
client.properties
|
|
205
|
+
.where(City: "Seattle")
|
|
206
|
+
.includes(:Media, :OpenHouses)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
#### Default scope and unscoped
|
|
210
|
+
|
|
211
|
+
Properties automatically filter to `StandardStatus in ('Active','Pending')`:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
# Default applied
|
|
215
|
+
client.properties.where(City: "Seattle")
|
|
216
|
+
# → StandardStatus in ('Active','Pending') and City eq 'Seattle'
|
|
217
|
+
|
|
218
|
+
# Overridden when you specify StandardStatus
|
|
219
|
+
client.properties.where(StandardStatus: 'Closed')
|
|
220
|
+
# → StandardStatus eq 'Closed'
|
|
221
|
+
|
|
222
|
+
# Explicitly removed
|
|
223
|
+
client.properties.unscoped.where(City: "Seattle")
|
|
224
|
+
# → City eq 'Seattle'
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
No default scope is applied for members, offices, open_houses, or media.
|
|
228
|
+
|
|
229
|
+
#### Iteration and execution
|
|
230
|
+
|
|
231
|
+
Queries execute lazily — the API call fires when you access the results:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
listings = client.properties.where(City: "Seattle")
|
|
235
|
+
listings.each { |l| puts l["ListPrice"] } # triggers API call
|
|
236
|
+
listings.length # uses cached results
|
|
237
|
+
listings[0] # uses cached results
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
For large datasets, `.each` and `.find_each` auto-paginate through `@odata.nextLink`:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
client.properties.where(["ModificationTimestamp > ?", 1.day.ago]).each do |listing|
|
|
244
|
+
process(listing)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
client.properties.find_each(batch_size: 200) do |listing|
|
|
248
|
+
process(listing)
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
#### Immutability
|
|
253
|
+
|
|
254
|
+
Each chainable method returns a new builder, so you can safely branch queries:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
base = client.properties.where(City: "Seattle")
|
|
258
|
+
active = base.where(StandardStatus: "Active")
|
|
259
|
+
closed = base.where(StandardStatus: "Closed")
|
|
260
|
+
# base, active, and closed are independent queries
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
### OData Queries (Direct)
|
|
266
|
+
|
|
267
|
+
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).
|
|
268
|
+
|
|
269
|
+
### Listing Requests
|
|
270
|
+
|
|
271
|
+
The simplest query is simply to call `properties` on the initialized client:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
client.properties(filter: "StandardStatus eq 'Active'")
|
|
103
275
|
```
|
|
104
276
|
|
|
105
277
|
The API will return a JSON response with an array of listing JSON objects.
|
|
106
278
|
|
|
107
279
|
### Getting a single listing
|
|
108
280
|
|
|
109
|
-
You can look up a single listing by sending a query with the listing's unique id (ListingKey).
|
|
281
|
+
You can look up a single listing by sending a query with the listing's unique id (ListingKey).
|
|
110
282
|
|
|
111
283
|
```ruby
|
|
112
284
|
client.property('3yd-BINDER-5508272')
|
|
@@ -9,6 +9,7 @@ module RESO
|
|
|
9
9
|
require 'tmpdir'
|
|
10
10
|
|
|
11
11
|
attr_accessor :access_token, :client_id, :client_secret, :auth_url, :base_url, :scope, :osn
|
|
12
|
+
attr_reader :last_request_url
|
|
12
13
|
|
|
13
14
|
def initialize(**opts)
|
|
14
15
|
@access_token, @client_id, @client_secret, @auth_url, @base_url, @scope, @osn = opts.values_at(:access_token, :client_id, :client_secret, :auth_url, :base_url, :scope, :osn)
|
|
@@ -56,6 +57,11 @@ module RESO
|
|
|
56
57
|
|
|
57
58
|
FILTERABLE_ENDPOINTS.keys.each do |method_name|
|
|
58
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
|
+
|
|
59
65
|
hash = args.first.is_a?(Hash) ? args.first : {}
|
|
60
66
|
|
|
61
67
|
filter = hash[:filter].to_s
|
|
@@ -236,9 +242,12 @@ module RESO
|
|
|
236
242
|
if params.present?
|
|
237
243
|
query = params.present? ? URI.encode_www_form(params).gsub("+", " ") : ""
|
|
238
244
|
uri.query && uri.query.length > 0 ? uri.query += '&' + query : uri.query = query
|
|
239
|
-
return URI::
|
|
245
|
+
return URI::DEFAULT_PARSER.unescape(uri.request_uri) if params.dig(:$debug).present?
|
|
240
246
|
end
|
|
241
247
|
|
|
248
|
+
# Store the full request URL for debugging
|
|
249
|
+
@last_request_url = uri.to_s
|
|
250
|
+
|
|
242
251
|
begin
|
|
243
252
|
req = Net::HTTP::Get.new(uri.request_uri)
|
|
244
253
|
req['Authorization'] = "Bearer #{auth_token}"
|
|
@@ -282,6 +291,13 @@ module RESO
|
|
|
282
291
|
raise
|
|
283
292
|
end
|
|
284
293
|
end
|
|
294
|
+
|
|
295
|
+
# Add metadata to response hash (if response is a hash)
|
|
296
|
+
if response.is_a?(Hash)
|
|
297
|
+
response['@reso_request_url'] = @last_request_url
|
|
298
|
+
response['@reso_auth_scope'] = scope
|
|
299
|
+
end
|
|
300
|
+
|
|
285
301
|
return response
|
|
286
302
|
end
|
|
287
303
|
|
|
@@ -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.0
|
|
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
|