All files / components/collections schemaValidation.ts

86.88% Statements 53/61
82.27% Branches 65/79
100% Functions 6/6
89.28% Lines 50/56

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179            6x                                                         6x 36x                   24x   24x 2x             22x   22x 33x 33x   33x 33x 2x 31x 1x   30x 30x 2x   28x       33x       33x 2x     33x 2x     33x 2x 1x   2x 1x       33x 10x       22x     22x   10x 10x 10x         22x                     70x 102x                                       12x 19x               19x 1x 1x 1x     18x     18x       18x     18x       18x      
/**
 * Shared schema field validation for create/edit collection flows.
 */
 
import type { FieldDefinition } from '@/services/collections.service';
 
export const SYSTEM_FIELD_NAMES = [
  'id',
  'account_id',
  'created_at',
  'created_by',
  'updated_at',
  'updated_by',
] as const;
 
export type SchemaFieldErrorKey =
  | 'name'
  | 'type'
  | 'collection'
  | 'mask_type'
  | 'expression'
  | 'return_type'
  | 'default';
 
export type SchemaFieldErrors = Record<
  number,
  Partial<Record<SchemaFieldErrorKey, string>>
>;
 
export interface SchemaValidationResult {
  valid: boolean;
  formError?: string;
  fieldErrors: SchemaFieldErrors;
}
 
const SYSTEM_NAME_SET = new Set<string>(
  SYSTEM_FIELD_NAMES.map((n) => n.toLowerCase()),
);
 
/**
 * Validate user-defined schema fields before create/update submit.
 * Does not include system fields — callers must never put them in the payload.
 */
export function validateSchemaFields(
  fields: FieldDefinition[],
): SchemaValidationResult {
  const fieldErrors: SchemaFieldErrors = {};
 
  if (fields.length === 0) {
    return {
      valid: false,
      formError: 'At least one field is required',
      fieldErrors,
    };
  }
 
  const seenNames = new Map<string, number>();
 
  for (let i = 0; i < fields.length; i++) {
    const field = fields[i];
    const errors: Partial<Record<SchemaFieldErrorKey, string>> = {};
 
    const name = field.name?.trim() ?? '';
    if (!name) {
      errors.name = 'Field name is required';
    } else if (SYSTEM_NAME_SET.has(name.toLowerCase())) {
      errors.name = `"${name}" is a reserved system field name`;
    } else {
      const normalized = name.toLowerCase();
      if (seenNames.has(normalized)) {
        errors.name = `Duplicate field name: "${field.name}"`;
      } else {
        seenNames.set(normalized, i);
      }
    }
 
    Iif (!field.type) {
      errors.type = 'Field type is required';
    }
 
    if (field.type === 'reference' && !field.collection) {
      errors.collection = 'Target collection is required for reference fields';
    }
 
    if (field.pii && !field.mask_type) {
      errors.mask_type = 'Mask type is required when PII is enabled';
    }
 
    if (field.type === 'computed') {
      if (!field.expression?.trim()) {
        errors.expression = 'Expression is required for computed fields';
      }
      if (!field.return_type) {
        errors.return_type = 'Return type is required for computed fields';
      }
    }
 
    if (Object.keys(errors).length > 0) {
      fieldErrors[i] = errors;
    }
  }
 
  const hasFieldErrors = Object.keys(fieldErrors).length > 0;
  let formError: string | undefined;
 
  if (hasFieldErrors) {
    // Prefer a stable first-message summary for form-level display
    const firstIndex = Math.min(...Object.keys(fieldErrors).map(Number));
    const firstErrors = fieldErrors[firstIndex];
    formError = firstErrors
      ? (Object.values(firstErrors)[0] as string)
      : 'Schema validation failed';
  }
 
  return {
    valid: !hasFieldErrors,
    formError,
    fieldErrors,
  };
}
 
/**
 * Normalize field list for dirty comparison (stable key order).
 */
export function normalizeSchemaForCompare(fields: FieldDefinition[]): string {
  return JSON.stringify(
    fields.map((f) => ({
      name: f.name,
      type: f.type,
      required: f.required ?? false,
      unique: f.unique ?? false,
      pii: f.pii ?? false,
      default: f.default ?? null,
      collection: f.collection ?? null,
      on_delete: f.on_delete ?? null,
      mask_type: f.mask_type ?? null,
      expression: f.expression ?? null,
      return_type: f.return_type ?? null,
    })),
  );
}
 
/**
 * Strip empty default so payload omits unused defaults.
 */
export function prepareSchemaPayload(fields: FieldDefinition[]): FieldDefinition[] {
  return fields.map((field) => {
    const next: FieldDefinition = {
      name: field.name.trim(),
      type: field.type,
      required: field.required ?? false,
      unique: field.unique ?? false,
      pii: field.pii ?? false,
    };
 
    if (field.type === 'computed') {
      Eif (field.expression) next.expression = field.expression;
      Eif (field.return_type) next.return_type = field.return_type;
      return next;
    }
 
    Iif (field.default !== undefined && field.default !== null && field.default !== '') {
      next.default = field.default;
    }
    Iif (field.type === 'reference') {
      if (field.collection) next.collection = field.collection;
      if (field.on_delete) next.on_delete = field.on_delete;
    }
    Iif (field.pii && field.mask_type) {
      next.mask_type = field.mask_type;
    }
    Iif (field.options) {
      next.options = field.options;
    }
 
    return next;
  });
}