@instantdb/core
Version:
Instant's core local abstraction
1,034 lines (938 loc) • 15.4 kB
text/typescript
import { i } from '../../src/schema';
import { validateQuery } from '../../src/queryValidation.ts';
import { expect, test, vi } from 'vitest';
import { id, InstantSchemaDef } from '../../src';
const testSchema = i.schema({
entities: {
users: i.entity({
name: i.string(),
email: i.string().indexed().unique(),
bio: i.string().optional(),
// this is a convenient way to typecheck custom JSON fields
// though we should probably have a backend solution for this
stuff: i.json<{ custom: string }>(),
junk: i.any(),
}),
posts: i.entity({
title: i.string().indexed(),
body: i.string(),
}),
comments: i.entity({
body: i.string(),
}),
unlinkedWithAnything: i.entity({
animal: i.string(),
count: i.string(),
}),
},
links: {
usersPosts: {
forward: {
on: 'users',
has: 'many',
label: 'posts',
},
reverse: {
on: 'posts',
has: 'one',
label: 'author',
},
},
postsComments: {
forward: {
on: 'posts',
has: 'many',
label: 'comments',
},
reverse: {
on: 'comments',
has: 'one',
label: 'post',
},
},
friendships: {
forward: {
on: 'users',
has: 'many',
label: 'friends',
},
reverse: {
on: 'users',
has: 'many',
label: '_friends',
},
},
referrals: {
forward: {
on: 'users',
has: 'many',
label: 'referred',
},
reverse: {
on: 'users',
has: 'one',
label: 'referrer',
},
},
},
});
const beValid = (
q: unknown,
schema: InstantSchemaDef<any, any, any> | null = testSchema,
) => {
expect(() => validateQuery(q, schema ?? undefined)).not.toThrow();
if (schema) {
expect(() => validateQuery(q, undefined)).not.toThrow();
}
};
const beWrong = (
q: unknown,
schema: InstantSchemaDef<any, any, any> | null = testSchema,
) => {
expect(() => validateQuery(q, schema ?? undefined)).toThrow();
};
test('validates top level types', () => {
beValid({});
beWrong('Testing');
beWrong(8392);
beWrong([]);
});
test('top level entitiy names', () => {
beValid({
posts: {},
});
beWrong({
users: {},
notInSchema: {},
});
beValid({
users: {},
posts: {},
});
beValid(
{
somethingsuperRandomButNoSchema: {},
},
null,
);
beWrong({ posts: [] });
});
test('links', () => {
beValid({
posts: {
comments: {},
},
});
beWrong({
posts: {
doesNotExist: {},
},
});
beWrong({
posts: {
unlinkedWithAnything: {},
},
});
beValid({
posts: {
comments: {},
},
});
});
test('dollar sign object', () => {
beWrong({
posts: {
$where: {
title: 'Drew',
},
},
});
beValid({
posts: {
$: {
where: {
title: 'Drew',
},
},
},
});
beWrong({
posts: {
$: {
badKey: {
title: 'Drew',
},
},
},
});
});
test('all valid dollar sign keys', () => {
beValid({
posts: {
$: {
where: { title: 'test' },
order: { title: 'asc' },
limit: 10,
last: 5,
first: 3,
offset: 2,
after: ['cursor', 'data', 'value', 1],
before: ['cursor', 'data', 'value', 2],
fields: ['title', 'body'],
},
},
});
beWrong({
posts: {
$: {
notARealFilter: 10,
},
},
});
beWrong({
posts: {
notARealFilter: 10,
$: {},
},
});
beValid({
posts: {
comments: {
$: {
where: {
body: 'test',
},
},
},
},
});
beWrong({
posts: {
comments: {
$: {
notARealFilter: 'hi',
},
},
},
});
});
test('where clause type validation', () => {
// Valid string values
beValid({
users: {
$: {
where: {
name: 'John',
email: 'john@example.com',
},
},
},
});
// Invalid string values
beWrong({
users: {
$: {
where: {
name: 123,
},
},
},
});
beWrong({
users: {
$: {
where: {
email: true,
},
},
},
});
// Valid any type (junk field)
beValid({
users: {
$: {
where: {
junk: 'string',
},
},
},
});
beValid({
users: {
$: {
where: {
junk: 123,
},
},
},
});
beValid({
users: {
$: {
where: {
junk: { complex: 'object' },
},
},
},
});
});
test('where clause operators', () => {
// Valid $in operator
beValid({
users: {
$: {
where: {
name: { $in: ['John', 'Jane'] },
},
},
},
});
// Valid 'in' operator (without $)
beValid({
users: {
$: {
where: {
name: { in: ['John', 'Jane'] },
},
},
},
});
// Invalid $in operator - not an array
beWrong({
users: {
$: {
where: {
name: { $in: 'John' },
},
},
},
});
// Invalid 'in' operator - not an array
beWrong({
users: {
$: {
where: {
name: { in: 'John' },
},
},
},
});
// Invalid $in operator - wrong type in array
beWrong({
users: {
$: {
where: {
name: { $in: ['John', 123] },
},
},
},
});
// Invalid 'in' operator - wrong type in array
beWrong({
users: {
$: {
where: {
name: { in: ['John', 123] },
},
},
},
});
// Any 'in' field is valid without schema
beValid(
{
users: {
$: {
where: {
name: { in: ['John', 123] },
},
},
},
},
null,
);
// Valid comparison operators
beValid({
posts: {
$: {
where: {
title: { $not: 'Draft' },
},
},
},
});
// Valid $ne operator (alias for $not)
beValid({
posts: {
$: {
where: {
title: { $ne: 'Draft' },
},
},
},
});
// Valid $gt, $lt, $gte, $lte operators
beValid({
posts: {
$: {
where: {
title: { $gt: 'A' },
},
},
},
});
beValid({
posts: {
$: {
where: {
title: { $lt: 'Z' },
},
},
},
});
beValid({
posts: {
$: {
where: {
title: { $gte: 'A' },
},
},
},
});
beValid({
posts: {
$: {
where: {
title: { $lte: 'Z' },
},
},
},
});
// Valid $like operator on string
beValid({
users: {
$: {
where: {
name: { $like: '%John%' },
},
},
},
});
// Invalid $like operator on non-string
beWrong({
posts: {
$: {
where: {
title: { $like: 123 },
},
},
},
});
// Valid $ilike operator on string
beWrong({
users: {
$: {
where: {
name: { $ilike: '%john%' },
},
},
},
});
// Invalid $ilike operator on non-string
beWrong({
posts: {
$: {
where: {
title: { $ilike: 123 },
},
},
},
});
// Invalid $isNull value type
beWrong({
users: {
$: {
where: {
bio: { $isNull: 'true' },
},
},
},
});
});
test('where clause unknown operators', () => {
beWrong({
users: {
$: {
where: {
name: { $unknownOperator: 'value' },
},
},
},
});
});
test('where clause unknown attributes', () => {
beWrong({
users: {
$: {
where: {
unknownAttribute: 'value',
},
},
},
});
});
test('where clause id validation', () => {
// Valid id
beValid({
users: {
$: {
where: {
id: 'user-123',
},
},
},
});
// Invalid id type
beWrong({
users: {
$: {
where: {
id: 123,
},
},
},
});
// Valid id with operators
beValid({
users: {
$: {
where: {
id: { $in: ['user-1', 'user-2'] },
},
},
},
});
});
test('where clause logical operators', () => {
// Valid or clause
beValid({
users: {
$: {
where: {
or: [{ name: 'John' }, { email: 'jane@example.com' }],
},
},
},
});
// Valid and clause
beValid({
users: {
$: {
where: {
and: [{ name: 'John' }, { bio: { $isNull: false } }],
},
},
},
});
// Invalid nested clause
beWrong({
users: {
$: {
where: {
or: [{ name: 123 }],
},
},
},
});
});
test('where clause dot notation validation', () => {
// Valid dot notation - users.posts.title
beValid({
users: {
$: {
where: {
'posts.title': 'Some Title',
},
},
},
});
// Valid dot notation - posts.author.name
beValid({
posts: {
$: {
where: {
'author.name': 'John Doe',
},
},
},
});
// Valid dot notation - users.posts.comments.body
beValid({
users: {
$: {
where: {
'posts.comments.body': 'Great comment!',
},
},
},
});
// Valid dot notation with operators
beValid({
users: {
$: {
where: {
'posts.title': { $like: '%tutorial%' },
},
},
},
});
// Valid dot notation with $ilike operator
beValid({
users: {
$: {
where: {
'posts.title': { $ilike: '%TUTORIAL%' },
},
},
},
});
// Valid dot notation - self-referential link (users.friends.name)
beValid({
users: {
$: {
where: {
'friends.name': 'Friend Name',
},
},
},
});
// Invalid dot notation - nonexistent link
beWrong({
users: {
$: {
where: {
'invalidLink.title': 'value',
},
},
},
});
// Invalid dot notation - nonexistent attribute
beWrong({
users: {
$: {
where: {
'posts.nonexistent': 'value',
},
},
},
});
// Invalid dot notation - no link between entities
beWrong({
users: {
$: {
where: {
'unlinkedWithAnything.animal': 'cat',
},
},
},
});
// Invalid dot notation - wrong type
beWrong({
users: {
$: {
where: {
'posts.title': 123,
},
},
},
});
// Invalid dot notation - using string operator on non-string
beWrong({
posts: {
$: {
where: {
'author.name': { $like: 123 },
},
},
},
});
// Invalid dot notation - using $ilike with wrong type
beWrong({
posts: {
$: {
where: {
'author.name': { $ilike: 123 },
},
},
},
});
// Valid dot notation with $in operator
beValid({
users: {
$: {
where: {
'posts.title': { $in: ['Title 1', 'Title 2'] },
},
},
},
});
// Invalid dot notation with $in operator - wrong type in array
beWrong({
users: {
$: {
where: {
'posts.title': { $in: ['Title 1', 123] },
},
},
},
});
// Valid dot notation with id field
beValid({
users: {
$: {
where: {
'posts.id': id(),
},
},
},
});
// Valid dot notation with optional field and $isNull
beValid({
posts: {
$: {
where: {
'author.bio': { $isNull: true },
},
},
},
});
// Don't need final attributes
beValid({
comments: { $: { where: { post: id() } } },
});
beWrong({
comments: { $: { where: { post: 'not-a-uuid' } } },
});
beValid({
users: { $: { where: { 'posts.comments': id() } } },
});
beWrong({
users: { $: { where: { 'posts.comments': 'not-a-uuid' } } },
});
});
test('pagination parameters can only be used at top-level namespaces', () => {
const cursor = ['cursor', 'data', 'value', 1];
beValid({
posts: {
$: {
limit: 10,
},
},
});
beValid({
posts: {
$: {
offset: 20,
},
},
});
beValid({
posts: {
$: {
before: cursor,
},
},
});
beValid({
posts: {
$: {
after: cursor,
},
},
});
beValid({
posts: {
$: {
first: 5,
},
},
});
beValid({
posts: {
$: {
last: 5,
},
},
});
// Valid - multiple pagination params at top-level
beValid({
users: {
$: {
limit: 5,
offset: 10,
},
posts: {
$: {
where: { title: 'Test' },
},
},
},
});
beWrong({
users: {
posts: {
$: {
offset: 10,
},
},
},
});
beWrong({
users: {
posts: {
$: {
before: cursor,
},
},
},
});
beWrong({
users: {
posts: {
$: {
after: cursor,
},
},
},
});
beWrong({
users: {
posts: {
$: {
first: 5,
},
},
},
});
beWrong({
users: {
posts: {
$: {
last: 5,
},
},
},
});
// Invalid - multiple pagination params in deeply nested namespace
beWrong({
users: {
posts: {
comments: {
$: {
limit: 5,
offset: 10,
first: 3,
},
},
},
},
});
beValid({
posts: {
$: {
before: cursor,
beforeInclusive: true,
},
},
});
beValid({
posts: {
$: {
after: cursor,
afterInclusive: true,
},
},
});
beWrong({
users: {
posts: {
$: {
beforeInclusive: true,
},
},
},
});
beWrong({
users: {
posts: {
$: {
afterInclusive: true,
},
},
},
});
// Valid - multiple top-level entities with different pagination params
beValid({
posts: {
$: {
limit: 10,
offset: 5,
afterInclusive: true,
},
},
users: {
$: {
first: 20,
after: cursor,
beforeInclusive: false,
},
},
});
});
test('relations with complex objects', () => {
beValid({
users: {
$: {
where: {
posts: {
$isNull: true,
},
},
},
},
});
beValid({
users: {
$: {
where: {
posts: {
$not: 'this',
},
},
},
},
});
beValid({
users: {
$: {
where: {
or: [
{
posts: {
$not: 'this',
},
},
],
},
},
},
});
beWrong({
users: {
$: {
where: {
posts: ' Invalid equality check',
},
},
},
});
});