Fix: JSON API schemas are now properly tied to API rather than table with correct expansion config.

This commit is contained in:
Sebastian Jeltsch
2025-04-09 11:44:24 +02:00
parent 2df6fe7988
commit 79a2dbd155
16 changed files with 339 additions and 269 deletions

View File

@@ -228,21 +228,42 @@ function ConflictResolutionSrategyToString(
}
}
export function getRecordApis(
config: Config | undefined,
tableName: string,
): RecordApiConfig[] {
return (config?.recordApis ?? []).filter(
(api) => api.tableName === tableName,
);
}
export function hasRecordApis(
config: Config | undefined,
tableName: string,
): boolean {
for (const api of config?.recordApis ?? []) {
if (api.tableName === tableName) {
return true;
}
}
return false;
}
function findRecordApi(
config: Config | undefined,
tableName: string,
): RecordApiConfig | undefined {
if (!config) {
return undefined;
}
const apis = getRecordApis(config, tableName);
for (const api of config.recordApis) {
if (api.tableName == tableName) {
return api;
}
switch (apis.length) {
case 0:
return undefined;
case 1:
return apis[0];
default:
console.warn("Multiple APIs not yet supported in UI, picking first.");
return apis[0];
}
return undefined;
}
function StyledHoverCard(props: { children: JSXElement }) {
@@ -298,7 +319,7 @@ export function RecordApiSettingsForm(props: {
// FIXME: We don't currently handle the "multiple APIs for a single table" case.
const currentApi = () =>
findRecordApi(config.data!.config!, props.schema.name);
findRecordApi(config.data!.config, props.schema.name);
const form = createForm(() => {
const tableName = props.schema.name;

View File

@@ -1,9 +1,18 @@
import { createSignal, createResource, Switch, Match, Show } from "solid-js";
import {
createMemo,
createSignal,
createResource,
Switch,
Match,
Show,
} from "solid-js";
import { createWritableMemo } from "@solid-primitives/memo";
import { adminFetch } from "@/lib/fetch";
import { TbDownload, TbColumns, TbColumnsOff } from "solid-icons/tb";
import { showSaveFileDialog } from "@/lib/utils";
import { iconButtonStyle } from "@/components/IconButton";
import { RecordApiConfig } from "@proto/config";
import type { Table } from "@bindings/Table";
import type { TableIndex } from "@bindings/TableIndex";
import type { TableTrigger } from "@bindings/TableTrigger";
@@ -33,7 +42,7 @@ const modes = ["Insert", "Select", "Update"] as const;
type Mode = (typeof modes)[number];
function SchemaDownloadButton(props: {
tableName: string;
apiName: string;
mode: Mode;
schema: object;
}) {
@@ -46,7 +55,7 @@ function SchemaDownloadButton(props: {
// possible fallback: https://stackoverflow.com/a/67806663
showSaveFileDialog({
contents: JSON.stringify(props.schema, null, " "),
filename: `${props.tableName}_${props.mode.toLowerCase()}_schema.json`,
filename: `${props.apiName}_${props.mode.toLowerCase()}_schema.json`,
}).catch(console.error);
}}
>
@@ -55,17 +64,23 @@ function SchemaDownloadButton(props: {
);
}
export function SchemaDialog(props: { tableName: string }) {
export function SchemaDialog(props: {
tableName: string;
apis: RecordApiConfig[];
}) {
const [mode, setMode] = createSignal<Mode>("Select");
const apiNames = createMemo(() => props.apis.map((api) => api.name));
const [api, setApi] = createWritableMemo(() => apiNames()[0] ?? "");
const args = () => ({
mode: mode(),
tableName: props.tableName,
apiName: api(),
});
const [schema] = createResource(args, async ({ mode, tableName }) => {
console.debug(`Fetching ${tableName}: ${mode}`);
const [schema] = createResource(args, async ({ mode, apiName }) => {
console.debug(`Fetching ${apiName}: ${mode}`);
const response = await adminFetch(
`/table/${tableName}/schema.json?mode=${mode}`,
`/schema/${apiName}/schema.json?mode=${mode}`,
);
return await response.json();
});
@@ -77,7 +92,8 @@ export function SchemaDialog(props: { tableName: string }) {
<TooltipTrigger as="div">
<TbColumns size={20} />
</TooltipTrigger>
<TooltipContent>JSON Schema of "{props.tableName}"</TooltipContent>
<TooltipContent>JSON Schemas of "{props.tableName}"</TooltipContent>
</Tooltip>
</DialogTrigger>
@@ -89,12 +105,31 @@ export function SchemaDialog(props: { tableName: string }) {
<div class="flex items-center gap-2">
<Show when={schema.state === "ready"}>
<SchemaDownloadButton
tableName={props.tableName}
apiName={api()}
schema={schema()}
mode={mode()}
/>
</Show>
<Select
value={api()}
onChange={setApi}
options={[...apiNames()]}
placeholder="API"
itemComponent={(props) => (
<SelectItem item={props.item}>
{props.item.rawValue}
</SelectItem>
)}
>
<SelectTrigger aria-label="Apis" class="w-[180px]">
<SelectValue>
{(state) => `API: ${state.selectedOption()}`}
</SelectValue>
</SelectTrigger>
<SelectContent />
</Select>
<Select
value={mode()}
onChange={setMode}
@@ -106,9 +141,9 @@ export function SchemaDialog(props: { tableName: string }) {
</SelectItem>
)}
>
<SelectTrigger aria-label="Fruit" class="w-[180px]">
<SelectValue<string>>
{(state) => state.selectedOption()}
<SelectTrigger aria-label="Mode" class="w-[180px]">
<SelectValue>
{(state) => `Mode: ${state.selectedOption()}`}
</SelectValue>
</SelectTrigger>
<SelectContent />

View File

@@ -40,7 +40,11 @@ import { FilterBar } from "@/components/FilterBar";
import { DestructiveActionButton } from "@/components/DestructiveActionButton";
import { IconButton } from "@/components/IconButton";
import { InsertUpdateRowForm } from "@/components/tables/InsertUpdateRow";
import { RecordApiSettingsForm } from "@/components/tables/RecordApiSettings";
import {
RecordApiSettingsForm,
hasRecordApis,
getRecordApis,
} from "@/components/tables/RecordApiSettings";
import { SafeSheet } from "@/components/SafeSheet";
import {
Tooltip,
@@ -52,7 +56,6 @@ import { createConfigQuery, invalidateConfig } from "@/lib/config";
import { type FormRow, RowData } from "@/lib/convert";
import { adminFetch } from "@/lib/fetch";
import { urlSafeBase64ToUuid } from "@/lib/utils";
import { RecordApiConfig } from "@proto/config";
import { dropTable, dropIndex } from "@/lib/table";
import { deleteRows, fetchRows, type FetchArgs } from "@/lib/row";
import {
@@ -212,6 +215,21 @@ function imageUrl(opts: {
return uri;
}
function tableOrViewSatisfiesRecordApiRequirements(
table: Table | View,
allTables: Table[],
): boolean {
const type = tableType(table);
if (type === "table") {
return tableSatisfiesRecordApiRequirements(table as Table, allTables);
} else if (type === "view") {
return viewSatisfiesRecordApiRequirements(table as View, allTables);
}
return false;
}
function TableHeaderRightHandButtons(props: {
table: Table | View;
allTables: Table[];
@@ -220,32 +238,12 @@ function TableHeaderRightHandButtons(props: {
const table = () => props.table;
const hidden = () => hiddenTable(table());
const type = () => tableType(table());
const satisfiesRecordApi = createMemo(() => {
const t = type();
if (t === "table") {
return tableSatisfiesRecordApiRequirements(
props.table as Table,
props.allTables,
);
} else if (t === "view") {
return viewSatisfiesRecordApiRequirements(
props.table as View,
props.allTables,
);
}
return false;
});
const satisfiesRecordApi = createMemo(() =>
tableOrViewSatisfiesRecordApiRequirements(props.table, props.allTables),
);
const config = createConfigQuery();
const recordApi = (): RecordApiConfig | undefined => {
for (const c of config.data?.config?.recordApis ?? []) {
if (c.tableName === table().name) {
return c;
}
}
};
const hasRecordApi = () => hasRecordApis(config?.data?.config, table().name);
return (
<div class="flex items-center justify-end gap-2">
@@ -293,7 +291,7 @@ function TableHeaderRightHandButtons(props: {
API
<Checkbox
disabled={!satisfiesRecordApi()}
checked={recordApi() !== undefined}
checked={hasRecordApi()}
/>
</Button>
</TooltipTrigger>
@@ -353,6 +351,40 @@ function TableHeaderRightHandButtons(props: {
);
}
function TableHeaderLeftButtons(props: {
table: Table | View;
indexes: TableIndex[];
triggers: TableTrigger[];
allTables: Table[];
rowsRefetch: () => Promise<void>;
}) {
const type = () => tableType(props.table);
const config = createConfigQuery();
const apis = createMemo(() =>
getRecordApis(config?.data?.config, props.table.name),
);
return (
<>
<IconButton tooltip="Refresh Data" onClick={props.rowsRefetch}>
<TbRefresh size={18} />
</IconButton>
{apis().length > 0 && (
<SchemaDialog tableName={props.table.name} apis={apis()} />
)}
{import.meta.env.DEV && type() === "table" && (
<DebugSchemaDialogButton
table={props.table as Table}
indexes={props.indexes}
triggers={props.triggers}
/>
)}
</>
);
}
function TableHeader(props: {
table: Table | View;
indexes: TableIndex[];
@@ -361,10 +393,8 @@ function TableHeader(props: {
schemaRefetch: () => Promise<void>;
rowsRefetch: () => Promise<void>;
}) {
const type = () => tableType(props.table);
const hasSchema = () => type() === "table";
const header = () => {
switch (type()) {
const headerTitle = () => {
switch (tableType(props.table)) {
case "view":
return "View";
case "virtualTable":
@@ -374,29 +404,19 @@ function TableHeader(props: {
}
};
const LeftButtons = () => (
<>
<IconButton tooltip="Refresh Data" onClick={props.rowsRefetch}>
<TbRefresh size={18} />
</IconButton>
{hasSchema() && <SchemaDialog tableName={props.table.name} />}
{hasSchema() && import.meta.env.DEV && (
<DebugSchemaDialogButton
table={props.table as Table}
indexes={props.indexes}
triggers={props.triggers}
/>
)}
</>
);
return (
<Header
title={header()}
title={headerTitle()}
titleSelect={props.table.name}
left={<LeftButtons />}
left={
<TableHeaderLeftButtons
table={props.table}
indexes={props.indexes}
triggers={props.triggers}
allTables={props.allTables}
rowsRefetch={props.rowsRefetch}
/>
}
right={
<TableHeaderRightHandButtons
table={props.table}

View File

@@ -112,7 +112,7 @@ pub struct ServerArgs {
#[derive(Args, Clone, Debug)]
pub struct JsonSchemaArgs {
/// Name of the table to infer the JSON Schema from.
pub table: String,
pub api: String,
/// Use-case for the type that determines which columns/fields will be required [Default:
/// Insert].

View File

@@ -10,14 +10,12 @@ use serde::Deserialize;
use std::rc::Rc;
use tokio::{fs, io::AsyncWriteExt};
use trailbase::{
api::{self, init_app_state, Email, InitArgs, TokenClaims},
api::{self, init_app_state, Email, InitArgs, JsonSchemaMode, TokenClaims},
constants::USER_TABLE,
DataDir, Server, ServerOptions,
};
use trailbase_cli::{
AdminSubCommands, DefaultCommandLineArgs, JsonSchemaModeArg, SubCommands, UserSubCommands,
};
use trailbase_cli::{AdminSubCommands, DefaultCommandLineArgs, SubCommands, UserSubCommands};
type BoxError = Box<dyn std::error::Error + Send + Sync>;
@@ -130,36 +128,18 @@ async fn async_main() -> Result<(), BoxError> {
Some(SubCommands::Schema(cmd)) => {
init_logger(false);
let conn = trailbase_sqlite::Connection::new(
|| api::connect_sqlite(Some(data_dir.main_db_path()), None),
None,
)?;
let table_metadata = api::TableMetadataCache::new(conn.clone()).await?;
let (_new_db, state) =
init_app_state(DataDir(args.data_dir), None, InitArgs::default()).await?;
let table_name = &cmd.table;
if let Some(table) = table_metadata.get(table_name) {
let (_validator, schema) = trailbase::api::build_json_schema(
table.name(),
&table.schema.columns,
cmd.mode.unwrap_or(JsonSchemaModeArg::Insert).into(),
)?;
let api_name = &cmd.api;
let Some(api) = state.lookup_record_api(api_name) else {
return Err(format!("Could not find api: '{api_name}'").into());
};
println!("{}", serde_json::to_string_pretty(&schema)?);
} else if let Some(view) = table_metadata.get_view(table_name) {
let Some(ref columns) = view.schema.columns else {
return Err(format!("Could not derive schema for complex view: '{table_name}'").into());
};
let mode: Option<JsonSchemaMode> = cmd.mode.map(|m| m.into());
let json_schema = trailbase::api::build_api_json_schema(&state, &api, mode)?;
let (_validator, schema) = trailbase::api::build_json_schema(
view.name(),
columns,
cmd.mode.unwrap_or(JsonSchemaModeArg::Insert).into(),
)?;
println!("{}", serde_json::to_string_pretty(&schema)?);
} else {
return Err(format!("Could not find table: '{table_name}'").into());
}
println!("{}", serde_json::to_string_pretty(&json_schema)?);
}
Some(SubCommands::Migration { suffix }) => {
init_logger(false);

View File

@@ -2,33 +2,30 @@ use axum::extract::{Path, Query, State};
use axum::http::header;
use axum::response::{IntoResponse, Response};
use serde::Deserialize;
use trailbase_schema::json_schema::JsonSchemaMode;
use crate::admin::AdminError as Error;
use crate::app_state::AppState;
use trailbase_schema::json_schema::{build_json_schema, JsonSchemaMode};
use crate::records::json_schema::build_api_json_schema;
#[derive(Clone, Debug, Deserialize)]
pub struct GetTableSchemaParams {
mode: Option<JsonSchemaMode>,
}
pub async fn get_table_schema_handler(
pub async fn get_api_json_schema_handler(
State(state): State<AppState>,
Path(table_name): Path<String>,
Path(record_api_name): Path<String>,
Query(query): Query<GetTableSchemaParams>,
) -> Result<Response, Error> {
let Some(table_metadata) = state.table_metadata().get(&table_name) else {
return Err(Error::Precondition(format!("Table {table_name} not found")));
let Some(api) = state.lookup_record_api(&record_api_name) else {
return Err(Error::Precondition(format!(
"API {record_api_name} not found"
)));
};
// FIXME: With ForeignKey expansion the schema depends on a specific record api and not just a
// table schema.
let (_schema, json) = build_json_schema(
table_metadata.name(),
&table_metadata.schema.columns,
query.mode.unwrap_or(JsonSchemaMode::Insert),
)?;
let json =
build_api_json_schema(&state, &api, query.mode).map_err(|err| Error::Internal(err.into()))?;
let mut response = serde_json::to_string_pretty(&json)?.into_response();
response.headers_mut().insert(

View File

@@ -1,3 +1,7 @@
mod get_api_json_schema;
pub(super) use get_api_json_schema::get_api_json_schema_handler;
use axum::extract::{Json, State};
use serde::{Deserialize, Serialize};
use ts_rs::TS;

View File

@@ -2,13 +2,13 @@ mod config;
mod error;
mod info;
mod jobs;
mod json_schema;
mod jwt;
mod list_logs;
mod oauth_providers;
mod parse;
mod query;
pub(crate) mod rows;
mod schema;
mod table;
pub(crate) mod user;
@@ -37,10 +37,6 @@ pub fn router() -> Router<AppState> {
.route("/index", patch(table::alter_index_handler))
.route("/index", delete(table::drop_index_handler))
// Table actions.
.route(
"/table/{table_name}/schema.json",
get(table::get_table_schema_handler),
)
.route("/table", post(table::create_table_handler))
.route("/table", delete(table::drop_table_handler))
.route("/table", patch(table::alter_table_handler))
@@ -55,8 +51,12 @@ pub fn router() -> Router<AppState> {
.route("/user", patch(user::update_user_handler))
.route("/user", delete(user::delete_user_handler))
// Schema actions
.route("/schema", get(schema::list_schemas_handler))
.route("/schema", post(schema::update_schema_handler))
.route("/schema", get(json_schema::list_schemas_handler))
.route("/schema", post(json_schema::update_schema_handler))
.route(
"/schema/{record_api_name}/schema.json",
get(json_schema::get_api_json_schema_handler),
)
// Logs
.route("/logs", get(list_logs::list_logs_handler))
// Query execution handler for the UI editor

View File

@@ -2,12 +2,10 @@
mod alter_index;
mod create_index;
mod drop_index;
mod get_table_schema;
pub(super) use alter_index::alter_index_handler;
pub(super) use create_index::create_index_handler;
pub(super) use drop_index::drop_index_handler;
pub(super) use get_table_schema::get_table_schema_handler;
// Tables
mod alter_table;

View File

@@ -236,7 +236,7 @@ impl AppState {
return &self.state.jwt;
}
pub(crate) fn lookup_record_api(&self, name: &str) -> Option<RecordApi> {
pub fn lookup_record_api(&self, name: &str) -> Option<RecordApi> {
for (record_api_name, record_api) in self.state.record_apis.load().iter() {
if record_api_name == name {
return Some(record_api.clone());

View File

@@ -64,10 +64,11 @@ pub mod api {
pub use crate::connection::connect_sqlite;
pub use crate::email::{Email, EmailError};
pub use crate::migrations::new_unique_migration_filename;
pub use crate::records::json_schema::build_api_json_schema;
pub use crate::server::{init_app_state, InitArgs};
pub use crate::table_metadata::TableMetadataCache;
pub use trailbase_schema::json_schema::{build_json_schema, JsonSchemaMode};
pub use trailbase_schema::json_schema::JsonSchemaMode;
}
pub(crate) mod rand {

View File

@@ -1,4 +1,5 @@
use axum::extract::{Json, Path, Query, State};
use log::*;
use serde::Deserialize;
use trailbase_schema::json_schema::{
build_json_schema, build_json_schema_expanded, Expand, JsonSchemaMode,
@@ -6,7 +7,7 @@ use trailbase_schema::json_schema::{
use crate::app_state::AppState;
use crate::auth::user::User;
use crate::records::{Permission, RecordError};
use crate::records::{Permission, RecordApi, RecordError};
#[derive(Debug, Clone, Deserialize)]
pub struct JsonSchemaQuery {
@@ -35,25 +36,34 @@ pub async fn json_schema_handler(
.check_record_level_access(Permission::Schema, None, None, user.as_ref())
.await?;
let mode = request.mode.unwrap_or(JsonSchemaMode::Insert);
match (api.expand(), mode) {
(Some(config_expand), JsonSchemaMode::Select) => {
let foreign_key_columns = config_expand.keys().map(|k| k.as_str()).collect::<Vec<_>>();
let expand = Expand {
tables: &state.table_metadata().tables(),
foreign_key_columns,
};
let (_schema, json) =
build_json_schema_expanded(api.table_name(), api.columns(), mode, Some(expand))
.map_err(|err| RecordError::Internal(err.into()))?;
return Ok(Json(json));
}
_ => {
let (_schema, json) = build_json_schema(api.table_name(), api.columns(), mode)
.map_err(|err| RecordError::Internal(err.into()))?;
return Ok(Json(json));
}
}
return Ok(Json(build_api_json_schema(&state, &api, request.mode)?));
}
pub fn build_api_json_schema(
state: &AppState,
api: &RecordApi,
mode: Option<JsonSchemaMode>,
) -> Result<serde_json::Value, RecordError> {
let mode = mode.unwrap_or(JsonSchemaMode::Insert);
if let (Some(config_expand), JsonSchemaMode::Select) = (api.expand(), mode) {
let all_tables = state.table_metadata().tables();
let foreign_key_columns = config_expand.keys().map(|k| k.as_str()).collect::<Vec<_>>();
let expand = Expand {
tables: &all_tables,
foreign_key_columns,
};
debug!("expanded: {expand:?}");
let (_schema, json) =
build_json_schema_expanded(api.api_name(), api.columns(), mode, Some(expand))
.map_err(|err| RecordError::Internal(err.into()))?;
return Ok(json);
}
let (_schema, json) = build_json_schema(api.api_name(), api.columns(), mode)
.map_err(|err| RecordError::Internal(err.into()))?;
return Ok(json);
}

View File

@@ -8,7 +8,7 @@ pub(crate) mod create_record;
pub(crate) mod delete_record;
mod error;
pub(crate) mod files;
mod json_schema;
pub(crate) mod json_schema;
pub(crate) mod list_records;
pub(crate) mod params;
pub mod query_builder;

View File

@@ -315,11 +315,11 @@ mod tests {
"type": "object",
"properties": {
"id": { "type": "integer" },
"fk": { "$ref": "#/$defs/fk" },
"fk": { "$ref": "#/$defs/foreign_table" },
},
"required": ["id"],
"$defs": {
"fk": {
"foreign_table": {
"type": "object",
"properties": {
"id" : { "type": "integer"},

View File

@@ -27,13 +27,14 @@ pub enum JsonSchemaMode {
/// setting. Not sure we should since this is more a feature for no-JS, HTTP-only apps, which
/// don't benefit from type-safety anyway.
pub fn build_json_schema(
table_or_view_name: &str,
title: &str,
columns: &[Column],
mode: JsonSchemaMode,
) -> Result<(Validator, serde_json::Value), JsonSchemaError> {
return build_json_schema_expanded(table_or_view_name, columns, mode, None);
return build_json_schema_expanded(title, columns, mode, None);
}
#[derive(Debug)]
pub struct Expand<'a> {
pub tables: &'a [TableMetadata],
pub foreign_key_columns: Vec<&'a str>,
@@ -42,7 +43,7 @@ pub struct Expand<'a> {
/// NOTE: Foreign keys can only reference tables not view, so the inline schemas don't need to be
/// able to reference views.
pub fn build_json_schema_expanded(
table_or_view_name: &str,
title: &str,
columns: &[Column],
mode: JsonSchemaMode,
expand: Option<Expand<'_>>,
@@ -52,7 +53,7 @@ pub fn build_json_schema_expanded(
let mut required_cols: Vec<String> = vec![];
for col in columns {
let mut found_def = false;
let mut def_name: Option<String> = None;
let mut not_null = false;
let mut default = false;
@@ -62,17 +63,18 @@ pub fn build_json_schema_expanded(
ColumnOption::Default(_) => default = true,
ColumnOption::Check(check) => {
if let Some(json_metadata) = extract_json_metadata(&ColumnOption::Check(check.clone()))? {
let new_def_name = &col.name;
match json_metadata {
JsonColumnMetadata::SchemaName(name) => {
let Some(schema) = crate::registry::get_schema(&name) else {
return Err(JsonSchemaError::NotFound(name.to_string()));
};
defs.insert(col.name.clone(), schema.schema);
found_def = true;
defs.insert(new_def_name.clone(), schema.schema);
def_name = Some(new_def_name.clone());
}
JsonColumnMetadata::Pattern(pattern) => {
defs.insert(col.name.clone(), pattern.clone());
found_def = true;
defs.insert(new_def_name.clone(), pattern.clone());
def_name = Some(new_def_name.clone());
}
}
}
@@ -98,49 +100,54 @@ pub fn build_json_schema_expanded(
..
} => {
if let (Some(expand), JsonSchemaMode::Select) = (&expand, mode) {
for metadata in &expand.foreign_key_columns {
if metadata != foreign_table {
continue;
}
let column_is_expanded = expand
.foreign_key_columns
.iter()
.any(|column_name| *column_name != col.name);
if !column_is_expanded {
continue;
}
if referred_columns.len() != 1 {
warn!("Skipping. Expected single reffered column : {referred_columns:?}");
continue;
}
let Some(table) = expand.tables.iter().find(|t| t.name() == foreign_table) else {
warn!("Failed to find table: {foreign_table}");
continue;
};
// TODO: Implement nesting.
let Some(table) = expand.tables.iter().find(|t| t.name() == foreign_table) else {
warn!("Failed to find table: {foreign_table}");
continue;
};
let Some(column) = table
let Some(pk_column) = (match referred_columns.len() {
0 => crate::metadata::find_pk_column_index(&table.schema.columns)
.map(|idx| &table.schema.columns[idx]),
1 => table
.schema
.columns
.iter()
.find(|c| c.name == referred_columns[0])
else {
warn!("Failed to find column: {}", referred_columns[0]);
.find(|c| c.name == referred_columns[0]),
_ => {
warn!("Skipping. Expected single referred column : {referred_columns:?}");
continue;
};
}
}) else {
warn!("Failed to find pk column for {}", table.name());
continue;
};
let (_validator, schema) =
build_json_schema(foreign_table, &table.schema.columns, mode)?;
defs.insert(
col.name.clone(),
serde_json::json!({
"type": "object",
"properties": {
"id": {
"type": column_data_type_to_json_type(column.data_type),
},
"data": schema,
let (_validator, schema) =
build_json_schema(foreign_table, &table.schema.columns, mode)?;
let new_def_name = foreign_table.clone();
defs.insert(
new_def_name.clone(),
serde_json::json!({
"type": "object",
"properties": {
"id": {
"type": column_data_type_to_json_type(pk_column.data_type),
},
"required": ["id"],
}),
);
found_def = true;
}
"data": schema,
},
"required": ["id"],
}),
);
def_name = Some(new_def_name);
}
}
_ => {}
@@ -161,34 +168,30 @@ pub fn build_json_schema_expanded(
JsonSchemaMode::Update => {}
}
if found_def {
let name = &col.name;
properties.insert(
name.clone(),
properties.insert(
col.name.clone(),
if let Some(def_name) = def_name {
serde_json::json!({
"$ref": format!("#/$defs/{name}")
}),
);
} else {
properties.insert(
col.name.clone(),
"$ref": format!("#/$defs/{def_name}")
})
} else {
serde_json::json!({
"type": column_data_type_to_json_type(col.data_type),
}),
);
}
})
},
);
}
let schema = if defs.is_empty() {
serde_json::json!({
"title": table_or_view_name,
"title": title,
"type": "object",
"properties": serde_json::Value::Object(properties),
"required": serde_json::json!(required_cols),
})
} else {
serde_json::json!({
"title": table_or_view_name,
"title": title,
"type": "object",
"properties": serde_json::Value::Object(properties),
"required": serde_json::json!(required_cols),

View File

@@ -348,11 +348,8 @@ pub fn find_user_id_foreign_key_columns(columns: &[Column], user_table_name: &st
return indexes;
}
/// Finds suitable Integer or UUIDv7 primary key columns, if present.
///
/// Cursors require certain properties like a stable, time-sortable primary key.
fn find_record_pk_column_index(columns: &[Column], tables: &[Table]) -> Option<usize> {
let primary_key_col_index = columns.iter().position(|col| {
pub(crate) fn find_pk_column_index(columns: &[Column]) -> Option<usize> {
return columns.iter().position(|col| {
for opt in &col.options {
if let ColumnOption::Unique { is_primary, .. } = opt {
return *is_primary;
@@ -360,67 +357,71 @@ fn find_record_pk_column_index(columns: &[Column], tables: &[Table]) -> Option<u
}
return false;
});
}
if let Some(index) = primary_key_col_index {
let column = &columns[index];
/// Finds suitable Integer or UUIDv7 primary key columns, if present.
///
/// Cursors require certain properties like a stable, time-sortable primary key.
fn find_record_pk_column_index(columns: &[Column], tables: &[Table]) -> Option<usize> {
let index = find_pk_column_index(columns)?;
let column = &columns[index];
if column.data_type == ColumnDataType::Integer {
// TODO: We should detect the "integer pk" desc case and at least warn:
// https://www.sqlite.org/lang_createtable.html#rowid.
return Some(index);
if column.data_type == ColumnDataType::Integer {
// TODO: We should detect the "integer pk" desc case and at least warn:
// https://www.sqlite.org/lang_createtable.html#rowid.
return Some(index);
}
for opts in &column.options {
lazy_static! {
static ref UUID_V7_RE: Regex = Regex::new(r"^is_uuid_v7\s*\(").expect("infallible");
}
for opts in &column.options {
lazy_static! {
static ref UUID_V7_RE: Regex = Regex::new(r"^is_uuid_v7\s*\(").expect("infallible");
}
match &opts {
// Check if the referenced column is a uuidv7 column.
ColumnOption::ForeignKey {
foreign_table,
referred_columns,
..
} => {
let Some(referred_table) = tables.iter().find(|t| t.name == *foreign_table) else {
error!("Failed to get foreign key schema for {foreign_table}");
continue;
};
if referred_columns.len() != 1 {
return None;
}
let referred_column = &referred_columns[0];
let col = referred_table
.columns
.iter()
.find(|c| c.name == *referred_column)?;
let mut is_pk = false;
for opt in &col.options {
match opt {
ColumnOption::Check(expr) if UUID_V7_RE.is_match(expr) => {
return Some(index);
}
ColumnOption::Unique { is_primary, .. } if *is_primary => {
is_pk = true;
}
_ => {}
}
}
if is_pk && col.data_type == ColumnDataType::Integer {
return Some(index);
}
match &opts {
// Check if the referenced column is a uuidv7 column.
ColumnOption::ForeignKey {
foreign_table,
referred_columns,
..
} => {
let Some(referred_table) = tables.iter().find(|t| t.name == *foreign_table) else {
error!("Failed to get foreign key schema for {foreign_table}");
continue;
};
if referred_columns.len() != 1 {
return None;
}
ColumnOption::Check(expr) if UUID_V7_RE.is_match(expr) => {
let referred_column = &referred_columns[0];
let col = referred_table
.columns
.iter()
.find(|c| c.name == *referred_column)?;
let mut is_pk = false;
for opt in &col.options {
match opt {
ColumnOption::Check(expr) if UUID_V7_RE.is_match(expr) => {
return Some(index);
}
ColumnOption::Unique { is_primary, .. } if *is_primary => {
is_pk = true;
}
_ => {}
}
}
if is_pk && col.data_type == ColumnDataType::Integer {
return Some(index);
}
_ => {}
return None;
}
ColumnOption::Check(expr) if UUID_V7_RE.is_match(expr) => {
return Some(index);
}
_ => {}
}
}