wowsql-sdk 1.3.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.
data/lib/wowsql/client.rb CHANGED
@@ -5,27 +5,54 @@ require_relative 'table'
5
5
 
6
6
  module WOWSQL
7
7
  # WOWSQL client for interacting with your database via REST API.
8
- #
8
+ #
9
9
  # This client is used for DATABASE OPERATIONS (CRUD on tables).
10
10
  # Use Service Role Key or Anonymous Key for authentication.
11
+ #
12
+ # Key Types:
13
+ # - Service Role Key: Full access to all database operations (server-side)
14
+ # - Anonymous Key: Public access with limited permissions (client-side)
15
+ #
16
+ # @example
17
+ # client = WOWSQL::WOWSQLClient.new(
18
+ # "myproject",
19
+ # "wowbase_service_..."
20
+ # )
21
+ # users = client.table("users").get
11
22
  class WOWSQLClient
12
- def initialize(project_url, api_key, timeout = 30)
23
+ attr_reader :api_url, :api_key, :timeout, :verify_ssl
24
+
25
+ # @param project_url [String] Project subdomain or full URL
26
+ # @param api_key [String] API key for database operations
27
+ # @param base_domain [String] Base domain (default: "wowsql.com")
28
+ # @param secure [Boolean] Use HTTPS (default: true)
29
+ # @param timeout [Integer] Request timeout in seconds (default: 30)
30
+ # @param verify_ssl [Boolean] Verify SSL certificates (default: true)
31
+ def initialize(project_url, api_key, base_domain: 'wowsql.com', secure: true, timeout: 30, verify_ssl: true)
13
32
  @api_key = api_key
14
33
  @timeout = timeout
34
+ @verify_ssl = verify_ssl
15
35
 
16
- # Build API URL
17
36
  if project_url.start_with?('http://') || project_url.start_with?('https://')
18
37
  base_url = project_url.chomp('/')
19
38
  if base_url.include?('/api')
20
- @api_url = base_url.sub('/api', '') + '/api/v2'
39
+ base_url = base_url.split('/api').first
40
+ @api_url = "#{base_url}/api/v2"
21
41
  else
22
- @api_url = base_url + '/api/v2'
42
+ @api_url = "#{base_url}/api/v2"
23
43
  end
24
44
  else
25
- @api_url = "https://#{project_url}.wowsql.com/api/v2"
45
+ protocol = secure ? 'https' : 'http'
46
+ if project_url.include?(".#{base_domain}") || project_url.end_with?(base_domain)
47
+ @api_url = "#{protocol}://#{project_url}/api/v2"
48
+ else
49
+ @api_url = "#{protocol}://#{project_url}.#{base_domain}/api/v2"
50
+ end
26
51
  end
27
52
 
28
- @conn = Faraday.new(url: @api_url) do |f|
53
+ ssl_options = verify_ssl ? {} : { verify: false }
54
+
55
+ @conn = Faraday.new(url: @api_url, ssl: ssl_options) do |f|
29
56
  f.request :json
30
57
  f.response :json
31
58
  f.adapter Faraday.default_adapter
@@ -37,7 +64,7 @@ module WOWSQL
37
64
  end
38
65
 
39
66
  # Get a table interface for fluent API.
40
- #
67
+ #
41
68
  # @param table_name [String] Name of the table
42
69
  # @return [Table] Table instance for the specified table
43
70
  def table(table_name)
@@ -45,7 +72,7 @@ module WOWSQL
45
72
  end
46
73
 
47
74
  # List all tables in the database.
48
- #
75
+ #
49
76
  # @return [Array<String>] List of table names
50
77
  def list_tables
51
78
  response = request('GET', '/tables', nil, nil)
@@ -53,32 +80,34 @@ module WOWSQL
53
80
  end
54
81
 
55
82
  # Get table schema information.
56
- #
83
+ #
57
84
  # @param table_name [String] Name of the table
58
85
  # @return [Hash] Table schema with columns and primary key
59
86
  def get_table_schema(table_name)
60
87
  request('GET', "/tables/#{table_name}/schema", nil, nil)
61
88
  end
62
89
 
90
+ # Close the HTTP connection.
91
+ def close
92
+ @conn.close if @conn.respond_to?(:close)
93
+ end
94
+
63
95
  # Make HTTP request to API.
64
96
  def request(method, path, params = nil, json = nil)
65
- begin
66
- response = @conn.public_send(method.downcase, path) do |req|
67
- req.params = params if params
68
- req.body = json if json
69
- end
70
-
71
- if response.status >= 400
72
- error_data = response.body.is_a?(Hash) ? response.body : {}
73
- error_msg = error_data['detail'] || error_data['message'] || "Request failed with status #{response.status}"
74
- raise WOWSQLException.new(error_msg, response.status, error_data)
75
- end
97
+ response = @conn.public_send(method.downcase, path) do |req|
98
+ req.params = params if params
99
+ req.body = json if json
100
+ end
76
101
 
