axhub-sdk 0.5.0 → 0.7.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 +4 -4
- data/README.md +16 -41
- data/lib/axhub_sdk/version.rb +1 -1
- data/lib/axhub_sdk.rb +78 -35
- metadata +2 -13
- data/lib/axhub_sdk/data/client.rb +0 -250
- data/lib/axhub_sdk/data/discover.rb +0 -148
- data/lib/axhub_sdk/data/dsl/ops.rb +0 -150
- data/lib/axhub_sdk/data/dsl/schema.rb +0 -66
- data/lib/axhub_sdk/data/dsl/validation.rb +0 -60
- data/lib/axhub_sdk/data/errors.rb +0 -68
- data/lib/axhub_sdk/data/pagination.rb +0 -100
- data/lib/axhub_sdk/data/projection.rb +0 -50
- data/lib/axhub_sdk/data/schema_cache.rb +0 -105
- data/lib/axhub_sdk/data/where_serializer.rb +0 -84
- data/lib/axhub_sdk/data.rb +0 -67
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'uri'
|
|
4
|
-
require_relative 'dsl/schema'
|
|
5
|
-
require_relative 'dsl/validation'
|
|
6
|
-
require_relative 'errors'
|
|
7
|
-
require_relative 'pagination'
|
|
8
|
-
require_relative 'projection'
|
|
9
|
-
require_relative 'schema_cache'
|
|
10
|
-
require_relative 'where_serializer'
|
|
11
|
-
require_relative 'discover'
|
|
12
|
-
|
|
13
|
-
module AxHub
|
|
14
|
-
module Data
|
|
15
|
-
# Ergonomic data layer: fluent builder + dynamic-table CRUD + offset
|
|
16
|
-
# pagination (mirrors node index.ts DataClient / TenantDataFactory /
|
|
17
|
-
# AppDataFactory / DataTableClient).
|
|
18
|
-
#
|
|
19
|
-
# Wire paths (EXACTLY as node, via the raw-path transport so row bodies and
|
|
20
|
-
# the list envelope are returned verbatim, no snake->camel rewriting):
|
|
21
|
-
# list / insert GET|POST /data/{tenant}/{app}/{table}
|
|
22
|
-
# get / update / delete GET|PATCH|DELETE /data/{tenant}/{app}/{table}/{id}
|
|
23
|
-
# count GET /data/{tenant}/{app}/{table}/_count
|
|
24
|
-
module_function
|
|
25
|
-
|
|
26
|
-
def _encode(value)
|
|
27
|
-
URI.encode_www_form_component(value.to_s)
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def _clamp_per_page(value)
|
|
31
|
-
return nil if value.nil?
|
|
32
|
-
return 100 unless value.is_a?(Numeric) && value.finite?
|
|
33
|
-
|
|
34
|
-
[100, [1, value.to_i].max].min
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def _reject_legacy_page_options(after, before, direction, _table_name)
|
|
38
|
-
return if after.nil? && before.nil? && direction.nil?
|
|
39
|
-
|
|
40
|
-
raise LegacyCursorError.new(
|
|
41
|
-
'after/before keyset cursors are not supported by the live AX Hub data API; use cursor/page numeric offset pagination'
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def _validate_plain_cursor(cursor, _table_name)
|
|
46
|
-
if cursor.length > MAX_CURSOR_TOKEN_LENGTH
|
|
47
|
-
raise InvalidCursorError.new("Cursor token exceeds maximum size (#{MAX_CURSOR_TOKEN_LENGTH} chars)")
|
|
48
|
-
end
|
|
49
|
-
if cursor.start_with?('v1:')
|
|
50
|
-
raise LegacyCursorError.new(
|
|
51
|
-
'Legacy v1: cursor token is not compatible with AX Hub offset-only pagination; restart pagination without cursor'
|
|
52
|
-
)
|
|
53
|
-
end
|
|
54
|
-
if Data.is_v2_cursor(cursor)
|
|
55
|
-
raise LegacyCursorError.new(
|
|
56
|
-
'v2 keyset cursors are not supported by the live AX Hub data API; restart pagination and use the numeric cursor returned by list()'
|
|
57
|
-
)
|
|
58
|
-
end
|
|
59
|
-
unless cursor.match?(/\A-?\d+\z/)
|
|
60
|
-
raise InvalidCursorError.new('Plain cursor must be a positive integer page or a v2: keyset token')
|
|
61
|
-
end
|
|
62
|
-
parsed = cursor.to_i
|
|
63
|
-
raise InvalidCursorError.new('Plain cursor must be a positive integer page or a v2: keyset token') if parsed < 1
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def _resolve_offset_page(cursor, page, table_name)
|
|
67
|
-
unless cursor.nil?
|
|
68
|
-
_validate_plain_cursor(cursor, table_name)
|
|
69
|
-
return cursor.to_i
|
|
70
|
-
end
|
|
71
|
-
return 1 if page.nil?
|
|
72
|
-
raise InvalidCursorError.new('page must be a positive integer') unless page.is_a?(Integer) && page >= 1
|
|
73
|
-
|
|
74
|
-
page
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
module Data
|
|
79
|
-
# Client bound to one {tenant}/{app}/{table} with CRUD + pagination.
|
|
80
|
-
class DataTableClient
|
|
81
|
-
attr_reader :schema
|
|
82
|
-
|
|
83
|
-
def initialize(client, tenant_slug, app_slug, table_name, schema = nil)
|
|
84
|
-
@client = client
|
|
85
|
-
@tenant_slug = tenant_slug
|
|
86
|
-
@app_slug = app_slug
|
|
87
|
-
@table_name = table_name
|
|
88
|
-
@schema = schema
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def list(where: nil, order_by: nil, select: nil, page: nil, page_size: nil, limit: nil, cursor: nil, after: nil, before: nil, direction: nil)
|
|
92
|
-
Data.validate_select_columns(@schema, select)
|
|
93
|
-
Data._reject_legacy_page_options(after, before, direction, @table_name)
|
|
94
|
-
resolved_page = Data._resolve_offset_page(cursor, page, @table_name)
|
|
95
|
-
per_page = Data._clamp_per_page(page_size.nil? ? limit : page_size)
|
|
96
|
-
query = Data.serialize_where(where).dup
|
|
97
|
-
query['per_page'] = per_page unless per_page.nil?
|
|
98
|
-
query['page'] = resolved_page if resolved_page != 1
|
|
99
|
-
sort = Data.serialize_order_by(order_by)
|
|
100
|
-
query['sort'] = sort if sort && sort != ''
|
|
101
|
-
serialized_select = Data.serialize_select(select)
|
|
102
|
-
query['_select'] = serialized_select unless serialized_select.nil?
|
|
103
|
-
raw = Data.map_where_required('list') { @client.request_raw('GET', _path, query: query) } || {}
|
|
104
|
-
items = Data.project_rows(raw['items'] || [], select)
|
|
105
|
-
# mirrors node: current_page falls back to the requested page, has_next
|
|
106
|
-
# reads the backend `has_more` flag verbatim, has_prev derives client-side.
|
|
107
|
-
current_page = raw['page'].nil? ? resolved_page : raw['page']
|
|
108
|
-
has_next = !!(raw['has_more'] || false)
|
|
109
|
-
has_prev = current_page > 1
|
|
110
|
-
PaginatedList.new(
|
|
111
|
-
items: items,
|
|
112
|
-
next_cursor: has_next ? (current_page + 1).to_s : nil,
|
|
113
|
-
first_cursor: has_prev ? (current_page - 1).to_s : nil,
|
|
114
|
-
has_next: has_next,
|
|
115
|
-
has_prev: has_prev,
|
|
116
|
-
total_is_exact: false
|
|
117
|
-
)
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def list_all(where: nil, order_by: nil, select: nil, page_size: nil, limit: nil, &block)
|
|
121
|
-
base = { where: where, order_by: order_by, select: select, limit: limit }
|
|
122
|
-
fetcher = lambda do |p|
|
|
123
|
-
kwargs = base.reject { |_k, v| v.nil? }
|
|
124
|
-
kwargs[:cursor] = p[:cursor] unless p[:cursor].nil?
|
|
125
|
-
ps = p[:page_size].nil? ? page_size : p[:page_size]
|
|
126
|
-
kwargs[:page_size] = ps unless ps.nil?
|
|
127
|
-
list(**kwargs)
|
|
128
|
-
end
|
|
129
|
-
Data.list_all(fetcher, { page_size: page_size }, &block)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def count(where: nil)
|
|
133
|
-
raw = Data.map_where_required('count') { @client.request_raw('GET', "#{_path}/_count", query: Data.serialize_where(where)) } || {}
|
|
134
|
-
raw['count']
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def get(row_id, select: nil)
|
|
138
|
-
Data.validate_select_columns(@schema, select)
|
|
139
|
-
serialized_select = Data.serialize_select(select)
|
|
140
|
-
query = serialized_select.nil? ? {} : { '_select' => serialized_select }
|
|
141
|
-
row = @client.request_raw('GET', _path(row_id), query: query) || {}
|
|
142
|
-
Data.project_row(row, select)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def insert(row)
|
|
146
|
-
Data.run_schema_validation(@schema, row, 'insert')
|
|
147
|
-
@client.request_raw('POST', _path, body: row)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def insert_many(rows)
|
|
151
|
-
rows.each { |row| Data.run_schema_validation(@schema, row, 'insert') }
|
|
152
|
-
# mirrors node: no bulk endpoint exists, so insertMany loops single
|
|
153
|
-
# inserts and returns { items, count }.
|
|
154
|
-
items = rows.map { |row| insert(row) }
|
|
155
|
-
{ 'items' => items, 'count' => items.length }
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def update(row_id, patch)
|
|
159
|
-
Data.run_schema_validation(@schema, patch, 'update')
|
|
160
|
-
@client.request_raw('PATCH', _path(row_id), body: patch)
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def delete(row_id)
|
|
164
|
-
@client.request_raw('DELETE', _path(row_id))
|
|
165
|
-
nil
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
private
|
|
169
|
-
|
|
170
|
-
def _path(row_id = nil)
|
|
171
|
-
base = "/data/#{Data._encode(@tenant_slug)}/#{Data._encode(@app_slug)}/#{Data._encode(@table_name)}"
|
|
172
|
-
row_id.nil? ? base : "#{base}/#{Data._encode(row_id)}"
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
class AppDataFactory
|
|
177
|
-
def initialize(data, tenant_slug, app_slug)
|
|
178
|
-
@data = data
|
|
179
|
-
@tenant_slug = tenant_slug
|
|
180
|
-
@app_slug = app_slug
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def table(table)
|
|
184
|
-
@data.table(@tenant_slug, @app_slug, table)
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def discover(table, fresh: nil, ttl_ms: nil)
|
|
188
|
-
@data.discover(@tenant_slug, @app_slug, table, fresh: fresh, ttl_ms: ttl_ms)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def invalidate_schema(table = nil)
|
|
192
|
-
if table.nil?
|
|
193
|
-
@data.invalidate_schema
|
|
194
|
-
else
|
|
195
|
-
@data.invalidate_schema(@tenant_slug, @app_slug, table)
|
|
196
|
-
end
|
|
197
|
-
end
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
class TenantDataFactory
|
|
201
|
-
def initialize(data, tenant_slug)
|
|
202
|
-
@data = data
|
|
203
|
-
@tenant_slug = tenant_slug
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
def app(app_slug)
|
|
207
|
-
AppDataFactory.new(@data, @tenant_slug, app_slug)
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
|
|
211
|
-
# Entry point for the ergonomic data layer; holds the per-client schema cache
|
|
212
|
-
# used by discover() (mirrors node DataClient).
|
|
213
|
-
class DataClient
|
|
214
|
-
def initialize(client, schema_cache: nil)
|
|
215
|
-
@client = client
|
|
216
|
-
@schema_cache = case schema_cache
|
|
217
|
-
when SchemaCache then schema_cache
|
|
218
|
-
when Hash then SchemaCache.new(**schema_cache.transform_keys(&:to_sym))
|
|
219
|
-
else SchemaCache.new
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def table(tenant_slug, app_slug, table)
|
|
224
|
-
schema = table.is_a?(DataTableSchema) ? table : nil
|
|
225
|
-
table_name = table.is_a?(DataTableSchema) ? table.table : table
|
|
226
|
-
DataTableClient.new(@client, tenant_slug, app_slug, table_name, schema)
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def scoped(tenant_slug)
|
|
230
|
-
TenantDataFactory.new(self, tenant_slug)
|
|
231
|
-
end
|
|
232
|
-
|
|
233
|
-
def discover(tenant_slug, app_slug, table, fresh: nil, ttl_ms: nil)
|
|
234
|
-
key = Data.schema_cache_key(tenant_slug, app_slug, table)
|
|
235
|
-
schema = @schema_cache.get_or_set(key, fresh: fresh, ttl_ms: ttl_ms) do
|
|
236
|
-
Data.fetch_discovered_schema(@client, tenant_slug, app_slug, table, fresh: fresh, ttl_ms: ttl_ms)
|
|
237
|
-
end
|
|
238
|
-
DataTableClient.new(@client, tenant_slug, app_slug, schema.table, schema)
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
def invalidate_schema(tenant_slug = nil, app_slug = nil, table = nil)
|
|
242
|
-
if !tenant_slug.nil? && !app_slug.nil? && !table.nil?
|
|
243
|
-
@schema_cache.invalidate(Data.schema_cache_key(tenant_slug, app_slug, table))
|
|
244
|
-
return
|
|
245
|
-
end
|
|
246
|
-
@schema_cache.invalidate
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
end
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'uri'
|
|
4
|
-
require_relative 'dsl/schema'
|
|
5
|
-
require_relative 'errors'
|
|
6
|
-
|
|
7
|
-
module AxHub
|
|
8
|
-
module Data
|
|
9
|
-
# Runtime schema introspection, with appId-resolution PRIMARY and the slug
|
|
10
|
-
# /inspect endpoint as a best-effort fallback, plus error normalization
|
|
11
|
-
# (mirrors node/python discover).
|
|
12
|
-
#
|
|
13
|
-
# Primary: GET /api/v1/apps?tenant_slug=... -> GET /api/v1/apps/{appId}/tables/{table}
|
|
14
|
-
# Fallback: GET /api/v1/tenants/{t}/apps/{a}/tables/{table}/inspect
|
|
15
|
-
# Neither endpoint has a generated operation-id, so discover goes through the
|
|
16
|
-
# raw-path transport. camelize: true here so table_name/tableName both resolve
|
|
17
|
-
# (inspect payload is metadata, not user row data).
|
|
18
|
-
APP_LOOKUP_PAGE_SIZE = 100
|
|
19
|
-
APP_LOOKUP_MAX_PAGES = 10
|
|
20
|
-
APP_LOOKUP_BUDGET_MS = 5_000
|
|
21
|
-
|
|
22
|
-
FORBIDDEN_COLUMN_NAMES = %w[__proto__ constructor prototype].freeze
|
|
23
|
-
COLUMN_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
|
|
24
|
-
|
|
25
|
-
module_function
|
|
26
|
-
|
|
27
|
-
def _encode(value)
|
|
28
|
-
URI.encode_www_form_component(value.to_s)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def fetch_discovered_schema(client, tenant_slug, app_slug, table, fresh: nil, ttl_ms: nil)
|
|
32
|
-
# The appId path is the route the `axhub` CLI uses and is verified to work
|
|
33
|
-
# with a data-ring PAT (2026-06). The slug `/inspect` route rejects a slug
|
|
34
|
-
# in the {tenant} path segment on the live backend ("tenant_id 형식이 잘못됐어요",
|
|
35
|
-
# HTTP 400) — a 400 not a 404, so the old slug-first order never reached the
|
|
36
|
-
# working path. appId is primary; slug inspect is a best-effort fallback. The
|
|
37
|
-
# appId error is the meaningful one, so it is what surfaces.
|
|
38
|
-
begin
|
|
39
|
-
_fetch_app_id_inspect(client, tenant_slug, app_slug, table)
|
|
40
|
-
rescue StandardError => err
|
|
41
|
-
begin
|
|
42
|
-
_fetch_slug_inspect(client, tenant_slug, app_slug, table)
|
|
43
|
-
rescue StandardError
|
|
44
|
-
raise _normalize_discover_error(err, table)
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def _fetch_slug_inspect(client, tenant_slug, app_slug, table)
|
|
50
|
-
path = "/api/v1/tenants/#{_encode(tenant_slug)}/apps/#{_encode(app_slug)}/tables/#{_encode(table)}/inspect"
|
|
51
|
-
raw = client.request_raw('GET', path, camelize: true)
|
|
52
|
-
schema_from_inspect_result(table, raw)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def _fetch_app_id_inspect(client, tenant_slug, app_slug, table)
|
|
56
|
-
app_id = _resolve_app_id(client, tenant_slug, app_slug)
|
|
57
|
-
raise TableNotFoundError.new("Dynamic data table '#{table}' was not found") if app_id.nil? || app_id.empty?
|
|
58
|
-
|
|
59
|
-
path = "/api/v1/apps/#{_encode(app_id)}/tables/#{_encode(table)}"
|
|
60
|
-
raw = client.request_raw('GET', path, camelize: true)
|
|
61
|
-
schema_from_inspect_result(table, raw)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def _resolve_app_id(client, tenant_slug, app_slug)
|
|
65
|
-
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
|
|
66
|
-
cursor = nil
|
|
67
|
-
APP_LOOKUP_MAX_PAGES.times do |page|
|
|
68
|
-
if Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0 - started_at > APP_LOOKUP_BUDGET_MS
|
|
69
|
-
raise IntrospectFailedError.new(
|
|
70
|
-
"app lookup budget exceeded (#{APP_LOOKUP_BUDGET_MS}ms) while searching for slug '#{app_slug}' in tenant '#{tenant_slug}'"
|
|
71
|
-
)
|
|
72
|
-
end
|
|
73
|
-
query = { 'tenant_slug' => tenant_slug, 'limit' => APP_LOOKUP_PAGE_SIZE }
|
|
74
|
-
query['cursor'] = cursor if cursor
|
|
75
|
-
raw = client.request_raw('GET', '/api/v1/apps', query: query, camelize: true)
|
|
76
|
-
raw ||= {}
|
|
77
|
-
items = raw['items'] || []
|
|
78
|
-
match = items.find { |app| app['slug'] == app_slug && app['id'].is_a?(String) }
|
|
79
|
-
return match['id'] if match && match['id']
|
|
80
|
-
|
|
81
|
-
# Empty page on the first request means the tenant truly has no apps.
|
|
82
|
-
return nil if page.zero? && items.empty?
|
|
83
|
-
|
|
84
|
-
next_cursor = raw['next_cursor'] || raw['nextCursor']
|
|
85
|
-
return nil if next_cursor.nil? || next_cursor == ''
|
|
86
|
-
|
|
87
|
-
cursor = next_cursor
|
|
88
|
-
end
|
|
89
|
-
raise ScanLimitExceededError.new(
|
|
90
|
-
"App lookup exceeded #{APP_LOOKUP_MAX_PAGES} pages x #{APP_LOOKUP_PAGE_SIZE} apps without finding slug '#{app_slug}'"
|
|
91
|
-
)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def _normalize_discover_error(err, table)
|
|
95
|
-
return err if err.is_a?(TableNotFoundError) || err.is_a?(IntrospectFailedError) || err.is_a?(ScanLimitExceededError)
|
|
96
|
-
|
|
97
|
-
if _not_found?(err)
|
|
98
|
-
return TableNotFoundError.new(
|
|
99
|
-
"Dynamic data table '#{table}' was not found",
|
|
100
|
-
request_id: (err.respond_to?(:request_id) ? err.request_id : nil)
|
|
101
|
-
)
|
|
102
|
-
end
|
|
103
|
-
status = err.respond_to?(:status) ? err.status : nil
|
|
104
|
-
if status.is_a?(Integer) && status >= 500
|
|
105
|
-
return IntrospectFailedError.new(
|
|
106
|
-
"Failed to introspect dynamic data table '#{table}'",
|
|
107
|
-
status: status,
|
|
108
|
-
retryable: (err.respond_to?(:retryable) ? !!err.retryable : false),
|
|
109
|
-
request_id: (err.respond_to?(:request_id) ? err.request_id : nil)
|
|
110
|
-
)
|
|
111
|
-
end
|
|
112
|
-
err
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def schema_from_inspect_result(table, raw)
|
|
116
|
-
raw ||= {}
|
|
117
|
-
columns = raw['columns'] || []
|
|
118
|
-
shape = {}
|
|
119
|
-
columns.each do |column|
|
|
120
|
-
name = column['name']
|
|
121
|
-
next if FORBIDDEN_COLUMN_NAMES.include?(name)
|
|
122
|
-
next unless name.is_a?(String) && COLUMN_NAME_RE.match?(name)
|
|
123
|
-
|
|
124
|
-
shape[name] = _column_type_to_def(column['type'])
|
|
125
|
-
end
|
|
126
|
-
table_name = raw['tableName'] || raw['table_name'] || raw['name'] || table
|
|
127
|
-
Data.define_schema({ 'table' => table_name, 'columns' => shape })
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def _column_type_to_def(col_type)
|
|
131
|
-
case col_type
|
|
132
|
-
when 'uuid' then 'uuid'
|
|
133
|
-
when 'int', 'integer', 'bigint' then 'integer'
|
|
134
|
-
when 'float', 'numeric', 'double precision', 'real' then 'number'
|
|
135
|
-
when 'bool', 'boolean' then 'boolean'
|
|
136
|
-
when 'timestamp', 'timestamptz', 'timestamp with time zone' then 'timestamp'
|
|
137
|
-
when 'json', 'jsonb' then 'json'
|
|
138
|
-
else 'string' # text / varchar / character varying / unknown -> string
|
|
139
|
-
end
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def _not_found?(err)
|
|
143
|
-
return true if err.is_a?(TableNotFoundError)
|
|
144
|
-
|
|
145
|
-
err.is_a?(AxHub::Error) && err.respond_to?(:status) && err.status == 404
|
|
146
|
-
end
|
|
147
|
-
end
|
|
148
|
-
end
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative 'schema'
|
|
4
|
-
require_relative '../errors'
|
|
5
|
-
|
|
6
|
-
module AxHub
|
|
7
|
-
module Data
|
|
8
|
-
# Predicate DSL: where(col).eq(v), and_(...), or_/not_/raw plus LIKE escaping
|
|
9
|
-
# and ReDoS guards (mirrors node/python dsl/ops).
|
|
10
|
-
#
|
|
11
|
-
# Query expressions are plain symbol-keyed Hashes:
|
|
12
|
-
# { op: :eq|:ne|:gt|:gte|:lt|:lte|:like, column: "c", value: v }
|
|
13
|
-
# { op: :in, column: "c", values: [...] }
|
|
14
|
-
# { op: :and|:or, clauses: [...] }
|
|
15
|
-
# { op: :not, clause: expr }
|
|
16
|
-
# { op: :raw, sql: "...", params?: [...] }
|
|
17
|
-
# Only and(eq/ne/gt/gte/lt/lte/in/like) and bare atoms are pushable to the
|
|
18
|
-
# live backend; or/not/raw raise in the where-serializer (mirrors node).
|
|
19
|
-
MAX_LIKE_PATTERN_LENGTH = 1024
|
|
20
|
-
MAX_CONSECUTIVE_WILDCARDS = 4
|
|
21
|
-
MAX_LIKE_ALTERNATION_SEGMENTS = 6
|
|
22
|
-
|
|
23
|
-
ESCAPE_LIKE_RE = /[\\%_]/
|
|
24
|
-
|
|
25
|
-
module_function
|
|
26
|
-
|
|
27
|
-
def escape_like(value)
|
|
28
|
-
return value if value == ''
|
|
29
|
-
|
|
30
|
-
value.gsub(ESCAPE_LIKE_RE) { |m| "\\#{m}" }
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Reject LIKE patterns that translate to catastrophic-backtracking regex
|
|
34
|
-
# shapes (mirrors node assertSafeLikePattern).
|
|
35
|
-
def assert_safe_like_pattern(pattern)
|
|
36
|
-
if pattern.length > MAX_LIKE_PATTERN_LENGTH
|
|
37
|
-
raise ValidationError.new("LIKE pattern exceeds #{MAX_LIKE_PATTERN_LENGTH} chars; refuse to compile", 'like_pattern_too_long')
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
run_of_wildcards = 0
|
|
41
|
-
segments = 0
|
|
42
|
-
i = 0
|
|
43
|
-
n = pattern.length
|
|
44
|
-
while i < n
|
|
45
|
-
ch = pattern[i]
|
|
46
|
-
if ch == '\\'
|
|
47
|
-
i += 2
|
|
48
|
-
run_of_wildcards = 0
|
|
49
|
-
next
|
|
50
|
-
end
|
|
51
|
-
if ch == '%'
|
|
52
|
-
run_of_wildcards += 1
|
|
53
|
-
if run_of_wildcards >= MAX_CONSECUTIVE_WILDCARDS
|
|
54
|
-
raise ValidationError.new("LIKE pattern has #{run_of_wildcards} consecutive '%'; refuse to compile (ReDoS guard)", 'like_pattern_redos')
|
|
55
|
-
end
|
|
56
|
-
else
|
|
57
|
-
segments += 1 if run_of_wildcards == 1
|
|
58
|
-
run_of_wildcards = 0
|
|
59
|
-
end
|
|
60
|
-
i += 1
|
|
61
|
-
end
|
|
62
|
-
if segments > MAX_LIKE_ALTERNATION_SEGMENTS
|
|
63
|
-
raise ValidationError.new("LIKE pattern has #{segments} '%X%' alternation segments; refuse to compile (ReDoS guard)", 'like_pattern_redos')
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def raw(sql, params = nil)
|
|
68
|
-
params.nil? ? { op: :raw, sql: sql } : { op: :raw, sql: sql, params: params }
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def and_(*clauses)
|
|
72
|
-
{ op: :and, clauses: clauses }
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def or_(*clauses)
|
|
76
|
-
{ op: :or, clauses: clauses }
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def not_(clause)
|
|
80
|
-
{ op: :not, clause: clause }
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
# Start a predicate for a column. Accepts a DataColumn, a String, or a Symbol.
|
|
84
|
-
#
|
|
85
|
-
# where(:status).eq("paid")
|
|
86
|
-
# where("status").eq("paid")
|
|
87
|
-
# where(schema.cols["status"]).eq("paid")
|
|
88
|
-
#
|
|
89
|
-
# Block form (idiomatic Ruby): yields the builder and returns its result, so
|
|
90
|
-
# the bare-atom expr can be written without a trailing chain.
|
|
91
|
-
#
|
|
92
|
-
# where(:status) { |c| c.eq("paid") }
|
|
93
|
-
def where(column)
|
|
94
|
-
name = column.is_a?(DataColumn) ? column.name : column.to_s
|
|
95
|
-
builder = WhereBuilder.new(name)
|
|
96
|
-
return yield(builder) if block_given?
|
|
97
|
-
|
|
98
|
-
builder
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
class LikeBuilder
|
|
102
|
-
def initialize(name)
|
|
103
|
-
@name = name
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def contains(value)
|
|
107
|
-
{ op: :like, column: @name, value: "%#{Data.escape_like(value)}%" }
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def starts_with(value)
|
|
111
|
-
{ op: :like, column: @name, value: "#{Data.escape_like(value)}%" }
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def ends_with(value)
|
|
115
|
-
{ op: :like, column: @name, value: "%#{Data.escape_like(value)}" }
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def raw(value)
|
|
119
|
-
Data.assert_safe_like_pattern(value)
|
|
120
|
-
{ op: :like, column: @name, value: value }
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
class WhereBuilder
|
|
125
|
-
attr_reader :like
|
|
126
|
-
|
|
127
|
-
def initialize(name)
|
|
128
|
-
@name = name
|
|
129
|
-
@like = LikeBuilder.new(name)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def eq(value) = _binary(:eq, value)
|
|
133
|
-
def ne(value) = _binary(:ne, value)
|
|
134
|
-
def gt(value) = _binary(:gt, value)
|
|
135
|
-
def gte(value) = _binary(:gte, value)
|
|
136
|
-
def lt(value) = _binary(:lt, value)
|
|
137
|
-
def lte(value) = _binary(:lte, value)
|
|
138
|
-
|
|
139
|
-
def in_(values)
|
|
140
|
-
{ op: :in, column: @name, values: values.to_a }
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
private
|
|
144
|
-
|
|
145
|
-
def _binary(op, value)
|
|
146
|
-
{ op: op, column: @name, value: value }
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
end
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module AxHub
|
|
4
|
-
module Data
|
|
5
|
-
# Schema definitions and define_schema (mirrors node/python dsl/schema).
|
|
6
|
-
#
|
|
7
|
-
# Column defs are either a primitive type string ("uuid" | "string" |
|
|
8
|
-
# "number" | "integer" | "boolean" | "timestamp" | "json") or an enum
|
|
9
|
-
# descriptor { type: "enum", values: [...] }. define_schema builds the `cols`
|
|
10
|
-
# accessor map used by the where(schema.cols["x"]) typed DSL path.
|
|
11
|
-
DataColumn = Struct.new(:table, :name, :definition) do
|
|
12
|
-
def initialize(table:, name:, definition:)
|
|
13
|
-
super(table, name, definition)
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
class DataTableSchema
|
|
18
|
-
attr_reader :table, :columns, :cols, :validate
|
|
19
|
-
|
|
20
|
-
def initialize(table:, columns:, cols:, validate: nil)
|
|
21
|
-
@table = table
|
|
22
|
-
@columns = columns
|
|
23
|
-
@cols = cols
|
|
24
|
-
@validate = validate
|
|
25
|
-
freeze
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
module_function
|
|
30
|
-
|
|
31
|
-
# Define a data table schema. Two call shapes mirror node's two overloads:
|
|
32
|
-
# define_schema("orders", { "id" => "uuid", "total" => "number" })
|
|
33
|
-
# define_schema({ "table" => "orders", "columns" => {...} }, validate: ...)
|
|
34
|
-
# An existing DataTableSchema is re-wrapped, optionally attaching `validate`.
|
|
35
|
-
def define_schema(table_or_input, columns = nil, validate: nil)
|
|
36
|
-
if table_or_input.is_a?(DataTableSchema)
|
|
37
|
-
return DataTableSchema.new(
|
|
38
|
-
table: table_or_input.table,
|
|
39
|
-
columns: table_or_input.columns,
|
|
40
|
-
cols: table_or_input.cols,
|
|
41
|
-
validate: validate.nil? ? table_or_input.validate : validate
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
if table_or_input.is_a?(Hash)
|
|
46
|
-
h = _stringify_keys(table_or_input)
|
|
47
|
-
table = h['table']
|
|
48
|
-
shape = _stringify_keys(h['columns'])
|
|
49
|
-
else
|
|
50
|
-
table = table_or_input
|
|
51
|
-
raise ArgumentError, 'define_schema requires columns when called with a table name' if columns.nil?
|
|
52
|
-
|
|
53
|
-
shape = _stringify_keys(columns)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
cols = shape.each_with_object({}) do |(name, definition), acc|
|
|
57
|
-
acc[name] = DataColumn.new(table: table, name: name, definition: definition)
|
|
58
|
-
end
|
|
59
|
-
DataTableSchema.new(table: table, columns: shape, cols: cols, validate: validate)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def _stringify_keys(hash)
|
|
63
|
-
hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative 'schema'
|
|
4
|
-
require_relative '../errors'
|
|
5
|
-
|
|
6
|
-
module AxHub
|
|
7
|
-
module Data
|
|
8
|
-
# Optional schema validation hook (mirrors node dsl/zod + python validation).
|
|
9
|
-
#
|
|
10
|
-
# The SDK duck-types a zod/dry-validation-style validator so the validation
|
|
11
|
-
# library stays an optional dependency and is never required. A validator is
|
|
12
|
-
# "schema-like" if it responds to `safe_parse` (or `safeParse`). On `update`
|
|
13
|
-
# a `partial` variant is used when available.
|
|
14
|
-
module_function
|
|
15
|
-
|
|
16
|
-
def validator_like?(value)
|
|
17
|
-
!value.nil? && (value.respond_to?(:safe_parse) || value.respond_to?(:safeParse))
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def _safe_parse(validator, data)
|
|
21
|
-
validator.respond_to?(:safe_parse) ? validator.safe_parse(data) : validator.safeParse(data)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
# Validate `data` against schema.validate before any network request. `mode`
|
|
25
|
-
# is "insert" or "update" (update uses `partial` when available).
|
|
26
|
-
def run_schema_validation(schema, data, mode)
|
|
27
|
-
validator = schema&.validate
|
|
28
|
-
return if validator.nil?
|
|
29
|
-
|
|
30
|
-
unless validator_like?(validator)
|
|
31
|
-
raise AxHub::Error.new(
|
|
32
|
-
category: 'configuration', code: 'validator_missing',
|
|
33
|
-
message: 'define_schema validate option requires a schema-like object with safe_parse'
|
|
34
|
-
)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
effective = validator
|
|
38
|
-
effective = validator.partial if mode == 'update' && validator.respond_to?(:partial)
|
|
39
|
-
result = _safe_parse(effective, data)
|
|
40
|
-
|
|
41
|
-
success = _read(result, :success)
|
|
42
|
-
return if success
|
|
43
|
-
|
|
44
|
-
error = _read(result, :error)
|
|
45
|
-
issues = _read(error, :issues) || []
|
|
46
|
-
count = issues.empty? ? 1 : issues.length
|
|
47
|
-
raise ValidationError.new("#{count} validation failure#{count == 1 ? '' : 's'} before network request", 'validation_failed')
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Read an attribute from either a duck-typed object or a Hash.
|
|
51
|
-
def _read(obj, key)
|
|
52
|
-
return nil if obj.nil?
|
|
53
|
-
return obj.public_send(key) if obj.respond_to?(key)
|
|
54
|
-
return obj[key] if obj.is_a?(Hash) && obj.key?(key)
|
|
55
|
-
return obj[key.to_s] if obj.is_a?(Hash) && obj.key?(key.to_s)
|
|
56
|
-
|
|
57
|
-
nil
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|