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