Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions example_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,26 @@
"name": "id",
"type": "integer",
"primaryKey": true,
"supportedOperation": ["editable", "deletable"]
"supportedOperations": ["editable", "deletable"]
},
{
"name": "email",
"type": "string",
"unique": true,
"nullable": false,
"supportedOperation": ["searchable"]
"supportedOperations": ["searchable", "equal"]
},
{
"name": "name",
"type": "string",
"nullable": true,
"supportedOperation": ["searchable"]
"supportedOperations": ["searchable", "sortable"]
},
{
"name": "is_active",
"type": "boolean",
"default": true
"default": true,
"supportedOperations": ["equal"]
}
],
"indexes": [
Expand All @@ -72,12 +73,14 @@
{
"name": "id",
"type": "integer",
"primaryKey": true
"primaryKey": true,
"supportedOperations": ["editable", "deletable"]
},
{
"name": "title",
"type": "string",
"nullable": false
"nullable": false,
"supportedOperations": ["searchable", "sortable"]
},
{
"name": "body",
Expand All @@ -87,11 +90,14 @@
{
"name": "user_id",
"type": "integer",
"nullable": false
"nullable": false,
"supportedOperations": ["equal", "oneOf"],
"supportedAggregation": ["count", "frequency"]
},
{
"name": "created_at",
"type": "datetime"
"type": "datetime",
"supportedOperations": ["lessThan", "greaterThan", "sortable"]
}
],
"indexes": [
Expand Down
5 changes: 3 additions & 2 deletions src/database/fk-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ export async function createForeignKeys(

let fkExists = false;
if (engine === 'pg') {
const query = SQL`SELECT EXISTS (SELECT FROM information_schema.table_constraints WHERE constraint_name = ${fk.name} AND constraint_type = 'FOREIGN KEY')`;
const res = await db.query<string, { exists: boolean }>(query.sql, query.values);
const query =
"SELECT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = $1 AND constraint_type = 'FOREIGN KEY')";
const res = await db.query<string, { exists: boolean }>(query, [fk.name]);
fkExists = res[0].exists;
} else {
// SQLite doesn't support ALTER TABLE ADD CONSTRAINT for FKs.
Expand Down
9 changes: 5 additions & 4 deletions src/database/index-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,13 @@ export async function createIndexes(
// Check if index already exists
let indexExists = false;
if (engine === 'pg') {
const query = SQL`SELECT EXISTS (SELECT FROM pg_indexes WHERE schemaname = 'public' AND indexname = ${index.name})`;
const res = await db.query<string, { exists: boolean }>(query.sql, query.values);
const query =
"SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE schemaname = 'public' AND indexname = $1)";
const res = await db.query<string, { exists: boolean }>(query, [index.name]);
indexExists = res[0].exists;
} else {
const query = SQL`SELECT count(*) as count FROM sqlite_master WHERE type='index' AND name=${index.name}`;
const res = await db.query<string, { count: number }>(query.sql, query.values);
const query = "SELECT count(*) as count FROM sqlite_master WHERE type='index' AND name=$1";
const res = await db.query<string, { count: number }>(query, [index.name]);
indexExists = res[0].count > 0;
}

Expand Down
2 changes: 1 addition & 1 deletion src/database/table-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function createTables(
let tableExists = false;
if (engine === 'pg') {
const res = await db.query<string, { exists: boolean }>(
"SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1)",
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1)",
[model.name]
);
tableExists = res[0].exists;
Expand Down
46 changes: 46 additions & 0 deletions src/routes/aggregate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { FastifyInstance } from 'fastify';

import { ModelConfig } from '../schema/config';
import { helloWorldResponseSchema } from './schema-helpers';

/**
* Register AGGREGATE routes for fields with supportedAggregation.
*
* For each model, for each field with non-empty supportedAggregation, creates:
* GET /{model}/aggregation/{columnName}
*
* Query params:
* - operations (string) — comma-separated list of aggregation operations to perform
*/
export function registerAggregateRoutes(app: FastifyInstance, models: ModelConfig[]): void {
for (const model of models) {
const aggregatableFields = model.fields.filter(
(f) => f.supportedAggregation && f.supportedAggregation.length > 0
);

for (const field of aggregatableFields) {
const operations = field.supportedAggregation!;

app.get(
`/${model.name}/aggregation/${field.name}`,
{
schema: {
description: `Get aggregation data for ${field.name} in ${model.name}`,
tags: [model.name],
querystring: {
type: 'object',
properties: {
operations: {
type: 'string',
description: `Comma-separated list of operations to perform: ${operations.join(', ')}`,
},
},
},
response: helloWorldResponseSchema,
},
},
async () => ({ message: 'hello world' })
);
}
}
}
46 changes: 46 additions & 0 deletions src/routes/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { FastifyInstance } from 'fastify';

import { ModelConfig } from '../schema/config';
import { mapDataTypeToJsonSchema, helloWorldResponseSchema } from './schema-helpers';

/**
* Register DELETE routes for deletable fields.
*
* For each model, for each field with 'deletable' in supportedOperations, creates:
* DELETE /{model}/{columnName}/:value
*
* Path params: the column value identifying the record to delete.
*/
export function registerDeleteRoutes(app: FastifyInstance, models: ModelConfig[]): void {
for (const model of models) {
const deletableFields = model.fields.filter((f) =>
f.supportedOperations?.includes('deletable')
);

for (const field of deletableFields) {
const paramSchema = mapDataTypeToJsonSchema(field.type);

app.delete(
`/${model.name}/${field.name}/:${field.name}`,
{
schema: {
description: `Delete ${model.name} record by ${field.name}`,
tags: [model.name],
params: {
type: 'object',
properties: {
[field.name]: {
...paramSchema,
description: `The ${field.name} value identifying the record to delete`,
},
},
required: [field.name],
},
response: helloWorldResponseSchema,
},
},
async () => ({ message: 'hello world' })
);
}
}
}
59 changes: 59 additions & 0 deletions src/routes/edit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { FastifyInstance } from 'fastify';

import { ModelConfig } from '../schema/config';
import { mapDataTypeToJsonSchema, helloWorldResponseSchema } from './schema-helpers';

/**
* Register EDIT routes for editable fields.
*
* For each model, for each field with 'editable' in supportedOperations, creates:
* PUT /{model}/{columnName}/:value
*
* Path params: the column value identifying the record to edit.
* Body: all other fields as optional properties for updating.
*/
export function registerEditRoutes(app: FastifyInstance, models: ModelConfig[]): void {
for (const model of models) {
const editableFields = model.fields.filter((f) => f.supportedOperations?.includes('editable'));

for (const field of editableFields) {
const paramSchema = mapDataTypeToJsonSchema(field.type);

// Body contains all other fields as optional update targets
const bodyProperties: Record<string, object> = {};
for (const otherField of model.fields) {
if (otherField.name === field.name) continue;
bodyProperties[otherField.name] = {
...mapDataTypeToJsonSchema(otherField.type),
description: `Updated value for ${otherField.name}`,
};
}

app.put(
`/${model.name}/${field.name}/:${field.name}`,
{
schema: {
description: `Edit ${model.name} record by ${field.name}`,
tags: [model.name],
params: {
type: 'object',
properties: {
[field.name]: {
...paramSchema,
description: `The ${field.name} value identifying the record to edit`,
},
},
required: [field.name],
},
body: {
type: 'object',
properties: bodyProperties,
},
response: helloWorldResponseSchema,
},
},
async () => ({ message: 'hello world' })
);
}
}
}
58 changes: 58 additions & 0 deletions src/routes/get-all.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { FastifyInstance } from 'fastify';

import { ModelConfig } from '../schema/config';
import {
buildFilterQueryProperties,
buildSortQueryProperties,
paginationQueryProperties,
helloWorldResponseSchema,
} from './schema-helpers';

/**
* Register GET_ALL routes for listing records (table-level).
*
* For each model that has fields, creates:
* GET /{model}/
*
* Query params:
* - Filter params for ALL fields based on their supportedOperations
* - orderBy / orderDir for sortable fields
* - page / limit for pagination
*/
export function registerGetAllRoutes(app: FastifyInstance, models: ModelConfig[]): void {
for (const model of models) {
if (model.fields.length === 0) continue;

const queryProperties: Record<string, object> = {};

// Add filter params for each field based on its supportedOperations
for (const field of model.fields) {
Object.assign(queryProperties, buildFilterQueryProperties(field));
}

// Add sort params for sortable fields
const sortableFields = model.fields
.filter((f) => f.supportedOperations?.includes('sortable'))
.map((f) => f.name);
Object.assign(queryProperties, buildSortQueryProperties(sortableFields));

// Add pagination
Object.assign(queryProperties, paginationQueryProperties);

app.get(
`/${model.name}/`,
{
schema: {
description: `Get all ${model.name} records`,
tags: [model.name],
querystring: {
type: 'object',
properties: queryProperties,
},
response: helloWorldResponseSchema,
},
},
async () => ({ message: 'hello world' })
);
}
}
67 changes: 67 additions & 0 deletions src/routes/index-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { FastifyInstance } from 'fastify';

import { ModelConfig } from '../schema/config';
import {
mapDataTypeToJsonSchema,
buildFilterQueryProperties,
paginationQueryProperties,
helloWorldResponseSchema,
} from './schema-helpers';

/**
* Register INDEX routes for primaryKey fields.
*
* For each model, for each field with primaryKey: true, creates:
* GET /{model}/{columnName}/:value
*
* Includes filter query params based on the field's supportedOperations.
* Includes pagination if the field is not unique.
*/
export function registerIndexRoutes(app: FastifyInstance, models: ModelConfig[]): void {
for (const model of models) {
const pkFields = model.fields.filter((f) => f.primaryKey);

for (const field of pkFields) {
const paramSchema = mapDataTypeToJsonSchema(field.type);

// Build filter query params from the field's supported operations
const filterProps = buildFilterQueryProperties(field);
const queryProperties: Record<string, object> = {
...filterProps,
};

// Primary keys are always unique, so no pagination needed.
// If we ever support non-PK indexable fields, add pagination for non-unique ones.
if (!field.unique && !field.primaryKey) {
Object.assign(queryProperties, paginationQueryProperties);
}

const schema: Record<string, unknown> = {
description: `Get ${model.name} record(s) by ${field.name}`,
tags: [model.name],
params: {
type: 'object',
properties: {
[field.name]: {
...paramSchema,
description: `The ${field.name} value to look up`,
},
},
required: [field.name],
},
response: helloWorldResponseSchema,
};

if (Object.keys(queryProperties).length > 0) {
schema.querystring = {
type: 'object',
properties: queryProperties,
};
}

app.get(`/${model.name}/${field.name}/:${field.name}`, { schema }, async () => ({
message: 'hello world',
}));
}
}
}
Loading
Loading