Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for advanced expressions #32

Merged
merged 26 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
dc27efe
Temp
colodenn Jan 23, 2025
2ab982e
Added expressions
colodenn Jan 23, 2025
84ab4c1
Ben please help me
colodenn Jan 23, 2025
94943ca
Fixed type to return type rather than undefined
colodenn Jan 23, 2025
861c09c
Added fix
colodenn Jan 28, 2025
57fdb86
Help me ben
colodenn Jan 28, 2025
b4810c6
Fixed types
colodenn Jan 28, 2025
e2d9c88
Type assertion
colodenn Jan 28, 2025
590b566
Merge branch 'main' into corny/ron-1099-1
colodenn Jan 28, 2025
e366f44
Merge branch 'main' into corny/ron-1099-1
colodenn Jan 28, 2025
99fdfd5
100% line coverage
colodenn Jan 28, 2025
35d0ce3
Merge branch 'main' of https://github.com/ronin-co/syntax into corny/…
colodenn Jan 28, 2025
96ca934
Merge branch 'corny/ron-1099-1' of https://github.com/ronin-co/syntax…
colodenn Jan 28, 2025
7f2535c
Adding query tests
colodenn Jan 28, 2025
6eafe06
Merge branch 'main' into corny/ron-1099-1
leo Jan 29, 2025
aeca7b8
Fixed types
colodenn Feb 4, 2025
e385f31
Fix linting
colodenn Feb 4, 2025
f4b20f3
Merge branch 'main' into corny/ron-1099-1
colodenn Feb 4, 2025
e1ff660
Restored old comments
colodenn Feb 4, 2025
59f26cf
Merge branch 'corny/ron-1099-1' of https://github.com/ronin-co/syntax…
colodenn Feb 4, 2025
8ede1b1
Linebreak
colodenn Feb 4, 2025
12faf94
Apply suggestions from code review
colodenn Feb 4, 2025
742c5c9
Added tsdoc for expressions
colodenn Feb 4, 2025
9c0cf41
Use object instead of never as default type
colodenn Feb 4, 2025
a1f5b56
Merge branch 'main' of https://github.com/ronin-co/syntax into corny/…
colodenn Feb 4, 2025
92f92b8
Linting
colodenn Feb 4, 2025
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
Binary file modified bun.lockb
Binary file not shown.
14 changes: 13 additions & 1 deletion src/schema/model.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type {
Chain,
FieldOutput,
SyntaxField,
blob,
boolean,
Expand Down Expand Up @@ -44,7 +46,17 @@ export type Primitives =
| ReturnType<typeof json>
| ReturnType<typeof date>
| ReturnType<typeof blob>
| NestedFieldsPrimitives;
| NestedFieldsPrimitives
| Chain<
FieldOutput<'string' | 'number' | 'boolean' | 'link' | 'json' | 'date' | 'blob'>,
| 'name'
| 'displayAs'
| 'unique'
| 'required'
| 'defaultValue'
| 'computedAs'
| 'check'
>;

export type PrimitivesItem =
| SyntaxField<'link'>
Expand Down
50 changes: 40 additions & 10 deletions src/schema/primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { type SyntaxItem, getSyntaxProxy } from '@/src/queries';
import type { ModelField } from '@ronin/compiler';

/** A utility type that maps an attribute's type to a function signature. */
type AttributeSignature<T> = T extends boolean
type AttributeSignature<T, Attribute> = T extends boolean
? () => any
: T extends boolean
? never
: (value: string) => any;
: Attribute extends keyof Omit<ModelFieldExpressions<T>, 'type' | 'slug'>
? ModelFieldExpressions<T>[Attribute]
: never;

/**
* Represents a chain of field attributes in the form of a function chain.
Expand All @@ -17,24 +17,54 @@ type AttributeSignature<T> = T extends boolean
* For each attribute key `K` not in `Used`, create a method using the signature derived
* from that attribute's type. Calling it returns a new `Chain` marking `K` as used.
*/
type Chain<Attrs, Used extends keyof Attrs = never> = {
export type Chain<Attrs, Used extends keyof Attrs = never> = {
// 1) Chainable methods for all keys that are not in `Used` or `type`
[K in Exclude<keyof Attrs, Used | 'type'>]: (
...args: Parameters<AttributeSignature<Attrs[K]>>
// @ts-expect-error: This is a valid use case.
...args: Array<AttributeSignature<TypeToTSType<Attrs['type']>, K>>
) => Chain<Attrs, Used | K>;
// 2) If `type` is defined in `Attrs`, add it as a read-only property
// biome-ignore lint/complexity/noBannedTypes: This is a valid use case.
} & ('type' extends keyof Attrs ? { readonly type: Attrs['type'] } : {});

type FieldInput<Type> = Partial<
Omit<Extract<ModelField, { type: Type }>, 'slug' | 'type'>
type TypeToTSType<Type> = Type extends 'string'
? string
: Type extends 'number'
? number
: Type extends 'boolean'
? boolean
: Type extends 'blob'
? Blob
: Type extends 'date'
? Date
: never;
colodenn marked this conversation as resolved.
Show resolved Hide resolved

type FieldInput<Type extends ModelField['type']> = Partial<
Omit<ModelField, keyof ModelFieldExpressions<TypeToTSType<Type>>> & {
type: 'link';
target: string;
kind?: 'one' | 'many';
colodenn marked this conversation as resolved.
Show resolved Hide resolved
actions?: {
onDelete?: 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'NO ACTION';
onUpdate?: 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'NO ACTION';
};
} & ModelFieldExpressions<TypeToTSType<Type>>
colodenn marked this conversation as resolved.
Show resolved Hide resolved
>;

type FieldOutput<Type extends ModelField['type']> = Omit<
Extract<ModelField, { type: Type }>,
export type FieldOutput<Type extends ModelField['type']> = Omit<
Extract<ModelField & ModelFieldExpressions<TypeToTSType<Type>>, { type: Type }>,
'slug'
>;

export type ModelFieldExpressions<Type> = {
check?: (fields: Record<string, Type>) => Type;
computedAs?: (fields: Record<string, Type>) => {
value: () => Type;
kind: 'VIRTUAL' | 'STORED';
};
defaultValue?: () => Type | Type;
};

export type SyntaxField<Type extends ModelField['type']> = SyntaxItem<FieldOutput<Type>>;

/**
Expand Down
253 changes: 253 additions & 0 deletions src/utils/expressions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
import { QUERY_SYMBOLS, getQuerySymbol } from '@ronin/compiler';

export const expression = (
expression: string,
): Record<typeof QUERY_SYMBOLS.EXPRESSION, string> => {
return { [QUERY_SYMBOLS.EXPRESSION]: expression };
};
colodenn marked this conversation as resolved.
Show resolved Hide resolved

/** Used to separate the components of an expression from each other. */
export const RONIN_EXPRESSION_SEPARATOR = '//.//';

type NestedObject = {
[key: string]: unknown | NestedObject;
};

/**
* Checks whether a given value is a query expression.
*
* @param value - The value to check.
*
* @returns A boolean indicating whether or not the provided value is an expression.
*/
const containsExpressionString = (value: unknown): boolean => {
return typeof value === 'string' && value.includes(RONIN_EXPRESSION_SEPARATOR);
};

/**
* Wraps an expression string into a query symbol that allows the compiler to easily
* detect and process it.
*
* @param value - The expression to wrap.
*
* @returns The provided expression wrapped in a query symbol.
*/
export const wrapExpression = (
value: string,
): Record<typeof QUERY_SYMBOLS.EXPRESSION, string> => {
const symbol = getQuerySymbol(value);
const existingExpression = symbol?.type === 'expression' ? symbol : null;

const components = (existingExpression ? existingExpression.value : value)
.split(RONIN_EXPRESSION_SEPARATOR)
.filter((part: string) => part.length > 0)
.map((part: string) => {
return part.startsWith(QUERY_SYMBOLS.FIELD) ? part : `'${part}'`;
})
.join(' || ');

return expression(components);
};

/**
* Recursively checks an object for query expressions and, if they are found, wraps them
* in a query symbol that allows the compiler to easily detect and process them.
*
* @param obj - The object containing potential expressions.
*
* @returns The updated object.
*/
export const wrapExpressions = (obj: NestedObject): NestedObject =>
Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
if (containsExpressionString(value)) return [key, wrapExpression(value as string)];

return [
key,
value && typeof value === 'object'
? wrapExpressions(value as NestedObject)
: value,
];
}),
);

