Repository: edgedb/edgedb

Issue:
Slicing behaves incorrectly on NULL empty sets
Another instance of a classic edgedb bug.

Slicing is a regular (non-optional) operation, and so empty set inputs should result in an empty set output, like such:
```
edgedb> select [1,2,3][1:<int64>{}];
[]
```

But empty sets that get represented as NULL produce output as if the argument was omitted:
```
edgedb> select [1,2,3][1:<optional int64>$0];
Parameter <int64>$0 (Ctrl+D for empty set `{}`): 
[[2, 3]]
```

The root of the issue is that all slicing (missing arguments or not) is done by calling a `_slice` function, and we represent missing bounds as NULL.


Discussion:
And json slicing behaves incorrectly on NULL subjects:
```
edgedb> select (<optional json>$0)[0:];
Parameter <json>$0 (Ctrl+D for empty set `{}`): 
edgedb error: InvalidValueError: cannot slice JSON UNKNOWN
  Hint: Slicing is only available for JSON arrays and strings.
```

Resolving patch:
diff --git a/edb/pgsql/compiler/expr.py b/edb/pgsql/compiler/expr.py
--- a/edb/pgsql/compiler/expr.py
+++ b/edb/pgsql/compiler/expr.py
@@ -276,29 +276,40 @@ def compile_IndexIndirection(
 
 @dispatch.compile.register(irast.SliceIndirection)
 def compile_SliceIndirection(
-        expr: irast.SliceIndirection, *,
-        ctx: context.CompilerContextLevel) -> pgast.BaseExpr:
+    expr: irast.SliceIndirection, *, ctx: context.CompilerContextLevel
+) -> pgast.BaseExpr:
+
     # Handle Expr[Index], where Expr may be std::str, array<T> or
     # std::json. For strings we translate this into substr calls.
     # Arrays use the native slice syntax. JSON is handled by a
     # combination of unnesting aggregation and array slicing.
     with ctx.new() as subctx:
+
+        # Postgres arrays use 32bit indexes, so EdgeDB inherits this limitation
+        def compile_and_cast_int(expr: irast.Base) -> pgast.BaseExpr:
+            pg_expr = dispatch.compile(expr, ctx=subctx)
+            return pgast.TypeCast(
+                arg=pg_expr, type_name=pgast.TypeName(name=("integer",))
+            )
+
         subctx.expr_exposed = False
         subj = dispatch.compile(expr.expr, ctx=subctx)
+
         if expr.start is None:
-            start: pgast.BaseExpr = pgast.NullConstant()
+            start: pgast.BaseExpr = pgast.LiteralExpr(expr="0")
         else:
-            start = dispatch.compile(expr.start, ctx=subctx)
+            start = compile_and_cast_int(expr.start)
+
         if expr.stop is None:
-            stop: pgast.BaseExpr = pgast.NullConstant()
+            # Max index in EdgeQL is 2^32-2 because during conversion
+            # to Postgres index, we add 1 to the stop and then subtract one.
+            stop: pgast.BaseExpr = pgast.LiteralExpr(expr=str(2**31 - 2))
         else:
-            stop = dispatch.compile(expr.stop, ctx=subctx)
+            stop = compile_and_cast_int(expr.stop)
 
     result = pgast.FuncCall(
-        name=('edgedb', '_slice'),
-        args=[subj, start, stop]
+        name=("edgedb", "_slice"), args=[subj, start, stop]
     )
-
     return result
 
 
diff --git a/edb/pgsql/metaschema.py b/edb/pgsql/metaschema.py
--- a/edb/pgsql/metaschema.py
+++ b/edb/pgsql/metaschema.py
@@ -1950,30 +1950,23 @@ def __init__(self) -> None:
 
 class ArraySliceFunction(dbops.Function):
     """Get an array slice."""
