Skip to content

Commit 81e3534

Browse files
committed
add support for "type" filter and passing test
1 parent 819dbf6 commit 81e3534

File tree

10 files changed

+252
-49
lines changed

10 files changed

+252
-49
lines changed

packages/host/app/lib/SQLiteAdapter.ts

+4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ export default class SQLiteAdapter implements DBAdapter {
1414
private _sqlite: typeof SQLiteWorker | undefined;
1515
private _dbId: string | undefined;
1616

17+
// TODO: one difference that I'm seeing is that it looks like "json_each" is
18+
// actually similar to "json_each_text" in postgres. i think we might need to
19+
// transform the SQL we run to deal with this difference.-
20+
1721
constructor(private schemaSQL?: string) {}
1822

1923
async startClient() {

packages/host/tests/helpers/const.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { type RealmInfo } from '@cardstack/runtime-common';
2+
export const testRealmURL = `http://test-realm/test/`;
3+
export const testRealmInfo: RealmInfo = {
4+
name: 'Unnamed Workspace',
5+
backgroundURL: null,
6+
iconURL: null,
7+
};

packages/host/tests/helpers/index.gts

+3-10
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ import {
1515
LooseSingleCardDocument,
1616
baseRealm,
1717
createResponse,
18-
RealmInfo,
1918
RealmPermissions,
2019
Deferred,
20+
type RealmInfo,
2121
type TokenClaims,
2222
} from '@cardstack/runtime-common';
2323

@@ -46,14 +46,14 @@ import {
4646
type FieldDef,
4747
} from 'https://cardstack.com/base/card-api';
4848

49+
import { testRealmInfo, testRealmURL } from './const';
4950
import percySnapshot from './percy-snapshot';
5051

5152
import { renderComponent } from './render-component';
5253
import { WebMessageStream, messageCloseHandler } from './stream';
5354
import visitOperatorMode from './visit-operator-mode';
5455

55-
export { percySnapshot };
56-
export { visitOperatorMode };
56+
export { visitOperatorMode, testRealmURL, testRealmInfo, percySnapshot };
5757
export * from './indexer';
5858

5959
const waiter = buildWaiter('@cardstack/host/test/helpers/index:onFetch-waiter');
@@ -144,13 +144,6 @@ export interface Dir {
144144
[name: string]: string | Dir;
145145
}
146146

147-
export const testRealmURL = `http://test-realm/test/`;
148-
export const testRealmInfo: RealmInfo = {
149-
name: 'Unnamed Workspace',
150-
backgroundURL: null,
151-
iconURL: null,
152-
};
153-
154147
export interface CardDocFiles {
155148
[filename: string]: LooseSingleCardDocument;
156149
}

packages/host/tests/helpers/indexer.ts

+24-12
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,38 @@ import {
33
asExpressions,
44
addExplicitParens,
55
separatedByCommas,
6+
type Expression,
67
type IndexedCardsTable,
78
type RealmVersionsTable,
89
} from '@cardstack/runtime-common';
910

11+
import { testRealmURL } from './const';
12+
13+
let defaultIndexEntry = {
14+
realm_version: 1,
15+
realm_url: testRealmURL,
16+
};
17+
1018
export async function setupIndex(
1119
client: IndexerDBClient,
1220
versionRows: RealmVersionsTable[],
1321
// only assert that the non-null columns need to be present in rows objects
14-
indexRows: (Pick<
15-
IndexedCardsTable,
16-
'card_url' | 'realm_version' | 'realm_url'
17-
> &
18-
Partial<
19-
Omit<IndexedCardsTable, 'card_url' | 'realm_version' | 'realm_url'>
20-
>)[],
22+
indexRows: (Pick<IndexedCardsTable, 'card_url'> &
23+
Partial<Omit<IndexedCardsTable, 'card_url'>>)[],
2124
) {
2225
let indexedCardsExpressions = indexRows.map((r) =>
23-
asExpressions(r, {
24-
jsonFields: ['deps', 'types', 'pristine_doc', 'error_doc', 'search_doc'],
25-
}),
26+
asExpressions(
27+
{ ...defaultIndexEntry, ...r },
28+
{
29+
jsonFields: [
30+
'deps',
31+
'types',
32+
'pristine_doc',
33+
'error_doc',
34+
'search_doc',
35+
],
36+
},
37+
),
2638
);
2739
let versionExpressions = versionRows.map((r) => asExpressions(r));
2840

@@ -38,7 +50,7 @@ export async function setupIndex(
3850
addExplicitParens(separatedByCommas(row.valueExpressions)),
3951
),
4052
),
41-
]);
53+
] as Expression);
4254
}
4355

