feat: core grammar, discovery and renderer; Postgres dialect; conformance suite
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { DEFAULT_LIMITS } from '../dist/index.js';
|
||||
import { buildResources } from '../dist/resources.js';
|
||||
|
||||
const col = (name, type = 'text', extra = {}) => ({ name, type, nativeType: type, nullable: true, generated: false, ...extra });
|
||||
|
||||
/** The demo schema as a catalog, without a database. */
|
||||
export function catalog() {
|
||||
return {
|
||||
functions: [],
|
||||
tables: [
|
||||
{ schema: 'demo', name: 'users', kind: 'table', primaryKey: ['id'],
|
||||
columns: [col('id', 'integer'), col('name'), col('password_hash'), col('limit', 'integer'), col('manager_id', 'integer')],
|
||||
foreignKeys: [{ columns: ['manager_id'], target: { schema: 'demo', table: 'users' }, targetColumns: ['id'] }] },
|
||||
{ schema: 'demo', name: 'assets', kind: 'table', primaryKey: ['id'],
|
||||
columns: [col('id', 'integer'), col('name'), col('status'), col('owner_id', 'integer'), col('cost', 'number'), col('active', 'boolean'), col('created_at', 'timestamp', { generated: true })],
|
||||
foreignKeys: [{ columns: ['owner_id'], target: { schema: 'demo', table: 'users' }, targetColumns: ['id'] }] },
|
||||
{ schema: 'demo', name: 'tags', kind: 'table', primaryKey: ['id'], columns: [col('id', 'integer'), col('name')], foreignKeys: [] },
|
||||
{ schema: 'demo', name: 'asset_tags', kind: 'table', primaryKey: ['asset_id', 'tag_id'],
|
||||
columns: [col('asset_id', 'integer'), col('tag_id', 'integer')],
|
||||
foreignKeys: [
|
||||
{ columns: ['asset_id'], target: { schema: 'demo', table: 'assets' }, targetColumns: ['id'] },
|
||||
{ columns: ['tag_id'], target: { schema: 'demo', table: 'tags' }, targetColumns: ['id'] },
|
||||
] },
|
||||
{ schema: 'other', name: 'secrets', kind: 'table', primaryKey: ['id'], columns: [col('id', 'integer')], foreignKeys: [] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function resources(options = {}) {
|
||||
return buildResources(catalog(), { db: null, expose: { schemas: ['demo'] }, ...options });
|
||||
}
|
||||
|
||||
export const limits = DEFAULT_LIMITS;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseRead } from '../dist/grammar/read.js';
|
||||
import { SqlRenderer } from '../dist/index.js';
|
||||
import { resources, limits } from './fixture.mjs';
|
||||
|
||||
const r = resources();
|
||||
const parse = (res, qs) => parseRead(r.get(res), new URLSearchParams(qs), limits);
|
||||
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return 'none'; };
|
||||
|
||||
test('unknown, hidden and quoted names', () => {
|
||||
assert.equal(code(() => parse('users', 'select=password_hash')), 'unknown_column');
|
||||
assert.equal(code(() => parse('users', 'nope=eq.1')), 'unknown_column');
|
||||
assert.deepEqual(parse('users', 'select="limit"').select.columns, ['limit']);
|
||||
assert.equal(parse('users', '"limit"=eq.2').where[0].path.column, 'limit');
|
||||
assert.equal(code(() => parse('users', 'limit=x')), 'bad_limit');
|
||||
});
|
||||
|
||||
test('values are checked against the column type', () => {
|
||||
assert.equal(code(() => parse('assets', 'id=eq.abc')), 'invalid_value');
|
||||
assert.equal(code(() => parse('assets', 'active=eq.yes')), 'invalid_value');
|
||||
assert.equal(code(() => parse('assets', 'cost=like.1*')), 'bad_operator');
|
||||
assert.equal(parse('assets', 'id=eq.4').where[0].value, 4);
|
||||
});
|
||||
|
||||
test('relation paths, presence and depth', () => {
|
||||
const f = parse('assets', 'owner.manager.name=eq.Ada').where[0];
|
||||
assert.deepEqual(f.path.relations.map((x) => x.name), ['owner', 'manager']);
|
||||
assert.equal(code(() => parse('assets', 'owner.manager.manager.name=eq.Ada')), 'too_deep');
|
||||
assert.equal(parse('assets', 'tags=is.empty').where[0].kind, 'present');
|
||||
assert.equal(code(() => parse('assets', 'order=tags.name')), 'bad_order');
|
||||
assert.equal(parse('assets', 'order=tags.count.desc').order[0].key.kind, 'count');
|
||||
});
|
||||
|
||||
test('limits are refused, not clamped', () => {
|
||||
assert.equal(code(() => parse('assets', 'limit=1001')), 'limit_exceeded');
|
||||
const many = Array.from({ length: 51 }, (_, i) => `id=neq.${i}`).join('&');
|
||||
assert.equal(code(() => parse('assets', many)), 'too_many_filters');
|
||||
});
|
||||
|
||||
test('the key is appended to the order; a grouped read orders by its columns', () => {
|
||||
assert.deepEqual(parse('assets', 'order=name.desc').order.map((o) => o.key.path.column), ['name', 'id']);
|
||||
assert.equal(code(() => parse('assets', 'select=status,count()&order=name')), 'bad_order');
|
||||
});
|
||||
|
||||
test('placeholders follow the text order, for positional databases too', () => {
|
||||
class Positional extends SqlRenderer { placeholder() { return '?'; } }
|
||||
const renderer = new Positional();
|
||||
const plan = parse('assets', 'select=id,owner(name)&status=eq.open');
|
||||
const scope = (res) => res.name === 'users' ? [{ kind: 'cmp', path: { relations: [], column: 'name' }, op: 'eq', negate: false, value: 'scoped' }] : [];
|
||||
const { sql, params } = renderer.read(plan, renderer.context(scope));
|
||||
assert.equal(sql.split('?').length - 1, params.length);
|
||||
assert.deepEqual(params, ['scoped', 'open']);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { resources } from './fixture.mjs';
|
||||
|
||||
test('only exposed schemas become resources', () => {
|
||||
const r = resources();
|
||||
assert.deepEqual([...r.keys()].sort(), ['asset_tags', 'assets', 'tags', 'users']);
|
||||
});
|
||||
|
||||
test('sensitive columns are hidden unless shown, and hide removes any column', () => {
|
||||
assert.equal(resources().get('users').columns.has('password_hash'), false);
|
||||
assert.equal(resources({ resources: { users: { show: ['password_hash'] } } }).get('users').columns.has('password_hash'), true);
|
||||
assert.equal(resources({ resources: { users: { hide: ['name'] } } }).get('users').columns.has('name'), false);
|
||||
});
|
||||
|
||||
test('foreign keys give to-one, to-many and many-to-many relations', () => {
|
||||
const r = resources();
|
||||
const rel = (res, name) => { const x = r.get(res).relations.get(name); return x && `${x.kind}:${x.target.name}${x.through ? ':through' : ''}`; };
|
||||
assert.equal(rel('assets', 'owner'), 'one:users');
|
||||
assert.equal(rel('users', 'assets'), 'many:assets');
|
||||
assert.equal(rel('users', 'manager'), 'one:users');
|
||||
assert.equal(rel('users', 'users'), 'many:users');
|
||||
assert.equal(rel('assets', 'tags'), 'many:tags:through');
|
||||
assert.equal(rel('tags', 'assets'), 'many:assets:through');
|
||||
});
|
||||
|
||||
test('relations can be renamed, removed and declared', () => {
|
||||
const r = resources({ resources: { users: { relations: { users: false, reports: { many: 'users', via: 'manager_id' } } } } });
|
||||
assert.equal(r.get('users').relations.has('users'), false);
|
||||
assert.equal(r.get('users').relations.get('reports').kind, 'many');
|
||||
assert.throws(() => resources({ resources: { users: { relations: { name: { one: 'users', via: 'manager_id' } } } } }), /name of a column/);
|
||||
});
|
||||
|
||||
test('writes are off by default and never reach generated or key columns', () => {
|
||||
assert.equal(resources().get('assets').write.create, false);
|
||||
const a = resources({ resources: { assets: { write: true } } }).get('assets');
|
||||
assert.equal(a.columns.get('name').create, true);
|
||||
assert.equal(a.columns.get('created_at').create, false);
|
||||
assert.equal(a.columns.get('id').update, false);
|
||||
assert.equal(a.write.delete, true);
|
||||
});
|
||||
|
||||
test('options naming unknown resources or columns fail at load', () => {
|
||||
assert.throws(() => resources({ resources: { nope: {} } }), /no exposed table/);
|
||||
assert.throws(() => resources({ resources: { users: { hide: ['nope'] } } }), /no column nope/);
|
||||
});
|
||||
Reference in New Issue
Block a user