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.
@@ -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