Module: AxHub::Data

Defined in:
lib/axhub_sdk/data.rb,
lib/axhub_sdk/data/client.rb,
lib/axhub_sdk/data/client.rb,
lib/axhub_sdk/data/errors.rb,
lib/axhub_sdk/data/dsl/ops.rb,
lib/axhub_sdk/data/discover.rb,
lib/axhub_sdk/data/dsl/schema.rb,
lib/axhub_sdk/data/pagination.rb,
lib/axhub_sdk/data/projection.rb,
lib/axhub_sdk/data/schema_cache.rb,
lib/axhub_sdk/data/dsl/validation.rb,
lib/axhub_sdk/data/where_serializer.rb

Defined Under Namespace

Classes: AppDataFactory, AppScope, DataClient, DataColumn, DataTableClient, DataTableSchema, IntrospectFailedError, InvalidCursorError, LegacyCursorError, LikeBuilder, ListAllItem, PaginatedList, ScanLimitExceededError, SchemaCache, TableNotFoundError, TenantDataFactory, TenantScope, ValidationError, WhereBuilder

Constant Summary collapse

MAX_LIKE_PATTERN_LENGTH =

Predicate DSL: where(col).eq(v), and_(…), or_/not_/raw plus LIKE escaping and ReDoS guards (mirrors node/python dsl/ops).

Query expressions are plain symbol-keyed Hashes:

{ op: :eq|:ne|:gt|:gte|:lt|:lte|:like, column: "c", value: v }
{ op: :in, column: "c", values: [...] }
{ op: :and|:or, clauses: [...] }
{ op: :not, clause: expr }
{ op: :raw, sql: "...", params?: [...] }

Only and(eq/ne/gt/gte/lt/lte/in/like) and bare atoms are pushable to the live backend; or/not/raw raise in the where-serializer (mirrors node).

1024
MAX_CONSECUTIVE_WILDCARDS =
4
MAX_LIKE_ALTERNATION_SEGMENTS =
6
ESCAPE_LIKE_RE =
/[\\%_]/
APP_LOOKUP_PAGE_SIZE =

Runtime schema introspection, with appId-resolution PRIMARY and the slug /inspect endpoint as a best-effort fallback, plus error normalization (mirrors node/python discover).

Primary: GET /api/v1/apps?tenant_slug=… -> GET /api/v1/apps/appId/tables/table Fallback: GET /api/v1/tenants/t/apps/a/tables/table/inspect Neither endpoint has a generated operation-id, so discover goes through the raw-path transport. camelize: true here so table_name/tableName both resolve (inspect payload is metadata, not user row data).

100
APP_LOOKUP_MAX_PAGES =
10
APP_LOOKUP_BUDGET_MS =
5_000
FORBIDDEN_COLUMN_NAMES =
%w[__proto__ constructor prototype].freeze
COLUMN_NAME_RE =
/\A[A-Za-z_][A-Za-z0-9_]*\z/
MAX_CURSOR_TOKEN_LENGTH =

Offset pagination helpers (subset of node core pagination that the data ergonomic layer depends on).

Ported: serialize_order_by / normalize_order_by, is_v2_cursor, MAX_CURSOR_TOKEN_LENGTH, list_all, and the PaginatedList / ListAllItem result shapes. Keyset encode/decode is intentionally NOT ported: the live AX Hub data API is offset-only, so the data layer only needs the order-by normalizer and the cursor-shape guards used to reject legacy keyset tokens.

4096
DEFAULT_SCHEMA_CACHE_TTL_MS =

Per-client schema cache for runtime data.discover (mirrors node/python schema-cache).

