wowsql-sdk 1.2.0 → 3.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.
@@ -1,6 +1,5 @@
1
1
  module WOWSQL
2
- # Base exception for WOWSQL SDK errors.
3
- class WOWSQLException < StandardError
2
+ class WOWSQLError < StandardError
4
3
  attr_reader :status_code, :response
5
4
 
6
5
  def initialize(message, status_code = nil, response = nil)
@@ -10,22 +9,24 @@ module WOWSQL
10
9
  end
11
10
  end
12
11
 
13
- # Storage-specific exception.
14
- class StorageException < WOWSQLException
15
- end
12
+ WOWSQLException = WOWSQLError
13
+
14
+ class StorageError < WOWSQLError; end
16
15
 
17
- # Exception raised when storage limit would be exceeded.
18
- class StorageLimitExceededException < StorageException
19
- def initialize(message, response = nil)
20
- super(message, 413, response)
16
+ class StorageLimitExceededError < StorageError
17
+ def initialize(message, status_code = 413, response = nil)
18
+ super(message, status_code, response)
21
19
  end
22
20
  end
23
21
 
24
- # Exception raised when operation requires service role key but anonymous key was used.
25
- class PermissionException < WOWSQLException
26
- def initialize(message)
27
- super(message, 403)
22
+ StorageException = StorageError
23
+ StorageLimitExceededException = StorageLimitExceededError
24
+
25
+ class SchemaPermissionError < WOWSQLError
26
+ def initialize(message, status_code = 403, response = nil)
27
+ super(message, status_code, response)
28
28
  end
29
29
  end
30
- end
31
30
 
31
+ PermissionException = SchemaPermissionError
32
+ end
@@ -4,187 +4,322 @@ module WOWSQL
4
4
  def initialize(client, table_name)
5
5
  @client = client
6
6
  @table_name = table_name
7
- @params = {}
8
- @select_columns = nil
9
- @filters = []
10
- @group_by = nil
11
- @having = []
12
- @order_items = nil
7
+ @options = {}
13
8
  end
14
9
 
10
+ # Select specific columns or expressions.
11
+ #
12
+ # @param columns [Array<String>] Column names or expressions
13
+ # @return [QueryBuilder] self for chaining
15
14
  def select(*columns)
16
- @select_columns = columns
15
+ if columns.length == 1 && columns[0].is_a?(Array)
16
+ @options['select'] = columns[0]
17
+ else
18
+ @options['select'] = columns.length > 1 ? columns.map(&:to_s) : (columns.first || '*')
19
+ end
17
20
  self
18
21
  end
19
22
 
20
- def eq(column, value)
21
- add_filter(column, 'eq', value)
23
+ # Add a filter condition.
24
+ #
25
+ # @param column [String, Hash] Column name or filter hash
26
+ # @param operator [String, nil] Operator (eq, neq, gt, gte, lt, lte, like, in, not_in, between, not_between, is, is_not)
27
+ # @param value [Object] Filter value
28
+ # @param logical_op [String] "AND" or "OR"
29
+ # @return [QueryBuilder] self for chaining
30
+ def filter(column, operator = nil, value = nil, logical_op: 'AND')
31
+ @options['filter'] ||= []
32
+
33
+ if column.is_a?(Hash)
34
+ if column.key?('column') || column.key?(:column)
35
+ col = column['column'] || column[:column]
36
+ op = column['operator'] || column[:operator]
37
+ val = column['value'] || column[:value]
38
+ lo = column['logical_op'] || column[:logical_op] || logical_op
39
+ @options['filter'] << { column: col, operator: op, value: val, logical_op: lo }
40
+ else
41
+ column.each do |col_name, col_value|
42
+ @options['filter'] << { column: col_name.to_s, operator: 'eq', value: col_value, logical_op: logical_op }
43
+ end
44
+ end
45
+ else
46
+ raise ArgumentError, "filter() missing required argument: 'operator'" if operator.nil?
47
+ @options['filter'] << { column: column, operator: operator, value: value, logical_op: logical_op }
48
+ end
49
+
22
50
  self
23
51
  end
24
52
 
53
+ def eq(column, value)
54
+ filter(column, 'eq', value)
55
+ end
56
+
25
57
  def neq(column, value)
26
- add_filter(column, 'neq', value)
27
- self
58
+ filter(column, 'neq', value)
28
59
  end
29
60
 
30
61
  def gt(column, value)
