Skip to content

Commit e184825

Browse files
authored
Merge pull request #103 from cipherstash/orderby-function
Enhance functions for operator-free environments like Supabase
2 parents ea7a97a + 1505695 commit e184825

File tree

8 files changed

+232
-21
lines changed

8 files changed

+232
-21
lines changed

.github/workflows/test-eql.yml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,17 @@ on:
55
- main
66
paths:
77
- ".github/workflows/test-eql.yml"
8-
- "src/*.sql"
9-
- "sql/*.sql"
8+
- "src/**/*.sql"
9+
- "sql/**/*.sql"
1010
- "tests/**/*"
1111
- "tasks/**/*"
1212

1313
pull_request:
14-
branches:
15-
- main
14+
# run on all pull requests
1615
paths:
1716
- ".github/workflows/test-eql.yml"
18-
- "src/*.sql"
19-
- "sql/*.sql"
17+
- "src/**/*.sql"
18+
- "sql/**/*.sql"
2019
- "tests/**/*"
2120
- "tasks/**/*"
2221

SUPABASE.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Supabase
2+
3+
4+
## No operators, no problems
5+
6+
Supabase [does not currently support](https://github.com/supabase/supautils/issues/72) custom operators.
7+
The EQL operator functions can be used in this situation.
8+
9+
In EQL, PostgreSQL operators are an alias for a function, so the implementation and behaviour remains the same across operators and functions.
10+
11+
| Operator | Function | Example |
12+
| -------- | -------------------------------------------------- | ----------------------------------------------------------------- |
13+
| `=` | `eql_v1.eq(eql_v1_encrypted, eql_v1_encrypted)` | `SELECT * FROM users WHERE eql_v1.eq(encrypted_email, $1)`<br> |
14+
| `<>` | `eql_v1.neq(eql_v1_encrypted, eql_v1_encrypted)` | `SELECT * FROM users WHERE eql_v1.neq(encrypted_email, $1)`<br> |
15+
| `<` | `eql_v1.lt(eql_v1_encrypted, eql_v1_encrypted)` | `SELECT * FROM users WHERE eql_v1.lt(encrypted_email, $1)`<br> |
16+
| `<=` | `eql_v1.lte(eql_v1_encrypted, eql_v1_encrypted)` | `SELECT * FROM users WHERE eql_v1.lte(encrypted_email, $1)`<br> |
17+
| `>` | `eql_v1.gt(eql_v1_encrypted, eql_v1_encrypted)` | `SELECT * FROM users WHERE eql_v1.gt(encrypted_email, $1)`<br> |
18+
| `>=` | `eql_v1.gte(eql_v1_encrypted, eql_v1_encrypted)` | `SELECT * FROM users WHERE eql_v1.gte(encrypted_email, $1)`<br> |
19+
| `~~` | `eql_v1.like(eql_v1_encrypted, eql_v1_encrypted)` | `SELECT * FROM users WHERE eql_v1.like(encrypted_email, $1)`<br> |
20+
| `~~*` | `eql_v1.ilike(eql_v1_encrypted, eql_v1_encrypted)` | `SELECT * FROM users WHERE eql_v1.ilike(encrypted_email, $1)`<br> |
21+
| `LIKE` | `eql_v1.like(eql_v1_encrypted, eql_v1_encrypted)` | `SELECT * FROM users WHERE eql_v1.like(encrypted_email, $1)`<br> |
22+
| `ILIKE` | `eql_v1.ilike(eql_v1_encrypted, eql_v1_encrypted)` | `SELECT * FROM users WHERE eql_v1.ilike(encrypted_email, $1)`<br> |
23+
24+
### Example SQL Statements
25+
26+
#### Equality `=`
27+
28+
29+
**Operator**
30+
```sql
31+
SELECT * FROM users WHERE encrypted_email = $1
32+
```
33+
34+
**Function**
35+
```sql
36+
SELECT * FROM users WHERE eql_v1.eq(encrypted_email, $1)
37+
```
38+
39+
40+
#### Like & ILIKE `~~, ~~*`
41+
42+
43+
**Operator**
44+
```sql
45+
SELECT * FROM users WHERE encrypted_email LIKE $1
46+
```
47+
48+
**Function**
49+
```sql
50+
SELECT * FROM users WHERE eql_v1.like(encrypted_email, $1)
51+
```
52+
53+
#### Case Sensitivity
54+
55+
The EQL `eql_v1.like` and `eql_v1.ilike` functions are equivalent.
56+
57+
The behaviour of EQL's encrypted `LIKE` operators is slightly different to the behaviour of PostgreSQL's `LIKE` operator.
58+
In EQL, the `LIKE` operator can be used on `match` indexes.
59+
Case sensitivity is determined by the [index term configuration](./docs/reference/INDEX.md#options-for-match-indexes-opts) of `match` indexes.
60+
A `match` index term can be configured to enable case sensitive searches with token filters (for example, `downcase` and `upcase`).
61+
The data is encrypted based on the index term configuration.
62+
The `LIKE` operation is always the same, even if the data is tokenised differently.
63+
The different operators are kept to preserve the semantics of SQL statements in client applications.
64+
65+
### `ORDER BY`
66+
67+
Ordering requires wrapping the ordered column in the `eql_v1.order_by` function, like this:
68+
69+
```sql
70+
SELECT * FROM users ORDER BY eql_v1.order_by(encrypted_created_at) DESC
71+
```
72+
73+
PostgreSQL uses operators when handling `ORDER BY` operations. The `eql_v1.order_by` function behaves in
74+

src/encryptindex/functions_test.sql

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
TRUNCATE TABLE eql_v1_configuration;
99

1010
-- Create a table with a plaintext column
11-
-- DROP TABLE IF EXISTS users;
11+
DROP TABLE IF EXISTS users;
1212
CREATE TABLE users
1313
(
1414
id bigint GENERATED ALWAYS AS IDENTITY,
@@ -63,7 +63,7 @@ $$ LANGUAGE plpgsql;
6363
TRUNCATE TABLE eql_v1_configuration;
6464

6565
-- Create a table with multiple plaintext columns
66-
-- DROP TABLE IF EXISTS users;
66+
DROP TABLE IF EXISTS users;
6767
CREATE TABLE users
6868
(
6969
id bigint GENERATED ALWAYS AS IDENTITY,
@@ -119,7 +119,7 @@ $$ LANGUAGE plpgsql;
119119
-- The schema should be validated first.
120120
-- Users table does not exist, so should fail.
121121
-- -----------------------------------------------
122-
-- DROP TABLE IF EXISTS users;
122+
DROP TABLE IF EXISTS users;
123123
TRUNCATE TABLE eql_v1_configuration;
124124

125125

@@ -148,7 +148,7 @@ $$ LANGUAGE plpgsql;
148148
--
149149
-- Schema validation is skipped
150150
-- -----------------------------------------------
151-
-- DROP TABLE IF EXISTS users;
151+
DROP TABLE IF EXISTS users;
152152
TRUNCATE TABLE eql_v1_configuration;
153153

154154
DO $$
@@ -194,7 +194,7 @@ INSERT INTO eql_v1_configuration (state, data) VALUES (
194194
);
195195

196196
-- Create a table with plaintext and encrypted columns
197-
-- DROP TABLE IF EXISTS users;
197+
DROP TABLE IF EXISTS users;
198198
CREATE TABLE users
199199
(
200200
id bigint GENERATED ALWAYS AS IDENTITY,
@@ -244,7 +244,7 @@ INSERT INTO eql_v1_configuration (state, data) VALUES (
244244
);
245245

246246
-- Create a table with plaintext and jsonb column
247-
-- DROP TABLE IF EXISTS users;
247+
DROP TABLE IF EXISTS users;
248248
CREATE TABLE users
249249
(
250250
id bigint GENERATED ALWAYS AS IDENTITY,
@@ -295,7 +295,7 @@ INSERT INTO eql_v1_configuration (state, data) VALUES (
295295

296296

297297
-- Create a table with multiple plaintext columns
298-
-- DROP TABLE IF EXISTS users;
298+
DROP TABLE IF EXISTS users;
299299
CREATE TABLE users
300300
(
301301
id bigint GENERATED ALWAYS AS IDENTITY,

src/operators/order_by.sql

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
-- REQUIRE: src/encrypted/types.sql
2+
-- REQUIRE: src/ore/types.sql
3+
-- REQUIRE: src/ore/functions.sql
4+
-- REQUIRE: src/ore/operators.sql
5+
-- REQUIRE: src/ore_cllw_u64_8/types.sql
6+
-- REQUIRE: src/ore_cllw_u64_8/functions.sql
7+
-- REQUIRE: src/ore_cllw_u64_8/operators.sql
8+
9+
-- order_by function for ordering when operators are not available.
10+
--
11+
-- There are multiple index terms that provide equality comparisons
12+
-- - ore_cllw_u64_8
13+
-- - ore_cllw_var_8
14+
-- - ore_64_8_v1
15+
--
16+
-- We check these index terms in this order and use the first one that exists for both parameters
17+
--
18+
--
19+
20+
-- DROP FUNCTION IF EXISTS eql_v1.order_by(a eql_v1_encrypted, b eql_v1_encrypted);
21+
22+
CREATE FUNCTION eql_v1.order_by(a eql_v1_encrypted)
23+
RETURNS eql_v1.ore_64_8_v1
24+
IMMUTABLE STRICT PARALLEL SAFE
25+
AS $$
26+
BEGIN
27+
BEGIN
28+
RETURN eql_v1.ore_64_8_v1(a);
29+
EXCEPTION WHEN OTHERS THEN
30+
-- PERFORM eql_v1.log('No ore_64_8_v1 index');
31+
END;
32+
33+
RETURN false;
34+
END;
35+
$$ LANGUAGE plpgsql;
36+
37+
-- TODO: make this work
38+
-- fails with jsonb format issue, which I think is due to the type casting
39+
--
40+
CREATE FUNCTION eql_v1.order_by_any(a anyelement)
41+
RETURNS anyelement
42+
IMMUTABLE STRICT PARALLEL SAFE
43+
AS $$
44+
DECLARE
45+
e eql_v1_encrypted;
46+
result ALIAS FOR $0;
47+
BEGIN
48+
49+
e := a::eql_v1_encrypted;
50+
51+
BEGIN
52+
result := eql_v1.ore_cllw_u64_8(e);
53+
EXCEPTION WHEN OTHERS THEN
54+
-- PERFORM eql_v1.log('No ore_cllw_u64_8 index');
55+
END;
56+
57+
BEGIN
58+
result := eql_v1.ore_cllw_var_8(e);
59+
EXCEPTION WHEN OTHERS THEN
60+
-- PERFORM eql_v1.log('No ore_cllw_u64_8 index');
61+
END;
62+
63+
BEGIN
64+
result := eql_v1.ore_64_8_v1(e);
65+
EXCEPTION WHEN OTHERS THEN
66+
-- PERFORM eql_v1.log('No ore_64_8_v1 index');
67+
END;
68+
69+
RETURN result;
70+
END;
71+
$$ LANGUAGE plpgsql;
72+

src/operators/order_by_test.sql

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
\set ON_ERROR_STOP on
2+
3+
--
4+
-- ORE - ORDER BY ore_64_8_v1(eql_v1_encrypted)
5+
--
6+
DO $$
7+
DECLARE
8+
e eql_v1_encrypted;
9+
ore_term eql_v1_encrypted;
10+
BEGIN
11+
SELECT ore.e FROM ore WHERE id = 42 INTO ore_term;
12+
13+
PERFORM assert_count(
14+
'ORDER BY eql_v1.order_by(e) DESC',
15+
format('SELECT id FROM ore WHERE e < %L ORDER BY eql_v1.order_by(e) DESC', ore_term),
16+
41);
17+
18+
PERFORM assert_result(
19+
'ORDER BY eql_v1.order_by(e) DESC returns correct record',
20+
format('SELECT id FROM ore WHERE e < %L ORDER BY eql_v1.order_by(e) DESC LIMIT 1', ore_term),
21+
'41');
22+
23+
PERFORM assert_result(
24+
'ORDER BY eql_v1.order_by(e) ASC',
25+
format('SELECT id FROM ore WHERE e < %L ORDER BY eql_v1.order_by(e) ASC LIMIT 1', ore_term),
26+
'1');
27+
END;
28+
$$ LANGUAGE plpgsql;

src/operators/~~.sql

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,23 @@
1515

1616
-- DROP FUNCTION IF EXISTS eql_v1.match(a eql_v1_encrypted, b eql_v1_encrypted);
1717

18-
CREATE FUNCTION eql_v1.match(a eql_v1_encrypted, b eql_v1_encrypted)
18+
CREATE FUNCTION eql_v1.like(a eql_v1_encrypted, b eql_v1_encrypted)
1919
RETURNS boolean AS $$
2020
SELECT eql_v1.match(a) @> eql_v1.match(b);
2121
$$ LANGUAGE SQL;
2222

2323

24+
--
25+
-- Case sensitivity depends on the index term configuration
26+
-- Function preserves the SQL semantics
27+
--
28+
CREATE FUNCTION eql_v1.ilike(a eql_v1_encrypted, b eql_v1_encrypted)
29+
RETURNS boolean AS $$
30+
SELECT eql_v1.match(a) @> eql_v1.match(b);
31+
$$ LANGUAGE SQL;
32+
33+
34+
2435
-- DROP OPERATOR BEFORE FUNCTION
2536
-- DROP OPERATOR IF EXISTS ~~ (eql_v1_encrypted, eql_v1_encrypted);
2637
-- DROP OPERATOR IF EXISTS ~~* (eql_v1_encrypted, eql_v1_encrypted);
@@ -31,7 +42,7 @@ CREATE FUNCTION eql_v1."~~"(a eql_v1_encrypted, b eql_v1_encrypted)
3142
RETURNS boolean
3243
AS $$
3344
BEGIN
34-
RETURN eql_v1.match(a, b);
45+
RETURN eql_v1.like(a, b);
3546
END;
3647
$$ LANGUAGE plpgsql;
3748

@@ -65,7 +76,7 @@ CREATE FUNCTION eql_v1."~~"(a eql_v1_encrypted, b jsonb)
6576
RETURNS boolean
6677
AS $$
6778
BEGIN
68-
RETURN eql_v1.match(a, b::eql_v1_encrypted);
79+
RETURN eql_v1.like(a, b::eql_v1_encrypted);
6980
END;
7081
$$ LANGUAGE plpgsql;
7182

@@ -100,7 +111,7 @@ CREATE FUNCTION eql_v1."~~"(a jsonb, b eql_v1_encrypted)
100111
RETURNS boolean
101112
AS $$
102113
BEGIN
103-
RETURN eql_v1.match(a::eql_v1_encrypted, b);
114+
RETURN eql_v1.like(a::eql_v1_encrypted, b);
104115
END;
105116
$$ LANGUAGE plpgsql;
106117

src/operators/~~_test.sql

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,17 @@ DECLARE
8787
e := create_encrypted_json(i, 'm');
8888

8989
PERFORM assert_result(
90-
format('eql_v1.match(eql_v1_encrypted, eql_v1_encrypted)', i),
91-
format('SELECT e FROM encrypted WHERE eql_v1.match(e, %L);', e));
90+
format('eql_v1.like(eql_v1_encrypted, eql_v1_encrypted)', i),
91+
format('SELECT e FROM encrypted WHERE eql_v1.like(e, %L);', e));
9292

9393
end loop;
9494

9595
-- Partial match
9696
e := create_encrypted_json('m')::jsonb || '{"m": [10, 11]}';
9797

9898
PERFORM assert_result(
99-
'eql_v1.match(eql_v1_encrypted, eql_v1_encrypted)',
100-
format('SELECT e FROM encrypted WHERE eql_v1.match(e, %L);', e));
99+
'eql_v1.like(eql_v1_encrypted, eql_v1_encrypted)',
100+
format('SELECT e FROM encrypted WHERE eql_v1.like(e, %L);', e));
101101

102102
END;
103103
$$ LANGUAGE plpgsql;

src/ore/functions_test.sql

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,31 @@ DO $$
1111
'SELECT eql_v1.ore_64_8_v1(''{}''::jsonb)');
1212

1313
END;
14+
$$ LANGUAGE plpgsql;
15+
16+
--
17+
-- ORE - ORDER BY ore_64_8_v1(eql_v1_encrypted)
18+
--
19+
DO $$
20+
DECLARE
21+
e eql_v1_encrypted;
22+
ore_term eql_v1_encrypted;
23+
BEGIN
24+
SELECT ore.e FROM ore WHERE id = 42 INTO ore_term;
25+
26+
PERFORM assert_count(
27+
'ORDER BY eql_v1.ore_64_8_v1(e) DESC',
28+
format('SELECT id FROM ore WHERE e < %L ORDER BY eql_v1.ore_64_8_v1(e) DESC', ore_term),
29+
41);
30+
31+
PERFORM assert_result(
32+
'ORDER BY eql_v1.ore_64_8_v1(e) DESC returns correct record',
33+
format('SELECT id FROM ore WHERE e < %L ORDER BY eql_v1.ore_64_8_v1(e) DESC LIMIT 1', ore_term),
34+
'41');
35+
36+
PERFORM assert_result(
37+
'ORDER BY eql_v1.ore_64_8_v1(e) ASC',
38+
format('SELECT id FROM ore WHERE e < %L ORDER BY eql_v1.ore_64_8_v1(e) ASC LIMIT 1', ore_term),
39+
'1');
40+
END;
1441
$$ LANGUAGE plpgsql;

0 commit comments

Comments
 (0)