aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/design/001-language-v0.md5
-rw-r--r--src/compile.c22
-rw-r--r--tests/check.py110
3 files changed, 126 insertions, 11 deletions
diff --git a/docs/design/001-language-v0.md b/docs/design/001-language-v0.md
index cefb229..660abae 100644
--- a/docs/design/001-language-v0.md
+++ b/docs/design/001-language-v0.md
@@ -95,7 +95,7 @@ state documents: bytes by key # 키마다 바이트 값 하나. 런타�
input show = message by key # 키(와 선택적 기준 버전)를 가진 메시지
input edit = message by key {
- start: u32 # 필드는 u32와 마지막 bytes 하나
+ start: u32 # 필드는 u32, number, bytes, text
remove: u32
insert: bytes
}
@@ -112,6 +112,9 @@ on edit {
```
- `on 메시지 { }`는 메시지가 도착할 때 한 번 실행된다. 필드는 `메시지.필드`, 키는 `key`로만 쓴다.
+- 필드 타입은 `u32`, `number`(유한한 수), `bytes`, `text`(UTF-8)이고 가변 필드(bytes, text)는 몇 개든 어디에나 둘 수 있다.
+ `input add = message { title: text, points: number }`. HTTP 호스트(`pure-serve`)는 JSON 본문의 객체를 이 필드들로
+ 옮긴다. 도착한 메시지의 text가 UTF-8이 아니거나 수가 유한하지 않으면 그 이벤트는 실패한다.
- `이름[key]`는 그 메시지 키의 문서다. 읽으면 bytes, `.bytes`는 크기(없으면 0)다. 없는 문서를 읽으면 `not_found`.
- `replace(a .. b, 값)`는 문서의 `[a, b)`를 바꾼다. 정수가 아니거나 범위 밖이면 이벤트가 `limit`으로 실패한다.
- `require 조건`이 거짓이면 이벤트가 `limit`으로 실패한다. 크기 정책처럼 저장소의 특수 인자였던 것이 문장 하나가 된다.
diff --git a/src/compile.c b/src/compile.c
index 9e90d26..26dd1cb 100644
--- a/src/compile.c
+++ b/src/compile.c
@@ -19,7 +19,7 @@ enum { CONST, LOAD, DT, ADD, SUB, MUL, DIV, NEG, LT, LE, GT, GE, EQ, NE, AND, OR
ITEM_GET, ITEM_SET, INDEX_LOAD, INDEX_STORE, INDEX_MOVE, INDEX_FIRST, INDEX_LAST, INDEX_NUM, ROW,
PICK, SLIDE, FOLD, TEXT_COMPARE, LIST_FIND, LIST_HAS };
enum { FOLD_COUNT, FOLD_SUM, FOLD_MAX, FOLD_MIN, FOLD_FIRST };
-enum { KIND_KEY = 1, KIND_MESSAGE = 2, KIND_TEXT = 3, FIELD_U32 = 1, FIELD_BYTES = 2 };
+enum { KIND_KEY = 1, KIND_MESSAGE = 2, KIND_TEXT = 3, FIELD_U32 = 1, FIELD_BYTES = 2, FIELD_NUMBER = 3, FIELD_TEXT = 4 };
enum { STATE_NUM = 0, STATE_TEXT = 1, STATE_CURSOR = 2, STATE_LIST = 3, STATE_INDEX = 4, UNIT_CHAR = 0, UNIT_LINE = 1 };
typedef enum { NUM = 1, BYTES, TEXT, CURSOR, UNIT, INDEX } Type;
typedef struct { Type type; int owner; } Kind; /* owner: a cursor's text state or an index's list state */
@@ -965,7 +965,8 @@ static bool key_name(const char *key) {
return false;
}
-/* { name: u32 ... } with at most one bytes field, which comes last. */
+/* { name: type ... }: u32 and number are fixed, bytes and text of any length
+ * (Pure lays them out: docs/design/008-module-format.md). */
static void message_fields(Compiler *c, Input *in) {
if (!is(c, "{")) return; /* the field block opens on the declaration's line */
take(c);
@@ -975,15 +976,17 @@ static void message_fields(Compiler *c, Input *in) {
if (reserved_name(name.text)) fail_at(c, name.line, name.column, "'%s' is reserved", name.text);
for (unsigned i = 0; i < in->fields; ++i)
if (!strcmp(in->field_names[i], name.text)) fail_at(c, name.line, name.column, "duplicate field '%s'", name.text);
- if (in->fields && in->field_types[in->fields - 1] == FIELD_BYTES)
- fail_at(c, name.line, name.column, "a bytes field must be the last field");
if (c->field_count == FIELDS) fail_at(c, name.line, name.column, "at most 32 message fields are allowed");
expect(c, ":", "':'");
- Token type = expect_name(c, "u32 or bytes");
- if (strcmp(type.text, "u32") && strcmp(type.text, "bytes"))
- fail_at(c, type.line, type.column, "field types are u32 and bytes");
+ Token type = expect_name(c, "u32, number, bytes or text");
+ static const struct { const char *name; int type; } types[] = {
+ {"u32", FIELD_U32}, {"number", FIELD_NUMBER}, {"bytes", FIELD_BYTES}, {"text", FIELD_TEXT}};
+ int field_type = 0;
+ for (size_t t = 0; t < sizeof(types) / sizeof(*types); ++t)
+ if (!strcmp(type.text, types[t].name)) field_type = types[t].type;
+ if (!field_type) fail_at(c, type.line, type.column, "field types are u32, number, bytes and text");
memcpy(in->field_names[in->fields], name.text, NAME);
- in->field_types[in->fields++] = type.text[0] == 'u' ? FIELD_U32 : FIELD_BYTES;
+ in->field_types[in->fields++] = field_type;
++c->field_count;
end(c);
}
@@ -1568,7 +1571,8 @@ static Kind value(Compiler *c, const Expr *e, bool handler, int height) {
for (unsigned i = 0; i < in->fields; ++i)
if (!strcmp(in->field_names[i], e->member)) {
emit(c, e->line, e->column, FIELD, i, 0);
- return (Kind){in->field_types[i] == FIELD_U32 ? NUM : BYTES, -1};
+ int type = in->field_types[i];
+ return (Kind){type == FIELD_TEXT ? TEXT : type == FIELD_BYTES ? BYTES : NUM, -1};
}
fail_at(c, e->line, e->column, "message '%s' has no field '%s'", base->name, e->member);
}
diff --git a/tests/check.py b/tests/check.py
index 02392ab..2fba383 100644
--- a/tests/check.py
+++ b/tests/check.py
@@ -79,7 +79,7 @@ DIAGNOSTICS = [
# v0b: messages, keyed documents and types
('state d: bytes by key\ninput m = message\non m {\n reply d[key]\n}\n', '4:9', 'd[key] needs the handler of a message by key'),
('input m = message by key { a: u32 }\non m {\n reply m.a\n}\n', '3:9', 'reply takes bytes or text, not a number'),
- ('input m = message by key {\n b: bytes\n a: u32\n}\n', '3:3', 'a bytes field must be the last field'),
+ ('input m = message {\n a: float\n}\n', '2:6', 'field types are u32, number, bytes and text'),
('input m = message by key\non m.down {\n}\n', '2:4', "'m' is a message; write on m { }"),
('input k = key Space\non k {\n}\n', '2:4', "'k' is a key input; write on k.down or on k.up"),
('state d: bytes by key\ninput m = message by key { a: bytes }\non m {\n d[1].replace(0 .. 0, m.a)\n}\n', '4:5',
@@ -250,6 +250,9 @@ ACCEPTED = [
# A keyed list: added, found, changed, asked and removed by key; texts ordered by bytes.
KEYED_LIST + ON_K + ' xs.add(T { s: "q" })\n at = xs["q"]\n xs["q"].a = xs["q"].a + 1\n xs.remove("q")\n}\n'
'view v = if xs.has("q") { 1 } else { 0 }\nview w = "a" < "b" && "b" >= "b" && "a" != "ab"\n',
+ # Message fields of every type, variable ones anywhere and several of them.
+ 'state n = 0\nstate t: text\ninput m = message {\n a: text\n b: number\n c: bytes\n d: u32\n e: text\n}\n'
+ 'on m {\n n = m.b + m.d + m.c.bytes\n t = "{m.a}{m.e}"\n}\n',
]
@@ -1033,6 +1036,110 @@ def http(port, method, path, body=b''):
connection.close()
+JSON_SOURCE = """record Note {
+ title: text
+ body: text
+ points = 0
+}
+state notes: list of Note by title
+state counted = 0
+input add = message {
+ title: text
+ points: number
+ body: text
+}
+input count = message {
+ n: u32
+}
+on add {
+ notes.add(Note { title: add.title, body: add.body, points: add.points })
+ reply add.title
+}
+on count { counted = counted + count.n }
+view counted_view = counted
+view all = each n in notes {
+ title = n.title
+ body = n.body
+ points = n.points
+}
+"""
+
+
+def server_json(aup, pure, work, rounds=300):
+ """Messages of text and number fields posted as JSON objects: strings
+ come out as Python's json module reads them (escapes, control bytes,
+ Korean, surrogate pairs), numbers as it reads them; a missing, unknown,
+ doubled or mistyped field, broken JSON and a lone surrogate answer 400."""
+ module, source = work / 'json.pmod', work / 'json.aup'
+ source.write_text(JSON_SOURCE)
+ compile_module(aup, source, module)
+ host = HttpHost([pure / 'build/pure-serve', '--module', module, '--store', work / 'json-store'])
+ rng = random.Random(77)
+ pieces = ['a', 'Z', ' ', '"', '\\', '/', '\n', '\t', '\x01', '\x1f', 'é', '가', '😀', '\u2028', 'x' * 20]
+ notes = {}
+
+ def post(path, body, json_body=True):
+ import http.client
+ connection = http.client.HTTPConnection('127.0.0.1', host.port, timeout=30)
+ try:
+ headers = {'Content-Length': str(len(body))}
+ if json_body: headers['Content-Type'] = 'application/json'
+ connection.request('POST', path, body, headers)
+ response = connection.getresponse()
+ return response.status, response.getheader('X-Pure-Error'), response.read()
+ finally:
+ connection.close()
+
+ try:
+ for _ in range(rounds):
+ title = ''.join(rng.choice(pieces) for _ in range(rng.randint(0, 6)))
+ body = ''.join(rng.choice(pieces) for _ in range(rng.randint(0, 4)))
+ points = rng.choice([0, 1, -2.5, 1e3, 3.25e-2, 12345678.5])
+ members = [('title', title), ('points', points), ('body', body)]
+ rng.shuffle(members)
+ text = json.dumps(dict(members), ensure_ascii=rng.random() < 0.5)
+ text = text.replace(': ', ':' + ' ' * rng.randint(0, 2)).replace(', ', ',\n' if rng.random() < 0.3 else ', ')
+ got = post('/inputs/add', text.encode())
+ if title in notes:
+ assert got[:2] == (409, 'exists'), (text, got)
+ continue
+ assert got == (200, 'none', title.encode()), (text, got)
+ notes[title] = (body, points)
+ rows = json.loads(http(host.port, 'GET', '/views/all')[3])['items']
+ want = [{'title': t, 'body': notes[t][0], 'points': notes[t][1]} for t in sorted(notes, key=lambda t: t.encode())]
+ assert rows == want, [(a, b) for a, b in zip(rows, want) if a != b][:2]
+ # The query gives fixed fields; a body without JSON fills a message's one variable field only.
+ assert post('/inputs/count?n=3', b'', json_body=False)[:2] == (200, 'none')
+ assert post('/inputs/count', b'{"n": 4}')[:2] == (200, 'none')
+ assert http(host.port, 'GET', '/views/counted_view')[3] == b'7'
+ assert post('/inputs/add?points=2', b'{"title": "q-from-query", "body": ""}')[:2] == (200, 'none')
+ wrong = [
+ (b'{"title": "m", "points": 1}', 'missing_field'),
+ (b'{"title": "m", "points": 1, "body": "", "extra": 1}', 'json_field'),
+ (b'{"title": "m", "title": "n", "points": 1, "body": ""}', 'json_field'),
+ (b'{"title": 1, "points": 1, "body": ""}', 'json_type'),
+ (b'{"title": "m", "points": "1", "body": ""}', 'json_type'),
+ (b'{"title": "m", "points": 1e999, "body": ""}', 'json_type'),
+ (b'{"title": "m", "points": 01, "body": ""}', 'json'),
+ (b'{"title": "m", "points": NaN, "body": ""}', 'json'),
+ (b'{"title": "m", "points": 1, "body": "",}', 'json'),
+ (b'{"title": "\\ud800", "points": 1, "body": ""}', 'json'),
+ (b'{"title": "\xff", "points": 1, "body": ""}', 'json'),
+ (b'{"title": "m", "points": 1, "body": ""} x', 'json'),
+ (b'[1]', 'json'),
+ (b'{"title": "m\n", "points": 1, "body": ""}', 'json'), # a raw newline in a string
+ ]
+ for body, error in wrong:
+ assert post('/inputs/add', body)[:2] == (400, error), (body, post('/inputs/add', body))
+ assert post('/inputs/add?points=2', b'{"title": "m", "points": 1, "body": ""}')[:2] == (400, 'json_field')
+ assert post('/inputs/add', b'plain', json_body=False)[:2] == (400, 'json') # two text fields need JSON
+ assert post('/inputs/count', b'{"n": -1}')[:2] == (400, 'json_type')
+ assert post('/inputs/count', b'{"n": 4294967296}')[:2] == (400, 'json_type')
+ finally:
+ host.stop()
+ return rounds + len(wrong)
+
+
def server_app(aup, pure, work, seeds, operations=200, threads=1):
"""An app served by its views and inputs, no routes written: random
POSTs (a message with query fields and a body, a text, a key) and GETs
@@ -1602,6 +1709,7 @@ def main():
server_app(aup, args.pure.resolve(), work, range(args.editor_seeds))
report['server_app_owner_requests'], report['server_app_owner_acknowledged_before_kill'] = \
server_app(aup, args.pure.resolve(), work, range(args.editor_seeds), threads=3)
+ report['server_json_requests'] = server_json(aup, args.pure.resolve(), work)
print(json.dumps(report))