wowsql-sdk 3.0.1 → 3.1.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.
@@ -1,99 +1,70 @@
1
+ require_relative 'exceptions'
2
+
1
3
  module WOWSQL
2
- # Fluent query builder for constructing database queries.
4
+ # Fluent query builder all operations translate to PostgREST query parameters.
5
+ #
6
+ # @example
7
+ # result = client.table("users")
8
+ # .select("id", "email", "name")
9
+ # .eq("is_active", true)
10
+ # .order_by("created_at", "desc")
11
+ # .limit(20)
12
+ # .get
3
13
  class QueryBuilder
4
14
  def initialize(client, table_name)
5
- @client = client
15
+ @client = client
6
16
  @table_name = table_name
7
- @options = {}
17
+ @filters = []
18
+ @select_cols = nil
19
+ @group_by = []
20
+ @having = []
21
+ @order = []
22
+ @limit_val = nil
23
+ @offset_val = nil
8
24
  end
9
25
 
10
- # Select specific columns or expressions.
11
- #
12
- # @param columns [Array<String>] Column names or expressions
13
- # @return [QueryBuilder] self for chaining
26
+ # ── Column selection ─────────────────────────────────────────
27
+
14
28
  def select(*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
29
+ cols = columns.length == 1 && columns[0].is_a?(Array) ? columns[0] : columns.map(&:to_s)
30
+ @select_cols = cols.empty? ? nil : cols
20
31
  self
21
32
  end
22
33
 
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'] ||= []
34
+ # ── Filters ──────────────────────────────────────────────────
32
35
 
36
+ def filter(column, operator = nil, value = nil, logical_op: 'AND')
33
37
  if column.is_a?(Hash)
34
38
  if column.key?('column') || column.key?(:column)
35
39
  col = column['column'] || column[:column]
36
- op = column['operator'] || column[:operator]
40
+ op = column['operator'] || column[:operator]
37
41
  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 }
42
+ lo = column['logical_op'] || column[:logical_op] || logical_op
43
+ @filters << { column: col.to_s, operator: op.to_s, value: val, logical_op: lo }
40
44
  else
41
45
  column.each do |col_name, col_value|
42
- @options['filter'] << { column: col_name.to_s, operator: 'eq', value: col_value, logical_op: logical_op }
46
+ @filters << { column: col_name.to_s, operator: 'eq', value: col_value, logical_op: logical_op }
43
47
  end
44
48
  end
45
49
  else
46
50
  raise ArgumentError, "filter() missing required argument: 'operator'" if operator.nil?
47
- @options['filter'] << { column: column, operator: operator, value: value, logical_op: logical_op }
51
+ @filters << { column: column.to_s, operator: operator.to_s, value: value, logical_op: logical_op }
48
52
  end
49
-
50
53
  self
51
54
  end
52
55
 
53
- def eq(column, value)
54
- filter(column, 'eq', value)
55
- end
56
-
57
- def neq(column, value)
58
- filter(column, 'neq', value)
59
- end
60
-
61
- def gt(column, value)
62
- filter(column, 'gt', value)
63
- end
64
-
65
- def gte(column, value)
66
- filter(column, 'gte', value)
67
- end
68
-
69
- def lt(column, value)
70
- filter(column, 'lt', value)
71
- end
72
-
73
- def lte(column, value)
74
- filter(column, 'lte', value)
75
- end
76
-
77
- def like(column, pattern)
78
- filter(column, 'like', pattern)
79
- end
80
-
81
- def is_null(column)
82
- filter(column, 'is', nil)
83
- end
84
-
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)
92
- end
93
-
94
- def not_in(column, values)
95
- filter(column, 'not_in', values)
96
- end
56
+ def eq(column, value) filter(column, 'eq', value) end
57
+ def neq(column, value) filter(column, 'neq', value) end
58
+ def gt(column, value) filter(column, 'gt', value) end
59
+ def gte(column, value) filter(column, 'gte', value) end
60
+ def lt(column, value) filter(column, 'lt', value) end
61
+ def lte(column, value) filter(column, 'lte', value) end
62
+ def like(column, pattern) filter(column, 'like', pattern) end
63
+ def ilike(column, pattern) filter(column, 'ilike', pattern) end
64
+ def is_null(column) filter(column, 'is', nil) end
65
+ def is_not_null(column) filter(column, 'is_not', nil) end
66
+ def in_list(column, values) filter(column, 'in', values) end
67
+ def not_in(column, values) filter(column, 'not_in', values) end
97
68
 
98
69
  def between(column, min_val, max_val)
99
70
  filter(column, 'between', [min_val, max_val])
@@ -103,223 +74,223 @@ module WOWSQL
103
74
  filter(column, 'not_between', [min_val, max_val])
104
75
  end
105
76
 
106
- # Add an OR filter condition.
107
77
  def or_filter(column, operator, value)