/**
* Wraps a raw SQL expression as-is.
* Use with caution as this bypasses SQL injection protection.
*
* @param expressions The raw SQL expression to use
colodenn marked this conversation as resolved.
Show resolved Hide resolved
*
* @returns The wrapped SQL expression
*/
export const sql = (expressions: string): any => {
NuroDev marked this conversation as resolved.
Show resolved Hide resolved
// TODO: Check expressions use '' rather than ""
return expression(expressions);
};

/** Valid operators for string concatenation */
type StringOperator = '||';

/** Valid arithmetic operators for numbers */
type NumberOperator = '+' | '-' | '*' | '/' | '%';

/** Valid comparison operators for numbers and strings */
type ComparisonOperator = '=' | '!=' | '>' | '<' | '>=' | '<=';

/**
* Creates a binary operation expression with type safety for operands.
*
* @param left The left operand
* @param operator The operator to use (string concatenation or arithmetic)
* @param right The right operand (must match type of left operand)
colodenn marked this conversation as resolved.
Show resolved Hide resolved
*
* @returns The formatted binary operation expression
colodenn marked this conversation as resolved.
Show resolved Hide resolved
*/
export const op = <
T extends string | number | Record<typeof QUERY_SYMBOLS.EXPRESSION, string>,
>(
left: T,
operator: NumberOperator | ComparisonOperator | StringOperator,
right: T,
): T => {
let leftValue = left;
if (typeof left === 'object') {
if (QUERY_SYMBOLS.FIELD in left) {
leftValue = `${QUERY_SYMBOLS.FIELD}${left[QUERY_SYMBOLS.FIELD]}` as T;
} else if (QUERY_SYMBOLS.EXPRESSION in left) {
leftValue = left[QUERY_SYMBOLS.EXPRESSION] as T;
}
}

let rightValue = right;
if (typeof right === 'object') {
if (QUERY_SYMBOLS.FIELD in right) {
rightValue = `${QUERY_SYMBOLS.FIELD}${right[QUERY_SYMBOLS.FIELD]}` as T;
} else if (QUERY_SYMBOLS.EXPRESSION in right) {
rightValue = right[QUERY_SYMBOLS.EXPRESSION] as T;
}
}

let wrappedLeft = leftValue;
if (
typeof leftValue === 'string' &&
!(
typeof left === 'object' &&
(QUERY_SYMBOLS.EXPRESSION in left || QUERY_SYMBOLS.FIELD in left)
)
) {
if (leftValue.startsWith(RONIN_EXPRESSION_SEPARATOR)) {
wrappedLeft = leftValue.replaceAll(RONIN_EXPRESSION_SEPARATOR, '') as T;
} else {
wrappedLeft = `'${leftValue}'` as T;
}
}

let wrappedRight = rightValue;
if (
typeof rightValue === 'string' &&
!(
typeof right === 'object' &&
(QUERY_SYMBOLS.EXPRESSION in right || QUERY_SYMBOLS.FIELD in right)
)
) {
if (rightValue.startsWith(RONIN_EXPRESSION_SEPARATOR)) {
wrappedRight = rightValue.replaceAll(RONIN_EXPRESSION_SEPARATOR, '') as T;
} else {
wrappedRight = `'${rightValue}'` as T;
}
}

return expression(`(${wrappedLeft} ${operator} ${wrappedRight})`) as unknown as T;
};

