diff options
| -rw-r--r-- | docs/design/001-language-v0.md | 4 | ||||
| -rw-r--r-- | src/compile.c | 94 | ||||
| -rw-r--r-- | tests/check.py | 74 |
3 files changed, 151 insertions, 21 deletions
diff --git a/docs/design/001-language-v0.md b/docs/design/001-language-v0.md index 660abae..7a5b0d6 100644 --- a/docs/design/001-language-v0.md +++ b/docs/design/001-language-v0.md @@ -115,6 +115,10 @@ on edit { - 필드 타입은 `u32`, `number`(유한한 수), `bytes`, `text`(UTF-8)이고 가변 필드(bytes, text)는 몇 개든 어디에나 둘 수 있다. `input add = message { title: text, points: number }`. HTTP 호스트(`pure-serve`)는 JSON 본문의 객체를 이 필드들로 옮긴다. 도착한 메시지의 text가 UTF-8이 아니거나 수가 유한하지 않으면 그 이벤트는 실패한다. +- 필드를 그룹으로 묶을 수 있다: `to: { city: text, zip: u32 }`. 그룹 안의 필드는 `메시지.그룹.필드`(`ship.to.city`)로 + 읽고, JSON 본문에서는 안의 객체(`{"to": {"city": "Seoul", "zip": 4}}`), 쿼리에서는 경로(`?to.zip=4`)다. 그룹은 겹칠 수 + 있고, 경로는 31자 이하, 한 메시지의 필드는 그룹 안까지 합쳐 64개 이하다. 모듈에서는 `to.city` 같은 이름의 필드로 펼쳐지므로 + 실행 비용이 없다. - `이름[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 27176a1..bccc2c3 100644 --- a/src/compile.c +++ b/src/compile.c @@ -9,7 +9,7 @@ /* Limits and opcodes of Pure module format v5. */ enum { STATES = 32, INPUTS = 32, VIEWS = 64, HANDLERS = 64, CODE = 65535, STACK = 64, NAME = 32, JOINS = 8, - DEPTH = 64, FIELDS = 32, COLLECTIONS = 1, CONSTANTS = 4096, CONSTANT_BYTES = 1 << 20, + DEPTH = 64, FIELDS = 64, COLLECTIONS = 1, CONSTANTS = 4096, CONSTANT_BYTES = 1 << 20, RECORDS = 8, RECORD_FIELDS = 64, FIELDS_PER_RECORD = 16, FNS = 64, PARAMS = 8, MODULES = 9 }; enum { CONST, LOAD, DT, ADD, SUB, MUL, DIV, NEG, LT, LE, GT, GE, EQ, NE, AND, OR, NOT, MIN, MAX, CLAMP, STORE, JZ, JMP, FIELD, DOC, DOC_SIZE, LEN, REPLACE, REPLY, REQUIRE, @@ -968,19 +968,42 @@ static bool key_name(const char *key) { return false; } +/* Whether a message already has a field or a group of this path. */ +static bool path_taken(const Input *in, const char *path) { + size_t length = strlen(path); + for (unsigned i = 0; i < in->fields; ++i) { + const char *name = in->field_names[i]; + size_t size = strlen(name); + if (!strcmp(name, path) || (size > length && !memcmp(name, path, length) && name[length] == '.') || + (length > size && !memcmp(path, name, size) && path[size] == '.')) return true; + } + return false; +} + /* { 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) { + * (Pure lays them out: docs/design/008-module-format.md). name: { ... } is a + * group of fields inside the message, named group.name (a JSON object inside + * a request's body). */ +static void message_fields(Compiler *c, Input *in, const char *group) { if (!is(c, "{")) return; /* the field block opens on the declaration's line */ take(c); separators(c); + unsigned first = in->fields; while (!is(c, "}")) { Token name = expect_name(c, "a field name"); 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 (c->field_count == FIELDS) fail_at(c, name.line, name.column, "at most 32 message fields are allowed"); + char path[NAME]; + int length = snprintf(path, sizeof(path), "%s%s%s", group, *group ? "." : "", name.text); + if (length < 0 || length >= NAME) + fail_at(c, name.line, name.column, "'%s.%s' is longer than %d characters", group, name.text, NAME - 1); + if (path_taken(in, path)) fail_at(c, name.line, name.column, "duplicate field '%s'", path); expect(c, ":", "':'"); + if (is(c, "{")) { + message_fields(c, in, path); + end(c); + continue; + } + if (c->field_count == FIELDS) fail_at(c, name.line, name.column, "at most %d message fields are allowed", FIELDS); 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}}; @@ -988,14 +1011,16 @@ static void message_fields(Compiler *c, Input *in) { 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); + memcpy(in->field_names[in->fields], path, NAME); in->field_types[in->fields++] = field_type; ++c->field_count; end(c); } + if (*group && in->fields == first) fail_at(c, c->token.line, c->token.column, "the group '%s' has no fields", group); take(c); } + /* A signed number literal: a numeric state or field default. */ static double signed_number(Compiler *c, const char *what) { double sign = 1; @@ -1273,7 +1298,7 @@ static void declaration(Compiler *c) { in->kind = KIND_MESSAGE; if (word(c, "by")) { take(c); expect_word(c, "key"); in->keyed = true; } ++c->input_count; - message_fields(c, in); + message_fields(c, in, ""); } else if (word(c, "text")) { take(c); in->kind = KIND_TEXT; @@ -1508,6 +1533,28 @@ static int parameter(const Compiler *c, const char *name) { return -1; } +/* A member chain on a message, as order.customer.name: the input and the + * path after it (customer.name). False for anything else. */ +static bool message_path(Compiler *c, const Expr *e, int *input, char path[NAME]) { + if (e->kind != E_MEMBER) return false; + const Expr *base = e->args[0]; + if (base->kind == E_NAME) { + if (parameter(c, base->name) >= 0 || c->module) return false; + *input = input_index(c, base->name); + if (*input < 0 || c->inputs[*input].kind != KIND_MESSAGE) return false; + snprintf(path, NAME, "%s", e->member); + return true; + } + char inner[NAME]; + if (!message_path(c, base, input, inner)) return false; + const Input *in = &c->inputs[*input]; + for (unsigned i = 0; i < in->fields; ++i) + if (!strcmp(in->field_names[i], inner)) return false; /* a field's own member, as m.text.bytes */ + int length = snprintf(path, NAME, "%s.%s", inner, e->member); + if (length < 0 || length >= NAME) snprintf(path, NAME, "%s", inner); /* too long: names nothing */ + return true; +} + /* A name of the app (a state, input or collection): not visible in a library * fn, and hidden by a parameter of the same name. */ static void app_name(Compiler *c, const Expr *e, const char *name) { @@ -1588,20 +1635,27 @@ static Kind value(Compiler *c, const Expr *e, bool handler, int height) { const Expr *base = e->args[0]; int library = base->kind == E_NAME && parameter(c, base->name) < 0 ? used_library(c, c->module, base->name) : -1; if (library >= 0) return inline_fn(c, e, library, e->member, handler, height); /* library.constant */ + int message; + char path[NAME]; + if (message_path(c, e, &message, path)) { + const Input *in = &c->inputs[message]; + if (message != c->message) + fail_at(c, e->line, e->column, "fields of '%s' are only available in its handler", in->name); + size_t length = strlen(path); + for (unsigned i = 0; i < in->fields; ++i) + if (!strcmp(in->field_names[i], path)) { + emit(c, e->line, e->column, FIELD, i, 0); + int type = in->field_types[i]; + return (Kind){type == FIELD_TEXT ? TEXT : type == FIELD_BYTES ? BYTES : NUM, -1}; + } + for (unsigned i = 0; i < in->fields; ++i) + if (!strncmp(in->field_names[i], path, length) && in->field_names[i][length] == '.') + fail_at(c, e->line, e->column, "'%s.%s' is a group of fields; read one of them, as in %s.%s", + in->name, path, in->name, in->field_names[i]); + fail_at(c, e->line, e->column, "message '%s' has no field '%s'", in->name, path); + } if (base->kind == E_NAME && parameter(c, base->name) < 0 && !c->module) { int input = input_index(c, base->name); - if (input >= 0 && c->inputs[input].kind == KIND_MESSAGE) { - if (input != c->message) - fail_at(c, e->line, e->column, "fields of '%s' are only available in its handler", base->name); - const Input *in = &c->inputs[input]; - 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); - 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); - } if (input >= 0 && c->inputs[input].kind == KIND_KEY) fail_at(c, base->line, base->column, "'%s' is a key input and has no fields", base->name); int state = state_index(c, base->name); diff --git a/tests/check.py b/tests/check.py index 7e23d94..dcbda5e 100644 --- a/tests/check.py +++ b/tests/check.py @@ -89,6 +89,14 @@ DIAGNOSTICS = [ ('input m = message by key { a: u32 }\nview v = m.a\n', '2:10', "fields of 'm' are only available in its handler"), ('state x = 0\ninput m = message by key { a: bytes }\non m {\n x = m.a\n}\n', '4:7', 'expected a number, not bytes'), ('input m = message by key { a: u32 }\non m {\n reply m.z\n}\n', '3:9', "message 'm' has no field 'z'"), + ('input m = message {\n g: {\n a: text\n }\n}\non m {\n reply m.g\n}\n', '7:9', + "'m.g' is a group of fields; read one of them, as in m.g.a"), + ('input m = message {\n g: {\n a: text\n }\n}\non m {\n reply m.g.b\n}\n', '7:9', "message 'm' has no field 'g.b'"), + ('input m = message {\n g: text\n g: {\n a: text\n }\n}\n', '3:3', "duplicate field 'g'"), + ('input m = message {\n g: {\n a: text\n }\n g: {\n b: text\n }\n}\n', '5:3', "duplicate field 'g'"), + ('input m = message {\n g: {\n }\n}\n', '3:3', "the group 'g' has no fields"), + ('input m = message {\n group_of_things: {\n another_long_name: text\n }\n}\n', '3:5', + "'group_of_things.another_long_name' is longer than 31 characters"), ('state x = 0\non frame {\n require x.bytes\n}\n', '3:11', "a number has no 'bytes'"), ('state d: bytes by key\nstate e: bytes by key\n', '2:7', 'at most one keyed collection is allowed'), ('state d: bytes by key\ninput m = message by key { a: bytes }\non m {\n d[key].replace(0 .. m.a, m.a)\n}\n', '4:23', @@ -1156,17 +1164,44 @@ input add = message { input count = message { n: u32 } +record Shipment { + title: text + city: text + note: text + zip = 0 +} +state shipped: list of Shipment +input ship = message { + title: text + to: { + city: text + zip: u32 + door: { + note: text + } + } +} on add { notes.add(Note { title: add.title, body: add.body, points: add.points }) reply add.title } on count { counted = counted + count.n } +on ship { + shipped.append(Shipment { title: ship.title, city: ship.to.city, note: ship.to.door.note, zip: ship.to.zip }) + reply ship.to.door.note +} view counted_view = counted view all = each n in notes { title = n.title body = n.body points = n.points } +view shipments = each s in shipped { + title = s.title + city = s.city + note = s.note + zip = s.zip +} """ @@ -1240,9 +1275,46 @@ def server_json(aup, pure, work, rounds=300): 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') + # Groups: objects inside the body, members in any order, strings + # read where they are (escaped ones unescaped over themselves). + shipments = [] + for _ in range(rounds // 3): + title, city, note = (''.join(rng.choice(pieces) for _ in range(rng.randint(0, 5))) for _ in range(3)) + zip_code = rng.randint(0, 99999) + door = [('note', note)] + to = [('city', city), ('zip', zip_code), ('door', dict(door))] + rng.shuffle(to) + members = [('title', title), ('to', dict(to))] + rng.shuffle(members) + text = json.dumps(dict(members), ensure_ascii=rng.random() < 0.5) + got = post('/inputs/ship', text.encode()) + assert got == (200, 'none', note.encode()), (text, got) + shipments.append({'title': title, 'city': city, 'note': note, 'zip': zip_code}) + # A group's fixed field from the query, by its path. + assert post('/inputs/ship?to.zip=7', b'{"title": "q", "to": {"city": "c", "door": {"note": "n"}}}')[:2] == \ + (200, 'none') + shipments.append({'title': 'q', 'city': 'c', 'note': 'n', 'zip': 7}) + rows = json.loads(http(host.port, 'GET', f'/views/shipments?count={len(shipments)}')[3])['items'] + assert rows == shipments, [(a, b) for a, b in zip(rows, shipments) if a != b][:2] + grouped = '{"title": "t", "to": {"city": "c", "zip": 1, "door": {"note": "n"}}}' + wrong_groups = [ + (grouped.replace('"zip": 1', '"zip": 1, "lat": 2'), 'json_field'), # unknown member of a group + (grouped.replace('"note": "n"', '"note": "n", "bell": {"x": 1}'), 'json_field'), + (grouped.replace('"door": {"note": "n"}', '"door": "n"'), 'json_type'), # a group as a value + (grouped.replace('"title": "t"', '"title": {"a": "t"}'), 'json_type'), # a field as a group + (grouped.replace('"door": {"note": "n"}', '"door": {}'), 'missing_field'), + (grouped.replace('"zip": 1', '"zip": 1, "zip": 2'), 'json_field'), + (grouped.replace('"zip": 1', '"zip": [1]'), 'json_type'), + (grouped.replace('"zip": 1', '"zip": null'), 'json_type'), + (grouped.replace('"note": "n"', '"note": "n\\ud800"'), 'json'), + (grouped.replace('"door": {"note": "n"}}', '"door": {"note": "n"}'), 'json'), # unclosed + ] + for body, error in wrong_groups: + assert post('/inputs/ship', body.encode())[:2] == (400, error), (body, post('/inputs/ship', body.encode())) + assert post('/inputs/ship?to.zip=7', grouped.encode())[:2] == (400, 'json_field') # given twice finally: host.stop() - return rounds + len(wrong) + return rounds + len(wrong) + rounds // 3 + len(wrong_groups) BEAT_SOURCE = """state beats = 0 |
