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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58fe7ec4d146147d271a0d45ac51c93c63a859657a2e490f13154cb2544d7c5c
4
- data.tar.gz: 61e76715b6167377f8ada506a1009a0d17ba4cf7de1137ef0292182b1e64315a
3
+ metadata.gz: d07a8c664cda837a170e4faa1f7e038d50bac47d7f7a8364f46d29255d06c1a8
4
+ data.tar.gz: a5ec6e603fe65f5c66e59032de39a4f44edfef1ea05d0ae4e92036d626e00e6e
5
5
  SHA512:
6
- metadata.gz: e7f18a11f9d0a9097c5a2283faa8143af934d9a9ddc2061573d36c29e9f5890874320923dc6a39a99e5d66e53160a17c2af8002c2a95c78b288310a39b66a9ec
7
- data.tar.gz: 985cf3cb78aff919b99cb8c23668bb58a8e95e6a9b5a4f33466480894285adeab861be59315e0d1154484fffafded483a221be5dd61a1eca45142d414ee02129
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.5.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
- bundler (>= 2.2.10)
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
- 2.3.7
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
- ### Listing Requests
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
- The simplest query is simply to call `properties` on the initialized client:
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].to_s
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
- return URI(endpoint).host ? URI(endpoint) : URI([base_url, endpoint].join)
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::decode(uri.request_uri) if params.dig(:$debug).present?
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
@@ -1,3 +1,3 @@
1
1
  module ResoApi
2
- VERSION = "1.8.13"
2
+ VERSION = "2.0.1"
3
3
  end
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: 1.8.13
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