Repository: PostHog/posthog
Pull request: feat(feature-flags): tags
## Problem

<!-- Who are we building for, what are their needs, why is this important? -->
Users currently have no way to categorize or label their feature flags
## Changes
<img width="663" alt="Screen Shot 2023-01-11 at 8 57 28 PM" src="https://user-images.githubusercontent.com/25164963/211957700-beaf47b1-9c1f-4ab2-9eeb-63cf2deadd03.png">
<img width="1164" alt="Screen Shot 2023-01-11 at 8 57 34 PM" src="https://user-images.githubusercontent.com/25164963/211957703-34c17c8c-0caf-46af-bece-7baa82b20288.png">
<img width="1078" alt="Screen Shot 2023-01-11 at 8 57 39 PM" src="https://user-images.githubusercontent.com/25164963/211957706-bd2499dc-aa87-4e82-aa3a-23efc4681760.png">

<!-- If there are frontend changes, please include screenshots. -->
<!-- If a reference design was involved, include a link to the relevant Figma frame! -->

👉 *Stay up-to-date with [PostHog coding conventions](https://posthog.com/docs/contribute/coding-conventions) for a smoother review.*

## How did you test this code?

<!-- Briefly describe the steps you took. -->
<!-- Include automated tests if possible, otherwise describe the manual testing routine. -->


Problem statement:
Mark feature flag as temporary or permanent
## Is your feature request related to a problem?

I'm trying to migrate from LaunchDarkly to PostHog.

One feature I'm missing is the ability to mark a feature flag as "temporary" or "permanent", to make cleanup and management of flags much easier.

## Describe the solution you'd like

Ability to Mark flags as temporary when you intend to remove them after releasing a feature. This lets you find and scrub those flags with ease.  

Ability to Mark flags as permanent when keeping them in your code long-term. Use them for operational things like disabling non-essential features during a spike in traffic.

#### *Thank you* for your feature request – we love each and every one!

Hints from issue discussion:
Thought about this before. could be good to have a generic "label" with some sensible defaults like `[experiment, devtool, rollout]` or something. That way users can decide what labels are most useful to their workflows.
There is a different ticket to add a end date to a feature flag I think this might over lap with this one

Commit message: regex sadness

Patch to review:
diff --git a/ee/clickhouse/views/test/test_clickhouse_experiments.py b/ee/clickhouse/views/test/test_clickhouse_experiments.py
index b27da90c6e627..0eb6f21d974bd 100644
--- a/ee/clickhouse/views/test/test_clickhouse_experiments.py
+++ b/ee/clickhouse/views/test/test_clickhouse_experiments.py
@@ -742,7 +742,7 @@ def test_used_in_experiment_is_populated_correctly_for_feature_flag_list(self) -
         ).json()
 
         # TODO: Make sure permission bool doesn't cause n + 1
-        with self.assertNumQueries(8):
+        with self.assertNumQueries(9):
             response = self.client.get(f"/api/projects/{self.team.id}/feature_flags")
             self.assertEqual(response.status_code, status.HTTP_200_OK)
             result = response.json()
diff --git a/frontend/src/scenes/feature-flags/FeatureFlag.tsx b/frontend/src/scenes/feature-flags/FeatureFlag.tsx
index dc91aa1de1af7..aa36c523504f2 100644
--- a/frontend/src/scenes/feature-flags/FeatureFlag.tsx
+++ b/frontend/src/scenes/feature-flags/FeatureFlag.tsx
@@ -61,6 +61,8 @@ import { PayGateMini } from 'lib/components/PayGateMini/PayGateMini'
 import { NodeKind } from '~/queries/schema'
 import { Query } from '~/queries/Query/Query'
 import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils'
