Skip to content

Commit

Permalink
:on-delete :cascade (#698)
Browse files Browse the repository at this point in the history
  • Loading branch information
tonsky authored Jan 27, 2025
1 parent f880249 commit 9cb9677
Show file tree
Hide file tree
Showing 21 changed files with 645 additions and 301 deletions.
18 changes: 18 additions & 0 deletions client/packages/core/__tests__/src/data/zeneca/attrs.json
Original file line number Diff line number Diff line change
Expand Up @@ -211,5 +211,23 @@
],
"unique?": false,
"index?": false
},
{
"id": "ddf89ebc-71e9-4a9b-80f2-9c2380949661",
"value-type": "ref",
"cardinality": "one",
"forward-identity": [
"caec81f9-5f14-4376-9842-fc0e6143c3ce",
"books",
"prequel"
],
"reverse-identity": [
"e0b307a2-0679-4490-ba58-e9a5a17e4901",
"books",
"sequels"
],
"unique?": false,
"index?": false,
"on-delete": "cascade"
}
]
2 changes: 1 addition & 1 deletion client/packages/core/__tests__/src/instaql.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,7 @@ test("objects are created by etype", () => {
},
).data.users[0];
expect(stopa.email).toEqual("[email protected]");
const chunk = tx.user[stopa.id].update({
const chunk = tx.not_users[stopa.id].update({
email: "[email protected]",
});
const txSteps = instaml.transform({ attrs: store.attrs }, chunk);
Expand Down
24 changes: 24 additions & 0 deletions client/packages/core/__tests__/src/store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,30 @@ test("delete entity", () => {
checkIndexIntegrity(newStoreTwo);
});

test("on-delete cascade", () => {
const book1 = uuid();
const book2 = uuid();
const book3 = uuid();
const chunk1 = tx.books[book1].update({ title: "book1", description: "series" });
const chunk2 = tx.books[book2].update({ title: "book2", description: "series" }).link({ prequel: book1 });
const chunk3 = tx.books[book3].update({ title: "book3", description: "series" }).link({ prequel: book2 });
const txSteps = instaml.transform({ attrs: store.attrs }, [chunk1, chunk2, chunk3]);
const newStore = transact(store, txSteps);
checkIndexIntegrity(newStore);
expect(
query({ store: newStore }, { books: { $: { where: { description: "series" }}}}).data.books.map((x) => x.title)
).toEqual(["book1", "book2", "book3"]);

const txStepsTwo = instaml.transform(
{ attrs: newStore.attrs },
tx.books[book1].delete(),
);
const newStoreTwo = transact(newStore, txStepsTwo);
expect(
query({ store: newStoreTwo }, { books: { $: { where: { description: "series" }}}}).data.books.map((x) => x.title)
).toEqual([]);
});

test("new attrs", () => {
const colorId = uuid();
const userId = uuid();
Expand Down
1 change: 1 addition & 0 deletions client/packages/core/src/schemaTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export type LinkDef<
on: FwdEntity;
label: FwdAttr;
has: FwdCardinality;
onDelete?: 'cascade';
};
reverse: {
on: RevEntity;
Expand Down
3 changes: 3 additions & 0 deletions client/packages/core/src/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,9 @@ function deleteEntity(store, args) {
deleteInMap(store.aev, [a, e, v]);
deleteInMap(store.vae, [v, a, e]);
}
if (attr && attr['on-delete'] === 'cascade') {
deleteEntity(store, [e, attr["forward-identity"]?.[1]]);
}
});
}
// Clear out vae index for `id` if we deleted all the reverse attributes
Expand Down
49 changes: 41 additions & 8 deletions client/www/components/dash/explorer/EditNamespaceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function EditNamespaceDialog({
| { type: 'main' }
| { type: 'delete' }
| { type: 'add' }
| { type: 'edit'; attrId: string }
| { type: 'edit'; attrId: string, isForward: boolean }
>({ type: 'main' });

async function deleteNs() {
Expand All @@ -65,11 +65,9 @@ export function EditNamespaceDialog({
onClose({ ok: true });
}

const screenAttrId = screen.type === 'edit' ? screen.attrId : null;

const screenAttr = useMemo(() => {
return namespace.attrs.find((a) => a.id === screenAttrId);
}, [screenAttrId, namespace.attrs]);
return screen.type === 'edit' && namespace.attrs.find((a) => a.id === screen.attrId && a.isForward === screen.isForward);
}, [screen.type === 'edit' ? screen.attrId : null, screen.type === 'edit' ? screen.isForward : null, namespace.attrs]);

return (
<>
Expand Down Expand Up @@ -107,7 +105,7 @@ export function EditNamespaceDialog({
className="px-2"
size="mini"
variant="subtle"
onClick={() => setScreen({ type: 'edit', attrId: attr.id })}
onClick={() => setScreen({ type: 'edit', attrId: attr.id, isForward: attr.isForward })}
>
Edit
</Button>
Expand Down Expand Up @@ -219,12 +217,15 @@ function AddAttrForm({
}) {
const [isIndex, setIsIndex] = useState(false);
const [isUniq, setIsUniq] = useState(false);
const [isCascade, setIsCascade] = useState(false);
const [checkedDataType, setCheckedDataType] =
useState<CheckedDataType | null>(null);
const [attrType, setAttrType] = useState<'blob' | 'ref'>('blob');
const [relationship, setRelationship] =
useState<RelationshipKinds>('many-many');

const isCascadeAllowed = relationship === 'one-one' || relationship === 'one-many';

const [reverseNamespace, setReverseNamespace] = useState<
SchemaNamespace | undefined
>(() => namespaces.find((n) => n.name !== namespace.name) ?? namespaces[0]);
Expand Down Expand Up @@ -278,6 +279,7 @@ function AddAttrForm({
'reverse-identity': [id(), reverseNamespace.name, reverseAttrName],
'value-type': 'ref',
'index?': false,
'on-delete': isCascadeAllowed && isCascade ? 'cascade' : undefined
};

const ops = [['add-attr', attr]];
Expand Down Expand Up @@ -412,6 +414,19 @@ function AddAttrForm({
setReverseAttrName={setReverseAttrName}
setRelationship={setRelationship}
/>

<div className="flex gap-2">
<Checkbox
checked={isCascadeAllowed && isCascade}
disabled={!isCascadeAllowed}
onChange={setIsCascade}
label={
<span>
<strong>Cascade delete</strong> When <strong>{reverseNamespace?.name}</strong> is deleted, all linked <strong>{namespace.name}</strong> will be deleted automatically
</span>
}
/>
</div>
</>
) : null}

Expand All @@ -430,7 +445,7 @@ function AddAttrForm({
Self-links must have different attribute names.
</span>
) : (
<>&nbsp;</>
null
)}
</div>
</ActionForm>
Expand Down Expand Up @@ -1056,6 +1071,9 @@ function EditAttrForm({
return relKind;
});

const [isCascade, setIsCascade] = useState(() => attr.onDelete === 'cascade');
const isCascadeAllowed = relationship === 'one-one' || relationship === 'one-many';

const linkValidation = validateLink({
attrName,
reverseAttrName,
Expand Down Expand Up @@ -1084,6 +1102,7 @@ function EditAttrForm({
attr.linkConfig.reverse.namespace,
reverseAttrName,
],
'on-delete': isCascade ? 'cascade' : null
},
],
];
Expand Down Expand Up @@ -1221,6 +1240,20 @@ function EditAttrForm({
setReverseAttrName={setReverseAttrName}
setRelationship={setRelationship}
/>

<div className="flex gap-2">
<Checkbox
checked={isCascadeAllowed && isCascade}
disabled={!isCascadeAllowed}
onChange={setIsCascade}
label={
<span>
<strong>Cascade delete</strong> When <strong>{attr.linkConfig.reverse!.namespace}</strong> is deleted, all linked <strong>{attr.linkConfig.forward.namespace}</strong> will be deleted automatically
</span>
}
/>
</div>

<div className="flex flex-col gap-6">
<ActionButton
disabled={!linkValidation.isValidLink}
Expand All @@ -1235,7 +1268,7 @@ function EditAttrForm({
Self-links must have different attribute names.
</span>
) : (
<>&nbsp;</>
null
)}
</div>
</ActionForm>
Expand Down
5 changes: 3 additions & 2 deletions client/www/components/ui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,8 @@ export function Checkbox({
return (
<label
className={cn(
'flex cursor-pointer items-center gap-2 disabled:cursor-default',
'flex cursor-pointer items-top gap-2',
disabled ? 'text-gray-400 cursor-default' : '',
labelClassName,
)}
title={title}
Expand All @@ -331,7 +332,7 @@ export function Checkbox({
title={title}
required={required}
className={cn(
'align-middle font-medium text-gray-900 disabled:text-gray-400 disabled:bg-gray-400',
'align-middle mt-0.5 font-medium text-gray-900 disabled:border-gray-300 disabled:bg-gray-200',
className,
)}
type="checkbox"
Expand Down
1 change: 1 addition & 0 deletions client/www/lib/schema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export function dbAttrsToExplorerSchema(
catalog: attrDesc.catalog,
checkedDataType: attrDesc['checked-data-type'],
sortable: attrDesc['index?'] && !!attrDesc['checked-data-type'],
onDelete: attrDesc['on-delete']
};
}
}
Expand Down
2 changes: 2 additions & 0 deletions client/www/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export interface DBAttr {
'inferred-types'?: Array<'string' | 'number' | 'boolean' | 'json'>;
catalog?: 'user' | 'system';
'checked-data-type'?: CheckedDataType;
'on-delete'?: 'cascade';
}

export interface SchemaNamespace {
Expand All @@ -184,6 +185,7 @@ export interface SchemaAttr {
catalog?: 'user' | 'system';
checkedDataType?: CheckedDataType;
sortable: boolean;
onDelete?: 'cascade';
}

export type InstantError = {
Expand Down
16 changes: 16 additions & 0 deletions client/www/pages/docs/modeling-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,22 @@ Our micro-blog example has the following relationship types:
- **One-to-many** between `comments` and `profiles`
- **Many-to-many** between `posts` and `tags`

### Cascade Delete

If forward link is defined with `has: "one"`, it can have `onDelete: "cascade"`. In that case, when referenced entity is deleted, all its connected entities will be deleted too:

```typescript
postAuthor: {
forward: { on: "posts", has: "one", label: "author", onDelete: "cascade" },
reverse: { on: "profiles", has: "many", label: "authoredPosts" },
}

// this will delete profile and all its posts
db.tx.profiles[user_id].delete();
```

Without `onDelete: "cascade"`, when deleting user, posts will just be unlinked from it, but keep existing.

## Publishing your schema

Now that you have your schema, you can use the CLI to `push` it to your app:
Expand Down
7 changes: 7 additions & 0 deletions server/deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,21 @@
:jvm-opts ["-XX:+UseZGC"
"-enableassertions"

;; force locale
"-Duser.language=en"
"-Duser.country=US"
"-Dfile.encoding=UTF-8"

;; clj-async-profiler
"-Djdk.attach.allowAttachSelf" ;; allow to attach
"-XX:+UnlockDiagnosticVMOptions" ;; better profile accuracy
"-XX:+DebugNonSafepoints" ;; better profile accuracy
"-XX:+EnableDynamicAgentLoading"

;; print stack traces instead of "Full report at ..."
"-Dclojure.main.report=stderr"
"-Dclojure.server.repl={:port 6006 :accept clojure.core.server/repl}"

;; hazelcast
"--add-modules" "java.se"
"--add-exports" "java.base/jdk.internal.ref=ALL-UNNAMED"
Expand Down
29 changes: 7 additions & 22 deletions server/src/instant/db/model/attr.clj
Original file line number Diff line number Diff line change
Expand Up @@ -242,33 +242,17 @@
"Namespaces are not allowed to start with a `$`.
Those are reserved for system namespaces.")}])))))

(defn validate-on-deletes!
"Prevents users from setting on-delete :cascade on attrs. This would
be a nice feature to release, but it needs some thought on what
restrictions we put in place. The implementation also needs optimization."
[attrs]
(doseq [attr attrs]
(when (:on-delete attr)
(ex/throw-validation-err!
:attributes
attr
[{:message "The :on-delete property can't be set on an attribute."}]))))

(defn insert-multi!
"Attr data is expressed as one object in clj but is persisted across two tables
in sql: `attrs` and `idents`.
We extract relevant data for each table and build a CTE to insert into
both tables in one statement"
([conn app-id attrs]
(insert-multi! conn app-id attrs {:allow-reserved-names? false
:allow-on-deletes? false}))
([conn app-id attrs {:keys [allow-reserved-names?
allow-on-deletes?]}]
(insert-multi! conn app-id attrs {:allow-reserved-names? false}))
([conn app-id attrs {:keys [allow-reserved-names?]}]
(when-not allow-reserved-names?
(validate-reserved-names! attrs))
(when-not allow-on-deletes?
(validate-on-deletes! attrs))
(with-cache-invalidation app-id
(sql/do-execute!
::insert-multi!
Expand Down Expand Up @@ -374,7 +358,7 @@

(defn- changes-that-require-attr-model-updates
[updates]
(let [ks #{:cardinality :value-type :unique? :index?}]
(let [ks #{:cardinality :value-type :unique? :index? :on-delete}]
(->> updates
(filter (fn [x]
(some (partial contains? x) ks))))))
Expand All @@ -395,10 +379,11 @@
{:values (attr-table-values app-id attr-table-updates)}]
[:attr-updates
{:update :attrs
:set {:value-type (not-null-or :attr-values.value-type :attrs.value-type)
:set {:value-type (not-null-or :attr-values.value-type :attrs.value-type)
:cardinality (not-null-or :attr-values.cardinality :attrs.cardinality)
:is-unique (not-null-or :attr-values.is-unique :attrs.is-unique)
:is-indexed (not-null-or :attr-values.is-indexed :attrs.is-indexed)}
:is-unique (not-null-or :attr-values.is-unique :attrs.is-unique)
:is-indexed (not-null-or :attr-values.is-indexed :attrs.is-indexed)
:on-delete :attr-values.on-delete}
:from [:attr-values]
:where [:and
[:= :attrs.id :attr-values.id]
Expand Down
Loading

0 comments on commit 9cb9677

Please sign in to comment.