Compare commits

...

2 Commits

Author SHA1 Message Date
Gaurav Tewari
89f3be486e chore(querybuilder): exercise builder_ai_query from the traces explorer
DEMO SCAFFOLD — remove before merge.

The AI o11y explorer page does not exist yet, so there is nowhere to run
the builder_ai_query plumbing end to end. This wires the traces explorer
to it behind `?aiDemo=1`, leaving the page byte-identical without the
param.

With the param set:
- all four views send builderQueryType: 'builder_ai_query', so
  compositeQuery.queries[].type changes on /query_range
- QuerySearch sends &type=builder_ai_query to /fields/keys
- the All/Root/Entrypoint span-scope select hides in List and Trace,
  matching the AI explorer's per-view policy

Each view's hand-built queryKey gains the flag as a discriminator.
Without it the two modes share a react-query cache entry, since all four
views override options.queryKey and none of the existing entries
distinguish them.

The real AI explorer page will pass these values directly, at which
point this commit and src/container/TracesExplorer/useIsAIQueryDemo.ts
can be dropped.
2026-08-06 17:12:36 +05:30
Gaurav Tewari
4a0c34c236 feat(querybuilder): support builder_ai_query envelope type
Adds the plumbing the AI o11y explorer needs from the query builder,
with every addition defaulted so existing behaviour is unchanged. No
caller sets the new options yet, so this is a no-op for all nine
current QueryBuilderV2 call sites.

Payload:
- add 'builder_ai_query' to the QueryType union, plus a narrowed
  BuilderQueryEnvelopeType alias. Narrow on purpose: the other members
  take a different spec shape and the backend decodes with
  DisallowUnknownFields, so passing one with a builder spec is a 400.
- convertBuilderQueriesToV5 takes the envelope type as a defaulted 4th
  param instead of hardcoding 'builder_query'.
- GetQueryResultsProps gains builderQueryType, threaded through
  prepareQueryRangePayloadV5 (which destructures explicitly, so it has
  to be named there — a spread does not reach it).

Response:
- mapQueryFromV5 is an if/else-if chain with no fallback, so an
  unrecognised envelope type was silently dropped rather than defaulted.
  builder_ai_query shares the builder-query spec shape and name
  namespace, so it now hydrates into queryData the same way.

Query builder props:
- showSpanScopeSelector (default true) lets a page hide the
  All/Root/Entrypoint select. Gated in QueryV2's memo so both render
  sites are covered. QueryProps already declared this prop and nothing
  read it; this gives that declaration meaning.
- fieldKeysQueryType is forwarded verbatim to /fields/keys as `type` so
  the backend can scope the suggested key set. Appended to the URL only
  when set, keeping every existing request byte-identical.

Deliberately not included: the AI explorer page itself, per-query AI
state, and 'trace' as a field context (the last needs a backend
decision — see frontend/docs/ai-explorer-qb-changeset.md).
2026-08-06 17:06:08 +05:30
18 changed files with 138 additions and 17 deletions

View File

@@ -23,6 +23,7 @@ export const getKeySuggestions = (
fieldDataType = '',
signalSource = '',
metricNamespace = '',
type,
} = props;
const encodedSignal = encodeURIComponent(signal);
@@ -32,8 +33,11 @@ export const getKeySuggestions = (
const encodedFieldDataType = encodeURIComponent(fieldDataType);
const encodedSource = encodeURIComponent(signalSource);
const encodedMetricNamespace = encodeURIComponent(metricNamespace);
// Appended only when the caller sets `type`, unlike the params above which are
// always sent. Keeps every existing request URL byte-identical.
const typeParam = type ? `&type=${encodeURIComponent(type)}` : '';
return axios.get(
`/fields/keys?signal=${encodedSignal}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&fieldContext=${encodedFieldContext}&fieldDataType=${encodedFieldDataType}&source=${encodedSource}&metricNamespace=${encodedMetricNamespace}`,
`/fields/keys?signal=${encodedSignal}&searchText=${encodedSearchText}&metricName=${encodedMetricName}&fieldContext=${encodedFieldContext}&fieldDataType=${encodedFieldDataType}&source=${encodedSource}&metricNamespace=${encodedMetricNamespace}${typeParam}`,
);
};

