packages feed

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 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