Skip to content

Commit 37c70e4

Browse files
committed
Normalize index identifiers on construction
Index column identifiers are stripped of outer quotes at construction. Indexes created by older versions still validate via a reconstructed legacy name candidate.
1 parent 3188039 commit 37c70e4

4 files changed

Lines changed: 220 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
* Add support for the `NUMERIC` column type.
99
* Fix getting the row estimate for `ModifyColumnMigration` if a table with the
1010
same name is present in multiple schemas.
11+
* Fix validation against historic table indices created with non-reserved keywords.
1112

1213
# hpqtypes-extras-1.19.0.0 (2025-11-27)
1314
* Compatibility with `hpqtypes` >= 0.13.0.0.

src/Database/PostgreSQL/PQTypes/Checks.hs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -748,9 +748,13 @@ checkDBStructure options tables = fmap mconcat . forM tables $ \(table, version)
748748
checkIndexes defs allIndexes =
749749
mconcat $
750750
checkEquality "INDEXes" defs (map fst indexes)
751-
: checkNames (indexName tblName) indexes
751+
: checkNames expectedName indexes
752752
: map localIndexInfo localIndexes
753753
where
754+
-- Recover the user-declared form because introspection always quotes.
755+
expectedName dbIdx =
756+
indexName tblName (fromMaybe dbIdx (L.find (== dbIdx) defs))
757+
754758
localIndexInfo (index, name) =
755759
validationInfo $
756760
T.concat
@@ -1539,8 +1543,8 @@ fetchTableIndex
15391543
-> (TableIndex, RawSQL ())
15401544
fetchTableIndex (name, Array1 keyColumns, Array1 includeColumns, method, unique, nullsNotDistinct, mconstraint) =
15411545
( TableIndex
1542-
{ idxColumns = map (indexColumn . (`rawSQL` ()) . stripIdentifierQuotes) keyColumns
1543-
, idxInclude = map ((`rawSQL` ()) . stripIdentifierQuotes) includeColumns
1546+
{ idxColumns = map (indexColumn . (`rawSQL` ())) keyColumns
1547+
, idxInclude = map (includeColumn . (`rawSQL` ())) includeColumns
15441548
, idxMethod = case method of
15451549
"gin" -> GIN
15461550
"btree" -> BTree
@@ -1551,10 +1555,6 @@ fetchTableIndex (name, Array1 keyColumns, Array1 includeColumns, method, unique,
15511555
}
15521556
, rawSQL name ()
15531557
)
1554-
where
1555-
stripIdentifierQuotes str = case fmap T.unsnoc <$> T.uncons str of
1556-
Just ('"', Just (identifier, '"')) -> identifier
1557-
_ -> str
15581558

15591559
-- *** FOREIGN KEYS ***
15601560

src/Database/PostgreSQL/PQTypes/Model/Index.hs

Lines changed: 70 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ module Database.PostgreSQL.PQTypes.Model.Index
1414
, uniqueIndexOnColumnWithCondition
1515
, uniqueIndexOnColumns
1616
, indexName
17+
, IncludeColumn (unIncludeColumn)
18+
, includeColumn
1719
, sqlCreateIndexMaybeDowntime
1820
, sqlCreateIndexConcurrently
1921
, sqlDropIndexMaybeDowntime
@@ -33,7 +35,7 @@ import Database.PostgreSQL.PQTypes
3335

3436
data TableIndex = TableIndex
3537
{ idxColumns :: [IndexColumn]
36-
, idxInclude :: [RawSQL ()]
38+
, idxInclude :: [IncludeColumn]
3739
, idxMethod :: IndexMethod
3840
, idxUnique :: Bool
3941
, -- \^ If creation of index with CONCURRENTLY fails, index
@@ -48,31 +50,71 @@ data TableIndex = TableIndex
4850
-- \^ NB: will only be used if idxUnique is set to True
4951
deriving (Eq, Ord, Show)
5052

53+
-- | A key column of an index, optionally with an operator class. The 'Bool'
54+
-- records whether the identifier was surrounded by double quotes in the source.
5155
data IndexColumn
52-
= IndexColumn (RawSQL ()) (Maybe (RawSQL ()))
56+
= IndexColumn (RawSQL ()) (Maybe (RawSQL ())) Bool
5357
deriving (Show)
5458

5559
-- If one of the two columns doesn't specify the operator class, we just ignore
5660
-- it and still treat them as equivalent.
5761
instance Eq IndexColumn where
58-
IndexColumn x Nothing == IndexColumn y _ = x == y
59-
IndexColumn x _ == IndexColumn y Nothing = x == y
60-
IndexColumn x (Just x') == IndexColumn y (Just y') = x == y && x' == y'
62+
IndexColumn x Nothing _ == IndexColumn y _ _ = x == y
63+
IndexColumn x _ _ == IndexColumn y Nothing _ = x == y
64+
IndexColumn x (Just x') _ == IndexColumn y (Just y') _ = x == y && x' == y'
6165

6266
instance Ord IndexColumn where
6367
compare = compare `on` indexColumnName
6468

6569
instance IsString IndexColumn where
66-
fromString s = IndexColumn (fromString s) Nothing
70+
fromString = indexColumn . fromString
71+
72+
-- | A column appearing in an @INCLUDE@ clause. The 'Bool' records whether the
73+
-- identifier was surrounded by double quotes in the source.
74+
data IncludeColumn = IncludeColumn {unIncludeColumn :: RawSQL (), _incWasQuoted :: Bool}
75+
deriving (Show)
76+
77+
instance Eq IncludeColumn where
78+
IncludeColumn x _ == IncludeColumn y _ = x == y
79+
80+
instance Ord IncludeColumn where
81+
compare = compare `on` unIncludeColumn
82+
83+
instance IsString IncludeColumn where
84+
fromString = includeColumn . fromString
85+
86+
-- | Strip a single surrounding pair of double quotes. The 'Bool' reports
87+
-- whether stripping happened.
88+
mkIdent :: RawSQL () -> (RawSQL (), Bool)
89+
mkIdent r = case fmap T.unsnoc <$> T.uncons (unRawSQL r) of
90+
Just ('"', Just (body, '"')) -> (rawSQL body (), True)
91+
_ -> (r, False)
92+
93+
-- | Wrap an identifier in double quotes for DDL emission.
94+
quoteIdent :: RawSQL () -> RawSQL ()
95+
quoteIdent r = "\"" <> r <> "\""
96+
97+
-- | Apply 'quoteIdent' iff the bit is set.
98+
quoteWhen :: Bool -> RawSQL () -> RawSQL ()
99+
quoteWhen True = quoteIdent
100+
quoteWhen False = id
101+
102+
-- | Lift a 'Text' transformation onto the body of a 'RawSQL' ().
103+
asText :: (T.Text -> T.Text) -> RawSQL () -> RawSQL ()
104+
asText f = (`rawSQL` ()) . f . unRawSQL
67105

68106
indexColumn :: RawSQL () -> IndexColumn
69-
indexColumn col = IndexColumn col Nothing
107+
indexColumn col = let (c, q) = mkIdent col in IndexColumn c Nothing q
70108

71109
indexColumnWithOperatorClass :: RawSQL () -> RawSQL () -> IndexColumn
72-
indexColumnWithOperatorClass col opclass = IndexColumn col (Just opclass)
110+
indexColumnWithOperatorClass col opclass =
111+
let (c, q) = mkIdent col in IndexColumn c (Just opclass) q
73112

74113
indexColumnName :: IndexColumn -> RawSQL ()
75-
indexColumnName (IndexColumn col _) = col
114+
indexColumnName (IndexColumn col _ _) = col
115+
116+
includeColumn :: RawSQL () -> IncludeColumn
117+
includeColumn col = let (c, q) = mkIdent col in IncludeColumn c q
76118

77119
data IndexMethod
78120
= BTree
@@ -148,22 +190,22 @@ uniqueIndexOnColumnWithCondition column whereC =
148190
, idxNotDistinctNulls = False
149191
}
150192

193+
-- | Canonical auto-generated name for an index.
151194
indexName :: RawSQL () -> TableIndex -> RawSQL ()
152195
indexName tname TableIndex {..} =
153-
flip rawSQL () $
154-
T.take 63 . unRawSQL $
155-
mconcat
156-
[ if idxUnique then "unique_idx__" else "idx__"
157-
, tname
158-
, "__"
159-
, mintercalate "__" $ map (asText sanitize . indexColumnName) idxColumns
160-
, if null idxInclude
161-
then ""
162-
else "$$" <> mintercalate "__" (map (asText sanitize) idxInclude)
163-
, maybe "" (("__" <>) . hashWhere) idxWhere
164-
]
196+
asText (T.take 63) $
197+
mconcat
198+
[ if idxUnique then "unique_idx__" else "idx__"
199+
, tname
200+
, "__"
201+
, mintercalate "__" $ map (\(IndexColumn col _ q) -> renderIdent q col) idxColumns
202+
, if null idxInclude
203+
then ""
204+
else "$$" <> mintercalate "__" (map (\(IncludeColumn col q) -> renderIdent q col) idxInclude)
205+
, maybe "" (("__" <>) . hashWhere) idxWhere
206+
]
165207
where
166-
asText f = flip rawSQL () . f . unRawSQL
208+
renderIdent q = asText sanitize . quoteWhen q
167209
-- See http://www.postgresql.org/docs/9.4/static/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS.
168210
-- Remove all unallowed characters and replace them by at most one adjacent dollar sign.
169211
sanitize = T.pack . foldr go [] . T.unpack
@@ -210,15 +252,18 @@ sqlCreateIndex_ concurrently tname idx@TableIndex {..} =
210252
", "
211253
( map
212254
( \case
213-
IndexColumn col Nothing -> col
214-
IndexColumn col (Just opclass) -> col <+> opclass
255+
IndexColumn col Nothing q -> quoteWhen q col
256+
IndexColumn col (Just opclass) q -> quoteWhen q col <+> opclass
215257
)
216258
idxColumns
217259
)
218260
, ")"
219261
, if null idxInclude
220262
then ""
221-
else " INCLUDE (" <> mintercalate ", " idxInclude <> ")"
263+
else
264+
" INCLUDE ("
265+
<> mintercalate ", " (map (\(IncludeColumn c q) -> quoteWhen q c) idxInclude)
266+
<> ")"
222267
, if idxUnique && idxNotDistinctNulls
223268
then " NULLS NOT DISTINCT"
224269
else ""

test/Main.hs

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2363,6 +2363,69 @@ reservedWordColumnTests connSource =
23632363

23642364
freshTestDB step
23652365

2366+
step "Definition using the legacy quoted form (\"\\\"timestamp\\\"\") still validates"
2367+
assertNoException "Quoted-keyword column in tblIndexes is accepted" $ do
2368+
let definitions = tableDefsWithPgCrypto [tableWithLegacyQuotedIndex]
2369+
migrateDatabase
2370+
defaultExtrasOptions
2371+
definitions
2372+
[createTableMigration tableWithLegacyQuotedIndex]
2373+
checkDatabase defaultExtrasOptions definitions
2374+
2375+
freshTestDB step
2376+
2377+
step "Index pre-existing under the quoted-source name validates"
2378+
assertNoException "Quoted-source index name is accepted by checkDatabase" $ do
2379+
-- Hand-roll the index under its canonical @$ident$@ name, then validate
2380+
-- against a definition that includes it.
2381+
migrateDatabase
2382+
defaultExtrasOptions
2383+
(tableDefsWithPgCrypto [tableWithLegacyQuotedIndexNoIndexes])
2384+
[createTableMigration tableWithLegacyQuotedIndexNoIndexes]
2385+
runSQL_
2386+
"CREATE INDEX \"idx__reserved_word_test5__$timestamp$\" \
2387+
\ON reserved_word_test5 (\"timestamp\")"
2388+
checkDatabase defaultExtrasOptions $
2389+
tableDefsWithPgCrypto [tableWithLegacyQuotedIndex]
2390+
2391+
freshTestDB step
2392+
2393+
step "Index with a reserved-word column in INCLUDE validates"
2394+
assertNoException "Reserved-word column in idxInclude is accepted" $ do
2395+
let definitions = tableDefsWithPgCrypto [tableWithReservedWordInclude]
2396+
migrateDatabase
2397+
defaultExtrasOptions
2398+
definitions
2399+
[createTableMigration tableWithReservedWordInclude]
2400+
checkDatabase defaultExtrasOptions definitions
2401+
2402+
freshTestDB step
2403+
2404+
step "Multi-column index mixing unquoted and quoted-keyword columns validates"
2405+
assertNoException "Mixed quoted/unquoted composite index is accepted" $ do
2406+
let definitions = tableDefsWithPgCrypto [tableWithMixedQuotedComposite]
2407+
migrateDatabase
2408+
defaultExtrasOptions
2409+
definitions
2410+
[createTableMigration tableWithMixedQuotedComposite]
2411+
checkDatabase defaultExtrasOptions definitions
2412+
2413+
freshTestDB step
2414+
2415+
step "Index on a JSONB expression is created without being quoted as an identifier"
2416+
assertNoException "Expression index is accepted" $ do
2417+
let definitions = tableDefsWithPgCrypto [tableWithExpressionIndex]
2418+
migrateDatabase
2419+
defaultExtrasOptions
2420+
definitions
2421+
[createTableMigration tableWithExpressionIndex]
2422+
checkDatabase defaultExtrasOptions definitions
2423+
2424+
freshTestDB step
2425+
2426+
-- NB: keep this step last. The CREATE INDEX failure on a reserved-word
2427+
-- column leaves the connection's transaction in aborted state, so a
2428+
-- subsequent `freshTestDB` (which runs DROP SCHEMA) fails.
23662429
step "Attempt to create a table with an index on a 'select' column"
23672430
assertException "Table with index on 'select' column can't be created" $ do
23682431
let definitions = tableDefsWithPgCrypto [tableWithSelectIndex]
@@ -2421,6 +2484,84 @@ reservedWordColumnTests connSource =
24212484
, tblIndexes = [indexOnColumn "select"]
24222485
}
24232486

2487+
tableWithLegacyQuotedIndex =
2488+
tblTable
2489+
{ tblName = "reserved_word_test5"
2490+
, tblVersion = 1
2491+
, tblColumns =
2492+
[ tblColumn {colName = "id", colType = UuidT, colNullable = False, colDefault = Just "gen_random_uuid()"}
2493+
, tblColumn {colName = "timestamp", colType = TimestampWithZoneT, colNullable = False}
2494+
]
2495+
, tblPrimaryKey = pkOnColumn "id"
2496+
, tblIndexes = [indexOnColumn "\"timestamp\""]
2497+
}
2498+
2499+
tableWithLegacyQuotedIndexNoIndexes = tableWithLegacyQuotedIndex {tblIndexes = []}
2500+
2501+
tableWithReservedWordInclude =
2502+
tblTable
2503+
{ tblName = "reserved_word_test6"
2504+
, tblVersion = 1
2505+
, tblColumns =
2506+
[ tblColumn {colName = "id", colType = UuidT, colNullable = False, colDefault = Just "gen_random_uuid()"}
2507+
, tblColumn {colName = "name", colType = TextT, colNullable = False}
2508+
, tblColumn {colName = "timestamp", colType = TimestampWithZoneT, colNullable = False}
2509+
]
2510+
, tblPrimaryKey = pkOnColumn "id"
2511+
, tblIndexes = [(uniqueIndexOnColumn "name") {idxInclude = ["timestamp"]}]
2512+
}
2513+
2514+
tableWithMixedQuotedComposite =
2515+
tblTable
2516+
{ tblName = "reserved_word_test7"
2517+
, tblVersion = 1
2518+
, tblColumns =
2519+
[ tblColumn {colName = "id", colType = UuidT, colNullable = False, colDefault = Just "gen_random_uuid()"}
2520+
, tblColumn {colName = "other_id", colType = UuidT, colNullable = False}
2521+
, tblColumn {colName = "name", colType = TextT, colNullable = False}
2522+
, tblColumn {colName = "time", colType = TimestampWithZoneT, colNullable = False}
2523+
]
2524+
, tblPrimaryKey = pkOnColumn "id"
2525+
, tblIndexes = [indexOnColumns ["other_id", "name", "\"time\""]]
2526+
}
2527+
2528+
tableWithExpressionIndex =
2529+
tblTable
2530+
{ tblName = "reserved_word_test8"
2531+
, tblVersion = 1
2532+
, tblColumns =
2533+
[ tblColumn {colName = "id", colType = UuidT, colNullable = False, colDefault = Just "gen_random_uuid()"}
2534+
, tblColumn {colName = "data", colType = JsonbT, colNullable = False}
2535+
]
2536+
, tblPrimaryKey = pkOnColumn "id"
2537+
, tblIndexes = [uniqueIndexOnColumn (indexColumn "(data ->> 'key'::text)")]
2538+
}
2539+
2540+
sqlCreateIndexEmissionTests :: TestTree
2541+
sqlCreateIndexEmissionTests =
2542+
testGroup
2543+
"sqlCreateIndex emission"
2544+
[ testCase "expression column is emitted without identifier quoting" $
2545+
assertCreateIndex
2546+
"CREATE UNIQUE INDEX unique_idx__t__$data$key$text$\
2547+
\ ON t USING BTree ((data ->> 'key'::text))"
2548+
(uniqueIndexOnColumn (indexColumn "(data ->> 'key'::text)"))
2549+
, testCase "quoted reserved-word column round-trips as a quoted identifier" $
2550+
assertCreateIndex
2551+
"CREATE INDEX idx__t__$timestamp$ ON t USING BTree (\"timestamp\")"
2552+
(indexOnColumn "\"timestamp\"")
2553+
, testCase "unquoted column is emitted bare" $
2554+
assertCreateIndex
2555+
"CREATE INDEX idx__t__name ON t USING BTree (name)"
2556+
(indexOnColumn "name")
2557+
]
2558+
where
2559+
assertCreateIndex expected idx =
2560+
assertEqual
2561+
"sqlCreateIndexMaybeDowntime output"
2562+
expected
2563+
(unRawSQL (sqlCreateIndexMaybeDowntime "t" idx))
2564+
24242565
sqlAnyAllTests :: TestTree
24252566
sqlAnyAllTests =
24262567
testGroup
@@ -2721,6 +2862,7 @@ main = do
27212862
, overlapingIndexesTests connSource
27222863
, nullsNotDistinctTests connSource
27232864
, reservedWordColumnTests connSource
2865+
, sqlCreateIndexEmissionTests
27242866
, sqlAnyAllTests
27252867
, enumTest connSource
27262868
, numericColumnTypeTest connSource

0 commit comments

Comments
 (0)