/**
* Generates a pseudo-random integer between -9223372036854775808 and +9223372036854775807.
*
* @returns SQL expression that evaluates to a random number
colodenn marked this conversation as resolved.
Show resolved Hide resolved
*/
export const random = (): number => {
return expression('random()') as unknown as number;
};

/**
* Calculates the absolute value of a number.
*
* @param value The number to get absolute value of
*
* @returns SQL expression that evaluates to the absolute value
colodenn marked this conversation as resolved.
Show resolved Hide resolved
*/
export const abs = (value: number | Record<string, string | number>): number => {
const valueExpression =
typeof value === 'object' && QUERY_SYMBOLS.EXPRESSION in value
? value[QUERY_SYMBOLS.EXPRESSION]
: value;
return expression(`abs(${valueExpression})`) as unknown as number;
};

/**
* Formats a timestamp according to the specified format string.
*
* @param format The format string (e.g. '%Y-%m-%d')
* @param timestamp The timestamp to format, or 'now' for current time
colodenn marked this conversation as resolved.
Show resolved Hide resolved
*
* @returns SQL expression that evaluates to the formatted timestamp
colodenn marked this conversation as resolved.
Show resolved Hide resolved
*/
export const strftime = (format: string, timestamp: string | 'now'): Date => {
return expression(`strftime('${format}', '${timestamp}')`) as unknown as Date;
};

