From 116cc78b60b32156bf65de5201ad58c18a8c73ca Mon Sep 17 00:00:00 2001 From: Thodoris Greasidis Date: Wed, 24 Apr 2024 17:57:15 +0300 Subject: [PATCH] Add option to optimize conditional uniqueness rules to partial unique indexes Change-type: minor See: https://balena.fibery.io/Work/Project/961 --- src/abstract-sql-compiler.ts | 1 + src/abstract-sql-schema-optimizer.ts | 14 +- src/schema-optimizations/check-constraint.ts | 25 +- .../partial-unique-index.ts | 388 +++++++++++ src/schema-optimizations/utils.ts | 58 ++ .../schema-rule-to-partial-unique-index.ts | 646 ++++++++++++++++++ 6 files changed, 1107 insertions(+), 25 deletions(-) create mode 100644 src/schema-optimizations/partial-unique-index.ts create mode 100644 src/schema-optimizations/utils.ts create mode 100644 test/abstract-sql/schema-rule-to-partial-unique-index.ts diff --git a/src/abstract-sql-compiler.ts b/src/abstract-sql-compiler.ts index d9f177fb..af6c1a76 100644 --- a/src/abstract-sql-compiler.ts +++ b/src/abstract-sql-compiler.ts @@ -787,6 +787,7 @@ const compileSchema = ( ): SqlModel => { abstractSqlModel = optimizeSchema(abstractSqlModel, { createCheckConstraints: false, + createPartialUniqueIndexes: false, }); let ifNotExistsStr = ''; diff --git a/src/abstract-sql-schema-optimizer.ts b/src/abstract-sql-schema-optimizer.ts index b49ab83e..babd6471 100644 --- a/src/abstract-sql-schema-optimizer.ts +++ b/src/abstract-sql-schema-optimizer.ts @@ -7,6 +7,7 @@ import type { AbstractSqlType, } from './abstract-sql-compiler.js'; import { convertRuleToCheckConstraint } from './schema-optimizations/check-constraint.js'; +import { convertRuleToPartialUniqueIndex } from './schema-optimizations/partial-unique-index.js'; export const generateRuleSlug = ( tableName: string, @@ -21,7 +22,11 @@ export const generateRuleSlug = ( export const optimizeSchema = ( abstractSqlModel: AbstractSqlModel, - { createCheckConstraints = true } = {}, + { + createCheckConstraints = true, + // TODO: default this to true in the next major + createPartialUniqueIndexes = false, + } = {}, ): AbstractSqlModel => { for (const resourceName of Object.keys(abstractSqlModel.tables)) { const table = abstractSqlModel.tables[resourceName]; @@ -95,6 +100,13 @@ export const optimizeSchema = ( return; } + if ( + createPartialUniqueIndexes && + convertRuleToPartialUniqueIndex(abstractSqlModel, ruleSE, ruleBody) + ) { + return; + } + return rule; }) .filter((v): v is NonNullable => v != null); diff --git a/src/schema-optimizations/check-constraint.ts b/src/schema-optimizations/check-constraint.ts index 07567045..6e66a98c 100644 --- a/src/schema-optimizations/check-constraint.ts +++ b/src/schema-optimizations/check-constraint.ts @@ -11,6 +11,7 @@ import { isFromNode, isSelectQueryNode, } from '../abstract-sql-compiler.js'; +import { convertReferencedFieldsToFields } from './utils.js'; import { generateRuleSlug } from '../abstract-sql-schema-optimizer.js'; const countFroms = (n: AbstractSqlType[]) => { @@ -27,30 +28,6 @@ const countFroms = (n: AbstractSqlType[]) => { return count; }; -const convertReferencedFieldsToFields = ( - tableNameOrAlias: string, - nodes: AbstractSqlType[], -) => { - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - if (Array.isArray(node)) { - if (node[0] === 'ReferencedField') { - if (node[1] !== tableNameOrAlias) { - throw new Error( - `Found ReferencedField of unexpected resource '${node[1]}' while converting ReferencedFields of '${tableNameOrAlias}' to Fields`, - ); - } - nodes[i] = ['Field', node[2]]; - } else { - convertReferencedFieldsToFields( - tableNameOrAlias, - node as AbstractSqlType[], - ); - } - } - } -}; - export function convertRuleToCheckConstraint( abstractSqlModel: AbstractSqlModel, ruleSE: string, diff --git a/src/schema-optimizations/partial-unique-index.ts b/src/schema-optimizations/partial-unique-index.ts new file mode 100644 index 00000000..57f41df7 --- /dev/null +++ b/src/schema-optimizations/partial-unique-index.ts @@ -0,0 +1,388 @@ +import { isDeepStrictEqual } from 'node:util'; +import { + convertReferencedFieldsToFields, + groupBy, + keyBy, + partition, +} from './utils.js'; +import type { + AbstractSqlField, + AbstractSqlModel, + AbstractSqlQuery, + AbstractSqlType, + AnyTypeNodes, + BooleanTypeNodes, + FromNode, + Index, + ReferencedFieldNode, + WhereNode, +} from '../abstract-sql-compiler.js'; +import { + isAliasNode, + isTableNode, + isReferencedFieldNode, + isSelectQueryNode, +} from '../abstract-sql-compiler.js'; +import { generateRuleSlug } from '../abstract-sql-schema-optimizer.js'; + +const replaceReferencedFieldAliases = ( + node: AbstractSqlType, + aliasMap: Record, +): AbstractSqlType => { + // We are on a leaf node, no need to further recurse + if (!Array.isArray(node)) { + return node; + } + if (isReferencedFieldNode(node)) { + const alias = node[1]; + const replacedAlias = aliasMap[alias]; + if (replacedAlias == null) { + return node; + } + return ['ReferencedField', replacedAlias, node[2]]; + } + return node.map((child) => { + if (!Array.isArray(child)) { + return child; + } + return replaceReferencedFieldAliases(child as AnyTypeNodes, aliasMap); + }) as typeof node; +}; + +/** + * Remove unnecessary NULL checks for referenced fields that are marked as required in the model + * when they are in the following form: + * ['Exists', ['ReferencedField', ...]] + * This is necessary to be able to produce partial unique indexes w/o unnecessary WHERE X IS NO NULL clauses + * or end up with a non-partial unique constraint when all checks are identified as unnecessary WHERE X IS NO NULL clauses. + */ +const removeExistChecksForRequiredReferencedFields = ( + resourceAlias: string, + fieldsByFieldName: Partial>, + nodes: BooleanTypeNodes[], +): BooleanTypeNodes[] => { + return nodes.filter((n) => { + if (Array.isArray(n) && n[0] === 'Exists' && Array.isArray(n[1])) { + const maybeFieldTypeNode = n[1]; + const fieldName = + isReferencedFieldNode(maybeFieldTypeNode) && + maybeFieldTypeNode[1] === resourceAlias + ? maybeFieldTypeNode[2] + : undefined; + if ( + typeof fieldName === 'string' && + fieldsByFieldName[fieldName]?.required === true + ) { + return false; + } + } + return true; + }); +}; + +function extractTableAndAlias(fromNode: FromNode) { + if (!isAliasNode(fromNode[1])) { + return; + } + if (!isTableNode(fromNode[1][1])) { + return; + } + return { + tableName: fromNode[1][1][1], + alias: fromNode[1][2], + }; +} + +const isRefEqualityCheck = ( + booleanNode: BooleanTypeNodes, +): booleanNode is ['Equals', ReferencedFieldNode, ReferencedFieldNode] => + booleanNode[0] === 'Equals' && + isReferencedFieldNode(booleanNode[1]) && + isReferencedFieldNode(booleanNode[2]); + +const isSameFieldRefEqualityCheck = ( + booleanNode: BooleanTypeNodes, + alias1: string, + alias2: string, +) => + isRefEqualityCheck(booleanNode) && + ((booleanNode[1][1] === alias1 && booleanNode[2][1] === alias2) || + (booleanNode[1][1] === alias2 && booleanNode[2][1] === alias1)) && + booleanNode[1][2] === booleanNode[2][2]; + +/** + * Converts rules that generate queries of the following format to partial unique indexes: + * SELECT NOT EXISTS ( # or SELECT (SELECT COUNT(*)...)) = 0 + * SELECT "resource.1"."partially unique field" + * FROM "resource" AS "resource.1" + * WHERE <"resource.1" uniqueness condition field checks> + * AND ( + * SELECT COUNT(*) + * FROM "resource" AS "resource.2" + * WHERE <"resource.2" uniqueness condition field checks, mirroring the "resource.1" checks in the outer query> + * AND <"resource.2"."partially unique field(s)" = "resource.1"."partially unique field(s)"> + * ) >= 2 + * ) AS "result"; + * + * SELECT ( # or SELECT NOT EXISTS + * SELECT COUNT(*) + * FROM "parent" AS "parent.0", + * "resource" AS "resource.1" + * WHERE <"resource.1" uniqueness condition field checks> + * AND "resource.1"."" = "parent.0"."id" + * AND ( + * SELECT COUNT(*) + * FROM "resource" AS "resource.2" + * WHERE <"resource.2" uniqueness condition field checks, mirroring the "resource.1" checks in the outer query> + * AND <"resource.2"."partially unique field(s)" = "resource.1"."partially unique field(s)"> + * AND "resource.2"."" = "parent.0"."id" + * ) >= 2 + * ) = 0 AS "result"; + */ +export function convertRuleToPartialUniqueIndex( + abstractSqlModel: AbstractSqlModel, + ruleSE: string, + ruleBody: AbstractSqlQuery, +) { + const outerSelectQuery = ruleBody[1]; + // Check if the query is of one of the following forms: + const queryChecksAbsenceOfRows = + // SELECT NOT EXISTS (SELECT ...) + (ruleBody[0] === 'NotExists' && isSelectQueryNode(outerSelectQuery)) || + // SELECT (SELECT COUNT(*) ...) = 0 + (ruleBody[0] === 'Equals' && + ruleBody[2][0] === 'Number' && + ruleBody[2][1] === 0 && + isSelectQueryNode(outerSelectQuery) && + isDeepStrictEqual(outerSelectQuery[1], ['Select', [['Count', '*']]])); + if (!queryChecksAbsenceOfRows) { + return; + } + + const outerNodesByType = groupBy( + outerSelectQuery.slice(1), + (node) => node[0], + ); + // Has to have 1 Select, 1 Where, and 1 or 2 Froms + if ( + Object.keys(outerNodesByType).length !== 3 || + outerNodesByType.Select?.length !== 1 || + (outerNodesByType.From?.length !== 1 && + outerNodesByType.From?.length !== 2) || + outerNodesByType.Where?.length !== 1 + ) { + return; + } + + const [outerWhereNode] = outerNodesByType.Where as [WhereNode]; + if (outerWhereNode[1][0] !== 'And') { + return; + } + let [, ...outerWhereParts] = outerWhereNode[1]; + const lastNode = outerWhereParts.pop(); + + // Has a SELECT COUNT(*) >= 2 + if ( + lastNode?.[0] !== 'GreaterThanOrEqual' || + !isDeepStrictEqual(lastNode[2], ['Number', 2]) + ) { + return; + } + const innerSelectNode = lastNode[1]; + if ( + !isSelectQueryNode(innerSelectNode) || + !isDeepStrictEqual(innerSelectNode[1], ['Select', [['Count', '*']]]) + ) { + return; + } + + const nestedNodesByType = groupBy( + innerSelectNode.slice(1), + (node) => node[0], + ); + // Has to have 1 Select, 1 Where, and 1 From + if ( + Object.keys(nestedNodesByType).length !== 3 || + nestedNodesByType.Select?.length !== 1 || + nestedNodesByType.From?.length !== 1 || + nestedNodesByType.Where?.length !== 1 + ) { + return; + } + + const [nestedWhereNode] = nestedNodesByType.Where as [WhereNode]; + if (nestedWhereNode[1][0] !== 'And') { + return; + } + + const nestedFromInfo = extractTableAndAlias( + nestedNodesByType.From[0] as FromNode, + ); + if (nestedFromInfo == null) { + return; + } + + const outerFromInfos = (outerNodesByType.From as FromNode[]).map( + extractTableAndAlias, + ); + if (!outerFromInfos.every((fi) => fi != null)) { + return; + } + + // Find on the outer FROM the table matching the one in the inner FROM + const tableWithUniquenessCheck = nestedFromInfo.tableName; + const [targetTableInfos, parentTableInfos] = partition( + outerFromInfos, + (fi) => fi.tableName === tableWithUniquenessCheck, + ); + if (targetTableInfos.length !== 1 || parentTableInfos.length > 1) { + return; + } + const [outerFromInfo] = targetTableInfos; + const [parentTableInfo] = parentTableInfos; + + // Remove the unnecessary "field IS NOT NULL" checks, for fields that + // the model knows are non-nullable. It's required for matching the + // inner & outer WHERE parts, but also makes the WHERE clause of the + // partial unique index simpler, which makes can be useful in more queries. + const table = Object.values(abstractSqlModel.tables).find( + (t) => t.name === tableWithUniquenessCheck, + ); + if (table == null) { + return; + } + const fieldsByFieldName = keyBy(table.fields, (f) => f.fieldName); + let [, ...nestedWhereParts] = nestedWhereNode[1]; + nestedWhereParts = removeExistChecksForRequiredReferencedFields( + nestedFromInfo.alias, + fieldsByFieldName, + nestedWhereParts, + ); + outerWhereParts = removeExistChecksForRequiredReferencedFields( + outerFromInfo.alias, + fieldsByFieldName, + outerWhereParts, + ); + + // Confirm that other that the nodes like `"resource.inner"."fieldX" = "resource.outer"."fieldX"` + // (which only appear in the inner query), all other nodes need to be matching between the inner & outer, + // with only the alias being different. + // ie the following parts need to match after replacing the aliases + // outer: WHERE <"resource.1" uniqueness condition field checks> + // AND "resource.1"."" = "parent.0"."id" + // inner: WHERE <"resource.2" uniqueness condition field checks, mirroring the "resource.1" checks in the outer query> + // AND "resource.2"."" = "parent.0"."id" + const nestedChecksToMatchWithOuter = nestedWhereParts.filter( + (n) => + !isSameFieldRefEqualityCheck( + n, + nestedFromInfo.alias, + outerFromInfo.alias, + ), + ); + const nestedToOuterAliasMap = { + [nestedFromInfo.alias]: outerFromInfo.alias, + }; + const reAliasedNestedChecksToMatch = nestedChecksToMatchWithOuter.map( + (node) => replaceReferencedFieldAliases(node, nestedToOuterAliasMap), + ); + if (!isDeepStrictEqual(outerWhereParts, reAliasedNestedChecksToMatch)) { + return; + } + // The outer & inner queries match! + + // Confirm that all "JOIN"ed referenced fields in the nested query + // are matched with the respective table of the outer query or the "parent" table. + // These effectively are the fields that the rule tries to check for conditional uniqueness. + // AND <"resource.2"."partially unique field(s)" = "resource.1"."partially unique field(s)"> + // AND "resource.2"."" = "parent.0"."id" -- only when the rule references a "parent" table + const [nestedRefFieldEqualityNodes, nestedPredicateChecks] = partition( + nestedWhereParts, + isRefEqualityCheck, + ); + if ( + !nestedRefFieldEqualityNodes.every( + ([, ref1, ref2]) => + // the nested table's field is "joined" with the same field of the outer table + // or the parent table's id field (if there is one in the query). + (ref1[1] === nestedFromInfo.alias && + (ref2[1] === outerFromInfo.alias || + ref2[1] === parentTableInfo?.alias)) || + // the references might appear in the reverse order. + (ref2[1] === nestedFromInfo.alias && + (ref1[1] === outerFromInfo.alias || + ref1[1] === parentTableInfo?.alias)), + ) + ) { + return; + } + + const nestedParentRefEqualityChecks = nestedRefFieldEqualityNodes.filter( + (n) => + !isSameFieldRefEqualityCheck( + n, + nestedFromInfo.alias, + outerFromInfo.alias, + ), + ); + + // There should only be a single `"resource.inner"."fieldX" = "parent.0".""` node + // and only when the query has a 'FROM ""' statement. + if (parentTableInfo == null) { + if (nestedParentRefEqualityChecks.length !== 0) { + return; + } + } else { + if (nestedParentRefEqualityChecks.length !== 1) { + return; + } + const parentTable = Object.values(abstractSqlModel.tables).find( + (t) => t.name === parentTableInfo.tableName, + ); + if ( + parentTable == null || + !nestedParentRefEqualityChecks.every( + ([, ref1, ref2]) => + (ref1[1] === nestedFromInfo.alias && + ref2[1] === parentTableInfo.alias && + ref2[2] === parentTable.idField) || + (ref2[1] === nestedFromInfo.alias && + ref1[1] === parentTableInfo.alias && + ref1[2] === parentTable.idField), + ) + ) { + return; + } + } + // The query seems to be in the supported format! + + // generate the index name using the original rule body + // before convertReferencedFieldsToFields modifies it. + const indexName = generateRuleSlug(tableWithUniquenessCheck, ruleBody); + let indexedColumns = nestedRefFieldEqualityNodes.map((node) => node[1][2]); + if (parentTableInfo != null) { + // Move the FK checks first in the index, so that it's useful in more queries. + const [fkFields, plainFields] = partition( + indexedColumns, + (field) => fieldsByFieldName[field]?.dataType === 'ForeignKey', + ); + indexedColumns = [...fkFields, ...plainFields]; + } + // This needs to run after we make sure that the rule is going to be replaced, + // since it modifies the rule's body in-place. + convertReferencedFieldsToFields(nestedFromInfo.alias, nestedPredicateChecks); + const index: Index = { + description: ruleSE, + name: indexName, + type: 'UNIQUE', + fields: indexedColumns, + ...(nestedPredicateChecks.length > 0 && { + predicate: + nestedPredicateChecks.length === 1 + ? nestedPredicateChecks[0] + : (['And', ...nestedPredicateChecks] as const), + }), + }; + table.indexes.push(index); + return index; +} diff --git a/src/schema-optimizations/utils.ts b/src/schema-optimizations/utils.ts new file mode 100644 index 00000000..052732e8 --- /dev/null +++ b/src/schema-optimizations/utils.ts @@ -0,0 +1,58 @@ +import type { AbstractSqlType } from '../abstract-sql-compiler.js'; + +// TODO: Move to Object.groupBy once we drop support for node 20 +export function groupBy(entries: T[], iteratee: (item: T) => K) { + const result: Partial> = Object.create(null); + for (const entry of entries) { + const key = `${iteratee(entry)}`; + result[key] ??= []; + result[key].push(entry); + } + return result; +} + +export function partition( + entries: T[], + iteratee: (item: T) => item is U, +): [U[], T[]]; +export function partition( + entries: T[], + iteratee: (item: T) => boolean, +): [T[], T[]]; +export function partition(entries: T[], iteratee: (item: T) => boolean) { + const result = groupBy(entries, iteratee); + return [result.true ?? [], result.false ?? []]; +} + +export function keyBy(entries: T[], iteratee: (item: T) => K) { + const result: Partial> = Object.create(null); + for (const entry of entries) { + const key = `${iteratee(entry)}`; + result[key] = entry; + } + return result; +} + +export const convertReferencedFieldsToFields = ( + tableNameOrAlias: string, + nodes: AbstractSqlType[], +) => { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (Array.isArray(node)) { + if (node[0] === 'ReferencedField') { + if (node[1] !== tableNameOrAlias) { + throw new Error( + `Found ReferencedField of unexpected resource '${node[1]}' while converting ReferencedFields of '${tableNameOrAlias}' to Fields`, + ); + } + nodes[i] = ['Field', node[2]]; + } else { + convertReferencedFieldsToFields( + tableNameOrAlias, + node as AbstractSqlType[], + ); + } + } + } +}; diff --git a/test/abstract-sql/schema-rule-to-partial-unique-index.ts b/test/abstract-sql/schema-rule-to-partial-unique-index.ts new file mode 100644 index 00000000..30167a6f --- /dev/null +++ b/test/abstract-sql/schema-rule-to-partial-unique-index.ts @@ -0,0 +1,646 @@ +import * as AbstractSQLCompiler from '../../out/abstract-sql-compiler.js'; +import { generateRuleSlug } from '../../out/abstract-sql-schema-optimizer.js'; +import { expect } from 'chai'; + +const generateSchema = ( + abstractSqlModel: AbstractSQLCompiler.AbstractSqlModel, +) => + AbstractSQLCompiler.postgres.compileSchema( + AbstractSQLCompiler.postgres.optimizeSchema(abstractSqlModel, { + createPartialUniqueIndexes: true, + }), + ); + +it('should identify and convert a conditional uniqueness rule on primitive fields to a partial UNIQUE INDEX', () => { + const schema = { + synonyms: {}, + relationships: {}, + tables: { + test: { + name: 'test', + resourceName: 'test', + idField: 'id', + fields: [ + { + dataType: 'Short Text', + fieldName: 'name', + required: true, + }, + { + dataType: 'Short Text', + fieldName: 'status', + required: false, + checks: [ + [ + 'In', + ['Field', 'status'], + ['Text', 'running'], + ['Text', 'failed'], + ['Text', 'succeeded'], + ], + ], + }, + { + fieldName: 'is invalidated', + dataType: 'Boolean', + required: true, + }, + ], + indexes: [], + primitive: false, + }, + }, + rules: [ + [ + 'Rule', + [ + 'Body', + [ + 'NotExists', + [ + 'SelectQuery', + ['Select', [['ReferencedField', 'test.1', 'name']]], + ['From', ['Alias', ['Table', 'test'], 'test.1']], + [ + 'Where', + [ + 'And', + [ + 'Equals', + ['Text', 'succeeded'], + ['ReferencedField', 'test.1', 'status'], + ], + ['Exists', ['ReferencedField', 'test.1', 'status']], + [ + 'Equals', + ['ReferencedField', 'test.1', 'is invalidated'], + ['Boolean', false], + ], + [ + 'GreaterThanOrEqual', + [ + 'SelectQuery', + ['Select', [['Count', '*']]], + ['From', ['Alias', ['Table', 'test'], 'test.3']], + [ + 'Where', + [ + 'And', + [ + 'Equals', + ['Text', 'succeeded'], + ['ReferencedField', 'test.3', 'status'], + ], + ['Exists', ['ReferencedField', 'test.3', 'status']], + [ + 'Equals', + ['ReferencedField', 'test.3', 'is invalidated'], + ['Boolean', false], + ], + [ + 'Equals', + ['ReferencedField', 'test.3', 'name'], + ['ReferencedField', 'test.1', 'name'], + ], + ], + ], + ], + ['Number', 2], + ], + ], + ], + ], + ], + ], + [ + 'StructuredEnglish', + 'It is necessary that each name that is of a test that has a status that is equal to "succeeded" and is not invalidated, is of at most one test that has a status that is equal to "succeeded" and is not invalidated.', + ], + ], + ], + lfInfo: { + rules: { + 'It is necessary that each name that is of a test that has a status that is equal to "succeeded" and is not invalidated, is of at most one test that has a status that is equal to "succeeded" and is not invalidated.': + { + root: { + table: 'test', + alias: 'test.1', + }, + }, + }, + }, + } satisfies AbstractSQLCompiler.AbstractSqlModel; + // compute the index auto-generated name upfront to ensure that that the generated name + // is not affected by any possible modifications that generateSchema() might do to the rule definition. + const expectedIndexName = generateRuleSlug('test', schema.rules[0][1][1]); + expect(generateSchema(schema)) + .to.have.property('createSchema') + .that.deep.equals([ + `\ +CREATE TABLE IF NOT EXISTS "test" ( + "name" VARCHAR(255) NOT NULL +, "status" VARCHAR(255) NULL CHECK ("status" IN ('running', 'failed', 'succeeded')) +, "is invalidated" BOOLEAN DEFAULT FALSE NOT NULL +);`, + `\ +-- It is necessary that each name that is of a test that has a status that is equal to "succeeded" and is not invalidated, is of at most one test that has a status that is equal to "succeeded" and is not invalidated. +CREATE UNIQUE INDEX IF NOT EXISTS "${expectedIndexName}" +ON "test" ("name") +WHERE ('succeeded' = "status" +AND "status" IS NOT NULL +AND "is invalidated" = FALSE);`, + ]); + expect(expectedIndexName).to.equal( + 'test$/DBLtT/ool+lP1+AWWoT22t3zgmd7DSVqrLe0dZiwbw=', + ); +}); + +it('should identify and convert a conditional uniqueness rule on primitive fields to a partial UNIQUE INDEX and not include unnecessary NULL checks in the WHERE clause', () => { + const schema = { + synonyms: {}, + relationships: {}, + tables: { + test: { + name: 'test', + resourceName: 'test', + idField: 'id', + fields: [ + { + dataType: 'Short Text', + fieldName: 'name', + // marked as optional to make sure a NULL check + // doesn't get added to the indexed fields + required: false, + }, + { + dataType: 'Short Text', + fieldName: 'status', + // marked as required to confirm that no unnecessary NULL check + // gets added to the index's predicate + required: true, + checks: [ + [ + 'In', + ['Field', 'status'], + ['Text', 'running'], + ['Text', 'failed'], + ['Text', 'succeeded'], + ], + ], + }, + { + fieldName: 'is invalidated', + dataType: 'Boolean', + required: true, + }, + ], + indexes: [], + primitive: false, + }, + }, + rules: [ + [ + 'Rule', + [ + 'Body', + [ + 'NotExists', + [ + 'SelectQuery', + ['Select', [['ReferencedField', 'test.1', 'name']]], + ['From', ['Alias', ['Table', 'test'], 'test.1']], + [ + 'Where', + [ + 'And', + [ + 'Equals', + ['Text', 'succeeded'], + ['ReferencedField', 'test.1', 'status'], + ], + ['Exists', ['ReferencedField', 'test.1', 'status']], + [ + 'Equals', + ['ReferencedField', 'test.1', 'is invalidated'], + ['Boolean', false], + ], + [ + 'GreaterThanOrEqual', + [ + 'SelectQuery', + ['Select', [['Count', '*']]], + ['From', ['Alias', ['Table', 'test'], 'test.3']], + [ + 'Where', + [ + 'And', + [ + 'Equals', + ['Text', 'succeeded'], + ['ReferencedField', 'test.3', 'status'], + ], + ['Exists', ['ReferencedField', 'test.3', 'status']], + [ + 'Equals', + ['ReferencedField', 'test.3', 'is invalidated'], + ['Boolean', false], + ], + [ + 'Equals', + ['ReferencedField', 'test.3', 'name'], + ['ReferencedField', 'test.1', 'name'], + ], + ], + ], + ], + ['Number', 2], + ], + ], + ], + ], + ], + ], + [ + 'StructuredEnglish', + 'It is necessary that each name that is of a test that has a status that is equal to "succeeded" and is not invalidated, is of at most one test that has a status that is equal to "succeeded" and is not invalidated.', + ], + ], + ], + lfInfo: { + rules: { + 'It is necessary that each name that is of a test that has a status that is equal to "succeeded" and is not invalidated, is of at most one test that has a status that is equal to "succeeded" and is not invalidated.': + { + root: { + table: 'test', + alias: 'test.1', + }, + }, + }, + }, + } satisfies AbstractSQLCompiler.AbstractSqlModel; + // compute the index auto-generated name upfront to ensure that that the generated name + // is not affected by any possible modifications that generateSchema() might do to the rule definition. + const expectedIndexName = generateRuleSlug('test', schema.rules[0][1][1]); + expect(generateSchema(schema)) + .to.have.property('createSchema') + .that.deep.equals([ + `\ +CREATE TABLE IF NOT EXISTS "test" ( + "name" VARCHAR(255) NULL +, "status" VARCHAR(255) NOT NULL CHECK ("status" IN ('running', 'failed', 'succeeded')) +, "is invalidated" BOOLEAN DEFAULT FALSE NOT NULL +);`, + `\ +-- It is necessary that each name that is of a test that has a status that is equal to "succeeded" and is not invalidated, is of at most one test that has a status that is equal to "succeeded" and is not invalidated. +CREATE UNIQUE INDEX IF NOT EXISTS "${expectedIndexName}" +ON "test" ("name") +WHERE ('succeeded' = "status" +AND "is invalidated" = FALSE);`, + ]); + expect(expectedIndexName).to.equal( + 'test$/DBLtT/ool+lP1+AWWoT22t3zgmd7DSVqrLe0dZiwbw=', + ); +}); + +it('should identify and convert a conditional uniqueness rule on referenced fields to a partial UNIQUE INDEX', () => { + const schema = { + synonyms: {}, + relationships: {}, + tables: { + parent: { + name: 'parent', + resourceName: 'parent', + idField: 'id', + fields: [ + { + dataType: 'Serial', + fieldName: 'id', + required: true, + index: 'PRIMARY KEY', + }, + ], + primitive: false, + indexes: [], + }, + child: { + name: 'child', + resourceName: 'child', + idField: 'id', + fields: [ + { + dataType: 'ForeignKey', + fieldName: 'is of-parent', + required: true, + references: { + resourceName: 'parent', + fieldName: 'id', + }, + }, + { + dataType: 'Short Text', + fieldName: 'name', + required: true, + }, + { + fieldName: 'must be unique', + dataType: 'Boolean', + required: true, + }, + ], + primitive: false, + indexes: [], + }, + }, + rules: [ + [ + 'Rule', + [ + 'Body', + [ + 'Equals', + [ + 'SelectQuery', + ['Select', [['Count', '*']]], + ['From', ['Alias', ['Table', 'parent'], 'parent.0']], + ['From', ['Alias', ['Table', 'child'], 'child.1']], + [ + 'Where', + [ + 'And', + [ + 'Equals', + ['ReferencedField', 'child.1', 'must be unique'], + ['Boolean', true], + ], + ['Exists', ['ReferencedField', 'child.1', 'name']], + [ + 'Equals', + ['ReferencedField', 'child.1', 'is of-parent'], + ['ReferencedField', 'parent.0', 'id'], + ], + [ + 'GreaterThanOrEqual', + [ + 'SelectQuery', + ['Select', [['Count', '*']]], + ['From', ['Alias', ['Table', 'child'], 'child.4']], + [ + 'Where', + [ + 'And', + [ + 'Equals', + ['ReferencedField', 'child.4', 'must be unique'], + ['Boolean', true], + ], + ['Exists', ['ReferencedField', 'child.4', 'name']], + [ + 'Equals', + ['ReferencedField', 'child.4', 'name'], + ['ReferencedField', 'child.1', 'name'], + ], + [ + 'Equals', + ['ReferencedField', 'child.4', 'is of-parent'], + ['ReferencedField', 'parent.0', 'id'], + ], + ], + ], + ], + ['Number', 2], + ], + ], + ], + ], + ['Number', 0], + ], + ], + [ + 'StructuredEnglish', + 'It is necessary that each parent that has a child 1 that must be unique and has a name1, has at most one child2 that must be unique and has a name2 that is equal to the name1.', + ], + ], + ], + lfInfo: { + rules: { + 'It is necessary that each parent that has a child 1 that must be unique and has a name1, has at most one child2 that must be unique and has a name2 that is equal to the name1.': + { + root: { + table: 'parent', + alias: 'parent.0', + }, + }, + }, + }, + } satisfies AbstractSQLCompiler.AbstractSqlModel; + // compute the index auto-generated name upfront to ensure that that the generated name + // is not affected by any possible modifications that generateSchema() might do to the rule definition. + const expectedIndexName = generateRuleSlug('child', schema.rules[0][1][1]); + expect(generateSchema(schema)) + .to.have.property('createSchema') + .that.deep.equals([ + `\ +CREATE TABLE IF NOT EXISTS "parent" ( + "id" SERIAL NOT NULL PRIMARY KEY +);`, + `\ +CREATE TABLE IF NOT EXISTS "child" ( + "is of-parent" INTEGER NOT NULL +, "name" VARCHAR(255) NOT NULL +, "must be unique" BOOLEAN DEFAULT FALSE NOT NULL +, FOREIGN KEY ("is of-parent") REFERENCES "parent" ("id") +);`, + `\ +-- It is necessary that each parent that has a child 1 that must be unique and has a name1, has at most one child2 that must be unique and has a name2 that is equal to the name1. +CREATE UNIQUE INDEX IF NOT EXISTS "${expectedIndexName}" +ON "child" ("is of-parent", "name") +WHERE ("must be unique" = TRUE);`, + ]); + expect(expectedIndexName).to.equal( + 'child$g+bFnJdVbfsu97AT3NVBKhKxUgMWDreZ391TgCScha4=', + ); +}); + +it('should identify and convert a conditional uniqueness rule on referenced fields to a partial UNIQUE INDEX regardless of the order of the params to the EqualsNodes', () => { + const schema = { + synonyms: {}, + relationships: { + parent: { + owns: { + child: { + $: ['id', ['child', 'belongs to-parent']], + }, + }, + }, + }, + tables: { + parent: { + name: 'parent', + resourceName: 'parent', + idField: 'id', + fields: [ + { + dataType: 'Serial', + fieldName: 'id', + required: true, + index: 'PRIMARY KEY', + }, + ], + primitive: false, + indexes: [], + }, + child: { + name: 'child', + resourceName: 'child', + idField: 'id', + fields: [ + { + dataType: 'ForeignKey', + fieldName: 'belongs to-parent', + required: true, + references: { + resourceName: 'parent', + fieldName: 'id', + }, + }, + { + dataType: 'Integer', + fieldName: 'first key', + required: true, + }, + { + dataType: 'Integer', + fieldName: 'second key', + required: true, + }, + { + dataType: 'Integer', + fieldName: 'optional key', + required: false, + }, + ], + primitive: false, + indexes: [], + }, + }, + rules: [ + [ + 'Rule', + [ + 'Body', + [ + 'Equals', + [ + 'SelectQuery', + ['Select', [['Count', '*']]], + ['From', ['Alias', ['Table', 'parent'], 'parent.0']], + ['From', ['Alias', ['Table', 'child'], 'child.1']], + [ + 'Where', + [ + 'And', + ['Exists', ['ReferencedField', 'child.1', 'optional key']], + [ + 'Equals', + ['ReferencedField', 'child.1', 'belongs to-parent'], + ['ReferencedField', 'parent.0', 'id'], + ], + [ + 'GreaterThanOrEqual', + [ + 'SelectQuery', + ['Select', [['Count', '*']]], + ['From', ['Alias', ['Table', 'child'], 'child.3']], + [ + 'Where', + [ + 'And', + [ + 'Equals', + ['ReferencedField', 'child.1', 'first key'], + ['ReferencedField', 'child.3', 'first key'], + ], + [ + 'Exists', + ['ReferencedField', 'child.3', 'first key'], + ], + [ + 'Equals', + ['ReferencedField', 'child.1', 'second key'], + ['ReferencedField', 'child.3', 'second key'], + ], + [ + 'Exists', + ['ReferencedField', 'child.3', 'second key'], + ], + [ + 'Equals', + ['ReferencedField', 'child.1', 'optional key'], + ['ReferencedField', 'child.3', 'optional key'], + ], + [ + 'Exists', + ['ReferencedField', 'child.3', 'optional key'], + ], + [ + 'Equals', + ['ReferencedField', 'child.3', 'belongs to-parent'], + ['ReferencedField', 'parent.0', 'id'], + ], + ], + ], + ], + ['Number', 2], + ], + ], + ], + ], + ['Number', 0], + ], + ], + [ + 'StructuredEnglish', + 'It is necessary that each parent that owns a child1 that has an optional key, owns at most one child2 that has a first key that is of the child1 and has a second key that is of the child1 and has an optional key that is of the child1.', + ], + ], + ], + lfInfo: { + rules: { + 'It is necessary that each parent that owns a child1 that has an optional key, owns at most one child2 that has a first key that is of the child1 and has a second key that is of the child1 and has an optional key that is of the child1.': + { + root: { + table: 'parent', + alias: 'parent.0', + }, + }, + }, + }, + } satisfies AbstractSQLCompiler.AbstractSqlModel; + // compute the index auto-generated name upfront to ensure that that the generated name + // is not affected by any possible modifications that generateSchema() might do to the rule definition. + const expectedIndexName = generateRuleSlug('child', schema.rules[0][1][1]); + expect(generateSchema(schema)) + .to.have.property('createSchema') + .that.deep.equals([ + `\ +CREATE TABLE IF NOT EXISTS "parent" ( + "id" SERIAL NOT NULL PRIMARY KEY +);`, + `\ +CREATE TABLE IF NOT EXISTS "child" ( + "belongs to-parent" INTEGER NOT NULL +, "first key" INTEGER NOT NULL +, "second key" INTEGER NOT NULL +, "optional key" INTEGER NULL +, FOREIGN KEY ("belongs to-parent") REFERENCES "parent" ("id") +);`, + `\ +-- It is necessary that each parent that owns a child1 that has an optional key, owns at most one child2 that has a first key that is of the child1 and has a second key that is of the child1 and has an optional key that is of the child1. +CREATE UNIQUE INDEX IF NOT EXISTS "${expectedIndexName}" +ON "child" ("belongs to-parent", "first key", "second key", "optional key") +WHERE ("optional key" IS NOT NULL);`, + ]); + expect(expectedIndexName).to.equal( + 'child$2pSl7vw7IjAvHFu1U50UXWTumZ9g3ASd/mWNxEnqW5Q=', + ); +});