-    text = '''
+
+    text = """
         SELECT
-            CASE
-                WHEN start IS NULL THEN
-                    val[:edgedb._normalize_array_index(
-                            stop, array_upper(val, 1)) - 1]
-                WHEN stop IS NULL THEN
-                    val[edgedb._normalize_array_index(
-                            start, array_upper(val, 1)):]
-                ELSE
-                    val[edgedb._normalize_array_index(
-                            start, array_upper(val, 1)):
-                        edgedb._normalize_array_index(
-                            stop, array_upper(val, 1)) - 1]
-            END
-    '''
+            val[edgedb._normalize_array_index(start, cardinality(val)):
+                edgedb._normalize_array_index(stop, cardinality(val)) - 1]
+    """
 
     def __init__(self) -> None:
         super().__init__(
-            name=('edgedb', '_slice'),
-            args=[('val', ('anyarray',)), ('start', ('bigint',)),
-                  ('stop', ('bigint',))],
-            returns=('anyarray',),
-            volatility='immutable',
+            name=("edgedb", "_slice"),
+            args=[
+                ("val", ("anyarray",)),
+                ("start", ("int",)),
+                ("stop", ("int",)),
+            ],
+            returns=("anyarray",),
+            volatility="immutable",
             text=self.text,
         )
 
@@ -2043,23 +2036,28 @@ def __init__(self) -> None:
 
 class SubstrProxyFunction(dbops.Function):
     """Same as substr, but interpret negative length as 0 instead."""
-    text = r'''
+
+    text = r"""
         SELECT
             CASE
                 WHEN length < 0 THEN ''
                 ELSE substr(val, start::int, length)
             END
-    '''
+    """
 
     def __init__(self) -> None:
         super().__init__(
-            name=('edgedb', '_substr'),
-            args=[('val', ('anyelement',)), ('start', ('bigint',)),
-                  ('length', ('int',))],
-            returns=('anyelement',),
-            volatility='immutable',
+            name=("edgedb", "_substr"),
+            args=[
+                ("val", ("anyelement",)),
+                ("start", ("int",)),
+                ("length", ("int",)),
+            ],
+            returns=("anyelement",),
+            volatility="immutable",
             strict=True,
-            text=self.text)
+            text=self.text,
+        )
 
 
 class LengthStringProxyFunction(dbops.Function):
@@ -2096,45 +2094,32 @@ def __init__(self) -> None:
 
 class StringSliceImplFunction(dbops.Function):
     """Get a string slice."""
-    text = r'''
+
+    text = r"""
         SELECT
-            CASE
-                WHEN start IS NULL THEN
-                    edgedb._substr(
-                        val,
-                        1,
-                        edgedb._normalize_array_index(
-                            stop, edgedb._length(val)) - 1
-                    )
-                WHEN stop IS NULL THEN
-                    substr(
-                        val,
-                        edgedb._normalize_array_index(
-                            start, edgedb._length(val))
-                    )
-                ELSE
-                    edgedb._substr(
-                        val,
-                        edgedb._normalize_array_index(
-                            start, edgedb._length(val)),
-                        edgedb._normalize_array_index(
-                            stop, edgedb._length(val)) -
-                        edgedb._normalize_array_index(
-                            start, edgedb._length(val))
-                    )
-            END
-    '''
+            edgedb._substr(
+                val,
+                edgedb._normalize_array_index(
+                    start, edgedb._length(val)),
+                edgedb._normalize_array_index(
+                    stop, edgedb._length(val)) -
+                edgedb._normalize_array_index(
+                    start, edgedb._length(val))
+            )
+    """
 
     def __init__(self) -> None:
         super().__init__(
-            name=('edgedb', '_str_slice'),
+            name=("edgedb", "_str_slice"),
             args=[
-                ('val', ('anyelement',)),
-                ('start', ('bigint',)), ('stop', ('bigint',))
+                ("val", ("anyelement",)),
+                ("start", ("int",)),
+                ("stop", ("int",)),
             ],
-            returns=('anyelement',),
-            volatility='immutable',
-            text=self.text)
+            returns=("anyelement",),
+            volatility="immutable",
+            text=self.text,
+        )
 
 
 class StringSliceFunction(dbops.Function):
