diff --git a/apps/web/src/layout/Header.tsx b/apps/web/src/layout/Header.tsx index 226a9931..fdb5b6f4 100644 --- a/apps/web/src/layout/Header.tsx +++ b/apps/web/src/layout/Header.tsx @@ -1,6 +1,6 @@ import {cms} from '@/cms' import styler, {Styler} from '@alinea/styler' -import {AnyLink} from 'alinea' +import {Link as AnyLink} from 'alinea' import {Entry} from 'alinea/core/Entry' import {HStack, Stack} from 'alinea/ui' import {IcRoundClose} from 'alinea/ui/icons/IcRoundClose' diff --git a/apps/web/src/page/BlogPage.tsx b/apps/web/src/page/BlogPage.tsx index 8ca920e5..db774e77 100644 --- a/apps/web/src/page/BlogPage.tsx +++ b/apps/web/src/page/BlogPage.tsx @@ -5,6 +5,7 @@ import {Link} from '@/layout/nav/Link' import {BlogOverview} from '@/schema/BlogOverview' import {BlogPost} from '@/schema/BlogPost' import styler from '@alinea/styler' +import {Query} from 'alinea' import {Entry} from 'alinea/core/Entry' import {VStack} from 'alinea/ui' import {MetadataRoute} from 'next' @@ -18,8 +19,7 @@ export default async function BlogPage() { type: BlogOverview, select: { title: BlogOverview.title, - posts: { - children: {}, + posts: Query.children({ type: BlogPost, select: { ...Entry, @@ -28,7 +28,7 @@ export default async function BlogPage() { author: BlogPost.author, publishDate: BlogPost.publishDate } - } + }) } }) return ( diff --git a/src/backend/Database.test.ts b/src/backend/Database.test.ts index a7e0afe5..09c51a71 100644 --- a/src/backend/Database.test.ts +++ b/src/backend/Database.test.ts @@ -164,7 +164,7 @@ test('fetch translations', async () => { location: example.workspaces.main.multiLanguage, select: { translations: { - translations: {}, + edge: 'translations', type: Page, select: Entry.locale } @@ -395,7 +395,6 @@ test('remove field contents', async () => { test('take/skip', async () => { const example = createExample() - const {Page} = example.schema const lastTwo = await example.find({ root: example.workspaces.main.pages, skip: 1, diff --git a/src/backend/data/ChangeSet.ts b/src/backend/data/ChangeSet.ts index eca7a371..1b11b6b6 100644 --- a/src/backend/data/ChangeSet.ts +++ b/src/backend/data/ChangeSet.ts @@ -157,8 +157,9 @@ export class ChangeSetCreator { select: { workspace: Entry.workspace, files: { + edge: 'children', + depth: 999, type: MediaFile, - children: {depth: 999}, select: {location: MediaFile.location} } }, diff --git a/src/backend/resolver/EntryResolver.ts b/src/backend/resolver/EntryResolver.ts index 7c691209..8884bb38 100644 --- a/src/backend/resolver/EntryResolver.ts +++ b/src/backend/resolver/EntryResolver.ts @@ -5,12 +5,12 @@ import {Expr} from 'alinea/core/Expr' import {Field} from 'alinea/core/Field' import {Filter} from 'alinea/core/Filter' import { + EdgeQuery, GraphQuery, Order, Projection, QuerySettings, querySource, - RelatedQuery, Status } from 'alinea/core/Graph' import { @@ -58,7 +58,7 @@ import {Builder} from 'rado/core/Builder' import {Functions} from 'rado/core/expr/Functions' import {input} from 'rado/core/expr/Input' import {jsonExpr} from 'rado/core/expr/Json' -import {getData, getTable, HasSql} from 'rado/core/Internal' +import {getData, getTable, HasSql, internalTarget} from 'rado/core/Internal' import {bm25, snippet} from 'rado/sqlite' import type {Database} from '../Database.js' import {Store} from '../Store.js' @@ -108,15 +108,19 @@ export class EntryResolver { } } + field(table: typeof EntryRow, field: Expr): HasSql { + const name = this.scope.nameOf(field) + if (!name) throw new Error(`Expression has no name ${field}`) + const isEntryField = name === 'path' || name === 'type' + if (isEntryField) return table[name] + return (table.data)[name] + } + expr(ctx: ResolveContext, expr: Expr): HasSql { const internal = getExpr(expr) switch (internal.type) { case 'field': - const name = this.scope.nameOf(expr) - if (!name) throw new Error(`Expression has no name ${expr}`) - const isEntryField = name === 'path' || name === 'type' - if (isEntryField) return ctx.Table[name] - return (ctx.Table.data)[name] + return this.field(ctx.Table, expr) case 'entryField': return ctx.Table[internal.name as keyof EntryRow] case 'call': @@ -161,7 +165,7 @@ export class EntryResolver { return [key, this.selectProjection(ctx, value as Projection)] }) ) - const related = value as RelatedQuery + const related = value as object as EdgeQuery const isSingle = this.isSingleResult(related) const query = this.query(ctx, related) return isSingle ? include.one(query) : include(query) @@ -176,7 +180,7 @@ export class EntryResolver { return this.selectProjection(ctx, fromEntries(entries(fields))) } - querySource(ctx: ResolveContext, query: RelatedQuery): Select { + querySource(ctx: ResolveContext, query: EdgeQuery): Select { const hasSearch = Boolean(query.search?.length) const {aliased} = getTable(ctx.Table) const cursor = hasSearch @@ -189,7 +193,7 @@ export class EntryResolver { ) : builder.select().from(ctx.Table) const from = alias(EntryRow, `E${ctx.depth - 1}`) // .as(source.id) - switch (querySource(query)) { + switch (query.edge) { case 'parent': return cursor.where(eq(ctx.Table.id, from.parentId)).limit(1) case 'next': @@ -209,14 +213,12 @@ export class EntryResolver { case 'siblings': return cursor.where( eq(ctx.Table.parentId, from.parentId), - query.siblings?.includeSelf ? undefined : ne(ctx.Table.id, from.id) + query?.includeSelf ? undefined : ne(ctx.Table.id, from.id) ) case 'translations': return cursor.where( eq(ctx.Table.id, from.id), - query.translations?.includeSelf - ? undefined - : ne(ctx.Table.locale, from.locale) + query?.includeSelf ? undefined : ne(ctx.Table.locale, from.locale) ) case 'children': const Child = alias(EntryRow, 'Child') @@ -245,10 +247,7 @@ export class EntryResolver { .where( is(Child.locale, from.locale), this.conditionStatus(Child, ctx.status), - lt( - self.level, - Math.min(query.children?.depth ?? 1, MAX_DEPTH) - ) + lt(self.level, Math.min(query?.depth ?? 1, MAX_DEPTH)) ) ) ) @@ -291,10 +290,7 @@ export class EntryResolver { .where( is(Parent.locale, from.locale), this.conditionStatus(Parent, ctx.status), - lt( - self.level, - Math.min(query.parents?.depth ?? MAX_DEPTH, MAX_DEPTH) - ) + lt(self.level, Math.min(query?.depth ?? MAX_DEPTH, MAX_DEPTH)) ) ) ) @@ -310,6 +306,19 @@ export class EntryResolver { is(ctx.Table.locale, from.locale) ) .orderBy(asc(ctx.Table.level)) + case 'entryMultiple': { + const linkedField = this.field(from, query.field) + return cursor + .innerJoin( + {[internalTarget]: sql`json_each(${linkedField}) as lF`} as any, + eq(ctx.Table.id, sql`lF.value->>'_entry'`) + ) + .orderBy(asc(sql`lF.id`)) + } + case 'entrySingle': { + const linkedField = this.field(from, query.field) + return cursor.where(eq(ctx.Table.id, sql`${linkedField}->>'_entry'`)) + } default: return cursor.orderBy(asc(ctx.Table.index)) } @@ -388,7 +397,7 @@ export class EntryResolver { query.root && typeof query.root === 'object' && hasRoot(query.root) ? this.scope.nameOf(query.root) : query.root - return this.conditionFilter(ctx, this.getField.bind(this), { + return this.conditionFilter(ctx, this.filterField.bind(this), { _id: query.id, _parentId: query.parentId, _path: query.path, @@ -509,7 +518,7 @@ export class EntryResolver { return and(...conditions) } - getField(ctx: ResolveContext, name: string) { + filterField(ctx: ResolveContext, name: string) { if (name.startsWith('_')) { const entryProp = name.slice(1) const key = entryProp as keyof EntryRow @@ -519,10 +528,10 @@ export class EntryResolver { return (ctx.Table.data)[name] } - query(ctx: ResolveContext, query: RelatedQuery): Select { + query(ctx: ResolveContext, query: GraphQuery): Select { const {type, filter, skip, take, orderBy, groupBy, first, search} = query ctx = ctx.increaseDepth().none - let q = this.querySource(ctx, query) + let q = this.querySource(ctx, query as EdgeQuery) if (skip) q = q.offset(skip) if (take) q = q.limit(take) const queryData = getData(q) @@ -537,7 +546,7 @@ export class EntryResolver { ? undefined : this.conditionLocale(ctx.Table, ctx.locale), this.conditionSearch(ctx.Table, search), - filter && this.conditionFilter(ctx, this.getField.bind(this), filter) + filter && this.conditionFilter(ctx, this.filterField.bind(this), filter) ) const toSelect = this.select(ctx.select, query) let result = new Select({ @@ -559,9 +568,14 @@ export class EntryResolver { return result } - isSingleResult(query: RelatedQuery): boolean { + isSingleResult(query: EdgeQuery): boolean { return Boolean( - query.first || query.get || query.parent || query.next || query.previous + query.first || + query.get || + query.count || + query.edge === 'parent' || + query.edge === 'next' || + query.edge === 'previous' ) } @@ -586,26 +600,31 @@ export class EntryResolver { ctx: PostContext, interim: Interim, query: GraphQuery - ) { + ): Promise { if (!interim) return const selected = this.projection(query) - if (selected && hasExpr(selected)) - return this.postExpr(ctx, interim, selected) + if (hasExpr(selected)) return this.postExpr(ctx, interim, selected) + if (querySource(selected)) + return this.post(ctx, interim, selected as EdgeQuery) await Promise.all( entries(selected).map(([key, value]) => { const source = querySource(value) if (source) - return this.post(ctx, interim[key], value as RelatedQuery) + return this.post(ctx, interim[key], value as EdgeQuery) return this.postExpr(ctx, interim[key], value as Expr) }) ) } - post(ctx: PostContext, interim: Interim, input: RelatedQuery) { + async post( + ctx: PostContext, + interim: Interim, + input: EdgeQuery + ): Promise { if (input.count === true) return const isSingle = this.isSingleResult(input) if (isSingle) return this.postRow(ctx, interim, input) - return Promise.all(interim.map((row: any) => this.postRow(ctx, row, input))) + await Promise.all(interim.map((row: any) => this.postRow(ctx, row, input))) } resolve = async (query: GraphQuery): Promise => { @@ -616,14 +635,14 @@ export class EntryResolver { ...query, location }) - const dbQuery = this.query(ctx, query as GraphQuery) - const singleResult = this.isSingleResult(query) + const asEdge = query as EdgeQuery + const dbQuery = this.query(ctx, asEdge) + const singleResult = this.isSingleResult(asEdge) const transact = async (tx: Store): Promise => { const rows = await dbQuery.all(tx) const linkResolver = new LinkResolver(this, tx, ctx) const result = singleResult ? rows[0] ?? null : rows - if (result) - await this.post({linkResolver}, result, query as GraphQuery) + if (result) await this.post({linkResolver}, result, asEdge) return result as T } if (query.preview) { diff --git a/src/core/Graph.ts b/src/core/Graph.ts index d7c87d8d..406dd372 100644 --- a/src/core/Graph.ts +++ b/src/core/Graph.ts @@ -1,15 +1,14 @@ import {Root, Workspace} from 'alinea/types' -import * as cito from 'cito' import {Config} from './Config.js' import {EntryFields} from './EntryFields.js' import {Expr} from './Expr.js' import {Condition, Filter} from './Filter.js' import {Infer, StoredRow} from './Infer.js' +import {HasField} from './Internal.js' import {Page} from './Page.js' import {PreviewRequest} from './Preview.js' import {Resolver} from './Resolver.js' import {Type} from './Type.js' -import {hasExact} from './util/Checks.js' import {Expand} from './util/Types.js' export type Location = Root | Workspace | Page | Array @@ -23,35 +22,74 @@ type FieldsOf = StoredRow< : unknown > -export interface RelatedQuery - extends GraphQuery { - translations?: EmptyObject | {includeSelf: boolean | undefined} - children?: EmptyObject | {depth: number | undefined} - parents?: EmptyObject | {depth: number | undefined} - siblings?: EmptyObject | {includeSelf: boolean | undefined} +export interface EdgeTranslations { + edge: 'translations' + includeSelf?: boolean +} + +export interface EdgeChildren { + edge: 'children' + depth?: number +} + +export interface EdgeParents { + edge: 'parents' + depth?: number +} + +export interface EdgeSiblings { + edge: 'siblings' + includeSelf?: boolean +} + +export interface EdgeParent { + edge: 'parent' +} + +export interface EdgeNext { + edge: 'next' +} + +export interface EdgePrevious { + edge: 'previous' +} - parent?: EmptyObject - next?: EmptyObject - previous?: EmptyObject +export interface EdgeEntries { + edge: 'entryMultiple' + field: HasField & Expr } -type IsRelated = - | {translations: EmptyObject | {includeSelf: boolean | undefined}} - | {children: EmptyObject | {depth: number | undefined}} - | {parents: EmptyObject | {depth: number | undefined}} - | {siblings: EmptyObject | {includeSelf: boolean | undefined}} - | {parent: EmptyObject} - | {next: EmptyObject} - | {previous: EmptyObject} - -interface ToSelect { - [key: string]: - | Expr - | RelatedQuery, Type | Array> - | ToSelect +export interface EdgeEntry { + edge: 'entrySingle' + field: HasField & Expr } -export type Projection = ToSelect | Expr +export type Edge = + | EdgeTranslations + | EdgeChildren + | EdgeParents + | EdgeSiblings + | EdgeParent + | EdgeNext + | EdgePrevious + | EdgeEntries + | EdgeEntry + +export type EdgeQuery< + Selection = unknown, + Types = unknown, + Include = unknown +> = GraphQuery & Edge + +type SelectContent = + | Expr + | EdgeQuery, Type | Array> + +interface SelectObject { + [key: string]: Projection +} + +export type Projection = SelectContent | SelectObject export type InferProjection = InferSelection export interface Order { @@ -60,15 +98,13 @@ export interface Order { caseSensitive?: boolean } -type InferSelection = Selection extends Expr +type InferSelection = Selection extends GraphQuery & Edge + ? Expand> + : Selection extends Expr ? V : { [K in keyof Selection]: Selection[K] extends Type ? Type.Infer - : Selection[K] extends Expr - ? V - : Selection[K] extends GraphQuery & IsRelated - ? Expand> : InferSelection } @@ -108,10 +144,10 @@ export type AnyQueryResult = Query extends CountQuery< any > ? number - : Query extends FirstQuery - ? QueryResult | null : Query extends GetQuery ? QueryResult + : Query extends FirstQuery + ? QueryResult | null : Query extends GraphQuery ? Array> : unknown @@ -212,7 +248,7 @@ export interface GraphQuery< export type SelectionGuard = Projection | undefined export type TypeGuard = Type | Array | undefined -export type IncludeGuard = ToSelect | undefined +export type IncludeGuard = SelectObject | undefined export class Graph { #resolver: Resolver @@ -262,32 +298,8 @@ export class Graph { } } -const plainObject = cito.type((value): value is object => { - return value?.constructor === Object -}) -const emptySource = plainObject.and(hasExact([])) -const depthSource = cito - .object({depth: cito.number.optional}) - .and(hasExact(['depth'])) -const includeSource = cito - .object({includeSelf: cito.boolean.optional}) - .and(hasExact(['includeSelf'])) -const translationsSource = cito.object({translations: includeSource}) -const childrenSource = cito.object({children: depthSource}) -const parentsSource = cito.object({parents: depthSource}) -const siblingsSource = cito.object({siblings: includeSource}) - -const parentSource = cito.object({parent: emptySource}) -const nextSource = cito.object({next: emptySource}) -const prevSource = cito.object({previous: emptySource}) - export function querySource(query: unknown) { if (query?.constructor !== Object) return - if (translationsSource.check(query)) return 'translations' - if (childrenSource.check(query)) return 'children' - if (parentsSource.check(query)) return 'parents' - if (siblingsSource.check(query)) return 'siblings' - if (parentSource.check(query)) return 'parent' - if (nextSource.check(query)) return 'next' - if (prevSource.check(query)) return 'previous' + const related = (query).edge + return related } diff --git a/src/core/media/Summary.ts b/src/core/media/Summary.ts index 3d52c119..c023d6c5 100644 --- a/src/core/media/Summary.ts +++ b/src/core/media/Summary.ts @@ -20,14 +20,14 @@ export function summarySelection(schema: Schema) { width: MediaFile.width, height: MediaFile.height, parents: { - parents: {}, + edge: 'parents' as const, select: { id: Entry.id, title: Entry.title } }, childrenAmount: { - children: {}, + edge: 'children' as const, count: true as const } } diff --git a/src/dashboard/atoms/EntryAtoms.ts b/src/dashboard/atoms/EntryAtoms.ts index eec5a241..4dd82cd6 100644 --- a/src/dashboard/atoms/EntryAtoms.ts +++ b/src/dashboard/atoms/EntryAtoms.ts @@ -89,7 +89,7 @@ const entryTreeItemLoaderAtom = atom(async get => { root: Entry.root, path: Entry.path, parentPaths: { - parents: {}, + edge: 'parents' as const, select: Entry.path } } @@ -101,7 +101,7 @@ const entryTreeItemLoaderAtom = atom(async get => { type: Entry.type, data, translations: { - translations: {}, + edge: 'translations', select: data } }, diff --git a/src/dashboard/atoms/EntryEditorAtoms.ts b/src/dashboard/atoms/EntryEditorAtoms.ts index fd67d582..0059ea47 100644 --- a/src/dashboard/atoms/EntryEditorAtoms.ts +++ b/src/dashboard/atoms/EntryEditorAtoms.ts @@ -137,7 +137,7 @@ export const entryEditorAtoms = atomFamily( select: { ...Entry, parents: { - parents: {}, + edge: 'parents', select: Entry.id } }, @@ -148,7 +148,7 @@ export const entryEditorAtoms = atomFamily( const withParents = await graph.first({ select: { parents: { - parents: {}, + edge: 'parents', select: { id: Entry.id, path: Entry.path @@ -346,7 +346,7 @@ export function createEntryEditor(entryData: EntryData) { entryId: Entry.id, path: Entry.path, paths: { - parents: {}, + edge: 'parents', select: Entry.path } }, @@ -396,7 +396,7 @@ export function createEntryEditor(entryData: EntryData) { select: { ...Entry, parentPaths: { - parents: {}, + edge: 'parents', select: Entry.path } }, diff --git a/src/dashboard/hook/UseUploads.ts b/src/dashboard/hook/UseUploads.ts index 43aaac1a..e1ad73a3 100644 --- a/src/dashboard/hook/UseUploads.ts +++ b/src/dashboard/hook/UseUploads.ts @@ -178,7 +178,7 @@ export function useUploads(onSelect?: (entry: EntryRow) => void) { url: Entry.url, path: Entry.path, parentPaths: { - parents: {}, + edge: 'parents', select: Entry.path } }, diff --git a/src/dashboard/view/entry/NewEntry.tsx b/src/dashboard/view/entry/NewEntry.tsx index cc13a0cc..33d51e70 100644 --- a/src/dashboard/view/entry/NewEntry.tsx +++ b/src/dashboard/view/entry/NewEntry.tsx @@ -50,12 +50,12 @@ const parentData = { level: Entry.level, parent: Entry.parentId, parentPaths: { - parents: {}, + edge: 'parents' as const, select: Entry.path }, childrenIndex: { first: true as const, - children: {}, + edge: 'children' as const, select: Entry.index, orderBy: {asc: Entry.index, caseSensitive: true} } diff --git a/src/field/link/LinkField.ts b/src/field/link/LinkField.ts index 13da712d..31c7e0a9 100644 --- a/src/field/link/LinkField.ts +++ b/src/field/link/LinkField.ts @@ -1,4 +1,11 @@ import type {FieldOptions, WithoutLabel} from 'alinea/core/Field' +import { + EdgeQuery, + GraphQuery, + IncludeGuard, + SelectionGuard, + TypeGuard +} from 'alinea/core/Graph' import type {Picker} from 'alinea/core/Picker' import {Reference} from 'alinea/core/Reference' import {Schema} from 'alinea/core/Schema' @@ -26,7 +33,17 @@ export interface LinkOptions extends LinkFieldOptions { export class LinkField< StoredValue extends Reference, QueryValue -> extends UnionField> {} +> extends UnionField> { + first< + Selection extends SelectionGuard = undefined, + const Type extends TypeGuard = undefined, + Include extends IncludeGuard = undefined + >( + query: GraphQuery + ): EdgeQuery & {first: true} { + return {edge: 'entrySingle', first: true, field: this, ...query} + } +} export function createLink( label: string, @@ -57,7 +74,37 @@ export function createLink( export class LinksField< StoredValue extends ListRow, QueryValue -> extends ListField>> {} +> extends ListField>> { + find< + Selection extends SelectionGuard = undefined, + Type extends TypeGuard = undefined, + Include extends IncludeGuard = undefined + >( + query: GraphQuery + ): EdgeQuery { + return {edge: 'entryMultiple', field: this, ...query} + } + + first< + Selection extends SelectionGuard = undefined, + const Type extends TypeGuard = undefined, + Include extends IncludeGuard = undefined + >( + query?: GraphQuery + ): EdgeQuery & {first: true} { + return {edge: 'entryMultiple', first: true, field: this, ...query} + } + + count< + Selection extends SelectionGuard = undefined, + const Type extends TypeGuard = undefined, + Include extends IncludeGuard = undefined + >( + query?: GraphQuery + ): EdgeQuery & {count: true} { + return {edge: 'entryMultiple', count: true, field: this, ...query} + } +} /** Create a link field configuration */ export function createLinks( diff --git a/src/picker/entry/EntryPicker.browser.tsx b/src/picker/entry/EntryPicker.browser.tsx index 9e9b5dbe..24dc75cc 100644 --- a/src/picker/entry/EntryPicker.browser.tsx +++ b/src/picker/entry/EntryPicker.browser.tsx @@ -125,7 +125,7 @@ export function EntryPickerModal({ select: { title: Entry.title, parents: { - parents: {}, + edge: 'parents', select: { id: Entry.id, title: Entry.title diff --git a/src/query.ts b/src/query.ts index a5020f90..db0d7a4a 100644 --- a/src/query.ts +++ b/src/query.ts @@ -25,8 +25,7 @@ export function children< Type extends TypeGuard = undefined, Include extends IncludeGuard = undefined >(query: GraphQuery & {depth?: number}) { - const {depth, ...rest} = query - return {children: {depth}, ...rest} + return {edge: 'children' as const, ...query} } export function parents< @@ -34,8 +33,7 @@ export function parents< Type extends TypeGuard = undefined, Include extends IncludeGuard = undefined >(query: GraphQuery & {depth?: number}) { - const {depth, ...rest} = query - return {parents: {depth}, ...rest} + return {edge: 'parents' as const, ...query} } export function translations< @@ -43,8 +41,7 @@ export function translations< Type extends TypeGuard = undefined, Include extends IncludeGuard = undefined >(query: GraphQuery & {includeSelf?: boolean}) { - const {includeSelf, ...rest} = query - return {translations: {includeSelf}, ...rest} + return {edge: 'translations' as const, ...query} } export function parent< @@ -52,7 +49,7 @@ export function parent< Type extends TypeGuard = undefined, Include extends IncludeGuard = undefined >(query: GraphQuery) { - return {parent: {}, ...query} + return {edge: 'parent' as const, ...query} } export function next< @@ -60,7 +57,7 @@ export function next< Type extends TypeGuard = undefined, Include extends IncludeGuard = undefined >(query: GraphQuery) { - return {next: {}, ...query} + return {edge: 'next' as const, ...query} } export function previous< @@ -68,7 +65,7 @@ export function previous< Type extends TypeGuard = undefined, Include extends IncludeGuard = undefined >(query: GraphQuery) { - return {previous: {}, ...query} + return {edge: 'previous' as const, ...query} } export function siblings< @@ -76,6 +73,5 @@ export function siblings< Type extends TypeGuard = undefined, Include extends IncludeGuard = undefined >(query: GraphQuery & {includeSelf?: boolean}) { - const {includeSelf, ...rest} = query - return {siblings: {includeSelf}, ...rest} + return {edge: 'siblings' as const, ...query} }