108
78
  filter(column, operator, value, logical_op: 'OR')
109
79
  end
110
80
 
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|
81
+ # ── Ordering / grouping ──────────────────────────────────────
82
+
83
+ def order_by(column, direction = 'asc')
84
+ if column.is_a?(Array)
85
+ column.each do |item|
122
86
  if item.is_a?(Hash)
123
- item
87
+ @order << { column: (item[:column] || item['column']).to_s,
88
+ direction: (item[:direction] || item['direction'] || 'asc').to_s }
124
89
  elsif item.is_a?(Array) && item.length == 2
125
- { column: item[0], direction: item[1] }
90
+ @order << { column: item[0].to_s, direction: item[1].to_s }
126
91
  end
127
- end.compact
128
- @options['order'] = order_items
92
+ end
93
+ else
94
+ @order << { column: column.to_s, direction: (direction || 'asc').to_s }
129
95
  end
130
96
  self
131
97
  end
132
98
 
133
- # Order results by a single column (alias for order_by).
134
99
  def order(column, direction = 'asc')
135
100
  order_by(column, direction)
136
101
  end
137
102
 
138
- # Limit number of results.
139
- def limit(n)
140
- @options['limit'] = n
103
+ def group_by(*columns)
104
+ cols = columns.length == 1 && columns[0].is_a?(Array) ? columns[0] : columns
105
+ @group_by = cols.map(&:to_s)
141
106
  self
142
107
  end
143
108
 
144
- # Skip records (pagination).
145
- def offset(n)
146
- @options['offset'] = n
109
+ def having(column, operator, value)
110
+ @having << { column: column.to_s, operator: operator.to_s, value: value }
147
111
  self
148
112
  end
149
113
 
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
114
+ # ── Pagination ───────────────────────────────────────────────
115
+
116
+ def limit(n)
117
+ @limit_val = n.to_i
162
118
  self
163
119
  end
164
120
 
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 }
121
+ def offset(n)
122
+ @offset_val = n.to_i
174
123
  self
175
124
  end
176
125
 
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
126
+ # ── Execution ────────────────────────────────────────────────
190
127
 
191
- if final_options['filter']
192
- filters = final_options['filter']
193
- body['filters'] = filters.is_a?(Array) ? filters : [filters]
128
+ # Execute the query using PostgREST native query parameters.
129
+ #
130
+ # @return [Hash] { data: Array, count: Integer, total: Integer, limit: Integer, offset: Integer }
131
+ def get(_additional_options = nil)
132
+ params = {}
133
+
134
+ # SELECT
135
+ sel = @select_cols ? @select_cols.dup : []
136
+ unless @group_by.empty?
137
+ sel = (sel + @group_by).uniq
194
138
  end
139
+ params['select'] = sel.join(',') unless sel.empty?
195
140
 
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
141
+ # FILTERS → PostgREST native column=op.value
142
+ filters_to_params(@filters).each { |k, v| params[k] = v }
200
143
 
201
- if final_options['having']
202
- hv = final_options['having']
203
- body['having'] = hv.is_a?(Array) ? hv : [hv]
144
+ # ORDER → PostgREST: order=col.asc,col2.desc
145
+ unless @order.empty?
146
+ parts = @order.map { |o| "#{o[:column]}.#{o[:direction] || 'asc'}" }
147
+ params['order'] = parts.join(',')
204
148
  end
205
149
 
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
150
+ params['limit'] = @limit_val.to_s if @limit_val
151
+ params['offset'] = @offset_val.to_s if @offset_val
215
152
 
216
- body['limit'] = final_options['limit'] if final_options['limit']
217
- body['offset'] = final_options['offset'] if final_options['offset']
153
+ result = @client.request('GET', "/#{@table_name}", params, nil, 'Prefer' => 'count=exact')
218
154
 
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]) }
155
+ data = normalise_data(result)
156
+ total = WOWSQLClient.parse_total_from_content_range(@client.last_content_range, data.length)
223
157
 
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]}"
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)
247
- end
158
+ {
159
+ 'data' => data,
160
+ 'count' => data.length,
161
+ 'total' => total,
162
+ 'limit' => @limit_val || 100,
163
+ 'offset' => @offset_val || 0
164
+ }
248
165
  end
249
166
 
250
- # Execute the query (alias for get).
251
- def execute
252
- get
253
- end
167
+ alias execute get
254
168
 
255
- # Get first record matching query.
256
- #
257
- # @return [Hash, nil] First record or nil
258
169
  def first
259
170
  result = limit(1).get
260
- data = result['data']
171
+ data = result['data']
261
172
  data && !data.empty? ? data[0] : nil
262
173
  end
263
174
 
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
175
  def single
269
176
  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