Uses an insertion-ordered Ruby Hash for deterministic LRU eviction (delete+reinsert = move-to-end on read/write, ‘shift` = evict oldest) and a negative-TTL stale-while-error window: a transient 5xx during refresh keeps the previous entry alive briefly instead of evicting it. The node version de-dupes concurrent in-flight loads; the sync Ruby port omits the in-flight map (no concurrency within a single synchronous call).

5 * 60_000
DEFAULT_SCHEMA_CACHE_MAX_ENTRIES =
1000
DEFAULT_SCHEMA_CACHE_NEGATIVE_TTL_MS =
30_000
PUSHABLE_BINARY =

Serialize the predicate DSL into backend filter query params (mirrors node where-serializer + python where_serializer).

Each pushable atom becomes column=<op>.<value> (PostgREST-style). Repeated columns collapse into an array so the transport emits repeated query params (URI.encode_www_form repeats array-valued keys). Only top-level and(…) of pushable atoms and bare atoms are accepted; or/not/raw and nested-and raise ValidationError — this matches the live backend’s filter grammar.

%i[eq ne gt gte lt lte like].freeze

Class Method Summary collapse

Class Method Details

._append_query(out, key, value) ⇒ Object



31
32
33
34
35
36
37
38
39
# File 'lib/axhub_sdk/data/where_serializer.rb', line 31

def _append_query(out, key, value)
  if !out.key?(key)
    out[key] = value
  elsif out[key].is_a?(Array)
    out[key] << value
  else
    out[key] = [out[key], value]
  end
end

._clamp_per_page(value) ⇒ Object



30
31
32
33
34
35
# File 'lib/axhub_sdk/data/client.rb', line 30

def _clamp_per_page(value)
  return nil if value.nil?
  return 100 unless value.is_a?(Numeric) && value.finite?

  [100, [1, value.to_i].max].min
end

._collect_pushable_filters(expr, allow_and:) ⇒ Object

Raises:



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/axhub_sdk/data/where_serializer.rb', line 41

def _collect_pushable_filters(expr, allow_and:)
  op = expr[:op]
  if PUSHABLE_BINARY.include?(op)
    return [{ column: expr[:column], value: "#{op}.#{_stringify(expr[:value])}" }]
  end

  if op == :in
    values = expr[:values].map { |v| _stringify(v) }
    bad = values.find { |v| v.include?(',') }
    unless bad.nil?
      raise ValidationError.new(
        "IN filter values cannot contain commas because the live backend uses comma-separated IN lists (bad value: #{bad})",
        'filter_in_comma'
      )
    end
    return [{ column: expr[:column], value: "in.#{values.join(',')}" }]
  end

  if op == :and && allow_and
    out = []
    expr[:clauses].each { |clause| out.concat(_collect_pushable_filters(clause, allow_and: false)) }
    return out
  end

  # or / not / raw / nested-and all fall through to the rejection below.
  raise ValidationError.new(
    "Data where clause '#{op}' cannot be pushed to the live backend; use top-level and(eq/ne/gt/gte/lt/lte/in/like) only",
    'unsupported_filter'
  )
end

._column_type_to_def(col_type) ⇒ Object



130
131
132
133
134
135
136
137
138
139
140
# File 'lib/axhub_sdk/data/discover.rb', line 130

def _column_type_to_def(col_type)
  case col_type
  when 'uuid' then 'uuid'
  when 'int', 'integer', 'bigint' then 'integer'
  when 'float', 'numeric', 'double precision', 'real' then 'number'
  when 'bool', 'boolean' then 'boolean'
  when 'timestamp', 'timestamptz', 'timestamp with time zone' then 'timestamp'
  when 'json', 'jsonb' then 'json'
  else 'string' # text / varchar / character varying / unknown -> string
  end
end

._encode(value) ⇒ Object



26
27
28
# File 'lib/axhub_sdk/data/client.rb', line 26

def _encode(value)
  URI.encode_www_form_component(value.to_s)
end

._fetch_app_id_inspect(client, tenant_slug, app_slug, table) ⇒ Object

Raises:



55
56
57
58
59
60
61
62
# File 'lib/axhub_sdk/data/discover.rb', line 55

def _fetch_app_id_inspect(client, tenant_slug, app_slug, table)
  app_id = _resolve_app_id(client, tenant_slug, app_slug)
  raise TableNotFoundError.new("Dynamic data table '#{table}' was not found") if app_id.nil? || app_id.empty?

  path = "/api/v1/apps/#{_encode(app_id)}/tables/#{_encode(table)}"
  raw = client.request_raw('GET', path, camelize: true)
  schema_from_inspect_result(table, raw)
end

._fetch_slug_inspect(client, tenant_slug, app_slug, table) ⇒ Object



49
50
51
52
53
# File 'lib/axhub_sdk/data/discover.rb', line 49

def _fetch_slug_inspect(client, tenant_slug, app_slug, table)
  path = "/api/v1/tenants/#{_encode(tenant_slug)}/apps/#{_encode(app_slug)}/tables/#{_encode(table)}/inspect"
  raw = client.request_raw('GET', path, camelize: true)
  schema_from_inspect_result(table, raw)
end

._normalize_discover_error(err, table) ⇒ Object



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/axhub_sdk/data/discover.rb', line 94

def _normalize_discover_error(err, table)
  return err if err.is_a?(TableNotFoundError) || err.is_a?(IntrospectFailedError) || err.is_a?(ScanLimitExceededError)

  if _not_found?(err)
    return TableNotFoundError.new(
      "Dynamic data table '#{table}' was not found",
      request_id: (err.respond_to?(:request_id) ? err.request_id : nil)
    )
  end
  status = err.respond_to?(:status) ? err.status : nil
  if status.is_a?(Integer) && status >= 500
    return IntrospectFailedError.new(
      "Failed to introspect dynamic data table '#{table}'",
      status: status,
      retryable: (err.respond_to?(:retryable) ? !!err.retryable : false),
      request_id: (err.respond_to?(:request_id) ? err.request_id : nil)
    )
  end
  err
end

._not_found?(err) ⇒ Boolean

Returns:

  • (Boolean)


142
143
144
145
146
# File 'lib/axhub_sdk/data/discover.rb', line 142

def _not_found?(err)
  return true if err.is_a?(TableNotFoundError)

  err.is_a?(AxHub::Error) && err.respond_to?(:status) && err.status == 404
end

._read(obj, key) ⇒ Object

Read an attribute from either a duck-typed object or a Hash.



51
52
53
54
55
56
57
58
# File 'lib/axhub_sdk/data/dsl/validation.rb', line 51

def _read(obj, key)
  return nil if obj.nil?
  return obj.public_send(key) if obj.respond_to?(key)
  return obj[key] if obj.is_a?(Hash) && obj.key?(key)
  return obj[key.to_s] if obj.is_a?(Hash) && obj.key?(key.to_s)

  nil
end

._reject_legacy_page_options(after, before, direction, _table_name) ⇒ Object

Raises:



37
38
39
40
41
42
43
# File 'lib/axhub_sdk/data/client.rb', line 37

def _reject_legacy_page_options(after, before, direction, _table_name)
  return if after.nil? && before.nil? && direction.nil?

  raise LegacyCursorError.new(
    'after/before keyset cursors are not supported by the live AX Hub data API; use cursor/page numeric offset pagination'
  )
end

._resolve_app_id(client, tenant_slug, app_slug) ⇒ Object



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/axhub_sdk/data/discover.rb', line 64

def _resolve_app_id(client, tenant_slug, app_slug)
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
  cursor = nil
  APP_LOOKUP_MAX_PAGES.times do |page|
    if Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0 - started_at > APP_LOOKUP_BUDGET_MS
      raise IntrospectFailedError.new(
        "app lookup budget exceeded (#{APP_LOOKUP_BUDGET_MS}ms) while searching for slug '#{app_slug}' in tenant '#{tenant_slug}'"
      )
    end
    query = { 'tenant_slug' => tenant_slug, 'limit' => APP_LOOKUP_PAGE_SIZE }
    query['cursor'] = cursor if cursor
    raw = client.request_raw('GET', '/api/v1/apps', query: query, camelize: true)
    raw ||= {}
    items = raw['items'] || []
    match = items.find { |app| app['slug'] == app_slug && app['id'].is_a?(String) }
    return match['id'] if match && match['id']

    # Empty page on the first request means the tenant truly has no apps.
    return nil if page.zero? && items.empty?

    next_cursor = raw['next_cursor'] || raw['nextCursor']
    return nil if next_cursor.nil? || next_cursor == ''

    cursor = next_cursor
  end
  raise ScanLimitExceededError.new(
    "App lookup exceeded #{APP_LOOKUP_MAX_PAGES} pages x #{APP_LOOKUP_PAGE_SIZE} apps without finding slug '#{app_slug}'"
  )
end

._resolve_offset_page(cursor, page, table_name) ⇒ Object

Raises:



66
67
68
69
70
71
72
73
74
75
# File 'lib/axhub_sdk/data/client.rb', line 66

def _resolve_offset_page(cursor, page, table_name)
  unless cursor.nil?
    _validate_plain_cursor(cursor, table_name)
    return cursor.to_i
  end
  return 1 if page.nil?
  raise InvalidCursorError.new('page must be a positive integer') unless page.is_a?(Integer) && page >= 1

  page
end

._safe_parse(validator, data) ⇒ Object



20
21
22
# File 'lib/axhub_sdk/data/dsl/validation.rb', line 20

def _safe_parse(validator, data)
  validator.respond_to?(:safe_parse) ? validator.safe_parse(data) : validator.safeParse(data)
end

._stringify(value) ⇒ Object



72
73
74
75
76
77
78
79
80
81
82
# File 'lib/axhub_sdk/data/where_serializer.rb', line 72

def _stringify(value)
  case value
  when Time then value.iso8601
  when DateTime, Date then value.iso8601
  when nil then 'null'
  when true then 'true'
  when false then 'false'
  when String, Integer, Float then value.to_s
  else JSON.generate(value)
  end
end

._stringify_keys(hash) ⇒ Object



62
63
64
# File 'lib/axhub_sdk/data/dsl/schema.rb', line 62

def _stringify_keys(hash)
  hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
end

._validate_plain_cursor(cursor, _table_name) ⇒ Object

Raises:



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'lib/axhub_sdk/data/client.rb', line 45

def _validate_plain_cursor(cursor, _table_name)
  if cursor.length > MAX_CURSOR_TOKEN_LENGTH
    raise InvalidCursorError.new("Cursor token exceeds maximum size (#{MAX_CURSOR_TOKEN_LENGTH} chars)")
  end
  if cursor.start_with?('v1:')
    raise LegacyCursorError.new(
      'Legacy v1: cursor token is not compatible with AX Hub offset-only pagination; restart pagination without cursor'
    )
  end
  if Data.is_v2_cursor(cursor)
    raise LegacyCursorError.new(
      'v2 keyset cursors are not supported by the live AX Hub data API; restart pagination and use the numeric cursor returned by list()'
    )
  end
  unless cursor.match?(/\A-?\d+\z/)
    raise InvalidCursorError.new('Plain cursor must be a positive integer page or a v2: keyset token')
  end
  parsed = cursor.to_i
  raise InvalidCursorError.new('Plain cursor must be a positive integer page or a v2: keyset token') if parsed < 1
end

.and_(*clauses) ⇒ Object



71
72
73
# File 'lib/axhub_sdk/data/dsl/ops.rb', line 71

def and_(*clauses)
  { op: :and, clauses: clauses }
end

.assert_safe_like_pattern(pattern) ⇒ Object

Reject LIKE patterns that translate to catastrophic-backtracking regex shapes (mirrors node assertSafeLikePattern).



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/axhub_sdk/data/dsl/ops.rb', line 35

def assert_safe_like_pattern(pattern)
  if pattern.length > MAX_LIKE_PATTERN_LENGTH
    raise ValidationError.new("LIKE pattern exceeds #{MAX_LIKE_PATTERN_LENGTH} chars; refuse to compile", 'like_pattern_too_long')
  end

  run_of_wildcards = 0
  segments = 0
  i = 0
  n = pattern.length
  while i < n
    ch = pattern[i]
    if ch == '\\'
      i += 2
      run_of_wildcards = 0
      next
    end
    if ch == '%'
      run_of_wildcards += 1
      if run_of_wildcards >= MAX_CONSECUTIVE_WILDCARDS
        raise ValidationError.new("LIKE pattern has #{run_of_wildcards} consecutive '%'; refuse to compile (ReDoS guard)", 'like_pattern_redos')
      end
    else
      segments += 1 if run_of_wildcards == 1
      run_of_wildcards = 0
    end
    i += 1
  end
  if segments > MAX_LIKE_ALTERNATION_SEGMENTS
    raise ValidationError.new("LIKE pattern has #{segments} '%X%' alternation segments; refuse to compile (ReDoS guard)", 'like_pattern_redos')
  end
end

.define_schema(table_or_input, columns = nil, validate: nil) ⇒ Object

Define a data table schema. Two call shapes mirror node’s two overloads:

define_schema("orders", { "id" => "uuid", "total" => "number" })
define_schema({ "table" => "orders", "columns" => {...} }, validate: ...)

An existing DataTableSchema is re-wrapped, optionally attaching ‘validate`.



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/axhub_sdk/data/dsl/schema.rb', line 35

def define_schema(table_or_input, columns = nil, validate: nil)
  if table_or_input.is_a?(DataTableSchema)
    return DataTableSchema.new(
      table: table_or_input.table,
      columns: table_or_input.columns,
      cols: table_or_input.cols,
      validate: validate.nil? ? table_or_input.validate : validate
    )
  end

  if table_or_input.is_a?(Hash)
    h = _stringify_keys(table_or_input)
    table = h['table']
    shape = _stringify_keys(h['columns'])
  else
    table = table_or_input
    raise ArgumentError, 'define_schema requires columns when called with a table name' if columns.nil?

    shape = _stringify_keys(columns)
  end

  cols = shape.each_with_object({}) do |(name, definition), acc|
    acc[name] = DataColumn.new(table: table, name: name, definition: definition)
  end
  DataTableSchema.new(table: table, columns: shape, cols: cols, validate: validate)
end

.escape_like(value) ⇒ Object



27
28
29
30
31
# File 'lib/axhub_sdk/data/dsl/ops.rb', line 27

def escape_like(value)
  return value if value == ''

  value.gsub(ESCAPE_LIKE_RE) { |m| "\\#{m}" }
end

.fetch_discovered_schema(client, tenant_slug, app_slug, table, fresh: nil, ttl_ms: nil) ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# File 'lib/axhub_sdk/data/discover.rb', line 31

def fetch_discovered_schema(client, tenant_slug, app_slug, table, fresh: nil, ttl_ms: nil)
  # The appId path is the route the `axhub` CLI uses and is verified to work
  # with a data-ring PAT (2026-06). The slug `/inspect` route rejects a slug
  # in the {tenant} path segment on the live backend ("tenant_id 형식이 잘못됐어요",
  # HTTP 400) — a 400 not a 404, so the old slug-first order never reached the
  # working path. appId is primary; slug inspect is a best-effort fallback. The
  # appId error is the meaningful one, so it is what surfaces.
  begin
    _fetch_app_id_inspect(client, tenant_slug, app_slug, table)
  rescue StandardError => err
    begin
      _fetch_slug_inspect(client, tenant_slug, app_slug, table)
    rescue StandardError
      raise _normalize_discover_error(err, table)
    end
  end
end

.is_v2_cursor(token) ⇒ Object



66
67
68
# File 'lib/axhub_sdk/data/pagination.rb', line 66

def is_v2_cursor(token)
  token.is_a?(String) && token.start_with?('v2:')
end

.list_all(fetcher, opts = {}) ⇒ Object

Drive a paginated fetcher to exhaustion, yielding each item and a drift marker when the backend total grows mid-iteration (mirrors node listAll). Returns an Enumerator when no block is given (idiomatic Ruby).



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/axhub_sdk/data/pagination.rb', line 73

def list_all(fetcher, opts = {})
  return enum_for(:list_all, fetcher, opts) unless block_given?

  cursor = opts[:cursor]
  initial_total = nil
  last_total = nil
  loop do
    page = fetcher.call(page_size: opts[:page_size], cursor: cursor)
    unless page.total.nil?
      if initial_total.nil?
        initial_total = page.total
        last_total = page.total
      else
        base = last_total.nil? ? initial_total : last_total
        if page.total > base
          yield ListAllItem.new(type: :drift, added_since: page.total - base)
          last_total = page.total
        end
      end
    end
    page.items.each { |item| yield ListAllItem.new(type: :item, value: item) }
    return if page.next_cursor.nil?

    cursor = page.next_cursor
  end
end

.normalize_order_by(order_by) ⇒ Object

order_by = String | Array<{ field: String, dir?: “asc”|“desc” }>



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# File 'lib/axhub_sdk/data/pagination.rb', line 31

def normalize_order_by(order_by)
  if order_by.is_a?(String)
    fields = []
    order_by.split(',').each do |part|
      trimmed = part.strip
      f = if trimmed.start_with?('-')
            { 'field' => trimmed[1..], 'dir' => 'desc' }
          elsif trimmed.start_with?('+')
            { 'field' => trimmed[1..], 'dir' => 'asc' }
          else
            { 'field' => trimmed, 'dir' => 'asc' }
          end
      fields << f unless f['field'].nil? || f['field'].empty?
    end
  elsif order_by && !order_by.empty?
    fields = order_by.map do |p|
      h = p.transform_keys(&:to_s)
      { 'field' => h['field'], 'dir' => h.fetch('dir', 'asc') }
    end
  else
    fields = []
  end
  if !fields.empty? && fields.none? { |f| f['field'] == 'id' }
    fields << { 'field' => 'id', 'dir' => 'asc' }
  end
  fields
end

.not_(clause) ⇒ Object



79
80
81
# File 'lib/axhub_sdk/data/dsl/ops.rb', line 79

def not_(clause)
  { op: :not, clause: clause }
end

.or_(*clauses) ⇒ Object



75
76
77
# File 'lib/axhub_sdk/data/dsl/ops.rb', line 75

def or_(*clauses)
  { op: :or, clauses: clauses }
end

.project_row(row, select) ⇒ Object



38
39
40
41
42
# File 'lib/axhub_sdk/data/projection.rb', line 38

def project_row(row, select)
  return row.dup if select.nil?

  select.each_with_object({}) { |k, acc| acc[k] = row[k] if row.key?(k) }
end

.project_rows(rows, select) ⇒ Object



44
45
46
47
48
# File 'lib/axhub_sdk/data/projection.rb', line 44

def project_rows(rows, select)
  return rows.map(&:dup) if select.nil?

  rows.map { |r| project_row(r, select) }
end

.raw(sql, params = nil) ⇒ Object



67
68
69
# File 'lib/axhub_sdk/data/dsl/ops.rb', line 67

def raw(sql, params = nil)
  params.nil? ? { op: :raw, sql: sql } : { op: :raw, sql: sql, params: params }
end

.run_schema_validation(schema, data, mode) ⇒ Object

Validate ‘data` against schema.validate before any network request. `mode` is “insert” or “update” (update uses `partial` when available).

Raises:



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/axhub_sdk/data/dsl/validation.rb', line 26

def run_schema_validation(schema, data, mode)
  validator = schema&.validate
  return if validator.nil?

  unless validator_like?(validator)
    raise AxHub::Error.new(
      category: 'configuration', code: 'validator_missing',
      message: 'define_schema validate option requires a schema-like object with safe_parse'
    )
  end

  effective = validator
  effective = validator.partial if mode == 'update' && validator.respond_to?(:partial)
  result = _safe_parse(effective, data)

  success = _read(result, :success)
  return if success

  error = _read(result, :error)
  issues = _read(error, :issues) || []
  count = issues.empty? ? 1 : issues.length
  raise ValidationError.new("#{count} validation failure#{count == 1 ? '' : 's'} before network request", 'validation_failed')
end

.schema_cache_key(tenant_slug, app_slug, table) ⇒ Object



22
23
24
# File 'lib/axhub_sdk/data/schema_cache.rb', line 22

def schema_cache_key(tenant_slug, app_slug, table)
  "#{tenant_slug}/#{app_slug}/#{table}"
end

.schema_from_inspect_result(table, raw) ⇒ Object



115
116
117
118
119
120
121
122
123
124
125
126
127
128
# File 'lib/axhub_sdk/data/discover.rb', line 115

def schema_from_inspect_result(table, raw)
  raw ||= {}
  columns = raw['columns'] || []
  shape = {}
  columns.each do |column|
    name = column['name']
    next if FORBIDDEN_COLUMN_NAMES.include?(name)
    next unless name.is_a?(String) && COLUMN_NAME_RE.match?(name)

    shape[name] = _column_type_to_def(column['type'])
  end
  table_name = raw['tableName'] || raw['table_name'] || raw['name'] || table
  Data.define_schema({ 'table' => table_name, 'columns' => shape })
end

.serialize_order_by(order_by) ⇒ Object



59
60
61
62
63
64
# File 'lib/axhub_sdk/data/pagination.rb', line 59

def serialize_order_by(order_by)
  normalized = normalize_order_by(order_by)
  return (order_by.is_a?(String) ? order_by : nil) if normalized.empty?

  normalized.map { |f| "#{f['dir'] == 'desc' ? '-' : ''}#{f['field']}" }.join(',')
end

.serialize_select(select) ⇒ Object



16
17
18
19
20
# File 'lib/axhub_sdk/data/projection.rb', line 16

def serialize_select(select)
  return nil if select.nil?

  select.join(',')
end

.serialize_where(expr) ⇒ Object



21
22
23
24
25
26
27
28
29
# File 'lib/axhub_sdk/data/where_serializer.rb', line 21

def serialize_where(expr)
  return {} if expr.nil?

  out = {}
  _collect_pushable_filters(expr, allow_and: true).each do |f|
    _append_query(out, f[:column], f[:value])
  end
  out
end

.validate_select_columns(schema, select) ⇒ Object

Raises:



22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/axhub_sdk/data/projection.rb', line 22

def validate_select_columns(schema, select)
  return if select.nil?

  if select.length.zero?
    raise ValidationError.new('select must include at least one column; omit select to fetch full rows', 'select_empty')
  end
  return if schema.nil?

  allowed = schema.columns.keys
  invalid = select.reject { |c| allowed.include?(c) }
  return if invalid.empty?

  plural = invalid.length == 1 ? '' : 's'
  raise ValidationError.new("select contains unknown column#{plural}: #{invalid.join(', ')}", 'select_unknown_column')
end

.validator_like?(value) ⇒ Boolean

Returns:

  • (Boolean)


16
17
18
# File 'lib/axhub_sdk/data/dsl/validation.rb', line 16

def validator_like?(value)
  !value.nil? && (value.respond_to?(:safe_parse) || value.respond_to?(:safeParse))
end

.where(column) ⇒ Object

Start a predicate for a column. Accepts a DataColumn, a String, or a Symbol.

where(:status).eq("paid")
where("status").eq("paid")
where(schema.cols["status"]).eq("paid")

Block form (idiomatic Ruby): yields the builder and returns its result, so the bare-atom expr can be written without a trailing chain.

where(:status) { |c| c.eq("paid") }


93
94
95
96
97
98
99
# File 'lib/axhub_sdk/data/dsl/ops.rb', line 93

def where(column)
  name = column.is_a?(DataColumn) ? column.name : column.to_s
  builder = WhereBuilder.new(name)
  return yield(builder) if block_given?

  builder
end