diff --git a/CHANGELOG.md b/CHANGELOG.md index 33c6684..3a035c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## Unreleased + +### Added + +* Support named and positional bind values through `QueryRequest#params`. + ## [0.7.0](https://github.com/altertable-ai/altertable-lakehouse-ruby/compare/altertable-lakehouse/v0.6.0...altertable-lakehouse/v0.7.0) (2026-08-26) diff --git a/README.md b/README.md index b61e966..e9e9201 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,8 @@ client.append( # Query data result = client.query_all( - statement: "SELECT * FROM main.public.events LIMIT 10" + statement: "SELECT * FROM main.public.events WHERE age >= $min_age", + params: { "min_age" => 25 } ) result[:rows].each { |row| puts row } ``` diff --git a/lib/altertable/lakehouse/client.rb b/lib/altertable/lakehouse/client.rb index 28bdd19..da84356 100644 --- a/lib/altertable/lakehouse/client.rb +++ b/lib/altertable/lakehouse/client.rb @@ -282,7 +282,7 @@ class QueryResult include Enumerable # metadata: the stream header object (first NDJSON line) - # columns: array of column name strings (second NDJSON line) + # columns: array of column names or typed column descriptors (second NDJSON line) attr_reader :metadata, :columns def initialize(enum) @@ -304,7 +304,7 @@ def each(&block) when 0 @metadata = item when 1 - @columns = item + @columns = normalize_columns(item) else if @columns.is_a?(Array) && item.is_a?(Array) block.call(@columns.zip(item).to_h) @@ -315,6 +315,19 @@ def each(&block) line_index += 1 end end + + private + + # The query service emits typed column descriptors, such as + # { "name" => "id", "type" => "INTEGER" }. Keep the public result + # shape stable by exposing names and using them as row keys. + def normalize_columns(columns) + return columns unless columns.is_a?(Array) + + columns.map do |column| + column.is_a?(Hash) && column.key?("name") ? column["name"] : column + end + end end end diff --git a/lib/altertable/lakehouse/models.rb b/lib/altertable/lakehouse/models.rb index 4a8bb05..740d45f 100644 --- a/lib/altertable/lakehouse/models.rb +++ b/lib/altertable/lakehouse/models.rb @@ -54,9 +54,9 @@ def self.from_h(h) end class QueryRequest < Request - attr_reader :statement, :catalog, :schema, :session_id, :compute_size, :sanitize, :limit, :offset, :timezone, :ephemeral, :visible, :requested_by, :query_id, :cache + attr_reader :statement, :catalog, :schema, :session_id, :compute_size, :sanitize, :limit, :offset, :timezone, :ephemeral, :visible, :requested_by, :query_id, :cache, :params - def initialize(statement:, catalog: nil, schema: nil, session_id: nil, compute_size: nil, sanitize: nil, limit: nil, offset: nil, timezone: nil, ephemeral: nil, visible: nil, requested_by: nil, query_id: nil, cache: nil) + def initialize(statement:, catalog: nil, schema: nil, session_id: nil, compute_size: nil, sanitize: nil, limit: nil, offset: nil, timezone: nil, ephemeral: nil, visible: nil, requested_by: nil, query_id: nil, cache: nil, params: nil) @statement = statement @catalog = catalog @schema = schema @@ -71,6 +71,7 @@ def initialize(statement:, catalog: nil, schema: nil, session_id: nil, compute_s @requested_by = requested_by @query_id = query_id @cache = cache + @params = params end def to_h @@ -88,6 +89,7 @@ def to_h h[:requested_by] = @requested_by if @requested_by h[:query_id] = @query_id if @query_id h[:cache] = @cache unless @cache.nil? + h[:params] = @params unless @params.nil? h end end diff --git a/rbi/altertable/lakehouse.rbi b/rbi/altertable/lakehouse.rbi index ba0235f..64bb073 100644 --- a/rbi/altertable/lakehouse.rbi +++ b/rbi/altertable/lakehouse.rbi @@ -300,6 +300,9 @@ module Altertable sig { returns(T.nilable(T::Boolean)) } def cache; end + sig { returns(T.nilable(T.any(T::Hash[String, T.untyped], T::Array[T.untyped]))) } + def params; end + sig do params( statement: String, @@ -315,10 +318,11 @@ module Altertable visible: T.nilable(T::Boolean), requested_by: T.nilable(String), query_id: T.nilable(String), - cache: T.nilable(T::Boolean) + cache: T.nilable(T::Boolean), + params: T.nilable(T.any(T::Hash[String, T.untyped], T::Array[T.untyped])) ).void end - def initialize(statement:, catalog: nil, schema: nil, session_id: nil, compute_size: nil, sanitize: nil, limit: nil, offset: nil, timezone: nil, ephemeral: nil, visible: nil, requested_by: nil, query_id: nil, cache: nil); end + def initialize(statement:, catalog: nil, schema: nil, session_id: nil, compute_size: nil, sanitize: nil, limit: nil, offset: nil, timezone: nil, ephemeral: nil, visible: nil, requested_by: nil, query_id: nil, cache: nil, params: nil); end sig { returns(T::Hash[Symbol, T.untyped]) } def to_h; end diff --git a/sig/altertable/lakehouse/models.rbs b/sig/altertable/lakehouse/models.rbs index 6adb1e2..dddaa0d 100644 --- a/sig/altertable/lakehouse/models.rbs +++ b/sig/altertable/lakehouse/models.rbs @@ -45,6 +45,7 @@ module Altertable attr_reader requested_by: String? attr_reader query_id: String? attr_reader cache: bool? + attr_reader params: (::Hash[String, untyped] | ::Array[untyped])? def initialize: ( statement: String, @@ -60,7 +61,8 @@ module Altertable ?visible: bool?, ?requested_by: String?, ?query_id: String?, - ?cache: bool? + ?cache: bool?, + ?params: (::Hash[String, untyped] | ::Array[untyped])? ) -> void def to_h: () -> ::Hash[Symbol, untyped] diff --git a/spec/altertable/lakehouse_spec.rb b/spec/altertable/lakehouse_spec.rb index 2b60a40..8d3c21e 100644 --- a/spec/altertable/lakehouse_spec.rb +++ b/spec/altertable/lakehouse_spec.rb @@ -143,6 +143,18 @@ # ── #query ─────────────────────────────────────────────────────────────────── describe "#query (streaming)" do + it "normalizes typed column descriptors to column names" do + stream = [ + { "statement" => "SELECT 1 AS id" }, + [{ "name" => "id", "type" => "INTEGER" }], + [1] + ] + result = Altertable::Lakehouse::QueryResult.new(stream.each) + + expect(result.to_a).to eq([{ "id" => 1 }]) + expect(result.columns).to eq(["id"]) + end + it "parses the header, column names and data rows from a SELECT" do result = client.query(statement: "SELECT 42 AS answer") rows = result.to_a @@ -173,8 +185,8 @@ ) rows = result.to_a - # The mock returns [] as the columns array when DuckDB produces no batches - expect(result.columns).to eq([]) + # Column metadata is present even when the query produces no rows. + expect(result.columns).to eq(["n"]) expect(rows).to be_empty end end @@ -201,6 +213,15 @@ expect(result[:rows]).to eq([{ "cached_value" => 1 }]) end + + it "serializes named bind parameters" do + request = Altertable::Lakehouse::Models::QueryRequest.new( + statement: "SELECT $min_age", + params: { "min_age" => 25 } + ) + + expect(request.to_h).to include(params: { "min_age" => 25 }) + end end # ── custom headers ───────────────────────────────────────────────────────────