77
- response.body
78
- rescue Faraday::Error => e
79
- raise WOWSQLException.new("Request failed: #{e.message}")
102
+ if response.status >= 400
103
+ error_data = response.body.is_a?(Hash) ? response.body : {}
104
+ error_msg = error_data['detail'] || error_data['message'] || "Request failed with status #{response.status}"
105
+ raise WOWSQLError.new(error_msg, response.status, error_data)
80
106
  end
107
+
108
+ response.body
109
+ rescue Faraday::Error => e
110
+ raise WOWSQLError.new("Request failed: #{e.message}")
81
111
  end
82
112
  end
83
113
  end
84
-
@@ -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,152 +4,246 @@ 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
- # Add a filter condition (generic method).
21
- #
22
- # @param column [String] Column name
23
- # @param operator [String] Operator (eq, neq, gt, gte, lt, lte, like, in, not_in, between, not_between, is, is_not)
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)
24
27
  # @param value [Object] Filter value
25
- # @param logical_op [String] Logical operator for combining with previous filters ("AND" or "OR")
26
- def filter(column, operator, value, logical_op = 'AND')
27
- add_filter(column, operator, value, logical_op)
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
+
28
50
  self
29
51
  end
30
52
 
31
53
  def eq(column, value)
32
- add_filter(column, 'eq', value)
33
- self
54
+ filter(column, 'eq', value)
34
55
  end
35
56
 
36
57
  def neq(column, value)
37
- add_filter(column, 'neq', value)
38
- self
58
+ filter(column, 'neq', value)
39
59
  end
40
60
 
41
61
  def gt(column, value)
42
- add_filter(column, 'gt', value)
43
- self
62
+ filter(column, 'gt', value)
44
63
  end
45
64
 
46
65
  def gte(column, value)
47
- add_filter(column, 'gte', value)
48
- self
66
+ filter(column, 'gte', value)
49
67
  end
50
68
 
51
69
  def lt(column, value)
52
- add_filter(column, 'lt', value)
53
- self
70
+ filter(column, 'lt', value)
54
71
  end
55
72
 
56
73
  def lte(column, value)
57
- add_filter(column, 'lte', value)
58
- self
74
+ filter(column, 'lte', value)
59
75
  end
60
76
 
61
77
  def like(column, pattern)
62
- add_filter(column, 'like', pattern)
63
- self
78
+ filter(column, 'like', pattern)
64
79
  end
65
80
 
66
81
  def is_null(column)
67
- @filters << { column: column, operator: 'is', value: nil, logical_op: 'AND' }
68
- self
82
+ filter(column, 'is', nil)
69
83
  end
70
84
 
71
85
  def is_not_null(column)
72
- @filters << { column: column, operator: 'is_not', value: nil, logical_op: 'AND' }
73
- self
86
+ filter(column, 'is_not', nil)
74
87
  end
75
88
 
76
- def in(column, values)
77
- @filters << { column: column, operator: 'in', value: values, logical_op: 'AND' }
78
- self
89
+ # Filter where column is in list of values.
90
+ def in_list(column, values)
91
+ filter(column, 'in', values)
79
92
  end
80
93
 
81
94
  def not_in(column, values)
82
- @filters << { column: column, operator: 'not_in', value: values, logical_op: 'AND' }
83
- self
95
+ filter(column, 'not_in', values)
84
96
  end
85
97
 
86
- def between(column, min, max)
87
- @filters << { column: column, operator: 'between', value: [min, max], logical_op: 'AND' }
88
- self
98
+ def between(column, min_val, max_val)
99
+ filter(column, 'between', [min_val, max_val])
89
100
  end
90
101
 
91
- def not_between(column, min, max)
92
- @filters << { column: column, operator: 'not_between', value: [min, max], logical_op: 'AND' }
93
- self
102
+ def not_between(column, min_val, max_val)
103
+ filter(column, 'not_between', [min_val, max_val])
94
104
  end
95
105
 
96
- def or(column, operator, value)
97
- @filters << { column: column, operator: operator, value: value, logical_op: 'OR' }
98
- self
106
+ # Add an OR filter condition.
107
+ def or_filter(column, operator, value)
108
+ filter(column, operator, value, logical_op: 'OR')
99
109
  end
100
110
 
101
- def group_by(*columns)
102
- @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
103
130
  self
104
131
  end
105
132
 
106
- def having(column, operator, value)
107
- @having << { column: column, operator: operator, value: value }
108
- self
133
+ # Order results by a single column (alias for order_by).
134
+ def order(column, direction = 'asc')
135
+ order_by(column, direction)
109
136
  end