@@ -2147,8 +2132,7 @@ def __init__(self) -> None:
         super().__init__(
             name=('edgedb', '_slice'),
             args=[
-                ('val', ('text',)),
-                ('start', ('bigint',)), ('stop', ('bigint',))
+                ('val', ('text',)), ('start', ('int',)), ('stop', ('int',))
             ],
             returns=('text',),
             volatility='immutable',
@@ -2165,8 +2149,7 @@ def __init__(self) -> None:
         super().__init__(
             name=('edgedb', '_slice'),
             args=[
-                ('val', ('bytea',)),
-                ('start', ('bigint',)), ('stop', ('bigint',))
+                ('val', ('bytea',)), ('start', ('int',)), ('stop', ('int',))
             ],
             returns=('bytea',),
             volatility='immutable',
@@ -2298,20 +2281,22 @@ def __init__(self) -> None:
 
 class JSONSliceFunction(dbops.Function):
     """Get a JSON array slice."""
-    text = r'''
+
+    text = r"""
         SELECT
-            CASE jsonb_typeof(val)
-            WHEN 'array' THEN (
-                to_jsonb(_slice(
+            CASE
+            WHEN val IS NULL THEN NULL
+            WHEN jsonb_typeof(val) = 'array' THEN (
+                to_jsonb(edgedb._slice(
                     (
-                        SELECT array_agg(value)
+                        SELECT coalesce(array_agg(value), '{}'::jsonb[])
                         FROM jsonb_array_elements(val)
                     ),
                     start, stop
                 ))
             )
-            WHEN 'string' THEN (
-                to_jsonb(_slice(val#>>'{}', start, stop))
+            WHEN jsonb_typeof(val) = 'string' THEN (
+                to_jsonb(edgedb._slice(val#>>'{}', start, stop))
             )
             ELSE
                 edgedb.raise(
@@ -2327,17 +2312,21 @@ class JSONSliceFunction(dbops.Function):
                     )
                 )
             END
-    '''
+    """
 
     def __init__(self) -> None:
         super().__init__(
-            name=('edgedb', '_slice'),
-            args=[('val', ('jsonb',)), ('start', ('bigint',)),
-                  ('stop', ('bigint',))],
-            returns=('jsonb',),
+            name=("edgedb", "_slice"),
+            args=[
+                ("val", ("jsonb",)),
+                ("start", ("int",)),
+                ("stop", ("int",)),
+            ],
+            returns=("jsonb",),
             # Same volatility as to_jsonb (stable)
-            volatility='stable',
-            text=self.text)
+            volatility="stable",
+            text=self.text,
+        )
 
 
 # We need custom casting functions for various datetime scalars in
diff --git a/edb/pgsql/patches.py b/edb/pgsql/patches.py
--- a/edb/pgsql/patches.py
+++ b/edb/pgsql/patches.py
@@ -248,4 +248,148 @@
     ('sql', '''
         INSERT INTO edgedb._dml_dummy VALUES (0, false)
     '''),
+
+    ('sql', '''
+DROP FUNCTION edgedb._slice(anyarray, bigint, bigint);
+    '''),
+    ('sql', '''
+CREATE FUNCTION edgedb._slice(val anyarray, start integer, stop integer)
+ RETURNS anyarray
+ LANGUAGE sql
+ IMMUTABLE
+AS $function$
+SELECT
+    val[edgedb._normalize_array_index(start, cardinality(val)):
+        edgedb._normalize_array_index(stop, cardinality(val)) - 1]
+$function$
+    '''),
+
+    ('sql', '''
+CREATE OR REPLACE FUNCTION edgedb._str_slice(
+    val anyelement, start integer, stop integer
+)
+ RETURNS anyelement
+ LANGUAGE sql
+ IMMUTABLE
+AS $function$
+SELECT
+    edgedb._substr(
+        val,
+        edgedb._normalize_array_index(
+            start, edgedb._length(val)),
+        edgedb._normalize_array_index(
+            stop, edgedb._length(val)) -
+        edgedb._normalize_array_index(
+            start, edgedb._length(val))
+    )
+$function$
+    '''),
+
+    ('sql', '''
+DROP FUNCTION edgedb._substr(anyelement, bigint, int)
+    '''),
+    ('sql', '''
+CREATE FUNCTION edgedb._substr(val anyelement, start integer, length integer)
+ RETURNS anyelement
+ LANGUAGE sql
+ IMMUTABLE STRICT
+AS $function$
+SELECT
+    CASE
+        WHEN length < 0 THEN ''
+        ELSE substr(val, start::int, length)
+    END
+$function$
+    '''),
+
+    ('sql', '''
+DROP FUNCTION edgedb._str_slice(anyelement, bigint, bigint)
+    '''),
+    ('sql', '''
+CREATE FUNCTION edgedb._str_slice(val anyelement, start integer, stop integer)
+ RETURNS anyelement
+ LANGUAGE sql
+ IMMUTABLE
+AS $function$
+SELECT
+    edgedb._substr(
+        val,
+        edgedb._normalize_array_index(
+            start, edgedb._length(val)),
+        edgedb._normalize_array_index(
+            stop, edgedb._length(val)) -
+        edgedb._normalize_array_index(
+            start, edgedb._length(val))
+    )
+$function$
+    '''),
+
+    ('sql', '''
+DROP FUNCTION edgedb._slice(text, bigint, bigint)
+    '''),
+    ('sql', '''
+CREATE FUNCTION edgedb._slice(val text, start integer, stop integer)
+ RETURNS text
+ LANGUAGE sql
+ IMMUTABLE
+AS $function$
+SELECT edgedb._str_slice(val, start, stop)
+$function$
+    '''),
+
+    ('sql', '''
+DROP FUNCTION edgedb._slice(bytea, bigint, bigint)
+    '''),
+    ('sql', '''
+CREATE FUNCTION edgedb._slice(val bytea, start integer, stop integer)
+ RETURNS bytea
+ LANGUAGE sql
+ IMMUTABLE
+AS $function$
+SELECT edgedb._str_slice(val, start, stop)
+$function$
+    '''),
+
+    ('sql', '''
+DROP FUNCTION edgedb._slice(jsonb, bigint, bigint)
+    '''),
+    ('sql', '''
+CREATE OR REPLACE FUNCTION edgedb._slice(
+    val jsonb, start integer, stop integer
+)
+ RETURNS jsonb
+ LANGUAGE sql
+ STABLE
+AS $function$
+SELECT
+    CASE
+    WHEN val IS NULL THEN NULL
+    WHEN jsonb_typeof(val) = 'array' THEN (
+        to_jsonb(edgedb._slice(
+            (
+                SELECT coalesce(array_agg(value), '{}'::jsonb[])
+                FROM jsonb_array_elements(val)
+            ),
+            start, stop
+        ))
+    )
+    WHEN jsonb_typeof(val) = 'string' THEN (
+        to_jsonb(edgedb._slice(val#>>'{}', start, stop))
+    )
+    ELSE
+        edgedb.raise(
+            NULL::jsonb,
+            'wrong_object_type',
+            msg => (
+                'cannot slice JSON '
+                || coalesce(jsonb_typeof(val), 'UNKNOWN')
+            ),
+            detail => (
+                '{"hint":"Slicing is only available for JSON arrays'
+                || ' and strings."}'
+            )
+        )
+    END
+$function$
+    '''),
 ]


Test patch:
diff --git a/tests/test_edgeql_select.py b/tests/test_edgeql_select.py
--- a/tests/test_edgeql_select.py
+++ b/tests/test_edgeql_select.py
@@ -5171,6 +5171,113 @@ async def test_edgeql_select_slice_03(self):
             }],
         )
 
+    async def test_edgeql_select_slice_04(self):
+
+        await self.assert_query_result(
+            r"""
+            select [1,2,3,4,5][1:];
+            """,
+            [[2, 3, 4, 5]],
+        )
+
+        await self.assert_query_result(
+            r"""
+            select [1,2,3,4,5][:3];
+            """,
+            [[1, 2, 3]],
+        )
+
+        await self.assert_query_result(
+            r"""
+            select [1,2,3][1:<int64>{}];
+            """,
+            [],
+        )
+
+        # try to trick the compiler and to pass NULL into edgedb._slice
+        await self.assert_query_result(
+            r"""
+            select [1,2,3][1:<optional int64>$0];
+            """,
+            [],
+            variables=(None,),
+        )
+
+        await self.assert_query_result(
+            r"""
+            select [1,2,3][<optional int64>$0:2];
+            """,
+            [],
+            variables=(None,),
+        )
+
+        await self.assert_query_result(
+            r"""
+            select [1,2,3][<optional int64>$0:<optional int64>$1];
+            """,
+            [],
+            variables=(None, None,),
+        )
+
+        self.assertEqual(
+            await self.con.query(
+                r"""
+                select to_json('[true, 3, 4, null]')[1:];
+                """
+            ),
+            edgedb.Set(('[3, 4, null]',))
+        )
+
+        self.assertEqual(
+            await self.con.query(
+                r"""
+                select to_json('[true, 3, 4, null]')[:2];
+                """
+            ),
+            edgedb.Set(('[true, 3]',))
+        )
+
+        await self.assert_query_result(
+            r"""
+            select (<optional json>$0)[2:];
+            """,
+            [],
+            variables=(None,),
+        )
+
+        self.assertEqual(
+            await self.con.query(
+                r"""
+                select to_json('"hello world"')[2:];
+                """
+            ),
+            edgedb.Set(('"llo world"',))
+        )
+
+        self.assertEqual(
+            await self.con.query(
+                r"""
+                select to_json('"hello world"')[:4];
+                """
+            ),
+            edgedb.Set(('"hell"',))
+        )
+
+        await self.assert_query_result(
+            r"""
+            select (<array<str>>[])[0:];
+            """,
+            [[]]
+        )
+
+        await self.assert_query_result(
+            r'''select to_json('[]')[0:];''',
+            # JSON:
+            [[]],
+            # Binary:
+            ['[]'],
+        )
+
     async def test_edgeql_select_tuple_01(self):
         await self.assert_query_result(
             r"""