31
- add_filter(column, 'gt', value)
32
- self
62
+ filter(column, 'gt', value)
33
63
  end
34
64
 
35
65
  def gte(column, value)
36
- add_filter(column, 'gte', value)
37
- self
66
+ filter(column, 'gte', value)
38
67
  end
39
68
 
40
69
  def lt(column, value)
41
- add_filter(column, 'lt', value)
42
- self
70
+ filter(column, 'lt', value)
43
71
  end
44
72
 
45
73
  def lte(column, value)
46
- add_filter(column, 'lte', value)
47
- self
74
+ filter(column, 'lte', value)
48
75
  end
49
76
 
50
77
  def like(column, pattern)
51
- add_filter(column, 'like', pattern)
52
- self
78
+ filter(column, 'like', pattern)
53
79
  end
54
80
 
55
81
  def is_null(column)
56
- @filters << { column: column, operator: 'is', value: nil, logical_op: 'AND' }
57
- self
82
+ filter(column, 'is', nil)
58
83
  end
59
84
 
60
- def in(column, values)
61
- @filters << { column: column, operator: 'in', value: values, logical_op: 'AND' }
62
- self
85
+ def is_not_null(column)
86
+ filter(column, 'is_not', nil)
87
+ end
88
+
89
+ # Filter where column is in list of values.
90
+ def in_list(column, values)
91
+ filter(column, 'in', values)
63
92
  end
64
93
 
65
94
  def not_in(column, values)
66
- @filters << { column: column, operator: 'not_in', value: values, logical_op: 'AND' }
67
- self
95
+ filter(column, 'not_in', values)
68
96
  end
69
97
 
70
- def between(column, min, max)
71
- @filters << { column: column, operator: 'between', value: [min, max], logical_op: 'AND' }
72
- self
98
+ def between(column, min_val, max_val)
99
+ filter(column, 'between', [min_val, max_val])
73
100
  end
74
101
 
75
- def not_between(column, min, max)
76
- @filters << { column: column, operator: 'not_between', value: [min, max], logical_op: 'AND' }
77
- self
102
+ def not_between(column, min_val, max_val)
103
+ filter(column, 'not_between', [min_val, max_val])
78
104
  end
79
105
 
80
- def or(column, operator, value)
81
- @filters << { column: column, operator: operator, value: value, logical_op: 'OR' }
82
- self
106
+ # Add an OR filter condition.
107
+ def or_filter(column, operator, value)
108
+ filter(column, operator, value, logical_op: 'OR')
83
109
  end
84
110
 
85
- def group_by(*columns)
86
- @group_by = columns
111
+ # Order results by column(s).
112
+ #
113
+ # @param column [String, Array] Column name or array of order items
114
+ # @param direction [String, nil] 'asc' or 'desc'
115
+ # @return [QueryBuilder] self for chaining
116
+ def order_by(column, direction = nil)
117
+ if column.is_a?(String)
118
+ @options['order'] = column
119
+ @options['order_direction'] = direction if direction
120
+ else
121
+ order_items = column.map do |item|
122
+ if item.is_a?(Hash)
123
+ item
124
+ elsif item.is_a?(Array) && item.length == 2
125
+ { column: item[0], direction: item[1] }
126
+ end
127
+ end.compact
128
+ @options['order'] = order_items
129
+ end
87
130
  self
88
131
  end
89
132
 
90
- def having(column, operator, value)
91
- @having << { column: column, operator: operator, value: value }
92
- self
133
+ # Order results by a single column (alias for order_by).
134
+ def order(column, direction = 'asc')
135
+ order_by(column, direction)
93
136
  end
94
137
 
95
- def order_by_multiple(*items)
96
- @order_items = items
138
+ # Limit number of results.
139
+ def limit(n)
140
+ @options['limit'] = n
97
141
  self
98
142
  end
99
143
 
100
- def order_by(column, desc: false)
101
- @params['order'] = column
102
- @params['order_direction'] = desc ? 'desc' : 'asc'
144
+ # Skip records (pagination).
145
+ def offset(n)
146
+ @options['offset'] = n
103
147
  self
104
148
  end
105
149
 
106
- def limit(limit)
107
- @params['limit'] = limit.to_s
150
+ # Group results by column(s).
151
+ #
152
+ # @param columns [String, Array<String>] Column(s) to group by
153
+ # @return [QueryBuilder] self for chaining
154
+ def group_by(*columns)
155
+ if columns.length == 1 && columns[0].is_a?(Array)
156
+ @options['group_by'] = columns[0]
157
+ elsif columns.length == 1 && columns[0].is_a?(String)
158
+ @options['group_by'] = [columns[0]]
159
+ else
160
+ @options['group_by'] = columns.map(&:to_s)
161
+ end
108
162
  self
