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
3 changes: 3 additions & 0 deletions docs/docs/3-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,13 @@ Primitive fields are fields where `kind` is either undefined or set to `'primiti
- `ID`
- `Boolean`
- `String` with optional fields `stringType` and `maxLength`
- `Time` (mapped to PostgreSQL `time without time zone`)
- `Int` with optional fields `intType`
- `Float` with optional fields `floatType`, `double`, `precision`, `scale`
- `Upload`

`Time` values are expected in a strict 24-hour format: `HH:mm` (from `00:00` to `23:59`).

Examples:

```ts
Expand Down
2 changes: 2 additions & 0 deletions migrations/20230912185644_setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const up = async (knex: Knex) => {
table.decimal('float', 1, 1).notNullable();
table.specificType('list', '"someEnum"[]').notNullable();
table.integer('xyz').notNullable();
table.specificType('time', 'time without time zone').nullable();
table.timestamp('createdAt').notNullable();
table.uuid('createdById').notNullable();
table.index('createdById');
Expand Down Expand Up @@ -70,6 +71,7 @@ export const up = async (knex: Knex) => {
table.index('anotherId');
table.foreign('anotherId').references('id').inTable('AnotherObject').onDelete('CASCADE');
table.integer('xyz').notNullable();
table.specificType('time', 'time without time zone').nullable();
});

