diff --git a/packages/base/boolean.gts b/packages/base/boolean.gts index e309094e68..2a3147ad74 100644 --- a/packages/base/boolean.gts +++ b/packages/base/boolean.gts @@ -2,6 +2,7 @@ import { primitive, serialize, queryableValue, + formatQuery, Component, useIndexBasedKey, FieldDef, @@ -48,10 +49,10 @@ export default class BooleanField extends FieldDef { } static [queryableValue](val: any): boolean { - if (typeof val === 'string') { - return val.toLowerCase() === 'true'; - } - return Boolean(val); + return asBoolean(val); + } + static [formatQuery](val: any): boolean { + return asBoolean(val); } static embedded = View; @@ -87,3 +88,10 @@ export default class BooleanField extends FieldDef { } }; } + +function asBoolean(val: any): boolean { + if (typeof val === 'string') { + return val.toLowerCase() === 'true'; + } + return Boolean(val); +} diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 8fe4625aef..044c47720d 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -57,6 +57,7 @@ export const useIndexBasedKey = Symbol.for('cardstack-use-index-based-key'); export const fieldDecorator = Symbol.for('cardstack-field-decorator'); export const fieldType = Symbol.for('cardstack-field-type'); export const queryableValue = Symbol.for('cardstack-queryable-value'); +export const formatQuery = Symbol.for('cardstack-format-query'); export const relativeTo = Symbol.for('cardstack-relative-to'); export const realmInfo = Symbol.for('cardstack-realm-info'); export const realmURL = Symbol.for('cardstack-realm-url'); @@ -1606,6 +1607,13 @@ export class BaseDef { } } + static [formatQuery](value: any): any { + if (primitive in this) { + return value; + } + throw new Error(`Cannot format query value for composite card/field`); + } + static [queryableValue](value: any, stack: BaseDef[] = []): any { if (primitive in this) { return value; @@ -2140,6 +2148,13 @@ export function getQueryableValue( return fieldOrCard.queryableValue(value, stack); } +export function formatQueryValue( + field: Field, + queryValue: any, +): any { + return field.card[formatQuery](queryValue); +} + function peekAtField(instance: BaseDef, fieldName: string): any { let field = getField( Reflect.getPrototypeOf(instance)!.constructor as typeof BaseDef, diff --git a/packages/base/code-ref.gts b/packages/base/code-ref.gts index 49ca5358de..6513815a8c 100644 --- a/packages/base/code-ref.gts +++ b/packages/base/code-ref.gts @@ -3,6 +3,7 @@ import { primitive, serialize, deserialize, + formatQuery, queryableValue, CardDef, BaseDefConstructor, @@ -51,18 +52,28 @@ export default class CodeRefField extends FieldDef { codeRef: ResolvedCodeRef | undefined, stack: CardDef[] = [], ) { - if (codeRef) { - // if a stack is passed in, use the containing card to resolve relative references - let moduleHref = - stack.length > 0 - ? new URL(codeRef.module, stack[0][relativeTo]).href - : codeRef.module; - return `${moduleHref}/${codeRef.name}`; - } - return undefined; + return maybeSerializeCodeRef(codeRef, stack); + } + static [formatQuery](codeRef: ResolvedCodeRef) { + return maybeSerializeCodeRef(codeRef); } static embedded = class Embedded extends BaseView {}; // The edit template is meant to be read-only, this field card is not mutable static edit = class Edit extends BaseView {}; } + +function maybeSerializeCodeRef( + codeRef: ResolvedCodeRef | undefined, + stack: CardDef[] = [], +) { + if (codeRef) { + // if a stack is passed in, use the containing card to resolve relative references + let moduleHref = + stack.length > 0 + ? new URL(codeRef.module, stack[0][relativeTo]).href + : codeRef.module; + return `${moduleHref}/${codeRef.name}`; + } + return undefined; +} diff --git a/packages/host/tests/cards/event.gts b/packages/host/tests/cards/event.gts new file mode 100644 index 0000000000..726d6c6e22 --- /dev/null +++ b/packages/host/tests/cards/event.gts @@ -0,0 +1,9 @@ +import { contains, field, CardDef } from 'https://cardstack.com/base/card-api'; +import DateField from 'https://cardstack.com/base/date'; +import StringField from 'https://cardstack.com/base/string'; + +export class Event extends CardDef { + @field title = contains(StringField); + @field venue = contains(StringField); + @field date = contains(DateField); +} diff --git a/packages/host/tests/cards/silly-number.gts b/packages/host/tests/cards/silly-number.gts index 249e2a6d61..d123419155 100644 --- a/packages/host/tests/cards/silly-number.gts +++ b/packages/host/tests/cards/silly-number.gts @@ -2,6 +2,7 @@ import { Component, FieldDef, queryableValue, + formatQuery, primitive, } from 'https://cardstack.com/base/card-api'; @@ -19,40 +20,47 @@ class View extends Component { export default class SillyNumberField extends FieldDef { static [primitive]: string[]; + static [formatQuery](value: string[]) { + return format(value); + } static [queryableValue](value: string[] | undefined) { - if (!value) { - return undefined; - } - let result = value.map((word) => { - switch (word) { - case 'zero': - return '0'; - case 'one': - return '1'; - case 'two': - return '2'; - case 'three': - return '3'; - case 'four': - return '4'; - case 'five': - return '5'; - case 'six': - return '6'; - case 'seven': - return '7'; - case 'eight': - return '8'; - case 'nine': - return '9'; - default: - return '0'; - } - }); - return parseInt(result.join('')); + return format(value); } static embedded = View; static isolated = View; static edit = View; } + +function format(value: string[] | undefined) { + if (!value) { + return undefined; + } + let result = value.map((word) => { + switch (word) { + case 'zero': + return '0'; + case 'one': + return '1'; + case 'two': + return '2'; + case 'three': + return '3'; + case 'four': + return '4'; + case 'five': + return '5'; + case 'six': + return '6'; + case 'seven': + return '7'; + case 'eight': + return '8'; + case 'nine': + return '9'; + default: + return '0'; + } + }); + return parseInt(result.join('')); +} diff --git a/packages/host/tests/integration/search-index-test.gts b/packages/host/tests/integration/search-index-test.gts index dd4ff1df91..1b200eadda 100644 --- a/packages/host/tests/integration/search-index-test.gts +++ b/packages/host/tests/integration/search-index-test.gts @@ -2953,6 +2953,38 @@ posts/ignore-me.json }, }, }, + 'event-1.json': { + data: { + type: 'card', + attributes: { + title: "Mango's Birthday", + venue: 'Dog Park', + date: '2024-10-30', + }, + meta: { + adoptsFrom: { + module: `${testModuleRealm}event`, + name: 'Event', + }, + }, + }, + }, + 'event-2.json': { + data: { + type: 'card', + attributes: { + title: "Van Gogh's Birthday", + venue: 'Backyard', + date: '2024-11-19', + }, + meta: { + adoptsFrom: { + module: `${testModuleRealm}event`, + name: 'Event', + }, + }, + }, + }, 'mango.json': { data: { type: 'card', @@ -3348,7 +3380,7 @@ posts/ignore-me.json } }); - test('can use a range filter with custom queryableValue', async function (assert) { + test('can use a range filter with custom formatQuery', async function (assert) { let { data: matching } = await indexer.search({ filter: { on: { module: `${testModuleRealm}dog`, name: 'Dog' }, @@ -3363,6 +3395,21 @@ posts/ignore-me.json ); }); + test('can use an eq filter with a date field', async function (assert) { + let { data: matching } = await indexer.search({ + filter: { + on: { module: `${testModuleRealm}event`, name: 'Event' }, + eq: { + date: '2024-10-30', + }, + }, + }); + assert.deepEqual( + matching.map((m) => m.id), + [`${paths.url}event-1`], + ); + }); + test(`gives a good error when query refers to missing card`, async function (assert) { try { await indexer.search({ @@ -3663,6 +3710,8 @@ posts/ignore-me.json `${paths.url}mango`, // dog `${paths.url}ringo`, // dog `${paths.url}vangogh`, // dog + `${paths.url}event-1`, // event + `${paths.url}event-2`, // event `${paths.url}friend2`, // friend `${paths.url}friend1`, // friend `${paths.url}person-card1`, // person diff --git a/packages/host/tests/unit/query-test.ts b/packages/host/tests/unit/query-test.ts index 4c10076325..8eaa753d0f 100644 --- a/packages/host/tests/unit/query-test.ts +++ b/packages/host/tests/unit/query-test.ts @@ -1,3 +1,4 @@ +import format from 'date-fns/format'; import { module, test } from 'qunit'; import { @@ -7,6 +8,7 @@ import { VirtualNetwork, baseRealm, IndexerDBClient, + internalKeyFor, } from '@cardstack/runtime-common'; import ENV from '@cardstack/host/config/environment'; @@ -15,10 +17,12 @@ import { shimExternals } from '@cardstack/host/lib/externals'; import { CardDef } from 'https://cardstack.com/base/card-api'; -import { testRealmURL, setupIndex, serializeCard } from '../helpers'; +import { testRealmURL, setupIndex, serializeCard, p } from '../helpers'; let cardApi: typeof import('https://cardstack.com/base/card-api'); let string: typeof import('https://cardstack.com/base/string'); +let date: typeof import('https://cardstack.com/base/date'); +let codeRef: typeof import('https://cardstack.com/base/code-ref'); let { sqlSchema, resolvedBaseRealmURL } = ENV; function getIds(resources: LooseCardResource[]): string[] { @@ -39,6 +43,8 @@ module('Unit | query', function (hooks) { cardApi = await loader.import(`${baseRealm.url}card-api`); string = await loader.import(`${baseRealm.url}string`); + date = await loader.import(`${baseRealm.url}date`); + codeRef = await loader.import(`${baseRealm.url}code-ref`); let { field, @@ -51,6 +57,8 @@ module('Unit | query', function (hooks) { setCardAsSavedForTest, } = cardApi; let { default: StringField } = string; + let { default: CodeRefField } = codeRef; + let { default: DateField } = date; class Address extends FieldDef { @field street = contains(StringField); @field city = contains(StringField); @@ -68,13 +76,38 @@ module('Unit | query', function (hooks) { class Cat extends CardDef { @field name = contains(StringField); } + class SimpleCatalogEntry extends CardDef { + @field title = contains(StringField); + @field ref = contains(CodeRefField); + } + class Event extends CardDef { + @field title = contains(StringField); + @field venue = contains(StringField); + @field date = contains(DateField); + } loader.shimModule(`${testRealmURL}person`, { Person }); loader.shimModule(`${testRealmURL}fancy-person`, { FancyPerson }); loader.shimModule(`${testRealmURL}cat`, { Cat }); + loader.shimModule(`${testRealmURL}catalog-entry`, { SimpleCatalogEntry }); + loader.shimModule(`${testRealmURL}event`, { Event }); + + let stringFieldEntry = new SimpleCatalogEntry({ + title: 'String Field', + ref: { + module: `${baseRealm.url}string`, + name: 'default', + }, + }); + let numberFieldEntry = new SimpleCatalogEntry({ + title: 'Number Field', + ref: { + module: `${baseRealm.url}number`, + name: 'default', + }, + }); let ringo = new Person({ - id: `${testRealmURL}ringo`, name: 'Ringo', address: new Address({ street: '100 Treat Street', @@ -82,7 +115,6 @@ module('Unit | query', function (hooks) { }), }); let vangogh = new Person({ - id: `${testRealmURL}vangogh`, name: 'Van Gogh', address: new Address({ street: '456 Grand Blvd', @@ -92,7 +124,6 @@ module('Unit | query', function (hooks) { friends: [ringo], }); let mango = new FancyPerson({ - id: `${testRealmURL}mango`, name: 'Mango', address: new Address({ street: '123 Main Street', @@ -101,14 +132,31 @@ module('Unit | query', function (hooks) { bestFriend: vangogh, friends: [vangogh, ringo], }); - let paper = new Cat({ id: `${testRealmURL}paper`, name: 'Paper' }); + let paper = new Cat({ name: 'Paper' }); + + let mangoBirthday = new Event({ + title: "Mango's Birthday", + venue: 'Dog Park', + date: p('2024-10-30'), + }); + let vangoghBirthday = new Event({ + title: "Van Gogh's Birthday", + venue: 'Backyard', + date: p('2024-11-19'), + }); + testCards = { mango, vangogh, ringo, paper, + mangoBirthday, + vangoghBirthday, + stringFieldEntry, + numberFieldEntry, }; - for (let card of Object.values(testCards)) { + for (let [name, card] of Object.entries(testCards)) { + card.id = `${testRealmURL}${name}`; setCardAsSavedForTest(card); } @@ -297,6 +345,99 @@ module('Unit | query', function (hooks) { assert.deepEqual(getIds(cards), [ringo.id], 'results are correct'); }); + test('can filter eq from a code ref query value', async function (assert) { + let { stringFieldEntry, numberFieldEntry } = testCards; + await setupIndex(client, [ + { + card: stringFieldEntry, + data: { + search_doc: { + title: stringFieldEntry.title, + ref: internalKeyFor((stringFieldEntry as any).ref, undefined), + }, + }, + }, + { + card: numberFieldEntry, + data: { + search_doc: { + title: numberFieldEntry.title, + ref: internalKeyFor((numberFieldEntry as any).ref, undefined), + }, + }, + }, + ]); + + let { cards, meta } = await client.search( + { + filter: { + on: { + module: `${testRealmURL}catalog-entry`, + name: 'SimpleCatalogEntry', + }, + eq: { + ref: { + module: `${baseRealm.url}string`, + name: 'default', + }, + }, + }, + }, + loader, + ); + + assert.strictEqual(meta.page.total, 1, 'the total results meta is correct'); + assert.deepEqual( + getIds(cards), + [stringFieldEntry.id], + 'results are correct', + ); + }); + + test('can filter eq from a date query value', async function (assert) { + let { mangoBirthday, vangoghBirthday } = testCards; + await setupIndex(client, [ + { + card: mangoBirthday, + data: { + search_doc: { + title: mangoBirthday.title, + venue: (mangoBirthday as any).venue, + date: format((mangoBirthday as any).date, 'yyyy-MM-dd'), + }, + }, + }, + { + card: vangoghBirthday, + data: { + search_doc: { + title: vangoghBirthday.title, + venue: (vangoghBirthday as any).venue, + date: format((vangoghBirthday as any).date, 'yyyy-MM-dd'), + }, + }, + }, + ]); + + let { cards, meta } = await client.search( + { + filter: { + on: { + module: `${testRealmURL}event`, + name: 'Event', + }, + eq: { + date: '2024-10-30', + }, + }, + }, + loader, + ); + + assert.strictEqual(meta.page.total, 1, 'the total results meta is correct'); + assert.deepEqual(getIds(cards), [mangoBirthday.id], 'results are correct'); + }); + test(`gives a good error when query refers to missing card`, async function (assert) { await setupIndex(client, []); diff --git a/packages/runtime-common/indexer/client.ts b/packages/runtime-common/indexer/client.ts index 911ea9851c..2057a0cb43 100644 --- a/packages/runtime-common/indexer/client.ts +++ b/packages/runtime-common/indexer/client.ts @@ -450,18 +450,19 @@ export class IndexerDBClient { path, exp, // Leaf field handler - async (_api, _field, expression) => { - // right now there is no need to run code from the Card/FieldDef to - // transform this query expression's value into a format that matches - // the search doc. the assumption is that when the search doc was - // created the `[queryableValue]` hook was run on the deserialized card - // data which forms the search doc value. the query expression should be - // serialized already in a manner that matches the serialization of the - // search doc value so we can compare apples to apples, e.g. dates - // strings in YYYY-MM-DD format. If that assumption changes then we can - // use the api and fieldCard callback params to run Card/FieldDef code - // as necessary here - return expression; + async (api, field, expression) => { + let queryValue: any; + let [value] = expression; + if (isParam(value)) { + queryValue = api.formatQueryValue(field, value.param); + } else if (typeof value === 'string') { + queryValue = api.formatQueryValue(field, value); + } else { + throw new Error( + `Do not know how to handle field value: ${JSON.stringify(value)}`, + ); + } + return [param(queryValue)]; }, ); } diff --git a/packages/runtime-common/search-index.ts b/packages/runtime-common/search-index.ts index 563c1eae2c..eb1f438c00 100644 --- a/packages/runtime-common/search-index.ts +++ b/packages/runtime-common/search-index.ts @@ -539,13 +539,13 @@ export class SearchIndex { } } - let qValueGT = api.getQueryableValue(fields[fields.length - 1], value.gt); - let qValueLT = api.getQueryableValue(fields[fields.length - 1], value.lt); - let qValueGTE = api.getQueryableValue( + let qValueGT = api.formatQueryValue(fields[fields.length - 1], value.gt); + let qValueLT = api.formatQueryValue(fields[fields.length - 1], value.lt); + let qValueGTE = api.formatQueryValue( fields[fields.length - 1], value.gte, ); - let qValueLTE = api.getQueryableValue( + let qValueLTE = api.formatQueryValue( fields[fields.length - 1], value.lte, ); @@ -627,7 +627,7 @@ export class SearchIndex { } } - let queryValue = api.getQueryableValue(fields[fields.length - 1], value); + let queryValue = api.formatQueryValue(fields[fields.length - 1], value); let matcher: (instanceValue: any) => boolean | null; if (filterType === 'eq') { matcher = (instanceValue: any) => {