Skip to content

Commit

Permalink
Allow for passing model entities as objects (#142)
Browse files Browse the repository at this point in the history
  • Loading branch information
leo authored Feb 13, 2025
1 parent f267f78 commit 3323dba
Show file tree
Hide file tree
Showing 19 changed files with 890 additions and 1,046 deletions.
29 changes: 3 additions & 26 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
PLURAL_MODEL_ENTITIES_VALUES,
ROOT_MODEL,
ROOT_MODEL_WITH_ATTRIBUTES,
getModelBySlug,
Expand Down Expand Up @@ -154,20 +153,17 @@ class Transaction {
fields: Array<InternalModelField>,
rows: Array<RawRow>,
single: true,
isMeta: boolean,
): RecordType;
#formatRows<RecordType = ResultRecord>(
fields: Array<InternalModelField>,
rows: Array<RawRow>,
single: false,
isMeta: boolean,
): Array<RecordType>;

#formatRows<RecordType = ResultRecord>(
fields: Array<InternalModelField>,
rows: Array<RawRow>,
single: boolean,
isMeta: boolean,
): RecordType | Array<RecordType> {
const records: Array<ResultRecord> = [];

Expand All @@ -185,22 +181,6 @@ class Transaction {
}
}

// If the query is used to alter the database schema, the result of the query
// will always be a model, because the only available queries for altering the
// database schema are `create.model`, `alter.model`, and `drop.model`. That means
// we need to ensure that the resulting record always matches the `Model` type,
// by formatting its fields accordingly.
if (
isMeta &&
(PLURAL_MODEL_ENTITIES_VALUES as ReadonlyArray<string>).includes(newSlug)
) {
newValue = newValue
? Object.entries(newValue as object).map(([slug, attributes]) => {
return { slug, ...attributes };
})
: [];
}