await knex.schema.createTable('Reaction', (table) => {
Expand Down
15 changes: 13 additions & 2 deletions src/bin/gqm/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@ export const generateGraphqlApiTypes = async (dateLibrary: DateLibrary) => {
documents: undefined,
generates: {
[`${generatedFolderPath}/api/index.ts`]: {
plugins: ['typescript', 'typescript-resolvers', { add: { content: DATE_CLASS_IMPORT[dateLibrary] } }],
plugins: [
'typescript',
'typescript-resolvers',
{ add: { content: DATE_CLASS_IMPORT[dateLibrary] } },
{ add: { content: `import type { Time } from '@smartive/graphql-magic';` } },
],
},
},
config: {
scalars: {
DateTime: DATE_CLASS[dateLibrary],
Time: 'Time',
},
},
});
Expand All @@ -30,7 +36,11 @@ export const generateGraphqlClientTypes = async () => {
documents: [graphqlQueriesPath, `${generatedFolderPath}/client/mutations.ts`],
generates: {
[`${generatedFolderPath}/client/index.ts`]: {
plugins: ['typescript', 'typescript-operations'],
plugins: [
'typescript',
'typescript-operations',
{ add: { content: `import type { Time } from '@smartive/graphql-magic';` } },
],
},
},
config: {
Expand All @@ -43,6 +53,7 @@ export const generateGraphqlClientTypes = async () => {
},
scalars: {
DateTime: 'string',
Time: 'Time',
},
ignoreNoDocuments: true,
},
Expand Down
8 changes: 8 additions & 0 deletions src/db/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export const generateDBModels = (models: Models, dateLibrary: DateLibrary) => {

writer.write(DATE_CLASS_IMPORT[dateLibrary]).blankLine();

writer.write(`type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';`);
writer.write(`type Hour = \`0\${Digit}\` | \`1\${Digit}\` | \`2\${'0' | '1' | '2' | '3'}\`;`);
writer.write(`type Minute = \`\${'0' | '1' | '2' | '3' | '4' | '5'}\${Digit}\`;`);
writer.write(`export type Time = \`\${Hour}:\${Minute}\`;`).blankLine();

for (const [key, value] of Object.entries(PRIMITIVE_TYPES)) {
writer.write(`export type ${key} = ${value};`).blankLine();
}
Expand Down Expand Up @@ -150,6 +155,9 @@ const getFieldType = (field: EntityField, dateLibrary: DateLibrary, input?: bool
if (field.type === 'DateTime') {
return (input ? `(${DATE_CLASS[dateLibrary]} | string)` : DATE_CLASS[dateLibrary]) + (field.list ? '[]' : '');
}
if (field.type === 'Time') {
return `Time${field.list ? '[]' : ''}`;
}

return get(PRIMITIVE_TYPES, field.type) + (field.list ? '[]' : '');
default: {
Expand Down
8 changes: 8 additions & 0 deletions src/migrations/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1565,6 +1565,9 @@ export class MigrationGenerator {
case 'DateTime':
col(`table.timestamp('${name}')`);
break;
case 'Time':
col(`table.specificType('${name}', 'time without time zone')`);
break;
case 'ID':
col(`table.uuid('${name}')`);
break;
Expand Down Expand Up @@ -1666,6 +1669,11 @@ export class MigrationGenerator {
return true;
}
}
if (field.type === 'Time') {
if (!['time without time zone', 'time'].includes(col.data_type)) {
return true;
}
}
}

return false;
Expand Down
2 changes: 2 additions & 0 deletions src/models/model-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type FieldDefinitionBase2 =
endOfDay?: boolean;
endOfMonth?: boolean;
}
| { type: 'Time' }
| ({
type: 'Int';
intType?: 'currency';
Expand Down Expand Up @@ -112,6 +113,7 @@ export type IDFieldDefinition = Extract<PrimitiveFieldDefinition, { type: 'ID' }
export type BooleanFieldDefinition = Extract<PrimitiveFieldDefinition, { type: 'Boolean' }>;
export type StringFieldDefinition = Extract<PrimitiveFieldDefinition, { type: 'String' }>;
export type DateTimeFieldDefinition = Extract<PrimitiveFieldDefinition, { type: 'DateTime' }>;
export type TimeFieldDefinition = Extract<PrimitiveFieldDefinition, { type: 'Time' }>;
export type IntFieldDefinition = Extract<PrimitiveFieldDefinition, { type: 'Int' }>;
export type FloatFieldDefinition = Extract<PrimitiveFieldDefinition, { type: 'Float' }>;
export type UploadFieldDefinition = Extract<PrimitiveFieldDefinition, { type: 'Upload' }>;
Expand Down
6 changes: 6 additions & 0 deletions src/models/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
PrimitiveFieldDefinition,
RawEnumModelDefinition,
StringFieldDefinition,
TimeFieldDefinition,
UnionModelDefinition,
UploadFieldDefinition,
} from '..';
Expand Down Expand Up @@ -52,6 +53,7 @@ export type IDField = IDFieldDefinition;
export type BooleanField = BooleanFieldDefinition;
export type StringField = StringFieldDefinition;
export type DateTimeField = DateTimeFieldDefinition;
export type TimeField = TimeFieldDefinition;
export type IntField = IntFieldDefinition;
export type FloatField = FloatFieldDefinition;
export type UploadField = UploadFieldDefinition;
Expand Down Expand Up @@ -80,6 +82,10 @@ export class Models {
kind: 'scalar',
name: 'DateTime',
},
{
kind: 'scalar',
name: 'Time',
},
{ kind: 'scalar', name: 'Upload' },
{
kind: 'raw-enum',
Expand Down
15 changes: 15 additions & 0 deletions src/resolvers/resolvers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { GraphQLScalarType, Kind } from 'graphql';
import { Models } from '../models/models';
import { and, isCreatable, isRootModel, isUpdatable, merge, not, typeToField } from '../models/utils';
import { parseTime, serializeTime } from '../utils/time';
import { mutationResolver } from './mutations';
import { queryResolver } from './resolver';

Expand All @@ -25,6 +27,19 @@ export const getResolvers = (models: Models) => {
[`${model.pluralField}_AGGREGATE`]: queryResolver,
})),
]),
Time: new GraphQLScalarType({
name: 'Time',
description: 'Time without date and timezone (HH:mm)',
serialize: (value) => (value == null ? value : serializeTime(value)),
parseValue: (value) => parseTime(value),
parseLiteral: (ast) => {
if (ast.kind !== Kind.STRING) {
throw new Error(`Invalid literal for Time scalar. Expected STRING, got ${ast.kind}.`);
}

return parseTime(ast.value);
},
}),
};
const mutations = [
...models.entities.filter(and(not(isRootModel), isCreatable)).map((model) => ({
Expand Down
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

export * from './dates';
export * from './rules';
export * from './time';
32 changes: 32 additions & 0 deletions src/utils/time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
type Hour = `0${Digit}` | `1${Digit}` | `2${'0' | '1' | '2' | '3'}`;
type Minute = `${'0' | '1' | '2' | '3' | '4' | '5'}${Digit}`;

export type Time = `${Hour}:${Minute}`;

const PARSE_TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
const SERIALIZE_TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)(?::[0-5]\d(?:\.\d+)?)?$/;

export const parseTime = (value: unknown): Time => {
if (typeof value !== 'string') {
throw new Error(`Time must be a string in HH:mm format. Received: ${typeof value}`);
}
const match = value.match(PARSE_TIME_RE);
if (!match) {
throw new Error(`Invalid Time value "${value}". Expected HH:mm in 24-hour format.`);
}

return `${match[1]}:${match[2]}` as Time;
};

export const serializeTime = (value: unknown): Time => {
if (typeof value !== 'string') {
throw new Error(`Time must be a string in HH:mm format. Received: ${typeof value}`);
}
const match = value.match(SERIALIZE_TIME_RE);
if (!match) {
throw new Error(`Invalid Time value "${value}". Expected HH:mm or HH:mm:ss.`);
}

return `${match[1]}:${match[2]}` as Time;
};
19 changes: 19 additions & 0 deletions tests/api/__snapshots__/query.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ exports[`query can be executed 1`] = `
},
"field": null,
"id": "fc4e013e-4cb0-4ef8-9f2e-3d475bdf2b90",
"time": "09:15",
"xyz": 2,
},
{
Expand All @@ -68,6 +69,7 @@ exports[`query can be executed 1`] = `
},
"field": "Some value",
"id": "604ab55d-ec3e-4857-9f27-219158f80e64",
"time": "14:30",
"xyz": 1,
},
],
Expand Down Expand Up @@ -333,3 +335,20 @@ exports[`query processes reverseFilters correctly 1`] = `
],
}
`;

