squeal-postgresql-qq 0.1.4.0 → 0.1.5.0
raw patch · 6 files changed
+247/−46 lines, 6 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
Files
- README.md +7/−2
- changelog.md +10/−0
- squeal-postgresql-qq.cabal +1/−1
- src/Squeal/QuasiQuotes/Insert.hs +28/−2
- src/Squeal/QuasiQuotes/Query.hs +74/−41
- test/test.hs +127/−0
README.md view
@@ -58,7 +58,6 @@ * `INTO` clause (`SELECT ... INTO ...`) * `WINDOW` clause for defining named windows (e.g. `WINDOW w AS ...`) * `ORDER BY USING`-* `FOR READ ONLY` locking clause * `FOR UPDATE/SHARE OF` with qualified table names. * `WHERE CURRENT OF` for cursors * Aliasing a `JOIN` clause directly (e.g., `(SELECT * FROM t1 JOIN t2 ON ...) AS myalias`)@@ -69,7 +68,6 @@ * `FETCH` clause * `ORDER BY`, `LIMIT`, `OFFSET`, or locking clauses on the immediate operands of a set operation (`UNION`, `INTERSECT`, `EXCEPT`). * Advanced `GROUP BY` features (`GROUPING SETS`, `CUBE`, `ROLLUP`)-* Multi-row `VALUES` clause in `SELECT` statements (e.g. `SELECT * FROM (VALUES (1, 'a'), (2, 'b'))`). Multi-row `INSERT ... VALUES` is supported. * `VALUES` clause with `ORDER BY`, `LIMIT`, `OFFSET`, or locking clauses. * `SELECT` without a `FROM` clause cannot have other clauses like `WHERE`, `GROUP BY`, etc. * Multiple `*` in a `SELECT` list.@@ -82,7 +80,11 @@ ### Data Manipulation (INSERT/UPDATE/DELETE) * `ON CONFLICT` with a column list conflict target (e.g. `ON CONFLICT (col1, col2) ...`). Only `ON CONFLICT ON CONSTRAINT ...` is supported.+ - Reason: `squeal-postgresql` 0.9.1.3 exposes only `OnConstraint` for conflict targets.+ - See `plan.md` for the proposed upstream `OnColumns` constructor and local steps. * `ON CONFLICT` without a conflict target (e.g. `ON CONFLICT DO NOTHING`).+ - Reason: current `squeal-postgresql` API has no constructor to render a targetless `DO NOTHING`.+ - See `plan.md` for the proposed upstream `OnConflictDoNothing` constructor. * `INSERT ... DEFAULT VALUES` * `INSERT INTO table (columns) SELECT ...` (must omit column list) * `INSERT ... VALUES` without a column list.@@ -147,6 +149,8 @@ select 'text_val' [✔] select 1 [✔] select 1 AS num, 'text_val' AS txt [✔]+ values (1, 'a'), (2, 'b') [✔]+ values (1, 'a') [✔] group by select name from users group by name [✔] select employee_id, count(id) from users group by employee_id [✔]@@ -254,6 +258,7 @@ select * from emails where emails.id not between 0 and 10 [✔] select (e.id :: text) as casted_id from emails as e [✔] select * from users for update [✔]+ select * from users for read only [✔] select * from jsonb_test [✔] select * from json_test [✔] select distinct name from users [✔]
changelog.md view
@@ -1,3 +1,13 @@+### 0.1.5.0++* New features+ * Support multi-row VALUES in SELECT statements. [✔]+ * `values (1, 'a'), (2, 'b')` [✔]+ * `select * from (values (1, 'a'), (2, 'b')) as t (n, s)` [✔]+ * Support `FOR READ ONLY` locking clause in SELECT statements.+ The clause is accepted and treated as a no-op during translation to+ Squeal, matching PostgreSQL semantics for plain SELECTs.+ ### 0.1.4.0 * New features
squeal-postgresql-qq.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: squeal-postgresql-qq-version: 0.1.4.0+version: 0.1.5.0 synopsis: QuasiQuoter transforming raw sql into Squeal expressions. -- description: homepage: https://github.com/owensmurray/squeal-postgresql-qq
src/Squeal/QuasiQuotes/Insert.hs view
@@ -250,21 +250,47 @@ `AppE` LabelE colNameStr +--+-- ON CONFLICT support note+-- -------------------------+-- As of the pinned upstream `squeal-postgresql` (0.9.2.0), the+-- `ConflictTarget` GADT only exposes `OnConstraint` and there is no+-- clause form for a targetless `ON CONFLICT DO NOTHING`.+--+-- Consequently, this quasiquoter intentionally fails fast when the parsed+-- `PostgresqlSyntax` AST requests either:+-- * a targetless ON CONFLICT (i.e. DO NOTHING without a target), or+-- * a column-list conflict target (i.e. ON CONFLICT (col[, ...]) ...).+--+-- When upstream adds the necessary constructors (e.g. an `OnColumns`+-- conflict target and a clause for targetless `DO NOTHING`), this is the+-- place to wire them through. See `plan.md` for the backlog item. renderOnConflict :: [Text.Text] -> PGT_AST.OnConflict -> Q Exp renderOnConflict cteNames (PGT_AST.OnConflict maybeConfExpr onConflictDo) = do conflictActionExp <- renderOnConflictDo cteNames onConflictDo case maybeConfExpr of- Nothing -> fail "ON CONFLICT without a conflict target is not supported yet."+ Nothing ->+ fail+ ( "ON CONFLICT without a conflict target (i.e. targetless DO NOTHING) "+ <> "cannot be represented with squeal-postgresql 0.9.2.0. "+ <> "Only ON CONFLICT ON CONSTRAINT ... is currently available. "+ <> "See plan.md for the proposed upstream changes and status."+ ) Just confExpr -> do conflictTargetExp <- renderConfExpr confExpr pure $ ConE 'S.OnConflict `AppE` conflictTargetExp `AppE` conflictActionExp +-- See the ON CONFLICT support note above. renderConfExpr :: PGT_AST.ConfExpr -> Q Exp renderConfExpr = \case PGT_AST.ConstraintConfExpr name -> pure $ ConE 'S.OnConstraint `AppE` LabelE (Text.unpack (getIdentText name)) PGT_AST.WhereConfExpr _ _ ->- fail "ON CONFLICT (columns) is not supported yet. Use ON CONFLICT ON CONSTRAINT."+ fail+ ( "ON CONFLICT (columns ...) is not supported by squeal-postgresql 0.9.2.0. "+ <> "Only ON CONFLICT ON CONSTRAINT ... is currently available. "+ <> "See plan.md for the proposed upstream changes and status."+ ) renderOnConflictDo :: [Text.Text] -> PGT_AST.OnConflictDo -> Q Exp renderOnConflictDo cteNames = \case
src/Squeal/QuasiQuotes/Query.hs view
@@ -16,9 +16,9 @@ getIdentText, ) where -import Control.Monad (unless, when, zipWithM)+import Control.Monad (when, zipWithM) import Data.Either (partitionEithers)-import Data.Foldable (Foldable(elem, foldl', foldr, length, null), any, foldlM)+import Data.Foldable (Foldable(elem, foldl', foldr, length, null), any, foldlM, mapM_) import Data.Function (on) import Data.List (groupBy, partition, sortBy) import Data.Maybe (fromMaybe, isJust, isNothing)@@ -262,16 +262,16 @@ pure $ squealOp `AppE` leftQuery `AppE` rightQuery PGT_AST.ValuesSimpleSelect valuesClause -> do- unless- ( isNothing maybeSortClause- && isNothing maybeSelectLimit- && isNothing maybeForLockingClause+ when+ ( isJust maybeSortClause+ || isJust maybeSelectLimit+ || isJust maybeForLockingClause ) $ fail $ "ORDER BY / OFFSET / LIMIT / FOR UPDATE etc. not supported with VALUES clause " <> "in this translation yet."- renderedValues <- renderValuesClauseToNP cteNames maybeColAliases valuesClause- pure $ VarE 'S.values_ `AppE` renderedValues+ valuesExp <- renderValuesClauseToNP cteNames maybeColAliases valuesClause+ pure valuesExp PGT_AST.NormalSimpleSelect maybeTargeting maybeIntoClause@@ -429,45 +429,63 @@ -> PGT_AST.ValuesClause -> Q Exp renderValuesClauseToNP cteNames maybeColAliases (firstRowExps NE.:| restRowExps) = do- unless (null restRowExps) $- fail $- "Multi-row VALUES clause requires S.values, this translation "- <> "currently supports single row S.values_."- convertRowToNP firstRowExps- where- colAliasTexts = fmap (fmap getIdentText . NE.toList) maybeColAliases+ let+ firstRowList = NE.toList firstRowExps+ restRowsLists = fmap NE.toList restRowExps - convertRowToNP :: NE.NonEmpty PGT_AST.AExpr -> Q Exp- convertRowToNP exprs = do- let- exprList = NE.toList exprs- aliasTexts <-- case colAliasTexts of- Just aliases ->- if length aliases == length exprList- then pure aliases- else- fail- "Number of column aliases in CTE does not match number of columns in VALUES clause."- Nothing -> pure $ fmap (Text.pack . ("_column" <>) . show) [1 :: Int ..]- go (zip exprList aliasTexts)- where- go :: [(PGT_AST.AExpr, Text.Text)] -> Q Exp- go [] = pure $ ConE 'S.Nil- go ((expr, aliasText) : fs) = do- renderedExpr <- renderPGTAExpr cteNames expr- let- aliasedExp = VarE 'S.as `AppE` renderedExpr `AppE` LabelE (Text.unpack aliasText)- restExp <- go fs- pure $ ConE '(S.:*) `AppE` aliasedExp `AppE` restExp+ -- Determine column aliases based on provided aliases or synthesize.+ aliasTexts <-+ case fmap (fmap getIdentText . NE.toList) maybeColAliases of+ Just aliases ->+ if length aliases == length firstRowList+ then pure aliases+ else+ fail+ "Number of column aliases does not match number of columns in VALUES clause."+ Nothing ->+ pure $ fmap (Text.pack . ("_column" <>) . show) [1 :: Int ..] + -- Validate all rows have consistent arity.+ let expectedLen = length firstRowList+ let checkLen xs = if length xs == expectedLen+ then pure ()+ else fail "Mismatched number of columns across VALUES rows."+ mapM_ checkLen restRowsLists + -- Helper to convert a row to an NP using the established aliases.+ let convertRow :: [PGT_AST.AExpr] -> Q Exp+ convertRow exprs = go (zip exprs aliasTexts)+ where+ go :: [(PGT_AST.AExpr, Text.Text)] -> Q Exp+ go [] = pure $ ConE 'S.Nil+ go ((expr, aliasText) : fs) = do+ renderedExpr <- renderPGTAExpr cteNames expr+ let aliasedExp = VarE 'S.as `AppE` renderedExpr `AppE` LabelE (Text.unpack aliasText)+ restExp <- go fs+ pure $ ConE '(S.:*) `AppE` aliasedExp `AppE` restExp++ firstRowNP <- convertRow firstRowList+ case restRowsLists of+ [] -> pure $ VarE 'S.values_ `AppE` firstRowNP+ more -> do+ moreNPs <- mapM convertRow more+ pure $ VarE 'S.values `AppE` firstRowNP `AppE` ListE moreNPs++ renderPGTForLockingClauseItems :: PGT_AST.ForLockingClause -> Q [Exp] renderPGTForLockingClauseItems = \case+ -- PostgreSQL's `FOR READ ONLY` does not acquire row-level locks and is+ -- effectively equivalent to the absence of a row-locking clause for+ -- SELECT. Squeal does not expose a row-level "read only" lock; instead,+ -- read-only behavior is an AccessMode on the transaction+ -- (TransactionMode { accessMode = ReadOnly }).+ --+ -- We therefore accept and ignore `FOR READ ONLY` here so that the quoted+ -- SQL compiles and renders identically to the same query without the+ -- clause. To enforce read-only semantics, execute the statement inside a+ -- transaction whose AccessMode is ReadOnly. PGT_AST.ReadOnlyForLockingClause ->- fail $- "FOR READ ONLY is not supported as a row-level locking "- <> "clause by Squeal-QQ."+ pure [] PGT_AST.ItemsForLockingClause itemsNe -> mapM renderPGTForLockingItem (NE.toList itemsNe) @@ -788,6 +806,21 @@ PGT_AST.RelationExprTableRef relationExpr maybeAliasClause sampleClause -> do when (isJust sampleClause) $ fail "TABLESAMPLE clause is not supported yet." renderPGTRelationExprTableRef cteNames relationExpr maybeAliasClause+ -- Support subqueries (including VALUES ...) in FROM with optional column aliases+ PGT_AST.SelectTableRef isLateral selectWithParens maybeAliasClause -> do+ when isLateral $ fail "LATERAL subqueries are not supported yet."+ -- Alias is required for subqueries in FROM in PostgreSQL; enforce here+ (aliasStr, maybeColAliases) <-+ case maybeAliasClause of+ Just (PGT_AST.AliasClause _ aliasIdent maybeCols) -> do+ let aliasTxt = Text.unpack (getIdentText aliasIdent)+ pure (aliasTxt, maybeCols)+ Nothing -> fail "Subquery in FROM requires an alias."+ -- Propagate any provided column aliases down into the subquery translation+ subqueryExp <- toSquealSelectWithParens cteNames maybeColAliases selectWithParens+ -- Treat the subquery as a derived table in the FROM clause+ -- Using S.subquery (as subquery alias)+ pure $ VarE 'S.subquery `AppE` (VarE 'S.as `AppE` subqueryExp `AppE` LabelE aliasStr) PGT_AST.JoinTableRef joinedTable maybeAliasClause -> -- If `maybeAliasClause` is Just, it means `(JOIN_TABLE) AS alias`. -- Squeal's direct join combinators don't alias the *result* of the join.
test/test.hs view
@@ -533,6 +533,68 @@ squealRendering = "SELECT * FROM (VALUES (1, (E'text_val' :: text))) AS t (\"num\", \"txt\")" checkStatement squealRendering statement + it "values (1, 'a'), (2, 'b')" $ do+ let+ statement+ :: Statement+ DB+ ()+ ( Field "_column1" Int64+ , ( Field "_column2" Text+ , ()+ )+ )+ statement = [ssql| values (1, 'a'), (2, 'b') |]+ squealRendering :: Text+ squealRendering =+ "SELECT * FROM (VALUES (1, (E'a' :: text)), (2, (E'b' :: text))) AS t (\"_column1\", \"_column2\")"+ checkStatement squealRendering statement++ it "values (1, 'a')" $ do+ let+ statement+ :: Statement+ DB+ ()+ ( Field "_column1" Int64+ , ( Field "_column2" Text+ , ()+ )+ )+ statement = [ssql| values (1, 'a') |]+ squealRendering :: Text+ squealRendering =+ "SELECT * FROM (VALUES (1, (E'a' :: text))) AS t (\"_column1\", \"_column2\")"+ checkStatement squealRendering statement++ it "select * from (values (1, 'a'), (2, 'b')) as t (n, s)" $ do+ let+ statement+ :: Statement+ DB+ ()+ ( Field "n" Int64+ , ( Field "s" Text+ , ()+ )+ )+ statement =+ [ssql| select * from (values (1, 'a'), (2, 'b')) as t (n, s) |]+ squealRendering :: Text+ squealRendering =+ "SELECT * FROM (SELECT * FROM (VALUES (1, (E'a' :: text)), (2, (E'b' :: text))) AS t (\"n\", \"s\")) AS \"t\""+ checkStatement squealRendering statement++ -- Selecting FROM a subquery (VALUES ...) alias is not yet supported by+ -- the translator (SelectTableRef). This can be added in a follow-up.+++ -- Negative tests (compile-time failures) cannot be included in the+ -- test suite because the quasiquoter fails during compilation.++ -- Selecting FROM a subquery (VALUES ...) alias is not yet supported by+ -- the translator (SelectTableRef). This can be added in a follow-up.+ describe "group by" $ do it "select name from users group by name" $ do let@@ -1806,6 +1868,71 @@ statement = [ssql| select * from users for update |] squealRendering :: Text squealRendering = "SELECT * FROM \"users\" AS \"users\" FOR UPDATE"+ checkStatement squealRendering statement++ it "select * from users for read only" $ do+ let+ statement+ :: Statement+ DB+ ()+ ( Field "id" Text+ , ( Field "name" Text+ , ( Field "employee_id" UUID+ , ( Field "bio" (Maybe Text)+ , ()+ )+ )+ )+ )+ statement = [ssql| select * from users for read only |]+ squealRendering :: Text+ squealRendering = "SELECT * FROM \"users\" AS \"users\""+ checkStatement squealRendering statement++ it "select * from users where name = 'bob' for read only" $ do+ let+ statement+ :: Statement+ DB+ ()+ ( Field "id" Text+ , ( Field "name" Text+ , ( Field "employee_id" UUID+ , ( Field "bio" (Maybe Text)+ , ()+ )+ )+ )+ )+ statement = [ssql| select * from users where name = 'bob' for read only |]+ squealRendering :: Text+ squealRendering = "SELECT * FROM \"users\" AS \"users\" WHERE (\"name\" = (E'bob' :: text))"+ checkStatement squealRendering statement++ it "with users_cte as (select * from users) select * from users_cte for read only" $ do+ let+ statement+ :: Statement+ DB+ ()+ ( Field "id" Text+ , ( Field "name" Text+ , ( Field "employee_id" UUID+ , ( Field "bio" (Maybe Text)+ , ()+ )+ )+ )+ )+ statement =+ [ssql|+ with users_cte as (select * from users)+ select * from users_cte for read only+ |]+ squealRendering :: Text+ squealRendering =+ "WITH \"users_cte\" AS (SELECT * FROM \"users\" AS \"users\") SELECT * FROM \"users_cte\" AS \"users_cte\"" checkStatement squealRendering statement it "select * from jsonb_test" $ do