diff options
| -rw-r--r-- | docs/design/001-language-v0.md | 5 | ||||
| -rw-r--r-- | src/compile.c | 22 | ||||
| -rw-r--r-- | tests/check.py | 110 |
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)) |
