diff --git a/host/tests/unit/search-index-test.ts b/host/tests/unit/search-index-test.ts index 1c9731e02c..3dcbd9b7cd 100644 --- a/host/tests/unit/search-index-test.ts +++ b/host/tests/unit/search-index-test.ts @@ -649,6 +649,7 @@ posts/ignore-me.gts import { contains, field, Card } from 'https://cardstack.com/base/card-api'; import StringCard from 'https://cardstack.com/base/string'; import IntegerCard from 'https://cardstack.com/base/integer'; + import DatetimeCard from 'https://cardstack.com/base/datetime'; export class Person extends Card { @field name = contains(StringCard); @@ -660,6 +661,19 @@ posts/ignore-me.gts @field description = contains(StringCard); @field author = contains(Person); @fields views = contains(IntegerCard); + @fields createdAt = contains(DatetimeCard); + } + + export class Article extends Post { + @fields publishedDate = contains(DatetimeCard); + } + `, + 'book.gts': ` + import { contains, field, Card } from 'https://cardstack.com/base/card-api'; + import Person from './cards.gts'; + + export class Book extends Card { + @field author = contains(Person); } `, 'card-1.json': { @@ -668,16 +682,16 @@ posts/ignore-me.gts attributes: { title: 'Card 1', description: 'Sample post', - author: { - name: 'Cardy', - }, - }, - meta: { - adoptsFrom: { - module: 'https://cardstack.com/base/card-api', - name: 'Card', - }, + author: { name: 'Cardy' }, }, + meta: { adoptsFrom: { module: `./cards`, name: 'Article' } }, + }, + }, + 'card-2.json': { + data: { + type: 'card', + attributes: { author: { name: 'Cardy' } }, + meta: { adoptsFrom: { module: `./book.gts`, name: 'Book' } }, }, }, 'cards/1.json': { @@ -686,17 +700,12 @@ posts/ignore-me.gts attributes: { title: 'Card 1', description: 'Sample post', - author: { - name: 'Carl Stack', - }, + author: { name: 'Carl Stack' }, createdAt: new Date(2022, 7, 1), views: 10, }, meta: { - adoptsFrom: { - module: `${paths.url}/Post`, - name: 'Card', - }, + adoptsFrom: { module: `${paths.url}cards`, name: 'Post' }, }, }, }, @@ -714,10 +723,7 @@ posts/ignore-me.gts views: 5, }, meta: { - adoptsFrom: { - module: `${paths.url}/Post`, - name: 'Card', - }, + adoptsFrom: { module: `${paths.url}cards`, name: 'Article' }, }, }, }, @@ -734,33 +740,23 @@ posts/ignore-me.gts test(`can search for cards by using the 'eq' filter`, async function (assert) { let matching = await indexer.search({ filter: { - eq: { - title: 'Card 1', - description: 'Sample post', - }, + on: { module: `${paths.url}cards`, name: 'Post' }, + eq: { title: 'Card 1', description: 'Sample post' }, }, }); - assert.strictEqual(matching.length, 2, 'found two cards'); - assert.strictEqual(matching[0]?.id, `${testRealmURL}card-1`); - assert.strictEqual(matching[1]?.id, `${testRealmURL}cards/1`); + assert.deepEqual( + matching.map((m) => m.id), + [`${paths.url}card-1`, `${paths.url}cards/1`] + ); }); test('can combine multiple filters', async function (assert) { let matching = await indexer.search({ filter: { every: [ - { - eq: { - title: 'Card 1', - }, - }, - { - not: { - eq: { - 'author.name': 'Cardy', - }, - }, - }, + { type: { module: `${paths.url}cards`, name: 'Post' } }, + { eq: { title: 'Card 1' } }, + { not: { eq: { 'author.name': 'Cardy' } } }, ], }, }); @@ -769,27 +765,38 @@ posts/ignore-me.gts }); test('can handle a filter with double negatives', async function (assert) { - // note: do we allow this? let matching = await indexer.search({ filter: { - not: { - not: { - not: { - eq: { - 'author.email': 'carl@stack.com', - }, - }, - }, - }, + on: { module: `${paths.url}cards`, name: 'Post' }, + not: { not: { not: { eq: { 'author.email': 'carl@stack.com' } } } }, }, }); - assert.strictEqual(matching.length, 2); - assert.strictEqual(matching[0]?.id, `${testRealmURL}card-1`); - assert.strictEqual(matching[1]?.id, `${testRealmURL}cards/1`); + assert.deepEqual( + matching.map((m) => m.id), + [`${paths.url}card-1`, `${paths.url}cards/1`] + ); + }); + + test('can filter by card type', async function (assert) { + let matching = await indexer.search({ + filter: { type: { module: `${paths.url}cards`, name: 'Article' } }, + }); + assert.deepEqual( + matching.map((m) => m.id), + [`${paths.url}card-1`, `${paths.url}cards/2`], + 'found cards of type Article' + ); + + matching = await indexer.search({ + filter: { type: { module: `${testRealmURL}cards`, name: 'Post' } }, + }); + assert.deepEqual( + matching.map((m) => m.id), + [`${paths.url}card-1`, `${paths.url}cards/1`, `${paths.url}cards/2`], + 'found cards of type Post' + ); }); - // Tests from hub/**/**/card-service-test.ts - skip('can filter by card type'); skip(`can filter on a card's own fields using gt`); skip(`gives a good error when query refers to missing card`); skip(`gives a good error when query refers to missing field`); @@ -797,32 +804,51 @@ posts/ignore-me.gts test(`can filter on a nested field using 'eq'`, async function (assert) { let matching = await indexer.search({ filter: { - eq: { - 'author.name': 'Carl Stack', - }, + on: { module: `${paths.url}cards`, name: 'Post' }, + eq: { 'author.name': 'Carl Stack' }, }, }); - assert.strictEqual(matching.length, 2); - assert.strictEqual(matching[0]?.id, `${testRealmURL}cards/1`); - assert.strictEqual(matching[1]?.id, `${testRealmURL}cards/2`); + assert.deepEqual( + matching.map((m) => m.id), + [`${paths.url}cards/1`, `${paths.url}cards/2`] + ); }); test('can negate a filter', async function (assert) { let matching = await indexer.search({ filter: { - not: { - eq: { - 'author.email': 'carl@stack.com', - }, - }, + every: [ + { type: { module: `${paths.url}cards`, name: 'Article' } }, + { not: { eq: { 'author.email': 'carl@stack.com' } } }, + ], }, }); - assert.strictEqual(matching.length, 2); + assert.strictEqual(matching.length, 1); assert.strictEqual(matching[0]?.id, `${testRealmURL}card-1`); - assert.strictEqual(matching[1]?.id, `${testRealmURL}cards/1`); }); - skip('can combine multiple types'); + test('can combine multiple types', async function (assert) { + let matching = await indexer.search({ + filter: { + any: [ + { + on: { module: `${paths.url}cards`, name: 'Article' }, + eq: { 'author.name': 'Cardy' }, + }, + { + on: { module: `${paths.url}book`, name: 'Book' }, + eq: { 'author.name': 'Cardy' }, + }, + ], + }, + }); + assert.deepEqual( + matching.map((m) => m.id), + [`${paths.url}card-1`, `${paths.url}card-2`] + ); + }); + + // sorting skip('can sort in alphabetical order'); skip('can sort in reverse alphabetical order'); skip('can sort in multiple string field conditions'); diff --git a/runtime-common/query.ts b/runtime-common/query.ts index e487d7febf..4eda2700fa 100644 --- a/runtime-common/query.ts +++ b/runtime-common/query.ts @@ -2,6 +2,7 @@ import * as JSON from "json-typescript"; import isEqual from "lodash/isEqual"; import { assertJSONValue, assertJSONPrimitive } from "./json-validation"; import qs from "qs"; +import { ExportedCardRef } from "@cardstack/runtime-common/search-index"; export interface Query { filter?: Filter; @@ -20,7 +21,7 @@ export type Filter = | CardTypeFilter; export interface TypedFilter { - on?: CardURL; + on?: ExportedCardRef; } interface SortExpression { @@ -35,7 +36,7 @@ export type Sort = SortExpression[]; // adopt from some particular card type--no other predicates are included in // this filter. export interface CardTypeFilter { - type: CardURL; + type: ExportedCardRef; } export interface AnyFilter extends TypedFilter { @@ -154,14 +155,14 @@ function assertFilter( } if ("type" in filter) { - assertCardId(filter.type, pointer.concat("type")); + assertCardType(filter.type, pointer.concat("type")); if (isEqual(Object.keys(filter), ["type"])) { return; // This is a pure card type filter } } if ("on" in filter) { - assertCardId(filter.on, pointer.concat("on")); + assertCardType(filter.on, pointer.concat("on")); } if ("any" in filter) { @@ -181,11 +182,13 @@ function assertFilter( } } -function assertCardId(id: any, pointer: string[]): asserts id is CardURL { - if (typeof id !== "string") { - throw new Error( - `${pointer.join("/") || "/"}: card id must be a string URL` - ); +function assertCardType(type: any, pointer: string[]) { + if ( + Object.keys(type).length > 2 || + !("module" in type) || + !("name" in type) + ) { + throw new Error(`${pointer.join("/") || "/"}: type is not valid`); } } diff --git a/runtime-common/search-index.ts b/runtime-common/search-index.ts index 4ef9af1364..856305e3c6 100644 --- a/runtime-common/search-index.ts +++ b/runtime-common/search-index.ts @@ -7,6 +7,11 @@ import ignore, { Ignore } from "ignore"; import { stringify } from "qs"; import { Query, Filter } from "./query"; +export type ExportedCardRef = { + module: string; + name: string; +}; + export type CardRef = | { type: "exportedCard"; @@ -495,12 +500,19 @@ export class SearchIndex { return url.href.startsWith(this.realm.url); } - // TODO: complete these types async search(query: Query): Promise { - let matcher = buildMatcher(query.filter); - return [...this.instances.values()] - .filter(matcher) - .map((entry) => entry.resource); + let matcher = this.buildMatcher(query.filter, { + module: `${baseRealm.url}card-api`, + name: "Card", + }); + + let results: SearchEntry[] = []; + for (let entry of [...this.instances.values()]) { + if (await matcher(entry)) { + results.push(entry); + } + } + return results.map((entry) => entry.resource); } async typeOf(ref: CardRef): Promise { @@ -516,6 +528,97 @@ export class SearchIndex { return this.instances.get(url)?.resource; } + async cardHasType( + ref: CardRef, + type: ExportedCardRef + ): Promise { + // only checks for exported cards + if (ref.type !== "exportedCard") { + return false; + } + + if ( + ref.name === type.name && + trimExecutableExtension(new URL(ref.module, this.realm.url)).href === + trimExecutableExtension(new URL(type.module, this.realm.url)).href + ) { + return true; + } + + let def = await this.typeOf(ref); + if (!def) { + return null; + } + if (def.super) { + return await this.cardHasType(def.super, type); + } + return false; + } + + // Matchers are three-valued (true, false, null) because a query that talks + // about a field that is not even present on a given card results in `null` to + // distinguish it from a field that is present but not matching the filter + // (`false`) + buildMatcher( + filter: Filter | undefined, + onRef: ExportedCardRef + ): (entry: SearchEntry) => Promise { + if (!filter) { + return async (_entry) => true; + } + + if ("type" in filter) { + return async (entry) => + await this.cardHasType( + { type: "exportedCard", ...entry.resource.meta.adoptsFrom }, + filter.type + ); + } + + let on = filter?.on ?? onRef; + + if ("any" in filter) { + let matchers = filter.any.map((f) => this.buildMatcher(f, on)); + return (entry) => some(matchers, (m) => m(entry)); + } + + if ("every" in filter) { + let matchers = filter.every.map((f) => this.buildMatcher(f, on)); + return (entry) => every(matchers, (m) => m(entry)); + } + + if ("not" in filter) { + let matcher = this.buildMatcher(filter.not, on); + return async (entry) => { + let inner = await matcher(entry); + if (inner == null) { + // irrelevant cards stay irrelevant, even when the query is inverted + return null; + } else { + return !inner; + } + }; + } + + if ("eq" in filter) { + return (entry) => + every(Object.entries(filter.eq), async ([fieldPath, value]) => { + if ( + await this.cardHasType( + { type: "exportedCard", ...entry.resource.meta.adoptsFrom }, + on + ) + ) { + return entry.searchData![fieldPath] === value; + } else { + return null; + } + }); + } + + throw new Error("Unknown filter"); + } + public isIgnored(url: URL): boolean { if (url.href === this.realm.url) { return false; // you can't ignore the entire realm @@ -547,7 +650,7 @@ export class SearchIndex { // so that we now how to ask for it's cards' definitions throw new Error(`not implemented`); } - let url = `${this.realm.baseRealmURL}_typeOf?${stringify(ref)}`; + let url = this.realm.baseRealmURL + "_typeOf?" + stringify(ref); let response = await fetch(url, { headers: { Accept: "application/vnd.api+json", @@ -615,28 +718,38 @@ function flatten(obj: Record): Record { return result; } -function buildMatcher( - filter: Filter | undefined -): (entry: SearchEntry) => boolean { - if (!filter) { - return (_entry) => true; - } - if ("every" in filter) { - let matchers = filter.every.map((f) => buildMatcher(f)); - return (entry) => matchers.every((m) => m(entry)); - } - - if ("not" in filter) { - let matcher = buildMatcher(filter.not); - return (entry) => !matcher(entry); +// three-valued version of Array.every that propagates nulls. Here, the presence +// of any nulls causes the whole thing to be null. +async function every( + list: T[], + predicate: (t: T) => Promise +): Promise { + let result = true; + for (let element of list) { + let status = await predicate(element); + if (status == null) { + return null; + } + result = result && status; } + return result; +} - if ("eq" in filter) { - return (entry) => - Object.entries(filter.eq).every( - ([fieldPath, value]) => entry.searchData![fieldPath] === value - ); +// three-valued version of Array.some that propagates nulls. Here, the whole +// expression becomes null only if the whole input is null. +async function some( + list: T[], + predicate: (t: T) => Promise +): Promise { + let result = null; + for (let element of list) { + let status = await predicate(element); + if (status === true) { + return true; + } + if (status === false) { + result = false; + } } - - throw new Error("Unknown filter"); + return result; }