110
137
 
111
- def order_by_multiple(*items)
112
- @order_items = items
138
+ # Limit number of results.
139
+ def limit(n)
140
+ @options['limit'] = n
113
141
  self
114
142
  end
115
143
 
116
- def order_by(column, desc: false)
117
- @params['order'] = column
118
- @params['order_direction'] = desc ? 'desc' : 'asc'
144
+ # Skip records (pagination).
145
+ def offset(n)
146
+ @options['offset'] = n
119
147
  self
120
148
  end
121
149
 
122
- # Order results by column (alias for order_by, backward compatibility).
123
- def order(column, direction = 'asc')
124
- order_by(column, desc: direction.downcase == 'desc')
125
- end
126
-
127
- def limit(limit)
128
- @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
129
162
  self
130
163
  end
131
164
 
132
- def offset(offset)
133
- @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 }
134
174
  self
135
175
  end
136
176
 
137
- def get
138
- # Check if we need POST endpoint (advanced features)
139
- has_advanced_features =
140
- (@group_by && !@group_by.empty?) ||
141
- !@having.empty? ||
142
- (@order_items && !@order_items.empty?) ||
143
- @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
183
+
184
+ body = {}
185
+
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
190
+
191
+ if final_options['filter']
192
+ filters = final_options['filter']
193
+ body['filters'] = filters.is_a?(Array) ? filters : [filters]
194
+ end
195
+
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
144
200
 
145
- if has_advanced_features
146
- # Use POST endpoint for advanced queries
147
- body = build_query_body
201
+ if final_options['having']
202
+ hv = final_options['having']
203
+ body['having'] = hv.is_a?(Array) ? hv : [hv]
204
+ end
205
+
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
215
+
216
+ body['limit'] = final_options['limit'] if final_options['limit']
217
+ body['offset'] = final_options['offset'] if final_options['offset']
218
+
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]) }
223
+
224
+ if has_advanced
148
225
  @client.request('POST', "/#{@table_name}/query", nil, body)
149
226
  else
150
- # Use GET endpoint for simple queries (backward compatibility)
151
- build_get_params
152
- @client.request('GET', "/#{@table_name}", @params, nil)
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]}"
237
+ end
238
+ params['filter'] = filter_strs.join(',')
239
+ end
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)
153
247
  end
154
248
  end
155
249
 
@@ -158,59 +252,74 @@ module WOWSQL
158
252
  get
159
253
  end
160
254
 
255
+ # Get first record matching query.
256
+ #
257
+ # @return [Hash, nil] First record or nil
161
258
  def first
162
259
  result = limit(1).get
163
- result['data']&.first
260
+ data = result['data']
261
+ data && !data.empty? ? data[0] : nil
164
262
  end
165
263
 
166
- private
167
-
168
- def add_filter(column, op, value)
169
- @filters << { column: column, operator: op, value: value, logical_op: 'AND' }
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]
170
274
  end
171
275
 
172
- def build_query_body
173
- body = {}
174
-
175
- body['select'] = @select_columns if @select_columns && !@select_columns.empty?
176
-
177
- body['filters'] = @filters if !@filters.empty?
178
-
179
- body['group_by'] = @group_by if @group_by && !@group_by.empty?
180
-
181
- body['having'] = @having if !@having.empty?
182
-
183
- if @order_items && !@order_items.empty?
184
- body['order_by'] = @order_items.map do |item|
185
- if item.is_a?(Hash)
186
- item
187
- else
188
- { column: item[0], direction: item[1] }
189
- end
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')
190
295
  end
191
- elsif @params['order']
192
- body['order_by'] = @params['order']
193
- body['order_direction'] = @params['order_direction'] || 'asc'
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
194
300
  end
195
301
 
196
- body['limit'] = @params['limit'].to_i if @params['limit']
197
- body['offset'] = @params['offset'].to_i if @params['offset']
198
-
199
- body
302
+ data = result['data']
303
+ data && !data.empty? ? data[0]['count'].to_i : 0
200
304
  end
201
305
 
202
- def build_get_params
203
- @params['select'] = @select_columns.join(',') if @select_columns && !@select_columns.empty?
204
-
205
- simple_filters = @filters.reject { |f| ['in', 'not_in', 'between', 'not_between'].include?(f[:operator]) }
206
- if !simple_filters.empty?
207
- filter_strings = simple_filters.map do |f|
208
- value_str = f[:value] || 'null'
209
- "#{f[:column]}.#{f[:operator]}.#{value_str}"
210
- end
211
- @params['filter'] = filter_strings.join(',')
212
- end
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
+ }
213
323
  end
214
324
  end
215
325
  end
216
-