|
| 1 | +/** |
| 2 | + * Guard: ON DELETE SET NULL requires nullable FK columns. |
| 3 | + * |
| 4 | + * Emitting SET NULL on a NOT NULL column is a Postgres/SQLite error at |
| 5 | + * runtime. buildExpectedSchema() must surface this conflict early — before |
| 6 | + * diff or emit — via SetNullNotNullableError. |
| 7 | + * |
| 8 | + * Three cases: |
| 9 | + * 1. aggregation (default set-null) + @required FK field → throws |
| 10 | + * 2. aggregation (default set-null) + nullable FK field → no throw |
| 11 | + * 3. aggregation + explicit @onDelete: restrict + @required FK field → no throw |
| 12 | + * (override avoids set-null entirely) |
| 13 | + */ |
| 14 | +import { describe, test, expect } from "bun:test"; |
| 15 | +import { MetaDataLoader, InMemorySource } from "@metaobjectsdev/metadata"; |
| 16 | +import { buildExpectedSchema } from "../../src/expected-schema.js"; |
| 17 | +import { SetNullNotNullableError } from "../../src/errors.js"; |
| 18 | + |
| 19 | +async function loadDoc(doc: unknown) { |
| 20 | + const { root, errors } = await new MetaDataLoader().load([ |
| 21 | + new InMemorySource(JSON.stringify(doc)), |
| 22 | + ]); |
| 23 | + expect(errors).toHaveLength(0); |
| 24 | + return root; |
| 25 | +} |
| 26 | + |
| 27 | +// --------------------------------------------------------------------------- |
| 28 | +// Helpers to build the test document |
| 29 | +// --------------------------------------------------------------------------- |
| 30 | + |
| 31 | +function makeDoc(options: { |
| 32 | + required: boolean; |
| 33 | + relationship: Record<string, unknown>; |
| 34 | +}) { |
| 35 | + const fieldChildren: unknown[] = options.required |
| 36 | + ? [{ "validator.required": {} }] |
| 37 | + : []; |
| 38 | + |
| 39 | + return { |
| 40 | + "metadata.root": { |
| 41 | + package: "acme", |
| 42 | + children: [ |
| 43 | + { |
| 44 | + "object.entity": { |
| 45 | + name: "Program", |
| 46 | + children: [ |
| 47 | + { "field.long": { name: "id" } }, |
| 48 | + { "identity.primary": { "@fields": "id" } }, |
| 49 | + ], |
| 50 | + }, |
| 51 | + }, |
| 52 | + { |
| 53 | + "object.entity": { |
| 54 | + name: "Week", |
| 55 | + children: [ |
| 56 | + { "field.long": { name: "id" } }, |
| 57 | + { |
| 58 | + "field.long": { |
| 59 | + name: "programId", |
| 60 | + children: fieldChildren, |
| 61 | + }, |
| 62 | + }, |
| 63 | + options.relationship, |
| 64 | + { |
| 65 | + "identity.reference": { |
| 66 | + name: "ref_program", |
| 67 | + "@fields": ["programId"], |
| 68 | + "@references": "Program", |
| 69 | + }, |
| 70 | + }, |
| 71 | + { "identity.primary": { "@fields": "id" } }, |
| 72 | + ], |
| 73 | + }, |
| 74 | + }, |
| 75 | + ], |
| 76 | + }, |
| 77 | + }; |
| 78 | +} |
| 79 | + |
| 80 | +// --------------------------------------------------------------------------- |
| 81 | +// Tests |
| 82 | +// --------------------------------------------------------------------------- |
| 83 | + |
| 84 | +describe("buildExpectedSchema — set-null requires nullable FK columns", () => { |
| 85 | + test("aggregation default (set-null) + @required FK field → SetNullNotNullableError", async () => { |
| 86 | + const root = await loadDoc( |
| 87 | + makeDoc({ |
| 88 | + required: true, |
| 89 | + relationship: { |
| 90 | + "relationship.aggregation": { |
| 91 | + name: "program", |
| 92 | + "@objectRef": "Program", |
| 93 | + "@cardinality": "one", |
| 94 | + }, |
| 95 | + }, |
| 96 | + }), |
| 97 | + ); |
| 98 | + |
| 99 | + expect(() => buildExpectedSchema(root)).toThrow(SetNullNotNullableError); |
| 100 | + }); |
| 101 | + |
| 102 | + test("SetNullNotNullableError message names entity, FK constraint, and offending field", async () => { |
| 103 | + const root = await loadDoc( |
| 104 | + makeDoc({ |
| 105 | + required: true, |
| 106 | + relationship: { |
| 107 | + "relationship.aggregation": { |
| 108 | + name: "program", |
| 109 | + "@objectRef": "Program", |
| 110 | + "@cardinality": "one", |
| 111 | + }, |
| 112 | + }, |
| 113 | + }), |
| 114 | + ); |
| 115 | + |
| 116 | + let caught: unknown; |
| 117 | + try { |
| 118 | + buildExpectedSchema(root); |
| 119 | + } catch (e) { |
| 120 | + caught = e; |
| 121 | + } |
| 122 | + |
| 123 | + expect(caught).toBeInstanceOf(SetNullNotNullableError); |
| 124 | + const err = caught as SetNullNotNullableError; |
| 125 | + // Must name the entity |
| 126 | + expect(err.message).toContain("Week"); |
| 127 | + // Must name the FK constraint |
| 128 | + expect(err.message).toContain("weeks_program_id_fk"); |
| 129 | + // Must name the offending field |
| 130 | + expect(err.message).toContain("programId"); |
| 131 | + // Must suggest a fix |
| 132 | + expect(err.message.toLowerCase()).toMatch(/@required|@ondelete|restrict|no-action/i); |
| 133 | + }); |
| 134 | + |
| 135 | + test("aggregation default (set-null) + nullable FK field → no error", async () => { |
| 136 | + const root = await loadDoc( |
| 137 | + makeDoc({ |
| 138 | + required: false, |
| 139 | + relationship: { |
| 140 | + "relationship.aggregation": { |
| 141 | + name: "program", |
| 142 | + "@objectRef": "Program", |
| 143 | + "@cardinality": "one", |
| 144 | + }, |
| 145 | + }, |
| 146 | + }), |
| 147 | + ); |
| 148 | + |
| 149 | + // Must not throw |
| 150 | + expect(() => buildExpectedSchema(root)).not.toThrow(); |
| 151 | + }); |
| 152 | + |
| 153 | + test("aggregation + explicit @onDelete: restrict + @required FK field → no error (override avoids set-null)", async () => { |
| 154 | + const root = await loadDoc( |
| 155 | + makeDoc({ |
| 156 | + required: true, |
| 157 | + relationship: { |
| 158 | + "relationship.aggregation": { |
| 159 | + name: "program", |
| 160 | + "@objectRef": "Program", |
| 161 | + "@cardinality": "one", |
| 162 | + "@onDelete": "restrict", |
| 163 | + }, |
| 164 | + }, |
| 165 | + }), |
| 166 | + ); |
| 167 | + |
| 168 | + // The explicit override changes onDelete to restrict → no set-null → no error |
| 169 | + expect(() => buildExpectedSchema(root)).not.toThrow(); |
| 170 | + }); |
| 171 | + |
| 172 | + test("aggregation + explicit @onDelete: no-action + @required FK field → no error (override avoids set-null)", async () => { |
| 173 | + const root = await loadDoc( |
| 174 | + makeDoc({ |
| 175 | + required: true, |
| 176 | + relationship: { |
| 177 | + "relationship.aggregation": { |
| 178 | + name: "program", |
| 179 | + "@objectRef": "Program", |
| 180 | + "@cardinality": "one", |
| 181 | + "@onDelete": "no-action", |
| 182 | + }, |
| 183 | + }, |
| 184 | + }), |
| 185 | + ); |
| 186 | + |
| 187 | + // no-action normalizes to undefined in the FK descriptor → no set-null → no error |
| 188 | + expect(() => buildExpectedSchema(root)).not.toThrow(); |
| 189 | + }); |
| 190 | + |
| 191 | + test("composition (cascade) + @required FK field → no error (cascade is fine on NOT NULL)", async () => { |
| 192 | + const root = await loadDoc( |
| 193 | + makeDoc({ |
| 194 | + required: true, |
| 195 | + relationship: { |
| 196 | + "relationship.composition": { |
| 197 | + name: "program", |
| 198 | + "@objectRef": "Program", |
| 199 | + "@cardinality": "one", |
| 200 | + }, |
| 201 | + }, |
| 202 | + }), |
| 203 | + ); |
| 204 | + |
| 205 | + expect(() => buildExpectedSchema(root)).not.toThrow(); |
| 206 | + }); |
| 207 | + |
| 208 | + test("explicit @onDelete: set-null on composition + @required FK field → SetNullNotNullableError", async () => { |
| 209 | + // Explicit set-null on any relationship subtype should also be caught |
| 210 | + const root = await loadDoc( |
| 211 | + makeDoc({ |
| 212 | + required: true, |
| 213 | + relationship: { |
| 214 | + "relationship.composition": { |
| 215 | + name: "program", |
| 216 | + "@objectRef": "Program", |
| 217 | + "@cardinality": "one", |
| 218 | + "@onDelete": "set-null", |
| 219 | + }, |
| 220 | + }, |
| 221 | + }), |
| 222 | + ); |
| 223 | + |
| 224 | + expect(() => buildExpectedSchema(root)).toThrow(SetNullNotNullableError); |
| 225 | + }); |
| 226 | +}); |
0 commit comments