Skip to content

Commit 3f9fde6

Browse files
committed
Support single-object endpoints as one-row tables
GET endpoints that return a JSON object (not an array) are now included in the catalog as tables that return a single row. e.g. GET /repos/{owner}/{repo} becomes a queryable table. Also fixes: - Wrapped array detection now requires array items to be objects, preventing false matches on primitive arrays like topics: [string] - All response columns are now nullable to handle APIs that return null for spec-declared "required" fields
1 parent b254f15 commit 3f9fde6

1 file changed

Lines changed: 37 additions & 18 deletions

File tree

crates/sqlize-core/src/spec/table_gen.rs

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,12 @@ use crate::error::{Error, Result};
1111

1212
use 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.
9798
fn 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

Comments
 (0)