+import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags'
+import { tagsModel } from '~/models/tagsModel'
 
 export const scene: SceneExport = {
     component: FeatureFlag,
@@ -80,7 +82,8 @@ function focusVariantKeyField(index: number): void {
 export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element {
     const { props, featureFlag, featureFlagLoading, featureFlagMissing, isEditingFlag } = useValues(featureFlagLogic)
     const { featureFlags } = useValues(enabledFeaturesLogic)
-    const { deleteFeatureFlag, editFeatureFlag, loadFeatureFlag } = useActions(featureFlagLogic)
+    const { deleteFeatureFlag, editFeatureFlag, loadFeatureFlag, triggerFeatureFlagUpdate } =
+        useActions(featureFlagLogic)
 
     const { addableRoles, unfilteredAddableRolesLoading, rolesToAdd, derivedRoles } = useValues(
         featureFlagPermissionsLogic({ flagId: featureFlag.id })
@@ -89,6 +92,9 @@ export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element {
         featureFlagPermissionsLogic({ flagId: featureFlag.id })
     )
 
+    const { tags } = useValues(tagsModel)
+    const { hasAvailableFeature } = useValues(userLogic)
+
     // whether the key for an existing flag is being changed
     const [hasKeyChanged, setHasKeyChanged] = useState(false)
 
@@ -213,6 +219,23 @@ export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element {
                                         defaultValue={featureFlag.name || ''}
                                     />
                                 </Field>
+                                {hasAvailableFeature(AvailableFeature.TAGGING) && (
+                                    <Field name="tags" label="Tags">
+                                        {({ value, onChange }) => {
+                                            return (
+                                                <ObjectTags
+                                                    tags={value}
+                                                    onChange={(_, tags) => onChange(tags)}
+                                                    saving={featureFlagLoading}
+                                                    tagsAvailable={tags.filter(
+                                                        (tag) => !featureFlag.tags?.includes(tag)
+                                                    )}
+                                                    className="insight-metadata-tags"
+                                                />
+                                            )
+                                        }}
+                                    </Field>
+                                )}
                                 <Field name="ensure_experience_continuity">
                                     {({ value, onChange }) => (
                                         <div className="border rounded p-4">
@@ -343,6 +366,34 @@ export function FeatureFlag({ id }: { id?: string } = {}): JSX.Element {
                                             )}
                                         </>
                                     }
+                                    caption={
+                                        <>
+                                            {featureFlag?.tags && (
+                                                <>
+                                                    {featureFlag.can_edit ? (
+                                                        <ObjectTags
+                                                            tags={featureFlag.tags}
+                                                            onChange={(_, tags) => {
+                                                                triggerFeatureFlagUpdate({ tags })
+                                                            }}
+                                                            saving={featureFlagLoading}
+                                                            tagsAvailable={tags.filter(
+                                                                (tag) => !featureFlag.tags?.includes(tag)
+                                                            )}
+                                                            className="insight-metadata-tags"
+                                                        />
+                                                    ) : featureFlag.tags.length ? (
+                                                        <ObjectTags
+                                                            tags={featureFlag.tags}
+                                                            saving={featureFlagLoading}
+                                                            staticOnly
+                                                            className="insight-metadata-tags"
+                                                        />
+                                                    ) : null}
+                                                </>
+                                            )}
+                                        </>
+                                    }
                                     buttons={
                                         <>
                                             <div className="flex items-center gap-2 mb-2">
diff --git a/frontend/src/scenes/feature-flags/FeatureFlags.tsx b/frontend/src/scenes/feature-flags/FeatureFlags.tsx
index 2afb0f3e6d20c..2a90559d7d7c0 100644
--- a/frontend/src/scenes/feature-flags/FeatureFlags.tsx
+++ b/frontend/src/scenes/feature-flags/FeatureFlags.tsx
@@ -4,7 +4,7 @@ import { Tabs } from 'antd'
 import { Link } from 'lib/components/Link'
 import { copyToClipboard, deleteWithUndo } from 'lib/utils'
 import { PageHeader } from 'lib/components/PageHeader'
-import { FeatureFlagGroupType, FeatureFlagType } from '~/types'
+import { AvailableFeature, FeatureFlagGroupType, FeatureFlagType } from '~/types'
 import { normalizeColumnTitle } from 'lib/components/Table/utils'
 import { urls } from 'scenes/urls'
 import stringWithWBR from 'lib/utils/stringWithWBR'
@@ -22,6 +22,8 @@ import { LemonInput, LemonSelect, LemonTag } from '@posthog/lemon-ui'
 import { Tooltip } from 'lib/components/Tooltip'
 import { IconLock } from 'lib/components/icons'
 import { router } from 'kea-router'
+import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags'
+import { userLogic } from 'scenes/userLogic'
 
 export const scene: SceneExport = {
     component: FeatureFlags,
@@ -33,6 +35,7 @@ function OverViewTab(): JSX.Element {
     const { featureFlagsLoading, searchedFeatureFlags, searchTerm, uniqueCreators, filters } =
         useValues(featureFlagsLogic)
     const { updateFeatureFlag, loadFeatureFlags, setSearchTerm, setFeatureFlagsFilters } = useActions(featureFlagsLogic)
+    const { hasAvailableFeature } = useValues(userLogic)
 
     const columns: LemonTableColumns<FeatureFlagType> = [
         {
@@ -74,6 +77,17 @@ function OverViewTab(): JSX.Element {
                 )
             },
         },
+        ...(hasAvailableFeature(AvailableFeature.TAGGING)
+            ? [
+                  {
+                      title: 'Tags',
+                      dataIndex: 'tags' as keyof FeatureFlagType,
+                      render: function Render(tags: FeatureFlagType['tags']) {
+                          return tags ? <ObjectTags tags={tags} staticOnly /> : null
+                      },
+                  } as LemonTableColumn<FeatureFlagType, keyof FeatureFlagType | undefined>,
+              ]
+            : []),
         createdByColumn<FeatureFlagType>() as LemonTableColumn<FeatureFlagType, keyof FeatureFlagType | undefined>,
         createdAtColumn<FeatureFlagType>() as LemonTableColumn<FeatureFlagType, keyof FeatureFlagType | undefined>,
         {
diff --git a/frontend/src/scenes/feature-flags/activityDescriptions.tsx b/frontend/src/scenes/feature-flags/activityDescriptions.tsx
index 45fd592c746c7..98c0d92ba0c16 100644
--- a/frontend/src/scenes/feature-flags/activityDescriptions.tsx
+++ b/frontend/src/scenes/feature-flags/activityDescriptions.tsx
@@ -12,6 +12,7 @@ import { FeatureFlagFilters, FeatureFlagGroupType, FeatureFlagType } from '~/typ
 import { pluralize } from 'lib/utils'
 import { SentenceList } from 'lib/components/ActivityLog/SentenceList'
 import { PropertyFilterButton } from 'lib/components/PropertyFilters/components/PropertyFilterButton'
+import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags'
 
 const nameOrLinkToFlag = (id: string | undefined, name: string | null | undefined): string | JSX.Element => {
     // detail.name
@@ -179,6 +180,32 @@ const featureFlagActionsMapping: Record<
 
         return { description: [<>{describeChange} experience continuity</>] }
     },
+    tags: function onTags(change) {
+        const tagsBefore = change?.before as string[]
+        const tagsAfter = change?.after as string[]
+        const addedTags = tagsAfter.filter((t) => tagsBefore.indexOf(t) === -1)
+        const removedTags = tagsBefore.filter((t) => tagsAfter.indexOf(t) === -1)
+
+        const changes: Description[] = []
+        if (addedTags.length) {
+            changes.push(
+                <>
+                    added {pluralize(addedTags.length, 'tag', 'tags', false)}{' '}
+                    <ObjectTags tags={addedTags} saving={false} style={{ display: 'inline' }} staticOnly />
+                </>
+            )
+        }
+        if (removedTags.length) {
+            changes.push(
+                <>
+                    removed {pluralize(removedTags.length, 'tag', 'tags', false)}{' '}
+                    <ObjectTags tags={removedTags} saving={false} style={{ display: 'inline' }} staticOnly />
+                </>
+            )
+        }
+
+        return { description: changes }
+    },
     // fields that are excluded on the backend
     id: () => null,
     created_at: () => null,
diff --git a/frontend/src/scenes/feature-flags/featureFlagLogic.test.ts b/frontend/src/scenes/feature-flags/featureFlagLogic.test.ts
index 0c10580fbb94e..0638a92dd11cb 100644
--- a/frontend/src/scenes/feature-flags/featureFlagLogic.test.ts
+++ b/frontend/src/scenes/feature-flags/featureFlagLogic.test.ts
@@ -26,6 +26,7 @@ function generateFeatureFlag(
         rollback_conditions: [],
         performed_rollback: false,
         can_edit: true,
+        tags: [],
     }
 }
 
diff --git a/frontend/src/scenes/feature-flags/featureFlagLogic.ts b/frontend/src/scenes/feature-flags/featureFlagLogic.ts
index d170f68f39a67..61385683aad94 100644
--- a/frontend/src/scenes/feature-flags/featureFlagLogic.ts
+++ b/frontend/src/scenes/feature-flags/featureFlagLogic.ts
@@ -65,6 +65,7 @@ const NEW_FLAG: FeatureFlagType = {
     rollback_conditions: [],
     performed_rollback: false,
     can_edit: true,
+    tags: [],
 }
 const NEW_VARIANT = {
     key: '',
@@ -165,6 +166,7 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
         loadAllInsightsForFlag: true,
         setAffectedUsers: (index: number, count?: number) => ({ index, count }),
         setTotalUsers: (count: number) => ({ count }),
+        triggerFeatureFlagUpdate: (payload: Partial<FeatureFlagType>) => ({ payload }),
     }),
     forms(({ actions, values }) => ({
         featureFlag: {
@@ -588,6 +590,16 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
                 }
             })
         },
+        triggerFeatureFlagUpdate: async ({ payload }) => {
+            if (values.featureFlag) {
+                const updatedFlag = await api.update(
+                    `api/projects/${values.currentTeamId}/feature_flags/${values.featureFlag.id}`,
+                    payload
+                )
+                actions.setFeatureFlag(updatedFlag)
+                featureFlagsLogic.findMounted()?.actions.updateFlag(updatedFlag)
+            }
+        },
     })),
     selectors({
         sentryErrorCount: [(s) => [s.sentryStats], (stats) => stats.total_count],
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 940ec303ae8e0..c642a519c4f3f 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -1832,6 +1832,7 @@ export interface FeatureFlagType {
     rollback_conditions: FeatureFlagRollbackConditions[]
     performed_rollback: boolean
     can_edit: boolean
+    tags: string[]
 }
 
 export interface FeatureFlagRollbackConditions {
diff --git a/latest_migrations.manifest b/latest_migrations.manifest
index 040a5c7d50afb..20a001e6a4219 100644
--- a/latest_migrations.manifest
+++ b/latest_migrations.manifest
@@ -3,6 +3,6 @@ auth: 0012_alter_user_first_name_max_length
 axes: 0006_remove_accesslog_trusted
 contenttypes: 0002_remove_content_type_name
 ee: 0014_roles_memberships_and_resource_access
-posthog: 0288_add_session_recording_persistence
+posthog: 0289_add_tags_to_feature_flags
 sessions: 0001_initial
 social_django: 0010_uid_db_index
diff --git a/posthog/api/feature_flag.py b/posthog/api/feature_flag.py
index 716f0a323c6af..572f103df47f9 100644
--- a/posthog/api/feature_flag.py
+++ b/posthog/api/feature_flag.py
@@ -11,6 +11,7 @@
 from posthog.api.forbid_destroy_model import ForbidDestroyModel
 from posthog.api.routing import StructuredViewSetMixin
 from posthog.api.shared import UserBasicSerializer
+from posthog.api.tagged_item import TaggedItemSerializerMixin, TaggedItemViewSetMixin
 from posthog.auth import PersonalAPIKeyAuthentication, TemporaryTokenAuthentication
 from posthog.event_usage import report_user_action
 from posthog.models import FeatureFlag
@@ -38,7 +39,7 @@ def has_object_permission(self, request: Request, view, feature_flag) -> bool:
             return can_user_edit_feature_flag(request, feature_flag)
 
 
-class FeatureFlagSerializer(serializers.HyperlinkedModelSerializer):
+class FeatureFlagSerializer(TaggedItemSerializerMixin, serializers.HyperlinkedModelSerializer):
     created_by = UserBasicSerializer(read_only=True)
     # :TRICKY: Needed for backwards compatibility
     filters = serializers.DictField(source="get_filters", required=False)
@@ -72,6 +73,7 @@ class Meta:
             "rollback_conditions",
             "performed_rollback",
             "can_edit",
+            "tags",
         ]
 
     def get_can_edit(self, feature_flag: FeatureFlag) -> bool:
@@ -163,6 +165,8 @@ def create(self, validated_data: Dict, *args: Any, **kwargs: Any) -> FeatureFlag
         request = self.context["request"]
         validated_data["created_by"] = request.user
         validated_data["team_id"] = self.context["team_id"]
+        tags = validated_data.pop("tags", None)  # tags are created separately below as global tag relationships
+
         self._update_filters(validated_data)
 
         variants = (validated_data.get("filters", {}).get("multivariate", {}) or {}).get("variants", [])
@@ -179,6 +183,8 @@ def create(self, validated_data: Dict, *args: Any, **kwargs: Any) -> FeatureFlag
         instance: FeatureFlag = super().create(validated_data)
         instance.update_cohorts()
 
+        self._attempt_set_tags(tags, instance)
+
         report_user_action(request.user, "feature flag created", instance.get_analytics_metadata())
 
         return instance
@@ -220,7 +226,7 @@ class Meta:
         ]
 
 
-class FeatureFlagViewSet(StructuredViewSetMixin, ForbidDestroyModel, viewsets.ModelViewSet):
+class FeatureFlagViewSet(TaggedItemViewSetMixin, StructuredViewSetMixin, ForbidDestroyModel, viewsets.ModelViewSet):
     """
     Create, read, update and delete feature flags. [See docs](https://posthog.com/docs/user-guides/feature-flags) for more information on feature flags.
 
diff --git a/posthog/api/test/__snapshots__/test_insight.ambr b/posthog/api/test/__snapshots__/test_insight.ambr
index b21e6f74d81d2..efd6eda433a34 100644
--- a/posthog/api/test/__snapshots__/test_insight.ambr
+++ b/posthog/api/test/__snapshots__/test_insight.ambr
@@ -765,6 +765,7 @@
          "posthog_taggeditem"."event_definition_id",
          "posthog_taggeditem"."property_definition_id",
          "posthog_taggeditem"."action_id",
+         "posthog_taggeditem"."feature_flag_id",
          "posthog_tag"."id",
          "posthog_tag"."name",
          "posthog_tag"."team_id"
@@ -929,7 +930,8 @@
          "posthog_taggeditem"."insight_id",
          "posthog_taggeditem"."event_definition_id",
          "posthog_taggeditem"."property_definition_id",
-         "posthog_taggeditem"."action_id"
+         "posthog_taggeditem"."action_id",
+         "posthog_taggeditem"."feature_flag_id"
   FROM "posthog_taggeditem"
   WHERE "posthog_taggeditem"."insight_id" IN (1,
                                               2,
diff --git a/posthog/management/commands/test_migrations_are_safe.py b/posthog/management/commands/test_migrations_are_safe.py
index c3f5380234413..34f1339aedfbd 100644
--- a/posthog/management/commands/test_migrations_are_safe.py
+++ b/posthog/management/commands/test_migrations_are_safe.py
@@ -15,6 +15,7 @@ def run_and_check_migration(variable):
                 sql = call_command("sqlmigrate", results[0], results[1])
                 if (
                     re.findall(r"(?<!DROP) (NOT NULL|DEFAULT)", sql, re.M & re.I)
+                    and not re.match(r"(?<!CREATE UNIQUE INDEX) (NOT NULL)", sql)
                     and "Create model" not in sql
                     and "-- not-null-ignore" not in sql
                 ):
diff --git a/posthog/migrations/0289_add_tags_to_feature_flags.py b/posthog/migrations/0289_add_tags_to_feature_flags.py
new file mode 100644
index 0000000000000..debf52c006c03
--- /dev/null
+++ b/posthog/migrations/0289_add_tags_to_feature_flags.py
@@ -0,0 +1,100 @@
+# Generated by Django 3.2.16 on 2023-01-12 17:33
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("posthog", "0288_add_session_recording_persistence"),
+    ]
+
+    operations = [
+        migrations.RemoveConstraint(
+            model_name="taggeditem",
+            name="exactly_one_related_object",
+        ),
+        migrations.AddField(
+            model_name="taggeditem",
+            name="feature_flag",
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name="tagged_items",
+                to="posthog.featureflag",
+            ),
+        ),
+        migrations.AlterUniqueTogether(
+            name="taggeditem",
+            unique_together={
+                ("tag", "dashboard", "insight", "event_definition", "property_definition", "action", "feature_flag")
+            },
+        ),
+        migrations.AddConstraint(
+            model_name="taggeditem",
+            constraint=models.UniqueConstraint(
+                condition=models.Q(("feature_flag__isnull", False)),
+                fields=("tag", "feature_flag"),
+                name="unique_feature_flag_tagged_item",
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name="taggeditem",
+            constraint=models.CheckConstraint(
+                check=models.Q(
+                    models.Q(
+                        ("dashboard__isnull", False),
+                        ("insight__isnull", True),
+                        ("event_definition__isnull", True),
+                        ("property_definition__isnull", True),
+                        ("action__isnull", True),
+                        ("feature_flag__isnull", True),
+                    ),
+                    models.Q(
+                        ("dashboard__isnull", True),
+                        ("insight__isnull", False),
+                        ("event_definition__isnull", True),
+                        ("property_definition__isnull", True),
+                        ("action__isnull", True),
+                        ("feature_flag__isnull", True),
+                    ),
+                    models.Q(
+                        ("dashboard__isnull", True),
+                        ("insight__isnull", True),
+                        ("event_definition__isnull", False),
+                        ("property_definition__isnull", True),
+                        ("action__isnull", True),
+                        ("feature_flag__isnull", True),
+                    ),
+                    models.Q(
+                        ("dashboard__isnull", True),
+                        ("insight__isnull", True),
+                        ("event_definition__isnull", True),
+                        ("property_definition__isnull", False),
+                        ("action__isnull", True),
+                        ("feature_flag__isnull", True),
+                    ),
+                    models.Q(
+                        ("dashboard__isnull", True),
+                        ("insight__isnull", True),
+                        ("event_definition__isnull", True),
+                        ("property_definition__isnull", True),
+                        ("action__isnull", False),
+                        ("feature_flag__isnull", True),
+                    ),
+                    models.Q(
+                        ("dashboard__isnull", True),
+                        ("insight__isnull", True),
+                        ("event_definition__isnull", True),
+                        ("property_definition__isnull", True),
+                        ("action__isnull", True),
+                        ("feature_flag__isnull", False),
+                    ),
+                    _connector="OR",
+                ),
+                name="exactly_one_related_object",
+            ),
+        ),
+    ]
diff --git a/posthog/models/tagged_item.py b/posthog/models/tagged_item.py
index 218677655f6af..3d6b73383aaf8 100644
--- a/posthog/models/tagged_item.py
+++ b/posthog/models/tagged_item.py
@@ -6,7 +6,7 @@
 
 from posthog.models.utils import UUIDModel
 
-RELATED_OBJECTS = ("dashboard", "insight", "event_definition", "property_definition", "action")
+RELATED_OBJECTS = ("dashboard", "insight", "event_definition", "property_definition", "action", "feature_flag")
 
 
 # Checks that exactly one object field is populated
@@ -61,6 +61,9 @@ class TaggedItem(UUIDModel):
     action: models.ForeignKey = models.ForeignKey(
         "Action", on_delete=models.CASCADE, null=True, blank=True, related_name="tagged_items"
     )
+    feature_flag: models.ForeignKey = models.ForeignKey(
+        "FeatureFlag", on_delete=models.CASCADE, null=True, blank=True, related_name="tagged_items"
+    )
 
     class Meta:
         unique_together = ("tag",) + RELATED_OBJECTS


Code review comments:
diff_hunk: @@ -15,6 +15,7 @@ def run_and_check_migration(variable):
                 sql = call_command("sqlmigrate", results[0], results[1])
                 if (
                     re.findall(r"(?<!DROP) (NOT NULL|DEFAULT)", sql, re.M & re.I)
+                    and not re.match(r"(?<!CREATE UNIQUE INDEX) (NOT NULL)", sql)
path: posthog/management/commands/test_migrations_are_safe.py
text: @author:
Was having trouble with the regex of finding all "NOT NULL" instances that aren't followed by CREATE UNIQUE TOGETHER / ALTER TABLE so hoping to get this in and then take it out after the PR is merged 🤗