177
+ data = result['data'] || []
178
+ raise WOWSQLError.new('No records found') if data.empty?
179
+ raise WOWSQLError.new('Multiple records found, expected one') if data.length > 1
273
180
  data[0]
274
181
  end
275
182
 
276
- # Get the total count of records matching the current filters.
277
- #
278
- # @return [Integer] Number of matching records
279
183
  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')
184
+ saved = [@select_cols, @group_by, @having, @order, @limit_val, @offset_val]
185
+ @select_cols = nil
186
+ @group_by = []
187
+ @having = []
188
+ @order = []
189
+ @limit_val = 0
190
+ @offset_val = nil
285
191
 
286
- @options['select'] = ['COUNT(*) as count']
192
+ begin
193
+ params = filters_to_params(@filters)
194
+ params['limit'] = '0'
195
+ @client.request('GET', "/#{@table_name}", params, nil, 'Prefer' => 'count=exact')
196
+ WOWSQLClient.parse_total_from_content_range(@client.last_content_range, 0)
197
+ ensure
198
+ @select_cols, @group_by, @having, @order, @limit_val, @offset_val = saved
199
+ end
200
+ end
287
201
 
202
+ def sum(column)
203
+ saved_sel, @select_cols = @select_cols, ["sum(#{column})"]
204
+ @limit_val = nil
205
+ @offset_val = nil
288
206
  begin
289
207
  result = get
208
+ (result.dig('data', 0, 'sum') || 0).to_f
290
209
  ensure
291
- if saved_select
292
- @options['select'] = saved_select
293
- else
294
- @options.delete('select')
295
- end
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
210
+ @select_cols = saved_sel
300
211
  end
212
+ end
301
213
 
302
- data = result['data']
303
- data && !data.empty? ? data[0]['count'].to_i : 0
214
+ def avg(column)
215
+ saved_sel, @select_cols = @select_cols, ["avg(#{column})"]
216
+ @limit_val = nil
217
+ @offset_val = nil
218
+ begin
219
+ result = get
220
+ (result.dig('data', 0, 'avg') || 0).to_f
221
+ ensure
222
+ @select_cols = saved_sel
223
+ end
304
224
  end
305
225
 
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
226
  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
227
+ page = [page.to_i, 1].max
228
+ offset_val = (page - 1) * per_page
229
+ result = limit(per_page).offset(offset_val).get
230
+ total = result['total'] || result['count'] || 0
316
231
  {
317
- 'data' => result['data'],
318
- 'page' => page,
319
- 'per_page' => per_page,
320
- 'total' => total,
321
- 'total_pages' => total_pages
232
+ 'data' => result['data'],
233
+ 'page' => page,
234
+ 'per_page' => per_page,
235
+ 'total' => total,
236
+ 'total_pages' => total > 0 ? (total + per_page - 1) / per_page : 0
322
237
  }
323
238
  end
239
+
240
+ private
241
+
242
+ # Translate filter list into PostgREST query parameters.
243
+ def filters_to_params(filters)
244
+ params = {}
245
+ filters.each do |f|
246
+ col = f[:column]
247
+ op = f[:operator]
248
+ val = f[:value]
249
+
250
+ case op
251
+ when 'eq' then params[col] = "eq.#{val}"
252
+ when 'neq' then params[col] = "neq.#{val}"
253
+ when 'gt' then params[col] = "gt.#{val}"
254
+ when 'gte' then params[col] = "gte.#{val}"
255
+ when 'lt' then params[col] = "lt.#{val}"
256
+ when 'lte' then params[col] = "lte.#{val}"
257
+ when 'like'
258
+ params[col] = "like.#{val.to_s.gsub('%', '*')}"
259
+ when 'ilike'
260
+ params[col] = "ilike.#{val.to_s.gsub('%', '*')}"
261
+ when 'is'
262
+ params[col] = val.nil? ? 'is.null' : "is.#{val}"
263
+ when 'is_not'
264
+ params[col] = val.nil? ? 'not.is.null' : "not.is.#{val}"
265
+ when 'in'
266
+ list = Array(val).join(',')
267
+ params[col] = "in.(#{list})"
268
+ when 'not_in'
269
+ list = Array(val).join(',')
270
+ params[col] = "not.in.(#{list})"
271
+ when 'between'
272
+ if val.is_a?(Array) && val.length == 2
273
+ params[col] = "gte.#{val[0]}"
274
+ params["#{col}_lte"] = "lte.#{val[1]}"
275
+ end
276
+ when 'not_between'
277
+ if val.is_a?(Array) && val.length == 2
278
+ params["#{col}_lt"] = "lt.#{val[0]}"
279
+ params["#{col}_gt"] = "gt.#{val[1]}"
280
+ end
281
+ end
282
+ end
283
+ params
284
+ end
285
+
286
+ def normalise_data(result)
287
+ case result
288
+ when Array then result
289
+ when Hash
290
+ result['data'] || result['rows'] || (result.key?('id') ? [result] : [])
291
+ else
292
+ []
293
+ end
294
+ end
324
295
  end