109
163
  end
110
164
 
111
- def offset(offset)
112
- @params['offset'] = offset.to_s
165
+ # Add a HAVING clause filter.
166
+ #
167
+ # @param column [String] Column or aggregate expression
168
+ # @param operator [String] Operator (eq, neq, gt, gte, lt, lte)
169
+ # @param value [Object] Filter value
170
+ # @return [QueryBuilder] self for chaining
171
+ def having(column, operator, value)
172
+ @options['having'] ||= []
173
+ @options['having'] << { column: column, operator: operator, value: value }
113
174
  self
114
175
  end
115
176
 
116
- def get
117
- # Check if we need POST endpoint (advanced features)
118
- has_advanced_features =
119
- (@group_by && !@group_by.empty?) ||
120
- !@having.empty? ||
121
- (@order_items && !@order_items.empty?) ||
122
- @filters.any? { |f| ['in', 'not_in', 'between', 'not_between'].include?(f[:operator]) }
177
+ # Execute the query.
178
+ #
179
+ # @return [Hash] Query response with data and metadata
180
+ def get(additional_options = nil)
181
+ final_options = @options.dup
182
+ final_options.merge!(additional_options) if additional_options
123
183
 
124
- if has_advanced_features
125
- # Use POST endpoint for advanced queries
126
- body = build_query_body
127
- @client.request('POST', "/#{@table_name}/query", nil, body)
128
- else
129
- # Use GET endpoint for simple queries (backward compatibility)
130
- build_get_params
131
- @client.request('GET', "/#{@table_name}", @params, nil)
132
- end
133
- end
134
-
135
- def first
136
- result = limit(1).get
137
- result['data']&.first
138
- end
184
+ body = {}
139
185
 
140
- private
186
+ if final_options['select']
187
+ sel = final_options['select']
188
+ body['select'] = sel.is_a?(String) ? sel.split(',').map(&:strip) : Array(sel)
189
+ end
141
190
 
142
- def add_filter(column, op, value)
143
- @filters << { column: column, operator: op, value: value, logical_op: 'AND' }
144
- end
191
+ if final_options['filter']
192
+ filters = final_options['filter']
193
+ body['filters'] = filters.is_a?(Array) ? filters : [filters]
194
+ end
145
195
 
146
- def build_query_body
147
- body = {}
196
+ if final_options['group_by']
197
+ gb = final_options['group_by']
198
+ body['group_by'] = gb.is_a?(String) ? gb.split(',').map(&:strip) : Array(gb)
199
+ end
148
200
 
149
- body['select'] = @select_columns if @select_columns && !@select_columns.empty?
201
+ if final_options['having']
202
+ hv = final_options['having']
203
+ body['having'] = hv.is_a?(Array) ? hv : [hv]
204
+ end
150
205
 
151
- body['filters'] = @filters if !@filters.empty?
206
+ if final_options['order']
207
+ ord = final_options['order']
208
+ if ord.is_a?(String)
209
+ body['order_by'] = ord
210
+ body['order_direction'] = final_options['order_direction'] || 'asc'
211
+ else
212
+ body['order_by'] = ord
213
+ end
214
+ end
152
215
 
153
- body['group_by'] = @group_by if @group_by && !@group_by.empty?
216
+ body['limit'] = final_options['limit'] if final_options['limit']
217
+ body['offset'] = final_options['offset'] if final_options['offset']
154
218
 
155
- body['having'] = @having if !@having.empty?
219
+ has_advanced = body.key?('group_by') ||
220
+ body.key?('having') ||
221
+ body['order_by'].is_a?(Array) ||
222
+ (body['filters'] || []).any? { |f| %w[in not_in between not_between].include?(f[:operator]) }
156
223
 
157
- if @order_items && !@order_items.empty?
158
- body['order_by'] = @order_items.map do |item|
159
- if item.is_a?(Hash)
160
- item
161
- else
162
- { column: item[0], direction: item[1] }
224
+ if has_advanced
225
+ @client.request('POST', "/#{@table_name}/query", nil, body)
226
+ else
227
+ params = {}
228
+ if body['select']
229
+ params['select'] = body['select'].is_a?(Array) ? body['select'].join(',') : body['select']
230
+ end
231
+ if body['filters']
232
+ filter_strs = body['filters'].map do |f|
233
+ if f[:value].is_a?(Array)
234
+ return @client.request('POST', "/#{@table_name}/query", nil, body)
235
+ end
236
+ "#{f[:column]}.#{f[:operator]}.#{f[:value]}"
163
237
  end