const { parentField, parentIsArray } = ((): {
parentField: string | null;
parentIsArray?: true;
Expand Down Expand Up @@ -379,13 +359,10 @@ class Transaction {
const { queryType, queryModel, queryInstructions } = splitQuery(query);
const model = getModelBySlug(this.models, queryModel);

// Whether the query interacts with the database schema.
const isMeta = queryModel === 'model' || queryModel === 'models';

// Allows the client to format fields whose type cannot be serialized in JSON,
// which is the format in which the compiler output is sent to the client.
const modelFields = Object.fromEntries(
model.fields.map((field) => [field.slug, field.type]),
Object.entries(model.fields).map(([slug, rest]) => [slug, rest.type]),
);

// The query is expected to count records.
Expand All @@ -400,7 +377,7 @@ class Transaction {
if (single) {
return addResult({
record: rows[0]
? this.#formatRows<RecordType>(selectedFields, rows, true, isMeta)
? this.#formatRows<RecordType>(selectedFields, rows, true)
: null,
modelFields,
});
Expand All @@ -410,7 +387,7 @@ class Transaction {

// The query is targeting multiple records.
const result: MultipleRecordResult<RecordType> = {
records: this.#formatRows<RecordType>(selectedFields, rows, false, isMeta),
records: this.#formatRows<RecordType>(selectedFields, rows, false),
modelFields,
};

Expand Down
12 changes: 7 additions & 5 deletions src/instructions/using.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Model } from '@/src/types/model';
import type { Model, ModelPreset } from '@/src/types/model';
import type { Instructions, SetInstructions } from '@/src/types/query';
import { QUERY_SYMBOLS, RoninError, findInObject, isObject } from '@/src/utils/helpers';

Expand All @@ -25,9 +25,9 @@ export const handleUsing = (
// If a preset with the slug `links` is being requested, add the presets of all link
// fields separately.
if ('links' in normalizedUsing) {
for (const field of model.fields) {
for (const [fieldSlug, field] of Object.entries(model.fields)) {
if (field.type !== 'link' || field.kind === 'many') continue;
normalizedUsing[field.slug] = null;
normalizedUsing[fieldSlug] = null;
}
}

Expand All @@ -36,7 +36,7 @@ export const handleUsing = (
if (!Object.hasOwn(normalizedUsing, presetSlug) || presetSlug === 'links') continue;

const arg = normalizedUsing[presetSlug];
const preset = model.presets?.find((preset) => preset.slug === presetSlug);
const preset = model.presets?.[presetSlug];

if (!preset) {
throw new RoninError({
Expand All @@ -45,7 +45,9 @@ export const handleUsing = (
});
}

const replacedUsingFilter = structuredClone(preset.instructions);
const replacedUsingFilter = structuredClone(
preset.instructions,
) as ModelPreset['instructions'];

// If an argument was provided for the preset, find the respective placeholders
// inside the preset and replace them with the value of the actual argument.
Expand Down
81 changes: 44 additions & 37 deletions src/model/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getModelBySlug, getSystemFields } from '@/src/model';
import type { Model, ModelPreset, PartialModel } from '@/src/types/model';
import type { Model, ModelField, ModelPreset, PartialModel } from '@/src/types/model';
import { QUERY_SYMBOLS, convertToSnakeCase } from '@/src/utils/helpers';
import title from 'title';

Expand Down Expand Up @@ -138,34 +138,34 @@ export const addDefaultModelAttributes = (model: PartialModel, isNew: boolean):
// If the model is being newly created or if new fields were provided for an existing
// model, we would like to re-generate the list of `identifiers` and attach the system
// fields to the model.
if (isNew || newFields.length > 0) {
if (isNew || Object.keys(newFields).length > 0) {
if (!copiedModel.identifiers) copiedModel.identifiers = {};

// Intelligently select a reasonable default for which field should be used as the
// display name of the records in the model (e.g. used in lists on the dashboard).
if (!copiedModel.identifiers.name) {
const suitableField = newFields.find(
(field) =>
const suitableField = Object.entries(newFields).find(
([fieldSlug, field]) =>
field.type === 'string' &&
field.required === true &&
['name'].includes(field.slug),
['name'].includes(fieldSlug),
);

copiedModel.identifiers.name = suitableField?.slug || 'id';
copiedModel.identifiers.name = suitableField?.[0] || 'id';
}

// Intelligently select a reasonable default for which field should be used as the
// slug of the records in the model (e.g. used in URLs on the dashboard).
if (!copiedModel.identifiers.slug) {
const suitableField = newFields.find(
(field) =>
const suitableField = Object.entries(newFields).find(
([fieldSlug, field]) =>
field.type === 'string' &&
field.unique === true &&
field.required === true &&
['slug', 'handle'].includes(field.slug),
['slug', 'handle'].includes(fieldSlug),
);

copiedModel.identifiers.slug = suitableField?.slug || 'id';
copiedModel.identifiers.slug = suitableField?.[0] || 'id';
}
}

Expand All @@ -186,13 +186,15 @@ export const addDefaultModelFields = (model: Model, isNew: boolean): Model => {

// If the model is being newly created or if new fields were provided for an existing
// model, we would like to attach the system fields to the model.
if (isNew || existingFields.length > 0) {
if (isNew || Object.keys(existingFields).length > 0) {
// Only add default fields that are not already present in the model.
const additionalFields = getSystemFields(copiedModel.idPrefix).filter((newField) => {
return !existingFields.some(({ slug }) => slug === newField.slug);
});
const additionalFields = Object.fromEntries(
Object.entries(getSystemFields(copiedModel.idPrefix)).filter(([newFieldSlug]) => {
return !Object.hasOwn(existingFields, newFieldSlug);
}),
);

copiedModel.fields = [...additionalFields, ...existingFields];
copiedModel.fields = { ...additionalFields, ...existingFields };
}

return copiedModel as Model;
Expand All @@ -207,14 +209,16 @@ export const addDefaultModelFields = (model: Model, isNew: boolean): Model => {
* @returns The model with default presets added.
*/
export const addDefaultModelPresets = (list: Array<Model>, model: Model): Model => {
const defaultPresets: Array<ModelPreset> = [];
const defaultPresets: Model['presets'] = {};

// Add default presets, which people can overwrite if they want to. Presets are
// used to provide concise ways of writing advanced queries, by allowing for defining
// complex queries inside the model definitions and re-using them across many
// different queries in the codebase of an application.
for (const field of model.fields || []) {
if (field.type === 'link' && !field.slug.startsWith('ronin.')) {
for (const [fieldSlug, rest] of Object.entries(model.fields || {})) {
const field = { slug: fieldSlug, ...rest } as ModelField;

if (field.type === 'link' && !fieldSlug.startsWith('ronin.')) {
const targetModel = getModelBySlug(list, field.target);

if (field.kind === 'many') {
Expand All @@ -224,11 +228,11 @@ export const addDefaultModelPresets = (list: Array<Model>, model: Model): Model

if (!systemModel) continue;

const preset = {
const preset: Omit<ModelPreset, 'slug'> = {
instructions: {
// Perform a LEFT JOIN that adds the associative table.
including: {
[field.slug]: {
[fieldSlug]: {
[QUERY_SYMBOLS.QUERY]: {
get: {
[systemModel.pluralSlug]: {
Expand Down Expand Up @@ -262,19 +266,18 @@ export const addDefaultModelPresets = (list: Array<Model>, model: Model): Model
},
},
},
slug: field.slug,
};

defaultPresets.push(preset);
defaultPresets[fieldSlug] = preset;
continue;
}

// For every link field, add a default preset for resolving the linked record in
// the model that contains the link field.
defaultPresets.push({
const preset: Omit<ModelPreset, 'slug'> = {
instructions: {
including: {
[field.slug]: {
[fieldSlug]: {
[QUERY_SYMBOLS.QUERY]: {
get: {
[targetModel.slug]: {
Expand All @@ -291,8 +294,9 @@ export const addDefaultModelPresets = (list: Array<Model>, model: Model): Model
},
},
},
slug: field.slug,
});
};

defaultPresets[fieldSlug] = preset;
}
}

Expand All @@ -304,12 +308,12 @@ export const addDefaultModelPresets = (list: Array<Model>, model: Model): Model
// Do not assign default presets for associative models.
if (subModel.system?.associationSlug) return null;

const field = subModel.fields?.find((field) => {
const field = Object.entries(subModel.fields).find(([_, field]) => {
return field.type === 'link' && field.target === model.slug;
});

if (!field) return null;
return { model: subModel, field };
return { model: subModel, field: { slug: field[0], ...field[1] } };
})
.filter((match) => match !== null);

Expand All @@ -319,7 +323,7 @@ export const addDefaultModelPresets = (list: Array<Model>, model: Model): Model

const presetSlug = childModel.system?.associationSlug || pluralSlug;

defaultPresets.push({
const preset: Omit<ModelPreset, 'slug'> = {
instructions: {
including: {
[presetSlug]: {
Expand All @@ -337,19 +341,22 @@ export const addDefaultModelPresets = (list: Array<Model>, model: Model): Model
},
},
},
slug: presetSlug,
});
};

defaultPresets[presetSlug] = preset;
}

if (defaultPresets.length > 0) {
const existingPresets = model.presets || [];
if (Object.keys(defaultPresets).length > 0) {
const existingPresets = model.presets;

// Only add default presets that are not already present in the model.
const additionalPresets = defaultPresets.filter((newPreset) => {
return !existingPresets.some(({ slug }) => slug === newPreset.slug);
});
const additionalPresets = Object.fromEntries(
Object.entries(defaultPresets).filter(([newPresetSlug]) => {
return !existingPresets?.[newPresetSlug];
}),
);

model.presets = [...additionalPresets, ...existingPresets];
model.presets = { ...additionalPresets, ...existingPresets };
}

return model;
Expand Down
Loading

0 comments on commit 3323dba

Please sign in to comment.