4456
if (versionExpressions.length > 0) {
@@ -53,6 +65,6 @@ export async function setupIndex(
5365
addExplicitParens(separatedByCommas(row.valueExpressions)),
5466
),
5567
),
56-
]);
68+
] as Expression);
5769
}
5870
}

packages/host/tests/unit/index-db-test.ts renamed to packages/host/tests/unit/indexer-test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const testRealmURL2 = `http://test-realm/test2/`;
1717

1818
let { sqlSchema } = ENV;
1919

20-
module('Unit | index-db', function (hooks) {
20+
module('Unit | indexer', function (hooks) {
2121
let adapter: SQLiteAdapter;
2222
let client: IndexerDBClient;
2323

+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { module, test } from 'qunit';
2+
3+
import {
4+
baseCardRef,
5+
IndexerDBClient,
6+
internalKeyFor,
7+
Loader,
8+
VirtualNetwork,
9+
baseRealm,
10+
} from '@cardstack/runtime-common';
11+
12+
import ENV from '@cardstack/host/config/environment';
13+
import SQLiteAdapter from '@cardstack/host/lib/SQLiteAdapter';
14+
import { shimExternals } from '@cardstack/host/lib/externals';
15+
16+
import { testRealmURL, setupIndex } from '../helpers';
17+
18+
let cardApi: typeof import('https://cardstack.com/base/card-api');
19+
let string: typeof import('https://cardstack.com/base/string');
20+
// let number: typeof import('https://cardstack.com/base/number');
21+
// let date: typeof import('https://cardstack.com/base/date');
22+
// let datetime: typeof import('https://cardstack.com/base/datetime');
23+
// let boolean: typeof import('https://cardstack.com/base/boolean');
24+
// let queryableValue: typeof queryableValueType;
25+
let { sqlSchema, resolvedBaseRealmURL } = ENV;
26+
27+
module('Unit | query', function (hooks) {
28+
let adapter: SQLiteAdapter;
29+
let client: IndexerDBClient;
30+
let loader: Loader;
31+
32+
hooks.beforeEach(async function () {
33+
let virtualNetwork = new VirtualNetwork();
34+
loader = virtualNetwork.createLoader();
35+
loader.addURLMapping(new URL(baseRealm.url), new URL(resolvedBaseRealmURL));
36+
shimExternals(virtualNetwork);
37+
38+
cardApi = await loader.import(`${baseRealm.url}card-api`);
39+
string = await loader.import(`${baseRealm.url}string`);
40+
// number = await loader.import(`${baseRealm.url}number`);
41+
// date = await loader.import(`${baseRealm.url}date`);
42+
// datetime = await loader.import(`${baseRealm.url}datetime`);
43+
// boolean = await loader.import(`${baseRealm.url}boolean`);
44+
// queryableValue = cardApi.queryableValue;
45+
46+
adapter = new SQLiteAdapter(sqlSchema);
47+
client = new IndexerDBClient(adapter);
48+
await client.ready();
49+
});
50+
51+
hooks.afterEach(async function () {
52+
await client.teardown();
53+
});
54+
55+
test('can filter by type', async function (assert) {
56+
let { field, contains, CardDef, serializeCard } = cardApi;
57+
let { default: StringField } = string;
58+
class Person extends CardDef {
59+
@field name = contains(StringField);
60+
}
61+
class FancyPerson extends Person {
62+
@field favoriteColor = contains(StringField);
63+
}
64+
class Cat extends CardDef {
65+
@field name = contains(StringField);
66+
}
67+
68+
loader.shimModule(`${testRealmURL}person`, { Person });
69+
loader.shimModule(`${testRealmURL}fancy-person`, { FancyPerson });
70+
loader.shimModule(`${testRealmURL}cat`, { Cat });
71+
72+
let mango = new FancyPerson({ id: `${testRealmURL}mango`, name: 'Mango' });
73+
let vango = new Person({ id: `${testRealmURL}vangogh`, name: 'Van Gogh' });
74+
let paper = new Cat({ id: `${testRealmURL}paper`, name: 'Paper' });
75+
let serializedMango = serializeCard(mango).data;
76+
let serializedVango = serializeCard(vango).data;
77+
let serializedPaper = serializeCard(paper).data;
78+
79+
// note the types are hand crafted to match the deserialized instances.
80+
// there is a mechanism to generate the types from the indexed instances but
81+
// that is not what we are testing here
82+
await setupIndex(
83+
client,
84+
[{ realm_url: testRealmURL, current_version: 1 }],
85+
[
86+
{
87+
card_url: `${testRealmURL}mango.json`,
88+
pristine_doc: serializedMango,
89+
types: [
90+
baseCardRef,
91+
{ module: `${testRealmURL}person`, name: 'Person' },
92+
{ module: `${testRealmURL}fancy-person`, name: 'FancyPerson' },
93+
].map((ref) => internalKeyFor(ref, undefined)),
94+
},
95+
{
96+
card_url: `${testRealmURL}vangogh.json`,
97+
pristine_doc: serializedVango,
98+
types: [
99+
baseCardRef,
100+
{ module: `${testRealmURL}person`, name: 'Person' },
101+
].map((ref) => internalKeyFor(ref, undefined)),
102+
},
103+
{
104+
card_url: `${testRealmURL}paper.json`,
105+
pristine_doc: serializedPaper,
106+
types: [
107+
baseCardRef,
108+
{ module: `${testRealmURL}cat`, name: 'Cat' },
109+
].map((ref) => internalKeyFor(ref, undefined)),
110+
},
111+
],
112+
);
113+
114+
let { cards, meta } = await client.search(
115+
{
116+
filter: {
117+
type: { module: `${testRealmURL}person`, name: 'Person' },
118+
},
119+
},
120+
loader,
121+
);
122+
123+
assert.strictEqual(meta.page.total, 2, 'the total results meta is correct');
124+
cards.sort((a, b) => a.id!.localeCompare(b.id!));
125+
assert.deepEqual(
126+
cards,
127+
[serializedMango, serializedVango],
128+
'results are correct',
129+
);
130+
});
131+
});

packages/runtime-common/indexer/client.ts

+53-21
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
apiFor,
77
baseCardRef,
88
loadCard,
9+
internalKeyFor,
910
} from '../index';
1011
import {
1112
type PgPrimitive,
@@ -60,7 +61,17 @@ interface GetEntryOptions {
6061
}
6162

6263
interface QueryResultsMeta {
63-
page: { total: number; cursor?: string };
64+
// TODO SQLite doesn't let us use cursors in the classic sense so we need to
65+
// keep track of page size and index number--note it is possible for mutate
66+
// between pages. Perhaps consider querying a specific realm version (and only
67+
// cleanup realm versions when making generations) so we can see consistent
68+
// paginated results...
69+
page: {
70+
total: number;
71+
realmVersion?: number;
72+
startIndex?: number;
73+
pageSize?: number;
74+
};
6475
}
6576

6677
const coerceTypes = Object.freeze({
@@ -171,7 +182,7 @@ export class IndexerDBClient {
171182
// which could have conflicting loaders. It is up to the caller to provide the
172183
// loader that we should be using.
173184
async search(
174-
{ filter, sort, page }: Query,
185+
{ filter }: Query,
175186
loader: Loader,
176187
// TODO this should be returning a CardCollectionDocument--handle that in
177188
// subsequent PR where we start storing card documents in "pristine_doc"
@@ -183,27 +194,47 @@ export class IndexerDBClient {
183194

184195
// need to pluck out the functions to add as tables from the
185196
// tabledValuedFunctions
186-
let tableValuedFunctions = conditions.reduce((tableValuedFunctions, i) => {
187-
let fns = i.filter(
188-
(i) => typeof i === 'object' && i.kind === 'table-valued',
189-
) as TableValuedFunction[];
190-
for (let fn of fns) {
191-
tableValuedFunctions.set(fn.as, fn);
192-
}
193-
return tableValuedFunctions;
194-
}, new Map<string, TableValuedFunction>());
197+
let tableValuedFunctions = [
198+
...conditions
199+
.reduce((tableValuedFunctions, i) => {
200+
let fns = i.filter(
201+
(i) => typeof i === 'object' && i.kind === 'table-valued',
202+
) as TableValuedFunction[];
203+
for (let fn of fns) {
204+
tableValuedFunctions.set(fn.as, fn);
205+
}
206+
return tableValuedFunctions;
207+
}, new Map<string, TableValuedFunction>())
208+
.values(),
209+
].map((t) => [`${t.fn} as ${t.as}`]);
195210

196211
let query = [
197-
'SELECT card_url, realm_url, pristine_doc',
198-
'FROM indexed_cards',
199-
...separatedByCommas(
200-
[...tableValuedFunctions.values()].map((t) => [`${t.fn} as ${t.as}`]),
201-
),
212+
'SELECT pristine_doc',
213+
'FROM',
214+
...separatedByCommas([['indexed_cards'], ...tableValuedFunctions]),
215+
'WHERE',
216+
...every(conditions),
217+
];
218+
let queryCount = [
219+
'SELECT count(*) as total',
220+
'FROM',
221+
...separatedByCommas([['indexed_cards'], ...tableValuedFunctions]),
202222
'WHERE',
203223
...every(conditions),
204224
];
205225

206-
let results = await this.queryCards(query, loader);
226+
let [totalResults, results] = await Promise.all([
227+
this.queryCards(queryCount, loader) as Promise<{ total: number }[]>,
228+
this.queryCards(query, loader) as Promise<
229+
Pick<IndexedCardsTable, 'pristine_doc'>[]
230+
>,
231+
]);
232+
233+
let cards = results
234+
.map((r) => r.pristine_doc)
235+
.filter(Boolean) as LooseCardResource[];
236+
let meta = { page: { total: totalResults[0].total } };
237+
return { cards, meta };
207238
}
208239

209240
private filterCondition(filter: Filter, onRef: CodeRef): CardExpression {
@@ -218,11 +249,12 @@ export class IndexerDBClient {
218249
throw new Error(`Unknown filter: ${JSON.stringify(filter)}`);
219250
}
220251

252+
// the type condition only consumes absolute URL card refs.
221253
private typeCondition(ref: CodeRef): CardExpression {
222254
return [
223-
tableValuedFunction('json(indexed_cards.types)', 'types_each', [
255+
tableValuedFunction('json_each(indexed_cards.types)', 'types_each', [
224256
'types_each.value =',
225-
param(JSON.stringify(ref)),
257+
param(internalKeyFor(ref, undefined)),
226258
]),
227259
];
228260
}
@@ -262,7 +294,7 @@ export class IndexerDBClient {
262294
let { path } = fieldQuery;
263295

264296
return await this.walkFilterFieldPath(
265-
await loadCard(fieldQuery.cardType, { loader }),
297+
await loadCard(fieldQuery.type, { loader }),
266298
path,
267299
['search_doc'],
268300
// Leaf field handler
@@ -290,7 +322,7 @@ export class IndexerDBClient {
290322
let exp = await this.makeExpression(value, loader);
291323

292324
return await this.walkFilterFieldPath(
293-
await loadCard(fieldValue.cardType, { loader }),
325+
await loadCard(fieldValue.type, { loader }),
294326
path,
295327
exp,
296328
// Leaf field handler

0 commit comments

Comments
 (0)