325
296
  end
data/lib/wowsql/schema.rb CHANGED
@@ -13,7 +13,7 @@ module WOWSQL
13
13
  # "wowsql_service_..."
14
14
  # )
15
15
  # schema.create_table("users", [
16
- # { "name" => "id", "type" => "SERIAL", "auto_increment" => true },
16
+ # { "name" => "id", "type" => "UUID", "auto_increment" => true },
17
17
  # { "name" => "email", "type" => "VARCHAR(255)", "unique" => true, "nullable" => false },
18
18
  # { "name" => "name", "type" => "VARCHAR(255)" },
19
19
  # { "name" => "metadata", "type" => "JSONB", "default" => "'{}'" },
@@ -24,11 +24,11 @@ module WOWSQL
24
24
 
25
25
  # @param project_url [String] Project subdomain or full URL
26
26
  # @param service_key [String] Service role key (wowsql_service_...)
27
- # @param base_domain [String] Base domain (default: "wowsql.com")
27
+ # @param base_domain [String] Base domain (default: "wowsqlconnect.com")
28
28
  # @param secure [Boolean] Use HTTPS (default: true)
29
29
  # @param timeout [Integer] Request timeout in seconds (default: 30)
30
30
  # @param verify_ssl [Boolean] Verify SSL certificates (default: true)
31
- def initialize(project_url, service_key, base_domain: 'wowsql.com', secure: true,
31
+ def initialize(project_url, service_key, base_domain: 'wowsqlconnect.com', secure: true,
32
32
  timeout: 30, verify_ssl: true)
33
33
  if project_url.start_with?('http://') || project_url.start_with?('https://')
34
34
  base = project_url.chomp('/')
@@ -64,10 +64,14 @@ module WOWSQL
64
64
  #
65
65
  # @param table_name [String] Name of the table
66
66
  # @param columns [Array<Hash>] Column definitions (name, type, auto_increment, unique, nullable, default)
67
- # @param primary_key [String, nil] Primary key column name
67
+ # @param primary_key [String] Primary key column name (must be the UUID column)
68
68
  # @param indexes [Array<String>, nil] Columns to create indexes on
69
69
  # @return [Hash]
70
- def create_table(table_name, columns, primary_key: nil, indexes: nil)
70
+ def create_table(table_name, columns, primary_key:, indexes: nil)
71
+ raise ArgumentError, 'primary_key is required; primary key must be UUID type.' if primary_key.nil? || primary_key.to_s.strip.empty?
72
+
73
+ assert_uuid_primary_key!(primary_key, columns)
74
+
71
75
  request('POST', '/api/v2/schema/tables', nil, {
72
76
  table_name: table_name,
73
77
  columns: columns,
@@ -76,6 +80,19 @@ module WOWSQL
76
80
  })
77
81
  end
78
82
 
83
+ private
84
+
85
+ def assert_uuid_primary_key!(primary_key, columns)
86
+ col = columns.find { |c| c['name'] == primary_key || c[:name] == primary_key }
87
+ raise ArgumentError, 'Primary key column not found in columns.' unless col
88
+
89
+ type_str = (col['type'] || col[:type]).to_s
90
+ first = type_str.strip.split(/\s+/, 2).first.to_s.upcase
91
+ raise ArgumentError, 'Primary key column must use PostgreSQL type UUID.' unless first == 'UUID'
92
+ end
93
+
94
+ public
95
+
79
96
  # Alter an existing table.
80
97
  #
81
98
  # Operations: add_column, drop_column, modify_column, rename_column
@@ -98,12 +98,12 @@ module WOWSQL
98
98
  # @param api_key [String] API key for authentication
99
99
  # @param project_slug [String] Explicit slug (used with base_url)
100
100
  # @param base_url [String] Explicit base URL (used with project_slug)
101
- # @param base_domain [String] Base domain (default: "wowsql.com")
101
+ # @param base_domain [String] Base domain (default: "wowsqlconnect.com")
102
102
  # @param secure [Boolean] Use HTTPS (default: true)
103
103
  # @param timeout [Integer] Request timeout in seconds (default: 60)
104
104
  # @param verify_ssl [Boolean] Verify SSL certificates (default: true)
105
105
  def initialize(project_url = '', api_key = '', project_slug: '', base_url: '',
106
- base_domain: 'wowsql.com', secure: true, timeout: 60, verify_ssl: true)
106
+ base_domain: 'wowsqlconnect.com', secure: true, timeout: 60, verify_ssl: true)
107
107
  if !project_slug.empty? && !base_url.empty?
108
108
  @base_url = base_url.chomp('/')
109
109
  @project_slug = project_slug