238
+ params['filter'] = filter_strs.join(',')
164
239
  end
165
- elsif @params['order']
166
- body['order_by'] = @params['order']
167
- body['order_direction'] = @params['order_direction'] || 'asc'
240
+ if body['order_by'].is_a?(String)
241
+ params['order'] = body['order_by']
242
+ params['order_direction'] = body['order_direction'] || 'asc'
243
+ end
244
+ params['limit'] = body['limit'] if body['limit']
245
+ params['offset'] = body['offset'] if body['offset']
246
+ @client.request('GET', "/#{@table_name}", params, nil)
168
247
  end
248
+ end
169
249
 
170
- body['limit'] = @params['limit'].to_i if @params['limit']
171
- body['offset'] = @params['offset'].to_i if @params['offset']
250
+ # Execute the query (alias for get).
251
+ def execute
252
+ get
253
+ end
172
254
 
173
- body
255
+ # Get first record matching query.
256
+ #
257
+ # @return [Hash, nil] First record or nil
258
+ def first
259
+ result = limit(1).get
260
+ data = result['data']
261
+ data && !data.empty? ? data[0] : nil
174
262
  end
175
263
 
176
- def build_get_params
177
- @params['select'] = @select_columns.join(',') if @select_columns && !@select_columns.empty?
264
+ # Get exactly one record. Raises if zero or more than one found.
265
+ #
266
+ # @return [Hash] The single matching record
267
+ # @raise [WOWSQLError] If zero or more than one record found
268
+ def single
269
+ result = limit(2).get
270
+ data = result['data'] || []
271
+ raise WOWSQLError.new('No records found') if data.empty?
272
+ raise WOWSQLError.new('Multiple records found, expected exactly one') if data.length > 1
273
+ data[0]
274
+ end
178
275
 
179
- simple_filters = @filters.reject { |f| ['in', 'not_in', 'between', 'not_between'].include?(f[:operator]) }
180
- if !simple_filters.empty?
181
- filter_strings = simple_filters.map do |f|
182
- value_str = f[:value] || 'null'
183
- "#{f[:column]}.#{f[:operator]}.#{value_str}"
276
+ # Get the total count of records matching the current filters.
277
+ #
278
+ # @return [Integer] Number of matching records
279
+ def count
280
+ saved_select = @options['select']
281
+ saved_group = @options.delete('group_by')
282
+ saved_having = @options.delete('having')
283
+ saved_order = @options.delete('order')
284
+ saved_dir = @options.delete('order_direction')
285
+
286
+ @options['select'] = ['COUNT(*) as count']
287
+
288
+ begin
289
+ result = get
290
+ ensure
291
+ if saved_select
292
+ @options['select'] = saved_select
293
+ else
294
+ @options.delete('select')
184
295
  end
185
- @params['filter'] = filter_strings.join(',')
296
+ @options['group_by'] = saved_group if saved_group
297
+ @options['having'] = saved_having if saved_having
298
+ @options['order'] = saved_order if saved_order
299
+ @options['order_direction'] = saved_dir if saved_dir
186
300
  end
301
+
302
+ data = result['data']
303
+ data && !data.empty? ? data[0]['count'].to_i : 0
304
+ end
305
+
306
+ # Paginate results with page-based interface.
307
+ #
308
+ # @param page [Integer] Page number (1-indexed)
309
+ # @param per_page [Integer] Records per page
310
+ # @return [Hash] Hash with data, page, per_page, total, total_pages
311
+ def paginate(page: 1, per_page: 20)
312
+ offset_val = ([page, 1].max - 1) * per_page
313
+ result = limit(per_page).offset(offset_val).get
314
+ total = result['total'] || result['count'] || 0
315
+ total_pages = total > 0 ? (total + per_page - 1) / per_page : 0
316
+ {
317
+ 'data' => result['data'],
318
+ 'page' => page,
319
+ 'per_page' => per_page,
320
+ 'total' => total,
321
+ 'total_pages' => total_pages
322
+ }
187
323
  end
188
324
  end
189
325
  end
190
-