/**
* Applies a JSON patch operation to a JSON document.
*
* @param patch The JSON patch document defining the modifications
* @param input The JSON document to patch
*
* @returns SQL expression that evaluates to the patched JSON document
colodenn marked this conversation as resolved.
Show resolved Hide resolved
*/
export const json_patch = (patch: string, input: string): string => {
return expression(`json_patch('${patch}', '${input}')`) as unknown as string;
};

/**
* Sets a value in a JSON document at the specified path.
* Creates the path if it doesn't exist and overwrites if it does.
*
* @param json The JSON document to modify
* @param path The path to set the value at
* @param value The value to set
*
* @returns SQL expression that evaluates to the modified JSON document
colodenn marked this conversation as resolved.
Show resolved Hide resolved
*/
export const json_set = (json: string, path: string, value: string): string => {
return expression(`json_set('${json}', '${path}', '${value}')`) as unknown as string;
};

/**
* Replaces a value in a JSON document at the specified path.
* Only modifies existing paths, will not create new ones.
*
* @param json The JSON document to modify
* @param path The path to replace the value at
* @param value The new value
*
* @returns SQL expression that evaluates to the modified JSON document
colodenn marked this conversation as resolved.
Show resolved Hide resolved
*/
export const json_replace = (json: string, path: string, value: string): string => {
return expression(
`json_replace('${json}', '${path}', '${value}')`,
) as unknown as string;
};

/**
* Inserts a value into a JSON document at the specified path.
* Only creates new paths, will not modify existing ones.
*
* @param json The JSON document to modify
* @param path The path to insert the value at
* @param value The value to insert
*
* @returns SQL expression that evaluates to the modified JSON document
colodenn marked this conversation as resolved.
Show resolved Hide resolved
*/
export const json_insert = (json: string, path: string, value: string): string => {
return expression(`json_insert('${json}', '${path}', '${value}')`) as unknown as string;
};
14 changes: 8 additions & 6 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,14 @@ export const mutateStructure = (

for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
if (isPlainObject(obj[key])) {
// Recursively mutate nested objects.
mutateStructure(obj[key] as NestedObject, callback);
} else {
// Call the mutation function for the value.
obj[key] = callback(obj[key]);
const value = obj[key];

if (isPlainObject(value)) {
// Recursively mutate nested plain objects
obj[key] = mutateStructure(value as NestedObject, callback);
} else if (typeof value !== 'function') {
// Apply mutation to non-function values
obj[key] = callback(value);
}
}
}
Expand Down
Loading
Loading