View File

@@ -13,6 +13,7 @@ import {
} from 'types/api/queryBuilder/queryBuilderData';
import {
BaseBuilderQuery,
BuilderQueryEnvelopeType,
FieldContext,
FieldDataType,
Filter,
@@ -322,6 +323,7 @@ export function convertBuilderQueriesToV5(
builderQueries: Record<string, any>,
requestType: RequestType,
panelType?: PANEL_TYPES,
builderQueryType: BuilderQueryEnvelopeType = 'builder_query',
): QueryEnvelope[] {
return Object.entries(builderQueries).map(
([queryName, queryData]): QueryEnvelope => {
@@ -363,10 +365,7 @@ export function convertBuilderQueriesToV5(
break;
}
return {
type: 'builder_query' as QueryType,
spec,
};
return { type: builderQueryType, spec };
},
);
}
@@ -558,6 +557,7 @@ export const prepareQueryRangePayloadV5 = ({
originalGraphType,
fillGaps,
dynamicVariables,
builderQueryType = 'builder_query',
}: GetQueryResultsProps): PrepareQueryRangePayloadV5Result => {
let legendMap: Record<string, string> = {};
const requestType = mapPanelTypeToRequestType(graphType);
@@ -594,6 +594,7 @@ export const prepareQueryRangePayloadV5 = ({
currentQueryData.data,
requestType,
graphType,
builderQueryType,
);
// Convert formulas as separate query type

View File

@@ -22,6 +22,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
isListViewPanel = false,
showOnlyWhereClause = false,
showTraceOperator = false,
showSpanScopeSelector = true,
fieldKeysQueryType,
version,
onSignalSourceChange,
signalSourceChangeEnabled = false,
@@ -209,6 +211,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
showSpanScopeSelector={showSpanScopeSelector}
fieldKeysQueryType={fieldKeysQueryType}
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
@@ -232,6 +236,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel}
showSpanScopeSelector={showSpanScopeSelector}
fieldKeysQueryType={fieldKeysQueryType}
signalSource={query.source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}

View File

@@ -47,7 +47,10 @@ import { validateQuery } from 'utils/queryValidationUtils';
import { unquote } from 'utils/stringUtils';
import { getRecentQueries } from 'lib/recentQueries/getRecentQueries';
import type { SignalType } from 'types/api/v5/queryRange';
import type {
BuilderQueryEnvelopeType,
SignalType,
} from 'types/api/v5/queryRange';
import {
queryExamples,
@@ -111,6 +114,12 @@ interface QuerySearchProps {
numberValues: number[];
complete: boolean;
}>;
/**
* Forwarded to `/fields/keys` as `type`, letting the backend scope the key set.
* Not named `type`: `QueryKeyDataSuggestionsProps.type` already means something
* else on the response side.
*/
fieldKeysQueryType?: BuilderQueryEnvelopeType;
}
function QuerySearch({
@@ -125,6 +134,7 @@ function QuerySearch({
initialExpression,
metricNamespace,
valueSuggestionsOverride,
fieldKeysQueryType,
}: QuerySearchProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
@@ -326,6 +336,7 @@ function QuerySearch({
metricName: debouncedMetricName ?? undefined,
signalSource: signalSource as 'meter' | '',
metricNamespace,
type: fieldKeysQueryType,
});
if (response.data.data) {
@@ -363,6 +374,7 @@ function QuerySearch({
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
metricNamespace,
fieldKeysQueryType,
],
);

View File

@@ -37,6 +37,9 @@ export const QueryV2 = forwardRef(function QueryV2(
hasTraceOperator = false,
version,
showOnlyWhereClause = false,
// Aliased: the local memo below is also called `showSpanScopeSelector`.
showSpanScopeSelector: allowSpanScopeSelector = true,
fieldKeysQueryType,
signalSource = '',
isMultiQueryAllowed = false,
onSignalSourceChange,
@@ -94,8 +97,8 @@ export const QueryV2 = forwardRef(function QueryV2(
);
const showSpanScopeSelector = useMemo(
() => dataSource === DataSource.TRACES,
[dataSource],
() => dataSource === DataSource.TRACES && allowSpanScopeSelector,
[dataSource, allowSpanScopeSelector],
);
const showInlineQuerySearch = useMemo(() => {
@@ -182,6 +185,7 @@ export const QueryV2 = forwardRef(function QueryV2(
queryData={query}
dataSource={dataSource}
signalSource={signalSource}
fieldKeysQueryType={fieldKeysQueryType}
/>
</div>
@@ -252,6 +256,7 @@ export const QueryV2 = forwardRef(function QueryV2(
queryData={query}
dataSource={dataSource}
signalSource={signalSource}
fieldKeysQueryType={fieldKeysQueryType}
/>
</div>

View File

@@ -2,6 +2,7 @@ import { ReactNode } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { BuilderQueryEnvelopeType } from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import { OrderByFilterProps } from './filters/OrderByFilter/OrderByFilter.interfaces';
@@ -33,6 +34,17 @@ export type QueryBuilderProps = {
showOnlyTraceOperator?: boolean;
showTraceViewSelector?: boolean;
showTraceOperator?: boolean;
/**
* Traces only. Default `true`. Set `false` to hide the
* All / Root / Entrypoint span-scope select — e.g. on views that render a
* single query as a bare filter, where a scope sub-filter is noise.
*/
showSpanScopeSelector?: boolean;
/**
* Forwarded verbatim to `/fields/keys` as `type` so the backend can scope the
* suggested key set. Never interpreted by the builder.
*/
fieldKeysQueryType?: BuilderQueryEnvelopeType;
version: string;
onChangeTraceView?: (view: TraceView) => void;
onSignalSourceChange?: (value: string) => void;

View File

@@ -1,6 +1,7 @@
import { IQueryBuilderState } from 'constants/queryBuilder';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { BuilderQueryEnvelopeType } from 'types/api/v5/queryRange';
export interface InitialStateI {
search: string;
@@ -30,6 +31,7 @@ export type QueryProps = {
showFunctions?: boolean;
version: string;
showSpanScopeSelector?: boolean;
fieldKeysQueryType?: BuilderQueryEnvelopeType;
showOnlyWhereClause?: boolean;
showTraceOperator?: boolean;
hasTraceOperator?: boolean;

View File

@@ -26,6 +26,7 @@ import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { useIsAIQueryDemo } from 'container/TracesExplorer/useIsAIQueryDemo';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
@@ -61,6 +62,7 @@ function ListView({
}: ListViewProps): JSX.Element {
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
useQueryBuilder();
const isAIQueryDemo = useIsAIQueryDemo();
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
@@ -140,6 +142,7 @@ function ListView({
graphType: panelType,
selectedTime: 'GLOBAL_TIME' as const,
globalSelectedInterval: globalSelectedTime as CustomTimeType,
builderQueryType: isAIQueryDemo ? 'builder_ai_query' : undefined,
params: {
dataSource: 'traces',
},
@@ -151,7 +154,7 @@ function ListView({
// ENTITY_VERSION_V4,
ENTITY_VERSION_V5,
{
queryKey,
queryKey: [...queryKey, isAIQueryDemo],
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&

View File

@@ -4,11 +4,13 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import ExplorerOrderBy from 'container/ExplorerOrderBy';
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useIsAIQueryDemo } from 'container/TracesExplorer/useIsAIQueryDemo';
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
import { DataSource } from 'types/common/queryBuilder';
function QuerySection(): JSX.Element {
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
const isAIQueryDemo = useIsAIQueryDemo();
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
const isList = panelTypes === PANEL_TYPES.LIST;
@@ -50,6 +52,10 @@ function QuerySection(): JSX.Element {
queryComponents={queryComponents}
panelType={panelTypes}
filterConfigs={filterConfigs}
// DEMO: in AI mode, List/Trace hide the span-scope select and suggestions
// are scoped to the gen_ai key set. Both are no-ops without ?aiDemo=1.
showSpanScopeSelector={!isAIQueryDemo || !isListViewPanel}
fieldKeysQueryType={isAIQueryDemo ? 'builder_ai_query' : undefined}
showOnlyWhereClause={
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
}

View File

@@ -15,6 +15,7 @@ import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { QueryTable } from 'container/QueryTable';
import { useIsAIQueryDemo } from 'container/TracesExplorer/useIsAIQueryDemo';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { AppState } from 'store/reducers';
@@ -36,6 +37,7 @@ function TableView({
queryKeyRef?: MutableRefObject<any>;
}): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const isAIQueryDemo = useIsAIQueryDemo();
const {
selectedTime: globalSelectedTime,
@@ -64,13 +66,14 @@ function TableView({
graphType: panelType || PANEL_TYPES.TABLE,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
builderQueryType: isAIQueryDemo ? 'builder_ai_query' : undefined,
params: {
dataSource: 'traces',
},
},
ENTITY_VERSION_V5,
{
queryKey,
queryKey: [...queryKey, isAIQueryDemo],
enabled: !!stagedQuery && panelType === PANEL_TYPES.TABLE,
},
);

View File

@@ -21,6 +21,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { useIsAIQueryDemo } from 'container/TracesExplorer/useIsAIQueryDemo';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
@@ -51,6 +52,7 @@ function TracesView({
queryKeyRef,
}: TracesViewProps): JSX.Element {
const { stagedQuery, panelType } = useQueryBuilder();
const isAIQueryDemo = useIsAIQueryDemo();
const {
selectedTime: globalSelectedTime,
@@ -97,6 +99,7 @@ function TracesView({
graphType: panelType || PANEL_TYPES.TRACE,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
builderQueryType: isAIQueryDemo ? 'builder_ai_query' : undefined,
params: {
dataSource: 'traces',
},
@@ -106,7 +109,7 @@ function TracesView({
},
ENTITY_VERSION_V5,
{
queryKey,
queryKey: [...queryKey, isAIQueryDemo],
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
},
);

View File

@@ -0,0 +1,24 @@
import useUrlQuery from 'hooks/useUrlQuery';
/**
* DEMO ONLY — delete this file before merging.
*
* The AI o11y explorer page does not exist yet, so there is nowhere to exercise
* the `builder_ai_query` plumbing end to end. This opts the Traces Explorer into
* AI-query behaviour when `?aiDemo=1` is present, leaving the page byte-identical
* otherwise:
*
* - `compositeQuery.queries[].type` becomes `builder_ai_query`
* - `/fields/keys` gains `&type=builder_ai_query`
* - the All/Root/Entrypoint span-scope select hides in List and Trace views
*
* Once the AI Explorer page lands, it passes those values unconditionally and
* this indirection goes away.
*/
export const AI_DEMO_QUERY_PARAM = 'aiDemo';
export function useIsAIQueryDemo(): boolean {
const urlQuery = useUrlQuery();
return urlQuery.get(AI_DEMO_QUERY_PARAM) === '1';
}

View File

@@ -23,6 +23,7 @@ import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import {
BuilderQueryEnvelopeType,
ExecStats,
MetricRangePayloadV5,
QueryRangeResponseV5,
@@ -399,4 +400,11 @@ export interface GetQueryResultsProps {
step?: number;
originalGraphType?: PANEL_TYPES;
dynamicVariables?: IDashboardVariable[];
/**
* Envelope type stamped on every builder query in the composite query.
* Defaults to `builder_query`; the AI Explorer passes `builder_ai_query` so the
* backend routes to its gen_ai statement builder. A serialisation concern, not
* part of the query — same category as `graphType` and `formatForWeb`.
*/
builderQueryType?: BuilderQueryEnvelopeType;
}

View File

@@ -30,19 +30,25 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
> = {};
const builderQueryTypes: Record<
string,
'builder_query' | 'builder_formula' | 'builder_trace_operator'
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
> = {};
const promQueries: IPromQLQuery[] = [];
const clickhouseQueries: IClickHouseQuery[] = [];
compositeQuery.queries?.forEach((q) => {
const spec = q.spec as BuilderQuery | PromQuery | ClickHouseQuery;
if (q.type === 'builder_query') {
// `builder_ai_query` shares the builder-query spec shape and name namespace,
// so it hydrates back into `queryData` exactly like `builder_query`. Without
// this branch the chain has no fallback and the envelope is silently dropped.
if (q.type === 'builder_query' || q.type === 'builder_ai_query') {
if (spec.name) {
builderQueries[spec.name] = convertBuilderQueryToIBuilderQuery(
spec as BuilderQuery,
);
builderQueryTypes[spec.name] = 'builder_query';
builderQueryTypes[spec.name] = q.type;
}
} else if (q.type === 'builder_formula') {
if (spec.name) {

View File

@@ -15,7 +15,10 @@ export const transformQueryBuilderDataModel = (
data: BuilderQueryDataResourse,
queryTypes?: Record<
string,
'builder_query' | 'builder_formula' | 'builder_trace_operator'
| 'builder_query'
| 'builder_ai_query'
| 'builder_formula'
| 'builder_trace_operator'
>,
): QueryBuilderData => {
const queryData: QueryBuilderData['queryData'] = [];

View File

@@ -12,6 +12,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useIsAIQueryDemo } from 'container/TracesExplorer/useIsAIQueryDemo';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
@@ -31,6 +32,7 @@ function TimeSeriesViewContainer({
queryKeyRef,
}: TimeSeriesViewProps): JSX.Element {
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
const isAIQueryDemo = useIsAIQueryDemo();
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
@@ -81,6 +83,7 @@ function TimeSeriesViewContainer({
graphType: panelType || PANEL_TYPES.TIME_SERIES,
selectedTime: 'GLOBAL_TIME',
globalSelectedInterval: globalSelectedTime,
builderQueryType: isAIQueryDemo ? 'builder_ai_query' : undefined,
params: {
dataSource,
},
@@ -88,7 +91,7 @@ function TimeSeriesViewContainer({
// ENTITY_VERSION_V4,
ENTITY_VERSION_V5,
{
queryKey,
queryKey: [...queryKey, isAIQueryDemo],
enabled: !!stagedQuery && panelType === PANEL_TYPES.TIME_SERIES,
},
);

View File

@@ -1,4 +1,7 @@
import { FieldDataType } from 'types/api/v5/queryRange';
import {
BuilderQueryEnvelopeType,
FieldDataType,
} from 'types/api/v5/queryRange';
export interface QueryKeyDataSuggestionsProps {
label: string;
@@ -35,6 +38,11 @@ export interface QueryKeyRequestProps {
metricName?: string;
metricNamespace?: string;
signalSource?: 'meter' | '';
/**
* Scopes the returned key set to a query-type sub-variant of the signal.
* `builder_ai_query` narrows `signal=traces` to the gen_ai attribute keys.
*/
type?: BuilderQueryEnvelopeType;
}
export interface QueryKeyValueSuggestionsProps {

View File

@@ -16,6 +16,7 @@ export type RequestType =
export type QueryType =
| 'builder_query'
| 'builder_ai_query'
| 'builder_trace_operator'
| 'builder_formula'
| 'builder_sub_query'
@@ -23,6 +24,17 @@ export type QueryType =
| 'clickhouse_sql'
| 'promql';
/**
* The envelope types `convertBuilderQueriesToV5` may emit. Deliberately narrower
* than `QueryType`: the remaining members take a different spec shape, and the
* backend decodes specs with `DisallowUnknownFields`, so sending one of them with
* a builder spec is a 400.
*/
export type BuilderQueryEnvelopeType = Extract<
QueryType,
'builder_query' | 'builder_ai_query'
>;
export type OrderDirection = 'asc' | 'desc';
export type JoinType = 'inner' | 'left' | 'right' | 'full' | 'cross';