exports[`query returns Time values for SomeObject 1`] = `
{
"manyObjects": [
{
"id": "fc4e013e-4cb0-4ef8-9f2e-3d475bdf2b90",
"time": "09:15",
"xyz": 2,
},
{
"id": "604ab55d-ec3e-4857-9f27-219158f80e64",
"time": "14:30",
"xyz": 1,
},
],
}
`;
17 changes: 17 additions & 0 deletions tests/api/query.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('query', () => {
id
field
xyz
time
another {
id
manyObjects(where: { id: "${SOME_ID}" }) {
Expand All @@ -26,6 +27,22 @@ describe('query', () => {
});
});

it('returns Time values for SomeObject', async () => {
await withServer(async (request) => {
expect(
await request(gql`
query GetTimes {
manyObjects(where: { another: { id: "${ANOTHER_ID}" } }, orderBy: [{ xyz: DESC }]) {
id
xyz
time
}
}
`),
).toMatchSnapshot();
});
});

it('processes reverseFilters correctly', async () => {
await withServer(async (request) => {
expect(
Expand Down
1 change: 1 addition & 0 deletions tests/unit/__snapshots__/resolve.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,6 @@ exports[`resolvers are generated correctly 1`] = `
"Reaction": {
"__resolveType": [Function],
},
"Time": "Time",
}
`;
7 changes: 5 additions & 2 deletions tests/utils/database/seed.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Knex } from 'knex';
import { pick } from 'lodash';
import { getColumnName, isInTable, modelNeedsTable } from '../../../src';
import { SeedData } from '../../generated/db';
import { models } from '../models';

export const ADMIN_ID = '04e45b48-04cf-4b38-bb25-b9af5ae0b2c4';
Expand All @@ -16,7 +15,7 @@ export const QUESTION_ID = '3d0f3254-282f-4f1f-95e3-c1f699f3c1e5';
export const ANSWER_ID = 'f2d7b3f1-8ea1-4c2c-91ec-024432da1b0d';
export const REVIEW_ID = '817c55de-2f77-4159-bd44-9837d868f889';

export const seed: SeedData = {
export const seed = {
User: [
{
id: ADMIN_ID,
Expand All @@ -42,6 +41,7 @@ export const seed: SeedData = {
float: 0,
list: ['A'],
xyz: 1,
time: '14:30',
},
{
id: SOME_ID_2,
Expand All @@ -50,20 +50,23 @@ export const seed: SeedData = {
float: 0.5,
list: ['B'],
xyz: 2,
time: '09:15',
},
{
id: SOME_ID_3,
anotherId: ANOTHER_ID_2,
float: 0.5,
list: ['B'],
xyz: 2,
time: '23:45',
},
{
id: SOME_ID_4,
anotherId: null,
float: 0.5,
list: ['B'],
xyz: 2,
time: '00:05',
},
],
Question: [
Expand Down
6 changes: 6 additions & 0 deletions tests/utils/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ const modelDefinitions: ModelDefinitions = [
orderable: true,
filterable: true,
},
{
name: 'time',
type: 'Time',
creatable: true,
updatable: true,
},
],
},
{
Expand Down
Loading