axhub-sdk 0.2.0 → 0.3.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 +154 -4
- data/axhub-sdk.gemspec +3 -0
- data/lib/axhub_sdk/data/client.rb +267 -0
- data/lib/axhub_sdk/data/discover.rb +148 -0
- data/lib/axhub_sdk/data/dsl/ops.rb +150 -0
- data/lib/axhub_sdk/data/dsl/schema.rb +66 -0
- data/lib/axhub_sdk/data/dsl/validation.rb +60 -0
- data/lib/axhub_sdk/data/errors.rb +48 -0
- data/lib/axhub_sdk/data/pagination.rb +100 -0
- data/lib/axhub_sdk/data/projection.rb +50 -0
- data/lib/axhub_sdk/data/schema_cache.rb +105 -0
- data/lib/axhub_sdk/data/where_serializer.rb +84 -0
- data/lib/axhub_sdk/data.rb +67 -0
- data/lib/axhub_sdk/version.rb +1 -1
- data/lib/axhub_sdk.rb +27 -2
- metadata +42 -3
|
@@ -0,0 +1,150 @@
|
|
|
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
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AxHub
|
|
4
|
+
module Data
|
|
5
|
+
# Typed data-layer errors. These subclass the existing single AxHub::Error so
|
|
6
|
+
# the (category, code) contract that the conformance vectors + error tests
|
|
7
|
+
# match on keeps working, while callers can still rescue the specific failure
|
|
8
|
+
# (mirrors node/python LegacyCursorError / InvalidCursorError / ValidationError
|
|
9
|
+
# / TableNotFoundError / IntrospectFailedError / ScanLimitExceededError).
|
|
10
|
+
class ValidationError < AxHub::Error
|
|
11
|
+
def initialize(message, code = 'validation', status: 0, retryable: false, request_id: nil)
|
|
12
|
+
super(category: 'validation', code: code, message: message, status: status, retryable: retryable, request_id: request_id)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Raised when an after/before keyset or v1:/v2: cursor token is supplied; the
|
|
17
|
+
# live AX Hub data API is offset-only (mirrors node LegacyCursorError).
|
|
18
|
+
class LegacyCursorError < AxHub::Error
|
|
19
|
+
def initialize(message, request_id: nil)
|
|
20
|
+
super(category: 'validation', code: 'legacy_cursor', message: message, status: 0, retryable: false, request_id: request_id)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class InvalidCursorError < AxHub::Error
|
|
25
|
+
def initialize(message, request_id: nil)
|
|
26
|
+
super(category: 'validation', code: 'invalid_cursor', message: message, status: 0, retryable: false, request_id: request_id)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class TableNotFoundError < AxHub::Error
|
|
31
|
+
def initialize(message, request_id: nil)
|
|
32
|
+
super(category: 'not_found', code: 'table_not_found', message: message, status: 404, retryable: false, request_id: request_id)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class IntrospectFailedError < AxHub::Error
|
|
37
|
+
def initialize(message, status: 0, retryable: false, request_id: nil)
|
|
38
|
+
super(category: 'internal', code: 'introspect_failed', message: message, status: status, retryable: retryable, request_id: request_id)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class ScanLimitExceededError < AxHub::Error
|
|
43
|
+
def initialize(message, request_id: nil)
|
|
44
|
+
super(category: 'internal', code: 'scan_limit_exceeded', message: message, status: 0, retryable: false, request_id: request_id)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AxHub
|
|
4
|
+
module Data
|
|
5
|
+
# Offset pagination helpers (subset of node core pagination that the data
|
|
6
|
+
# ergonomic layer depends on).
|
|
7
|
+
#
|
|
8
|
+
# Ported: serialize_order_by / normalize_order_by, is_v2_cursor,
|
|
9
|
+
# MAX_CURSOR_TOKEN_LENGTH, list_all, and the PaginatedList / ListAllItem
|
|
10
|
+
# result shapes. Keyset encode/decode is intentionally NOT ported: the live
|
|
11
|
+
# AX Hub data API is offset-only, so the data layer only needs the order-by
|
|
12
|
+
# normalizer and the cursor-shape guards used to reject legacy keyset tokens.
|
|
13
|
+
MAX_CURSOR_TOKEN_LENGTH = 4096
|
|
14
|
+
|
|
15
|
+
PaginatedList = Struct.new(
|
|
16
|
+
:items, :next_cursor, :first_cursor, :has_next, :has_prev, :total, :total_is_exact,
|
|
17
|
+
keyword_init: true
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Either an item (type == :item) or a drift marker (type == :drift) when the
|
|
21
|
+
# backend total grows mid-scan.
|
|
22
|
+
ListAllItem = Struct.new(:type, :value, :added_since, keyword_init: true) do
|
|
23
|
+
def initialize(type:, value: nil, added_since: 0)
|
|
24
|
+
super(type: type, value: value, added_since: added_since)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# order_by = String | Array<{ field: String, dir?: "asc"|"desc" }>
|
|
31
|
+
def normalize_order_by(order_by)
|
|
32
|
+
if order_by.is_a?(String)
|
|
33
|
+
fields = []
|
|
34
|
+
order_by.split(',').each do |part|
|
|
35
|
+
trimmed = part.strip
|
|
36
|
+
f = if trimmed.start_with?('-')
|
|
37
|
+
{ 'field' => trimmed[1..], 'dir' => 'desc' }
|
|
38
|
+
elsif trimmed.start_with?('+')
|
|
39
|
+
{ 'field' => trimmed[1..], 'dir' => 'asc' }
|
|
40
|
+
else
|
|
41
|
+
{ 'field' => trimmed, 'dir' => 'asc' }
|
|
42
|
+
end
|
|
43
|
+
fields << f unless f['field'].nil? || f['field'].empty?
|
|
44
|
+
end
|
|
45
|
+
elsif order_by && !order_by.empty?
|
|
46
|
+
fields = order_by.map do |p|
|
|
47
|
+
h = p.transform_keys(&:to_s)
|
|
48
|
+
{ 'field' => h['field'], 'dir' => h.fetch('dir', 'asc') }
|
|
49
|
+
end
|
|
50
|
+
else
|
|
51
|
+
fields = []
|
|
52
|
+
end
|
|
53
|
+
if !fields.empty? && fields.none? { |f| f['field'] == 'id' }
|
|
54
|
+
fields << { 'field' => 'id', 'dir' => 'asc' }
|
|
55
|
+
end
|
|
56
|
+
fields
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def serialize_order_by(order_by)
|
|
60
|
+
normalized = normalize_order_by(order_by)
|
|
61
|
+
return (order_by.is_a?(String) ? order_by : nil) if normalized.empty?
|
|
62
|
+
|
|
63
|
+
normalized.map { |f| "#{f['dir'] == 'desc' ? '-' : ''}#{f['field']}" }.join(',')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def is_v2_cursor(token)
|
|
67
|
+
token.is_a?(String) && token.start_with?('v2:')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Drive a paginated fetcher to exhaustion, yielding each item and a drift
|
|
71
|
+
# marker when the backend total grows mid-iteration (mirrors node listAll).
|
|
72
|
+
# Returns an Enumerator when no block is given (idiomatic Ruby).
|
|
73
|
+
def list_all(fetcher, opts = {})
|
|
74
|
+
return enum_for(:list_all, fetcher, opts) unless block_given?
|
|
75
|
+
|
|
76
|
+
cursor = opts[:cursor]
|
|
77
|
+
initial_total = nil
|
|
78
|
+
last_total = nil
|
|
79
|
+
loop do
|
|
80
|
+
page = fetcher.call(page_size: opts[:page_size], cursor: cursor)
|
|
81
|
+
unless page.total.nil?
|
|
82
|
+
if initial_total.nil?
|
|
83
|
+
initial_total = page.total
|
|
84
|
+
last_total = page.total
|
|
85
|
+
else
|
|
86
|
+
base = last_total.nil? ? initial_total : last_total
|
|
87
|
+
if page.total > base
|
|
88
|
+
yield ListAllItem.new(type: :drift, added_since: page.total - base)
|
|
89
|
+
last_total = page.total
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
page.items.each { |item| yield ListAllItem.new(type: :item, value: item) }
|
|
94
|
+
return if page.next_cursor.nil?
|
|
95
|
+
|
|
96
|
+
cursor = page.next_cursor
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module AxHub
|
|
6
|
+
module Data
|
|
7
|
+
# Column projection: `select` serialization, validation, and client-side row
|
|
8
|
+
# narrowing (mirrors node/python projection).
|
|
9
|
+
#
|
|
10
|
+
# serialize_select joins columns with commas into the `_select` query param.
|
|
11
|
+
# validate_select_columns rejects an empty select and, when a schema is known,
|
|
12
|
+
# unknown columns. project_row/project_rows narrow returned rows to the
|
|
13
|
+
# selected keys client-side. Rows are string-keyed (camelize-off transport).
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def serialize_select(select)
|
|
17
|
+
return nil if select.nil?
|
|
18
|
+
|
|
19
|
+
select.join(',')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def validate_select_columns(schema, select)
|
|
23
|
+
return if select.nil?
|
|
24
|
+
|
|
25
|
+
if select.length.zero?
|
|
26
|
+
raise ValidationError.new('select must include at least one column; omit select to fetch full rows', 'select_empty')
|
|
27
|
+
end
|
|
28
|
+
return if schema.nil?
|
|
29
|
+
|
|
30
|
+
allowed = schema.columns.keys
|
|
31
|
+
invalid = select.reject { |c| allowed.include?(c) }
|
|
32
|
+
return if invalid.empty?
|
|
33
|
+
|
|
34
|
+
plural = invalid.length == 1 ? '' : 's'
|
|
35
|
+
raise ValidationError.new("select contains unknown column#{plural}: #{invalid.join(', ')}", 'select_unknown_column')
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def project_row(row, select)
|
|
39
|
+
return row.dup if select.nil?
|
|
40
|
+
|
|
41
|
+
select.each_with_object({}) { |k, acc| acc[k] = row[k] if row.key?(k) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def project_rows(rows, select)
|
|
45
|
+
return rows.map(&:dup) if select.nil?
|
|
46
|
+
|
|
47
|
+
rows.map { |r| project_row(r, select) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'dsl/schema'
|
|
4
|
+
|
|
5
|
+
module AxHub
|
|
6
|
+
module Data
|
|
7
|
+
# Per-client schema cache for runtime data.discover (mirrors node/python
|
|
8
|
+
# schema-cache).
|
|
9
|
+
#
|
|
10
|
+
# Uses an insertion-ordered Ruby Hash for deterministic LRU eviction
|
|
11
|
+
# (delete+reinsert = move-to-end on read/write, `shift` = evict oldest) and a
|
|
12
|
+
# negative-TTL stale-while-error window: a transient 5xx during refresh keeps
|
|
13
|
+
# the previous entry alive briefly instead of evicting it. The node version
|
|
14
|
+
# de-dupes concurrent in-flight loads; the sync Ruby port omits the in-flight
|
|
15
|
+
# map (no concurrency within a single synchronous call).
|
|
16
|
+
DEFAULT_SCHEMA_CACHE_TTL_MS = 5 * 60_000
|
|
17
|
+
DEFAULT_SCHEMA_CACHE_MAX_ENTRIES = 1000
|
|
18
|
+
DEFAULT_SCHEMA_CACHE_NEGATIVE_TTL_MS = 30_000
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def schema_cache_key(tenant_slug, app_slug, table)
|
|
23
|
+
"#{tenant_slug}/#{app_slug}/#{table}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class SchemaCache
|
|
27
|
+
Entry = Struct.new(:schema, :expires_at, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
def initialize(max_entries: nil, ttl_ms: nil, negative_ttl_ms: nil)
|
|
30
|
+
@store = {}
|
|
31
|
+
@max_entries = [1, max_entries.nil? ? DEFAULT_SCHEMA_CACHE_MAX_ENTRIES : max_entries].max
|
|
32
|
+
@ttl_ms = [1, ttl_ms.nil? ? DEFAULT_SCHEMA_CACHE_TTL_MS : ttl_ms].max
|
|
33
|
+
@negative_ttl_ms = [0, negative_ttl_ms.nil? ? DEFAULT_SCHEMA_CACHE_NEGATIVE_TTL_MS : negative_ttl_ms].max
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def size
|
|
37
|
+
@store.size
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get(key)
|
|
41
|
+
entry = @store[key]
|
|
42
|
+
return nil if entry.nil?
|
|
43
|
+
|
|
44
|
+
if entry.expires_at <= _now_ms
|
|
45
|
+
@store.delete(key)
|
|
46
|
+
return nil
|
|
47
|
+
end
|
|
48
|
+
# refresh recency: move to end (delete + reinsert)
|
|
49
|
+
@store.delete(key)
|
|
50
|
+
@store[key] = entry
|
|
51
|
+
entry.schema
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def set(key, schema, ttl_ms = nil)
|
|
55
|
+
@store.delete(key)
|
|
56
|
+
@store[key] = Entry.new(schema: schema, expires_at: _now_ms + [1, ttl_ms.nil? ? @ttl_ms : ttl_ms].max)
|
|
57
|
+
_evict_overflow
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def invalidate(key = nil)
|
|
62
|
+
if key.nil?
|
|
63
|
+
@store.clear
|
|
64
|
+
else
|
|
65
|
+
@store.delete(key)
|
|
66
|
+
end
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def get_or_set(key, fresh: nil, ttl_ms: nil)
|
|
71
|
+
unless fresh
|
|
72
|
+
cached = get(key)
|
|
73
|
+
return cached unless cached.nil?
|
|
74
|
+
end
|
|
75
|
+
previous = @store[key]
|
|
76
|
+
begin
|
|
77
|
+
schema = yield
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
if !previous.nil? && @negative_ttl_ms.positive? && _transient_server_error?(e)
|
|
80
|
+
@store.delete(key)
|
|
81
|
+
@store[key] = Entry.new(schema: previous.schema, expires_at: _now_ms + @negative_ttl_ms)
|
|
82
|
+
end
|
|
83
|
+
raise
|
|
84
|
+
end
|
|
85
|
+
set(key, schema, ttl_ms)
|
|
86
|
+
schema
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def _now_ms
|
|
92
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def _transient_server_error?(err)
|
|
96
|
+
status = err.respond_to?(:status) ? err.status : nil
|
|
97
|
+
status.is_a?(Integer) && status >= 500
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def _evict_overflow
|
|
101
|
+
@store.shift while @store.size > @max_entries # oldest first
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'date'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
|
|
7
|
+
module AxHub
|
|
8
|
+
module Data
|
|
9
|
+
# Serialize the predicate DSL into backend filter query params (mirrors node
|
|
10
|
+
# where-serializer + python where_serializer).
|
|
11
|
+
#
|
|
12
|
+
# Each pushable atom becomes column=<op>.<value> (PostgREST-style). Repeated
|
|
13
|
+
# columns collapse into an array so the transport emits repeated query params
|
|
14
|
+
# (URI.encode_www_form repeats array-valued keys). Only top-level and(...) of
|
|
15
|
+
# pushable atoms and bare atoms are accepted; or/not/raw and nested-and raise
|
|
16
|
+
# ValidationError — this matches the live backend's filter grammar.
|
|
17
|
+
PUSHABLE_BINARY = %i[eq ne gt gte lt lte like].freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def serialize_where(expr)
|
|
22
|
+
return {} if expr.nil?
|
|
23
|
+
|
|
24
|
+
out = {}
|
|
25
|
+
_collect_pushable_filters(expr, allow_and: true).each do |f|
|
|
26
|
+
_append_query(out, f[:column], f[:value])
|
|
27
|
+
end
|
|
28
|
+
out
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def _append_query(out, key, value)
|
|
32
|
+
if !out.key?(key)
|
|
33
|
+
out[key] = value
|
|
34
|
+
elsif out[key].is_a?(Array)
|
|
35
|
+
out[key] << value
|
|
36
|
+
else
|
|
37
|
+
out[key] = [out[key], value]
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def _collect_pushable_filters(expr, allow_and:)
|
|
42
|
+
op = expr[:op]
|
|
43
|
+
if PUSHABLE_BINARY.include?(op)
|
|
44
|
+
return [{ column: expr[:column], value: "#{op}.#{_stringify(expr[:value])}" }]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if op == :in
|
|
48
|
+
values = expr[:values].map { |v| _stringify(v) }
|
|
49
|
+
bad = values.find { |v| v.include?(',') }
|
|
50
|
+
unless bad.nil?
|
|
51
|
+
raise ValidationError.new(
|
|
52
|
+
"IN filter values cannot contain commas because the live backend uses comma-separated IN lists (bad value: #{bad})",
|
|
53
|
+
'filter_in_comma'
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
return [{ column: expr[:column], value: "in.#{values.join(',')}" }]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if op == :and && allow_and
|
|
60
|
+
out = []
|
|
61
|
+
expr[:clauses].each { |clause| out.concat(_collect_pushable_filters(clause, allow_and: false)) }
|
|
62
|
+
return out
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# or / not / raw / nested-and all fall through to the rejection below.
|
|
66
|
+
raise ValidationError.new(
|
|
67
|
+
"Data where clause '#{op}' cannot be pushed to the live backend; use top-level and(eq/ne/gt/gte/lt/lte/in/like) only",
|
|
68
|
+
'unsupported_filter'
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def _stringify(value)
|
|
73
|
+
case value
|
|
74
|
+
when Time then value.iso8601
|
|
75
|
+
when DateTime, Date then value.iso8601
|
|
76
|
+
when nil then 'null'
|
|
77
|
+
when true then 'true'
|
|
78
|
+
when false then 'false'
|
|
79
|
+
when String, Integer, Float then value.to_s
|
|
80
|
+
else JSON.generate(value)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|