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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e3cb2e251bb0012fb425337a94a97c8015cc478687ac72f8d7915598c75eed53
4
- data.tar.gz: 8670d59a706aa895795e8ff03d180dd2ddec49c5d882b8ed46eafa7c35f7af5a
3
+ metadata.gz: 3a24486d919c84b2bd716ce37c8b37ee62fca566624f3434acb475dfd2de462b
4
+ data.tar.gz: 0e0cdc536424944953720bfe4947b99dc2333c672aab55c6d4cb42908785e7a7
5
5
  SHA512:
6
- metadata.gz: 24bf2687c88bd99184ee1e341a3378c19e91385ad025e8dd9a46d35d3e6d8593bf32125a4d9ab06be2a789bd34c894aeeafbab524547b05156c2d8f1267b24dd
7
- data.tar.gz: e3ad4b195f9b690d2b40faf18c4fdefbf747eb9ed4f9fc8cf083a152d3a345512a21212a4eb67c8750e08ddbef1364f7df435c6dcf64506b7bf70fe1c21384c0
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.5.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
- 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
@@ -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
- ### Listing Requests
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
- The simplest query is simply to call `properties` on the initialized client:
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::decode(uri.request_uri) if params.dig(:$debug).present?
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
@@ -1,3 +1,3 @@
1
1
  module ResoApi
2
- VERSION = "1.8.12"
2
+ VERSION = "2.0.0"
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.12
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