@@ -11,11 +11,12 @@ use crate::error::{Error, Result};
1111
1212use super :: column_map:: { columns_from_schema, resolve_boxed_schema, resolve_schema} ;
1313
14- /// Generate virtual tables from all GET list- endpoints in the spec.
14+ /// Generate virtual tables from all GET endpoints in the spec.
1515///
16- /// Only considers GET operations that return an array (list endpoints).
17- /// Single-resource endpoints (GET /repos/{owner}/{repo}) are skipped
18- /// because they map to keyed access on the list table.
16+ /// Considers GET operations that return:
17+ /// - An array (list endpoints)
18+ /// - A wrapped array (e.g. `{"data": [...]}`)
19+ /// - A single object (treated as a one-row table)
1920///
2021/// When multiple paths produce the same table name, a disambiguated name
2122/// is constructed from the path context (e.g., `git_branches` vs `branches`).
@@ -93,7 +94,7 @@ fn matches_tag_filter(op: &Operation, filter: Option<&[&str]>) -> bool {
9394}
9495
9596/// Try to build a virtual table from a GET operation.
96- /// Returns `None` if the endpoint isn't a list endpoint (doesn 't return an array) .
97+ /// Returns `None` if the response schema can 't be extracted .
9798fn try_build_table (
9899 spec : & OpenAPI ,
99100 path_str : & str ,
@@ -102,7 +103,7 @@ fn try_build_table(
102103 base_url : & str ,
103104) -> Result < Option < VirtualTable > > {
104105 // Find the success response schema and its content type
105- let Some ( ( item_schema, content_type, data_path) ) = extract_list_item_schema ( spec, operation)
106+ let Some ( ( item_schema, content_type, data_path) ) = extract_response_schema ( spec, operation)
106107 else {
107108 return Ok ( None ) ;
108109 } ;
@@ -132,16 +133,18 @@ fn try_build_table(
132133 // field's metadata (description, nullability). This handles columns like `state`
133134 // which is both filterable and present in the response.
134135 let response_columns = columns_from_schema ( spec, item_schema, "" ) ?;
135- for resp_col in response_columns {
136+ for mut resp_col in response_columns {
137+ // APIs frequently return null for "required" fields, so treat all
138+ // response columns as nullable to avoid Arrow schema mismatches.
139+ resp_col. nullable = true ;
140+
136141 if let Some ( existing) = columns. iter_mut ( ) . find ( |c| c. name == resp_col. name ) {
137142 if matches ! ( existing. role, ColumnRole :: QueryParam ) {
138143 existing. role = ColumnRole :: QueryParamAndResponse ;
139- // Prefer the response field's metadata — it describes the value,
140- // not the filter semantics.
141144 if resp_col. description . is_some ( ) {
142145 existing. description = resp_col. description ;
143146 }
144- existing. nullable = resp_col . nullable ;
147+ existing. nullable = true ;
145148 }
146149 } else {
147150 columns. push ( resp_col) ;
@@ -172,19 +175,20 @@ fn try_build_table(
172175 } ) )
173176}
174177
175- /// Extract the item schema from a list endpoint's response .
178+ /// Extract the response schema from a GET endpoint.
176179///
177180/// Returns (item_schema, content_type, data_path) where:
178- /// - `item_schema` is the schema of each array element
181+ /// - `item_schema` is the schema of each result item (or the single object)
179182/// - `content_type` is the Accept header value
180- /// - `data_path` is `None` for top-level arrays, or `Some("field")` for
183+ /// - `data_path` is `None` for top-level arrays/objects , or `Some("field")` for
181184/// wrapped responses like `{"data": [...]}`
182185///
183- /// Handles two response shapes:
186+ /// Handles three response shapes:
184187/// 1. Top-level array: `[{...}, {...}]`
185188/// 2. Wrapped array: `{"data": [{...}], "has_more": true}` — finds the
186189/// object property whose type is `array` and extracts its items schema.
187- fn extract_list_item_schema < ' a > (
190+ /// 3. Single object: `{...}` — treated as a one-row table.
191+ fn extract_response_schema < ' a > (
188192 spec : & ' a OpenAPI ,
189193 operation : & ' a Operation ,
190194) -> Option < ( & ' a openapiv3:: Schema , String , Option < String > ) > {
@@ -218,19 +222,34 @@ fn extract_list_item_schema<'a>(
218222 }
219223
220224 // Case 2: wrapped array — object with a property that's an array of objects
225+ // Only matches when the array items are objects (e.g. Stripe's {"data": [{...}]}),
226+ // not primitive arrays like {"topics": ["rust", "sql"]}.
221227 if let SchemaKind :: Type ( OaType :: Object ( obj) ) = & schema. schema_kind {
222228 for ( field_name, prop_ref) in & obj. properties {
223229 let Some ( prop_schema) = resolve_boxed_schema ( spec, prop_ref) else {
224230 continue ;
225231 } ;
226232 if let SchemaKind :: Type ( OaType :: Array ( arr) ) = & prop_schema. schema_kind {
227- let items_ref = arr. items . as_ref ( ) ?;
228- let item_schema = resolve_boxed_schema ( spec, items_ref) ?;
229- return Some ( ( item_schema, content_type. clone ( ) , Some ( field_name. clone ( ) ) ) ) ;
233+ if let Some ( items_ref) = arr. items . as_ref ( ) {
234+ if let Some ( item_schema) = resolve_boxed_schema ( spec, items_ref) {
235+ if matches ! ( item_schema. schema_kind, SchemaKind :: Type ( OaType :: Object ( _) ) ) {
236+ return Some ( (
237+ item_schema,
238+ content_type. clone ( ) ,
239+ Some ( field_name. clone ( ) ) ,
240+ ) ) ;
241+ }
242+ }
243+ }
230244 }
231245 }
232246 }
233247
248+ // Case 3: single object — treated as a one-row table
249+ if matches ! ( schema. schema_kind, SchemaKind :: Type ( OaType :: Object ( _) ) ) {
250+ return Some ( ( schema, content_type. clone ( ) , None ) ) ;
251+ }
252+
234253 None
235254}
236255
0 commit comments